Compare commits
121 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3aec2ca89b | |||
| 0f7261dd92 | |||
| 5c041ea857 | |||
| a528868656 | |||
| c82af28fd4 | |||
| d72e5db0a5 | |||
| 9907cfb0a0 | |||
| 0971b47bec | |||
| f2ea250364 | |||
| a0ab949399 | |||
| 30ed68ae22 | |||
| f2aa376304 | |||
| 1b48862923 | |||
| 25417ffac8 | |||
| 4dfbc5a828 | |||
| f0f5bc7551 | |||
| f002db2929 | |||
| c5791574f5 | |||
| 8c1fbfb429 | |||
| 8e5501a23b | |||
| 6ed41c3b87 | |||
| 7414c176d9 | |||
| 01f68d4038 | |||
| 21276e20d1 | |||
| 9fb9648e4c | |||
| af7689a1bc | |||
| 175bff4b29 | |||
| 7c1cdea2eb | |||
| 2bfa491b6a | |||
| 41fbfc9ba6 | |||
| 1fa7c0eb43 | |||
| 839b238e74 | |||
| ec22d57cd1 | |||
| 23ef283272 | |||
| 327579f9b6 | |||
| e5f9d2903d | |||
| 1f3aa4e111 | |||
| 3f88d1c49a | |||
| 5348ba1c65 | |||
| 2f35fd44a1 | |||
| 470fe70f07 | |||
| 399bf557f3 | |||
| fc2ca6cc26 | |||
| d9d42ec956 | |||
| 5e41c74682 | |||
| 02ad846dfa | |||
| 4e7eda65ed | |||
| 3fabe4f591 | |||
| 81234ef2ab | |||
| 4bd28390ea | |||
| fa886899fc | |||
| a81126a03a | |||
| 164c555bb4 | |||
| e5926893a9 | |||
| dbcaf705c2 | |||
| f6809be81d | |||
| 3ddf70888e | |||
| fe7dcf33ec | |||
| 733450bf86 | |||
| 1dae9622e1 | |||
| da0fa5c9ad | |||
| e576dea676 | |||
| 4cac51a831 | |||
| fef844e220 | |||
| 0f45bb5792 | |||
| c195c5da7d | |||
| cb26367f46 | |||
| 31dc89c0d7 | |||
| c95c3003f7 | |||
| 35b3fa6d9c | |||
| e217d62f42 | |||
| e62801caaf | |||
| e6008bb299 | |||
| 9a49646d8a | |||
| 9934e4d765 | |||
| b6a397460e | |||
| 2b75c07571 | |||
| ebbc55c4d0 | |||
| 4e6ee3629f | |||
| 46bd0f6709 | |||
| e6c1ed8753 | |||
| 2b92562538 | |||
| 078bb1ade6 | |||
| f205464aa4 | |||
| b820d3917c | |||
| fea81f03fc | |||
| 75aa71a886 | |||
| 398eefc380 | |||
| b549e319c2 | |||
| 1a57b67e14 | |||
| 378246728b | |||
| 41e326f376 | |||
| f9adeca5ca | |||
| 838f87c224 | |||
| 5b519165fc | |||
| 6b012a31ce | |||
| 1a2b23a066 | |||
| f109530c5f | |||
| 4cb132199c | |||
| fe16b3259c | |||
| 2e46954fe0 | |||
| aedc63c40d | |||
| 00d13d3c35 | |||
| 1b8b3ad538 | |||
| 5f75cfd691 | |||
| 5f89def2ce | |||
| 7c26eb9a24 | |||
| d951fe8fc5 | |||
| ac01135ca8 | |||
| 5e89e35e02 | |||
| 7e7afeaf82 | |||
| c6fa6d294b | |||
| fc2d17fc78 | |||
| 6fb5aa08ff | |||
| 9f072285a6 | |||
| c58ebe4b9b | |||
| 0dd187fe30 | |||
| ea78792c03 | |||
| 9954225701 | |||
| 6bb2e078c0 | |||
| 1fe35335a5 |
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
After Width: | Height: | Size: 626 KiB |
|
After Width: | Height: | Size: 582 KiB |
|
After Width: | Height: | Size: 472 KiB |
|
After Width: | Height: | Size: 730 KiB |
@@ -10,8 +10,6 @@ name: ci
|
||||
# The T3 content tier asserts Pokemon Red facts; scripts/test.sh detects
|
||||
# data/generated/ is absent and skips it rather than failing.
|
||||
#
|
||||
# Runs alongside release.yml, which is untouched by this file.
|
||||
|
||||
on:
|
||||
push:
|
||||
# Integration branch + release branch. PRs already run via pull_request
|
||||
@@ -24,12 +22,91 @@ concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ios-changes:
|
||||
name: detect iOS changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.paths.outputs.changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: paths
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(mobile/ios/|scripts/build_ios\.sh$)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
ios-build:
|
||||
name: iOS build
|
||||
needs: ios-changes
|
||||
if: needs.ios-changes.outputs.changed == 'true'
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
outputs:
|
||||
ipa_url: ${{ steps.upload-ipa.outputs.artifact-url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: import signing certificate
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
keychain_path="$RUNNER_TEMP/gen1recomp-ci-signing.keychain-db"
|
||||
ci_dir="${POKEMON_CI_DIR:-$HOME/.config/pokemon-ci}"
|
||||
p12="$ci_dir/signing.p12"
|
||||
passfile="$ci_dir/signing.pass"
|
||||
[ -f "$p12" ] && [ -f "$passfile" ] || exit 1
|
||||
p12pw="$(cat "$passfile")"
|
||||
kcpw="$(openssl rand -base64 24)"
|
||||
echo "::add-mask::$kcpw"
|
||||
security delete-keychain "$keychain_path" 2>/dev/null || true
|
||||
security create-keychain -p "$kcpw" "$keychain_path"
|
||||
security set-keychain-settings "$keychain_path"
|
||||
security unlock-keychain -p "$kcpw" "$keychain_path"
|
||||
security import "$p12" -P "$p12pw" -k "$keychain_path" -T /usr/bin/codesign -T /usr/bin/security
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$kcpw" "$keychain_path" >/dev/null
|
||||
existing="$(security list-keychains -d user | sed -e 's/^[[:space:]]*//' -e 's/"//g')"
|
||||
security list-keychains -d user -s "$keychain_path" $existing
|
||||
- name: install xcbeautify
|
||||
run: brew list xcbeautify >/dev/null 2>&1 || brew install xcbeautify
|
||||
- name: build iOS release
|
||||
env:
|
||||
CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }}
|
||||
run: |
|
||||
if [ "$CANONICAL_REPOSITORY" = true ]; then
|
||||
scripts/build_ios.sh --fetch --device --release
|
||||
else
|
||||
scripts/build_ios.sh --fetch --release
|
||||
fi
|
||||
- name: upload iOS release artifact
|
||||
id: upload-ipa
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-ios-ipa
|
||||
path: dist/ios/gen1recomp.ipa
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
- name: clean up signing keychain
|
||||
if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }}
|
||||
run: security delete-keychain "$RUNNER_TEMP/gen1recomp-ci-signing.keychain-db" 2>/dev/null || true
|
||||
|
||||
headless:
|
||||
name: headless suites (no ROM)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# LuaJIT, not lua5.4: LOVE 11.x embeds LuaJIT 2.1 and the engine is
|
||||
# written to Lua 5.1 semantics, so CI must run the interpreter the
|
||||
@@ -47,7 +124,7 @@ jobs:
|
||||
name: fixture dataset integrity
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- run: sudo apt-get update && sudo apt-get install -y luajit
|
||||
- run: python3 -m pip install --upgrade pillow
|
||||
|
||||
@@ -89,7 +166,7 @@ jobs:
|
||||
name: screenshot differ (capture not yet wired)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- run: python3 -m pip install --upgrade pillow
|
||||
|
||||
# 21-testing-and-ci §"Testing & acceptance criteria": compare_shots
|
||||
@@ -137,7 +214,7 @@ jobs:
|
||||
name: mod lint (no ROM-derived content)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# the MK305 dump check key-diffs shipped tables through luajit, and
|
||||
# modkit treats a missing interpreter as a fatal MK100 -- without
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
name: iOS artifact comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [ci]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: artifact
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
run: |
|
||||
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp-ios-ipa") | .id')"
|
||||
[ -n "$artifact_id" ] || exit 0
|
||||
head_owner="${HEAD_REPOSITORY%%/*}"
|
||||
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
|
||||
[ -n "$pr_number" ] || exit 0
|
||||
echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
|
||||
- name: Delete existing comment
|
||||
if: steps.artifact.outputs.pr_number != ''
|
||||
uses: izhangzhihao/delete-comment@master
|
||||
with:
|
||||
github_token: ${{ github.token }}
|
||||
delete_user_name: github-actions[bot]
|
||||
issue_number: ${{ steps.artifact.outputs.pr_number }}
|
||||
- name: Get build info
|
||||
id: build-info
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
commit_hash="$(printf '%s' "$HEAD_SHA" | cut -c1-7)"
|
||||
build_time="$(date "+%Y-%m-%d %H:%M:%S")"
|
||||
echo "hash=$commit_hash" >> "$GITHUB_OUTPUT"
|
||||
echo "time=$build_time" >> "$GITHUB_OUTPUT"
|
||||
- name: comment iOS artifact
|
||||
if: steps.artifact.outputs.pr_number != ''
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
message: |
|
||||
[gen1recomp.ipa](${{ steps.artifact.outputs.artifact_url }})
|
||||
|
||||
**Commit**: [#${{ steps.build-info.outputs.hash }}](https://github.com/${{ github.event.workflow_run.head_repository.full_name }}/commit/${{ github.event.workflow_run.head_sha }})
|
||||
**Build Time**: `${{ steps.build-info.outputs.time }}`
|
||||
|
||||
<sub>This comment was automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})</sub>
|
||||
pr-number: ${{ steps.artifact.outputs.pr_number }}
|
||||
comment-tag: ios-build-result
|
||||
github-token: ${{ github.token }}
|
||||
@@ -24,6 +24,7 @@ on:
|
||||
paths-ignore:
|
||||
- '.github/**'
|
||||
- '**.md'
|
||||
- 'mobile/ios/app-repo.json'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
@@ -42,11 +43,17 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: [self-hosted, macOS]
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
|
||||
steps:
|
||||
# The self-hosted runner lives under the machine owner's home
|
||||
# directory; mask it first so absolute paths in every later step's
|
||||
# output show up as *** in the public workflow logs.
|
||||
- name: Mask runner paths
|
||||
run: echo "::add-mask::$HOME"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
@@ -121,6 +128,7 @@ jobs:
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Import signing certificate into a temporary keychain
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEYCHAIN_PATH="$RUNNER_TEMP/pokemon-signing.keychain-db"
|
||||
@@ -170,14 +178,23 @@ jobs:
|
||||
set -euo pipefail
|
||||
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Build iOS
|
||||
- name: Install xcbeautify
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Device Release IPA; signs with the Apple Development identity on
|
||||
# the runner (auto team detection). Users on other Apple IDs still
|
||||
# re-sign or build via docs/ios-install.md.
|
||||
scripts/build_ios.sh --fetch --device --release \
|
||||
--version "${{ steps.ver.outputs.version }}"
|
||||
brew list xcbeautify >/dev/null 2>&1 || brew install xcbeautify
|
||||
|
||||
- name: Build iOS
|
||||
env:
|
||||
CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$CANONICAL_REPOSITORY" = true ]; then
|
||||
scripts/build_ios.sh --fetch --device --release \
|
||||
--version "${{ steps.ver.outputs.version }}"
|
||||
else
|
||||
scripts/build_ios.sh --fetch --release \
|
||||
--version "${{ steps.ver.outputs.version }}"
|
||||
fi
|
||||
|
||||
- name: Build Anbernic RG34XXSP port
|
||||
run: |
|
||||
@@ -187,6 +204,7 @@ jobs:
|
||||
./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Notarize & staple macOS app
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ci_dir="${POKEMON_CI_DIR:-$HOME/.config/pokemon-ci}"
|
||||
@@ -221,6 +239,7 @@ jobs:
|
||||
echo "Notarized + stapled ✓"
|
||||
|
||||
- name: Stage release assets
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
id: assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -260,6 +279,7 @@ jobs:
|
||||
cat "$outdir/sha256sums.txt"
|
||||
|
||||
- name: Publish GitHub Release
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -364,7 +384,72 @@ jobs:
|
||||
|
||||
echo "Published release $tag"
|
||||
|
||||
- name: Update iOS app repository
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v="${{ steps.ver.outputs.version }}"
|
||||
ipa="dist/release/gen1recomp-${v}-ios.ipa"
|
||||
app_repo="mobile/ios/app-repo.json"
|
||||
[ -f "$ipa" ] || { echo "::error::$ipa not found"; exit 1; }
|
||||
[ -f "$app_repo" ] || { echo "::error::$app_repo not found"; exit 1; }
|
||||
|
||||
date="$(date -u +"%Y-%m-%d")"
|
||||
size="$(wc -c < "$ipa" | tr -d '[:space:]')"
|
||||
download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${v}/gen1recomp-${v}-ios.ipa"
|
||||
localized_description="Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke"
|
||||
release_notes="$(GH_TOKEN="${{ github.token }}" gh release view "v${v}" --json body --jq '.body // ""' 2>/dev/null || true)"
|
||||
if [ -n "$release_notes" ]; then
|
||||
localized_description="$release_notes"
|
||||
fi
|
||||
entry="$(jq -n \
|
||||
--arg version "$v" \
|
||||
--arg date "$date" \
|
||||
--arg download_url "$download_url" \
|
||||
--arg localized_description "$localized_description" \
|
||||
--argjson size "$size" \
|
||||
'{version: $version, date: $date, size: $size, downloadURL: $download_url, localizedDescription: $localized_description}')"
|
||||
|
||||
if jq -e --arg version "$v" \
|
||||
'any(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions[]?; .version == $version)' \
|
||||
"$app_repo" >/dev/null; then
|
||||
jq --arg version "$v" --argjson entry "$entry" \
|
||||
'(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= map(if .version == $version then $entry else . end)' \
|
||||
"$app_repo" > "$app_repo.tmp"
|
||||
else
|
||||
jq --argjson entry "$entry" \
|
||||
'(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= [$entry] + .' \
|
||||
"$app_repo" > "$app_repo.tmp"
|
||||
fi
|
||||
mv "$app_repo.tmp" "$app_repo"
|
||||
|
||||
# main is PR-only for everyone except deploy keys (the "main protection"
|
||||
# ruleset's bypass actor), so this push must authenticate with the
|
||||
# RELEASE_DEPLOY_KEY deploy key over SSH; the workflow's GITHUB_TOKEN
|
||||
# would be rejected by the branch protection.
|
||||
- name: Commit iOS app repository
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
env:
|
||||
DEPLOY_KEY: ${{ secrets.RELEASE_DEPLOY_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git add mobile/ios/app-repo.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "app-repo.json unchanged; nothing to push"
|
||||
exit 0
|
||||
fi
|
||||
key="$RUNNER_TEMP/release-deploy-key"
|
||||
printf '%s\n' "$DEPLOY_KEY" > "$key"
|
||||
chmod 600 "$key"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git commit -m "chore(ios): update app-repo.json [skip ci]"
|
||||
git -c core.sshCommand="ssh -i $key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \
|
||||
push "git@github.com:${GITHUB_REPOSITORY}.git" "HEAD:${GITHUB_REF_NAME}"
|
||||
rm -f "$key"
|
||||
|
||||
- name: Clean up signing keychain
|
||||
if: always()
|
||||
if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }}
|
||||
run: |
|
||||
security delete-keychain "$RUNNER_TEMP/pokemon-signing.keychain-db" 2>/dev/null || true
|
||||
rm -f "$RUNNER_TEMP/release-deploy-key"
|
||||
|
||||
@@ -118,6 +118,7 @@ not a hard error, so the list can grow without breaking old mods.
|
||||
| `QUEST` | New story, NPCs, dialogue, cutscenes | content |
|
||||
| `MECHANIC` | New or changed battle/field mechanics via hooks/effects | overhaul |
|
||||
| `GRAPHICS` | Sprite / tileset / palette / font changes | content |
|
||||
| `LANGUAGE` | A translation: `text`, `strings` and the glyphs it needs | content |
|
||||
| `AUDIO` | Music, sfx, cries | content |
|
||||
| `UI` | New or modified screens, menus, overlays | content / overhaul |
|
||||
| `TOOL` | Dev/QoL utilities, overlays, inter-mod libraries | content |
|
||||
@@ -128,6 +129,15 @@ not a hard error, so the list can grow without breaking old mods.
|
||||
keeps validating with the value it has shipped since before the taxonomy
|
||||
existed.
|
||||
|
||||
A translation may also set `"language": true` in the manifest. That is the
|
||||
one claim online play acts on: an install running nothing but verified
|
||||
translations may take an ONLINE MATCH or a TOURNAMENT instead of being
|
||||
asked to restart vanilla. The claim is checked, not taken -- the mod
|
||||
qualifies only if every record it writes lands in `text`, `strings` or
|
||||
`font`, it wraps no hook, subscribes to no event and requests no
|
||||
permission. Anything else and it is an ordinary content mod that happens to
|
||||
ship text.
|
||||
|
||||
### 4. `mod.card`
|
||||
|
||||
The manifest is the *engine's* contract: identity, load order, dependencies,
|
||||
|
||||
@@ -4,6 +4,9 @@ A native LÖVE2D recreation of Poke Red, Blue and Yellow. The engine and map
|
||||
behavior are hand-written Lua; game data and graphics are decoded from a ROM
|
||||
supplied by the player.
|
||||
|
||||
> [!CAUTION]
|
||||
> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project.
|
||||
|
||||
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
|
||||
|
||||
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu)
|
||||
@@ -75,6 +78,19 @@ The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
||||
sound effects, and cries are synthesized while the game runs from compact
|
||||
audio channel programs copied out of the verified ROM.
|
||||
|
||||
### A note on Windows Defender warnings
|
||||
|
||||
Windows Defender sometimes flags the Windows build with a generic
|
||||
machine-learning detection such as `Trojan:Win32/Wacatac!ml` (#621). This is
|
||||
a known false positive: the exe is the official LÖVE runtime with the game
|
||||
archive appended (the standard way LÖVE games ship), and Defender's
|
||||
heuristics distrust unsigned executables with appended data. Every release
|
||||
publishes SHA-256 checksums (`sha256sums.txt`) so you can verify your
|
||||
download, and you can confirm a flagged file yourself on
|
||||
[VirusTotal](https://www.virustotal.com), where these builds come back clean
|
||||
on every engine except Defender's heuristic. False positives are reported to
|
||||
Microsoft as they come up.
|
||||
|
||||
## Controls
|
||||
|
||||
|
||||
@@ -96,6 +112,7 @@ supported out of the box.
|
||||
| Key | What it does |
|
||||
| --------- | ---------------------------------------------------- |
|
||||
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
|
||||
| `1` | Cycle GAME SPEED up (controller: R2 faster, L2 slower) |
|
||||
| `2` | Cycle COLORS |
|
||||
| `3` | Cycle TILT (free-roam overworld) |
|
||||
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
||||
@@ -105,8 +122,8 @@ supported out of the box.
|
||||
| `F10` | Open / close the mod manager |
|
||||
|
||||
|
||||
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
|
||||
and persist in `options.lua`.
|
||||
COLORS, TILT, ZOOM, GBC FX, GAME SPEED, and VOID FILL are also in the
|
||||
Options menu and persist in `options.lua`.
|
||||
|
||||
### Low-end devices
|
||||
|
||||
@@ -195,6 +212,16 @@ Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
|
||||
build and install from source on a Mac instead, see
|
||||
[docs/ios-install.md](docs/ios-install.md).
|
||||
|
||||
<div>
|
||||
<a href="https://intradeus.github.io/http-protocol-redirector?r=sidestore://source?url=https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json"><img src="./.github/resources/sidestore-badge.png" alt="Add to SideStore" height="60"></a>
|
||||
|
||||
<a href="https://intradeus.github.io/http-protocol-redirector?r=feather://source/https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json"><img src="./.github/resources/feather-badge.png" alt="Add to Feather" height="60"></a>
|
||||
|
||||
<a href="https://intradeus.github.io/http-protocol-redirector?r=altstore://source?url=https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json"><img src="./.github/resources/altstore-badge.png" alt="Add to AltStore" height="60"></a>
|
||||
|
||||
<a href="https://github.com/bryanthaboi/gen1recomp/releases/latest"><img src="./.github/resources/github-badge.png" alt="Download from GitHub" height="60"></a>
|
||||
</div>
|
||||
|
||||
## Handhelds
|
||||
|
||||
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
|
||||
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -89,7 +89,7 @@ mkdir -p "$GAME_SRC"
|
||||
# tools/save-editor is part of that payload: the launcher's Edit button on a
|
||||
# save row opens it in-process (main.lua).
|
||||
(cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
main.lua conf.lua src libs data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if unzip -Z1 "$WORK/game-payload.zip" \
|
||||
|
||||
@@ -1683,158 +1683,172 @@ return {
|
||||
{ 0, 0, 0 },
|
||||
},
|
||||
},
|
||||
-- Species -> palette NAME, taken from pokered-gbc data/pokemon/palettes.asm
|
||||
-- ELSE branch -- the one that is NOT gated on GEN_2_GRAPHICS.
|
||||
--
|
||||
-- That file has two tables. The IF GEN_2_GRAPHICS branch assigns a
|
||||
-- PER-SPECIES palette (PAL_BULBASAUR, PAL_SQUIRTLE, ...) authored for Gen 2
|
||||
-- sprite art; the ELSE branch keeps Gen 1's own assignments (GREENMON,
|
||||
-- CYANMON, ...). This port extracts Gen 1 pics from the ROM, so the Gen 1
|
||||
-- branch is the matching one. The per-species table was imported here by
|
||||
-- mistake, which pointed every mon at colours shaded for different art:
|
||||
-- Bulbasaur wore PAL_BULBASAUR's red-orange over a sprite that has no red
|
||||
-- on it, and Squirtle wore PAL_SQUIRTLE's shell brown on his head.
|
||||
--
|
||||
-- The palette VALUES below are still pokered-gbc's, so ADVANCED keeps its
|
||||
-- richer colours -- only which palette each species points at changed.
|
||||
pokemon = {
|
||||
ABRA = "ABRA",
|
||||
AERODACTYL = "AERODACTYL",
|
||||
ALAKAZAM = "ALAKAZAM",
|
||||
ARBOK = "ARBOK",
|
||||
ARCANINE = "ARCANINE",
|
||||
ARTICUNO = "ARTICUNO",
|
||||
BEEDRILL = "BEEDRILL",
|
||||
BELLSPROUT = "BELLSPROUT",
|
||||
BLASTOISE = "BLASTOISE",
|
||||
BULBASAUR = "BULBASAUR",
|
||||
BUTTERFREE = "BUTTERFREE",
|
||||
CATERPIE = "CATERPIE",
|
||||
CHANSEY = "CHANSEY",
|
||||
CHARIZARD = "CHARIZARD",
|
||||
CHARMANDER = "CHARMANDER",
|
||||
CHARMELEON = "CHARMELEON",
|
||||
CLEFABLE = "CLEFABLE",
|
||||
CLEFAIRY = "CLEFAIRY",
|
||||
CLOYSTER = "CLOYSTER",
|
||||
CUBONE = "CUBONE",
|
||||
DEWGONG = "DEWGONG",
|
||||
DIGLETT = "DIGLETT",
|
||||
DITTO = "DITTO",
|
||||
DODRIO = "DODRIO",
|
||||
DODUO = "DODUO",
|
||||
DRAGONAIR = "DRAGONAIR",
|
||||
DRAGONITE = "DRAGONITE",
|
||||
DRATINI = "DRATINI",
|
||||
DROWZEE = "DROWZEE",
|
||||
DUGTRIO = "DUGTRIO",
|
||||
EEVEE = "EEVEE",
|
||||
EKANS = "EKANS",
|
||||
ELECTABUZZ = "ELECTABUZZ",
|
||||
ELECTRODE = "ELECTRODE",
|
||||
EXEGGCUTE = "EXEGGCUTE",
|
||||
EXEGGUTOR = "EXEGGUTOR",
|
||||
FARFETCHD = "FARFETCH_D",
|
||||
FEAROW = "FEAROW",
|
||||
FLAREON = "FLAREON",
|
||||
GASTLY = "GASTLY",
|
||||
GENGAR = "GENGAR",
|
||||
GEODUDE = "GEODUDE",
|
||||
GLOOM = "GLOOM",
|
||||
GOLBAT = "GOLBAT",
|
||||
GOLDEEN = "GOLDEEN",
|
||||
GOLDUCK = "GOLDUCK",
|
||||
GOLEM = "GOLEM",
|
||||
GRAVELER = "GRAVELER",
|
||||
GRIMER = "GRIMER",
|
||||
GROWLITHE = "GROWLITHE",
|
||||
GYARADOS = "GYARADOS",
|
||||
HAUNTER = "HAUNTER",
|
||||
HITMONCHAN = "HITMONCHAN",
|
||||
HITMONLEE = "HITMONLEE",
|
||||
HORSEA = "HORSEA",
|
||||
HYPNO = "HYPNO",
|
||||
IVYSAUR = "IVYSAUR",
|
||||
JIGGLYPUFF = "JIGGLYPUFF",
|
||||
JOLTEON = "JOLTEON",
|
||||
JYNX = "JYNX",
|
||||
KABUTO = "KABUTO",
|
||||
KABUTOPS = "KABUTOPS",
|
||||
KADABRA = "KADABRA",
|
||||
KAKUNA = "KAKUNA",
|
||||
KANGASKHAN = "KANGASKHAN",
|
||||
KINGLER = "KINGLER",
|
||||
KOFFING = "KOFFING",
|
||||
KRABBY = "KRABBY",
|
||||
LAPRAS = "LAPRAS",
|
||||
LICKITUNG = "LICKITUNG",
|
||||
MACHAMP = "MACHAMP",
|
||||
MACHOKE = "MACHOKE",
|
||||
MACHOP = "MACHOP",
|
||||
MAGIKARP = "MAGIKARP",
|
||||
MAGMAR = "MAGMAR",
|
||||
MAGNEMITE = "MAGNEMITE",
|
||||
MAGNETON = "MAGNETON",
|
||||
MANKEY = "MANKEY",
|
||||
MAROWAK = "MAROWAK",
|
||||
MEOWTH = "MEOWTH",
|
||||
METAPOD = "METAPOD",
|
||||
MEW = "MEW",
|
||||
MEWTWO = "MEWTWO",
|
||||
MOLTRES = "MOLTRES",
|
||||
MR_MIME = "MR_MIME",
|
||||
MUK = "MUK",
|
||||
NIDOKING = "NIDOKING",
|
||||
NIDOQUEEN = "NIDOQUEEN",
|
||||
NIDORAN_F = "NIDORAN_F",
|
||||
NIDORAN_M = "NIDORAN_M",
|
||||
NIDORINA = "NIDORINA",
|
||||
NIDORINO = "NIDORINO",
|
||||
NINETALES = "NINETALES",
|
||||
ODDISH = "ODDISH",
|
||||
OMANYTE = "OMANYTE",
|
||||
OMASTAR = "OMASTAR",
|
||||
ONIX = "ONIX",
|
||||
PARAS = "PARAS",
|
||||
PARASECT = "PARASECT",
|
||||
PERSIAN = "PERSIAN",
|
||||
PIDGEOT = "PIDGEOT",
|
||||
PIDGEOTTO = "PIDGEOTTO",
|
||||
PIDGEY = "PIDGEY",
|
||||
PIKACHU = "PIKACHU",
|
||||
PINSIR = "PINSIR",
|
||||
POLIWAG = "POLIWAG",
|
||||
POLIWHIRL = "POLIWHIRL",
|
||||
POLIWRATH = "POLIWRATH",
|
||||
PONYTA = "PONYTA",
|
||||
PORYGON = "PORYGON",
|
||||
PRIMEAPE = "PRIMEAPE",
|
||||
PSYDUCK = "PSYDUCK",
|
||||
RAICHU = "RAICHU",
|
||||
RAPIDASH = "RAPIDASH",
|
||||
RATICATE = "RATICATE",
|
||||
RATTATA = "RATTATA",
|
||||
RHYDON = "RHYDON",
|
||||
RHYHORN = "RHYHORN",
|
||||
SANDSHREW = "SANDSHREW",
|
||||
SANDSLASH = "SANDSLASH",
|
||||
SCYTHER = "SCYTHER",
|
||||
SEADRA = "SEADRA",
|
||||
SEAKING = "SEAKING",
|
||||
SEEL = "SEEL",
|
||||
SHELLDER = "SHELLDER",
|
||||
SLOWBRO = "SLOWBRO",
|
||||
SLOWPOKE = "SLOWPOKE",
|
||||
SNORLAX = "SNORLAX",
|
||||
SPEAROW = "SPEAROW",
|
||||
SQUIRTLE = "SQUIRTLE",
|
||||
STARMIE = "STARMIE",
|
||||
STARYU = "STARYU",
|
||||
TANGELA = "TANGELA",
|
||||
TAUROS = "TAUROS",
|
||||
TENTACOOL = "TENTACOOL",
|
||||
TENTACRUEL = "TENTACRUEL",
|
||||
VAPOREON = "VAPOREON",
|
||||
VENOMOTH = "VENOMOTH",
|
||||
VENONAT = "VENONAT",
|
||||
VENUSAUR = "VENUSAUR",
|
||||
VICTREEBEL = "VICTREEBEL",
|
||||
VILEPLUME = "VILEPLUME",
|
||||
VOLTORB = "VOLTORB",
|
||||
VULPIX = "VULPIX",
|
||||
WARTORTLE = "WARTORTLE",
|
||||
WEEDLE = "WEEDLE",
|
||||
WEEPINBELL = "WEEPINBELL",
|
||||
WEEZING = "WEEZING",
|
||||
WIGGLYTUFF = "WIGGLYTUFF",
|
||||
ZAPDOS = "ZAPDOS",
|
||||
ZUBAT = "ZUBAT",
|
||||
ABRA = "YELLOWMON",
|
||||
AERODACTYL = "GRAYMON",
|
||||
ALAKAZAM = "YELLOWMON",
|
||||
ARBOK = "PURPLEMON",
|
||||
ARCANINE = "REDMON",
|
||||
ARTICUNO = "BLUEMON",
|
||||
BEEDRILL = "YELLOWMON",
|
||||
BELLSPROUT = "GREENMON",
|
||||
BLASTOISE = "CYANMON",
|
||||
BULBASAUR = "GREENMON",
|
||||
BUTTERFREE = "CYANMON",
|
||||
CATERPIE = "GREENMON",
|
||||
CHANSEY = "PINKMON",
|
||||
CHARIZARD = "REDMON",
|
||||
CHARMANDER = "REDMON",
|
||||
CHARMELEON = "REDMON",
|
||||
CLEFABLE = "PINKMON",
|
||||
CLEFAIRY = "PINKMON",
|
||||
CLOYSTER = "GRAYMON",
|
||||
CUBONE = "GRAYMON",
|
||||
DEWGONG = "BLUEMON",
|
||||
DIGLETT = "BROWNMON",
|
||||
DITTO = "GRAYMON",
|
||||
DODRIO = "BROWNMON",
|
||||
DODUO = "BROWNMON",
|
||||
DRAGONAIR = "BLUEMON",
|
||||
DRAGONITE = "BROWNMON",
|
||||
DRATINI = "GRAYMON",
|
||||
DROWZEE = "YELLOWMON",
|
||||
DUGTRIO = "BROWNMON",
|
||||
EEVEE = "GRAYMON",
|
||||
EKANS = "PURPLEMON",
|
||||
ELECTABUZZ = "YELLOWMON",
|
||||
ELECTRODE = "YELLOWMON",
|
||||
EXEGGCUTE = "PINKMON",
|
||||
EXEGGUTOR = "GREENMON",
|
||||
FARFETCHD = "BROWNMON",
|
||||
FEAROW = "BROWNMON",
|
||||
FLAREON = "REDMON",
|
||||
GASTLY = "PURPLEMON",
|
||||
GENGAR = "PURPLEMON",
|
||||
GEODUDE = "GRAYMON",
|
||||
GLOOM = "REDMON",
|
||||
GOLBAT = "BLUEMON",
|
||||
GOLDEEN = "REDMON",
|
||||
GOLDUCK = "CYANMON",
|
||||
GOLEM = "GRAYMON",
|
||||
GRAVELER = "GRAYMON",
|
||||
GRIMER = "PURPLEMON",
|
||||
GROWLITHE = "BROWNMON",
|
||||
GYARADOS = "BLUEMON",
|
||||
HAUNTER = "PURPLEMON",
|
||||
HITMONCHAN = "BROWNMON",
|
||||
HITMONLEE = "BROWNMON",
|
||||
HORSEA = "CYANMON",
|
||||
HYPNO = "YELLOWMON",
|
||||
IVYSAUR = "GREENMON",
|
||||
JIGGLYPUFF = "PINKMON",
|
||||
JOLTEON = "YELLOWMON",
|
||||
JYNX = "MEWMON",
|
||||
KABUTO = "BROWNMON",
|
||||
KABUTOPS = "BROWNMON",
|
||||
KADABRA = "YELLOWMON",
|
||||
KAKUNA = "YELLOWMON",
|
||||
KANGASKHAN = "BROWNMON",
|
||||
KINGLER = "REDMON",
|
||||
KOFFING = "PURPLEMON",
|
||||
KRABBY = "REDMON",
|
||||
LAPRAS = "CYANMON",
|
||||
LICKITUNG = "PINKMON",
|
||||
MACHAMP = "GRAYMON",
|
||||
MACHOKE = "GRAYMON",
|
||||
MACHOP = "GRAYMON",
|
||||
MAGIKARP = "REDMON",
|
||||
MAGMAR = "REDMON",
|
||||
MAGNEMITE = "GRAYMON",
|
||||
MAGNETON = "GRAYMON",
|
||||
MANKEY = "BROWNMON",
|
||||
MAROWAK = "GRAYMON",
|
||||
MEOWTH = "YELLOWMON",
|
||||
METAPOD = "GREENMON",
|
||||
MEW = "MEWMON",
|
||||
MEWTWO = "MEWMON",
|
||||
MOLTRES = "REDMON",
|
||||
MR_MIME = "PINKMON",
|
||||
MUK = "PURPLEMON",
|
||||
NIDOKING = "PURPLEMON",
|
||||
NIDOQUEEN = "BLUEMON",
|
||||
NIDORAN_F = "BLUEMON",
|
||||
NIDORAN_M = "PURPLEMON",
|
||||
NIDORINA = "BLUEMON",
|
||||
NIDORINO = "PURPLEMON",
|
||||
NINETALES = "YELLOWMON",
|
||||
ODDISH = "GREENMON",
|
||||
OMANYTE = "BLUEMON",
|
||||
OMASTAR = "BLUEMON",
|
||||
ONIX = "GRAYMON",
|
||||
PARAS = "REDMON",
|
||||
PARASECT = "REDMON",
|
||||
PERSIAN = "YELLOWMON",
|
||||
PIDGEOT = "BROWNMON",
|
||||
PIDGEOTTO = "BROWNMON",
|
||||
PIDGEY = "BROWNMON",
|
||||
PIKACHU = "YELLOWMON",
|
||||
PINSIR = "BROWNMON",
|
||||
POLIWAG = "BLUEMON",
|
||||
POLIWHIRL = "BLUEMON",
|
||||
POLIWRATH = "BLUEMON",
|
||||
PONYTA = "REDMON",
|
||||
PORYGON = "GRAYMON",
|
||||
PRIMEAPE = "BROWNMON",
|
||||
PSYDUCK = "YELLOWMON",
|
||||
RAICHU = "YELLOWMON",
|
||||
RAPIDASH = "REDMON",
|
||||
RATICATE = "GRAYMON",
|
||||
RATTATA = "GRAYMON",
|
||||
RHYDON = "GRAYMON",
|
||||
RHYHORN = "GRAYMON",
|
||||
SANDSHREW = "BROWNMON",
|
||||
SANDSLASH = "BROWNMON",
|
||||
SCYTHER = "GREENMON",
|
||||
SEADRA = "CYANMON",
|
||||
SEAKING = "REDMON",
|
||||
SEEL = "BLUEMON",
|
||||
SHELLDER = "GRAYMON",
|
||||
SLOWBRO = "PINKMON",
|
||||
SLOWPOKE = "PINKMON",
|
||||
SNORLAX = "PINKMON",
|
||||
SPEAROW = "BROWNMON",
|
||||
SQUIRTLE = "CYANMON",
|
||||
STARMIE = "GRAYMON",
|
||||
STARYU = "REDMON",
|
||||
TANGELA = "BLUEMON",
|
||||
TAUROS = "GRAYMON",
|
||||
TENTACOOL = "CYANMON",
|
||||
TENTACRUEL = "CYANMON",
|
||||
VAPOREON = "CYANMON",
|
||||
VENOMOTH = "PURPLEMON",
|
||||
VENONAT = "PURPLEMON",
|
||||
VENUSAUR = "GREENMON",
|
||||
VICTREEBEL = "GREENMON",
|
||||
VILEPLUME = "REDMON",
|
||||
VOLTORB = "YELLOWMON",
|
||||
VULPIX = "REDMON",
|
||||
WARTORTLE = "CYANMON",
|
||||
WEEDLE = "YELLOWMON",
|
||||
WEEPINBELL = "GREENMON",
|
||||
WEEZING = "PURPLEMON",
|
||||
WIGGLYTUFF = "PINKMON",
|
||||
ZAPDOS = "YELLOWMON",
|
||||
ZUBAT = "BLUEMON",
|
||||
},
|
||||
source = "pokered-gbc data/super_palettes.asm + data/mon_palettes.asm + color/**",
|
||||
world = {
|
||||
|
||||
@@ -14,9 +14,9 @@ return {
|
||||
-- else -> .WhatsLostIsLostText (player has TM_DIG)
|
||||
TEXT_CERULEANTRASHEDHOUSE_FISHING_GURU = {
|
||||
{ "check_item", "TM_DIG" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_CeruleanTrashedHouseFishingGuruTheyStoleATMText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_CeruleanTrashedHouseFishingGuruWhatsLostIsLostText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Fuchsia City (pokered/scripts/FuchsiaCity.asm)
|
||||
--
|
||||
-- The exhibit signs are text_asm bodies: PrintText of a single text_far,
|
||||
-- then DisplayPokedex for the exhibited species. The preview marks the
|
||||
-- species seen but not owned, same as the S.S. Anne passenger's Snorlax.
|
||||
--
|
||||
-- The fossil sign branches on the Mt. Moon fossil events. The exhibit
|
||||
-- holds the fossil the player did NOT take: taking the Dome Fossil puts
|
||||
-- Omanyte on display, taking the Helix Fossil puts Kabuto. With neither
|
||||
-- event set the sign only prints its undetermined line and no dex entry
|
||||
-- opens.
|
||||
--
|
||||
-- The other text pointers (city sign, Safari Game signs, mart/center/gym
|
||||
-- and warden signs, the four NPCs, the exhibited-mon FuchsiaCityPokemonText
|
||||
-- rows) are plain text_far wrappers that resolve through Data:resolveText,
|
||||
-- so they are not ported here.
|
||||
return {
|
||||
FUCHSIA_CITY = {
|
||||
talk = {
|
||||
-- FuchsiaCityChanseySignText: PrintText(_FuchsiaCityChanseySignText),
|
||||
-- then DisplayPokedex CHANSEY.
|
||||
TEXT_FUCHSIACITY_CHANSEY_SIGN = {
|
||||
{ "show_text", "_FuchsiaCityChanseySignText" },
|
||||
{ "mark_seen", "CHANSEY" },
|
||||
{ "push_screen", "DexEntryMenu", "CHANSEY" },
|
||||
},
|
||||
|
||||
-- FuchsiaCityVoltorbSignText: PrintText(_FuchsiaCityVoltorbSignText),
|
||||
-- then DisplayPokedex VOLTORB.
|
||||
TEXT_FUCHSIACITY_VOLTORB_SIGN = {
|
||||
{ "show_text", "_FuchsiaCityVoltorbSignText" },
|
||||
{ "mark_seen", "VOLTORB" },
|
||||
{ "push_screen", "DexEntryMenu", "VOLTORB" },
|
||||
},
|
||||
|
||||
-- FuchsiaCityKangaskhanSignText: PrintText(_FuchsiaCityKangaskhanSignText),
|
||||
-- then DisplayPokedex KANGASKHAN.
|
||||
TEXT_FUCHSIACITY_KANGASKHAN_SIGN = {
|
||||
{ "show_text", "_FuchsiaCityKangaskhanSignText" },
|
||||
{ "mark_seen", "KANGASKHAN" },
|
||||
{ "push_screen", "DexEntryMenu", "KANGASKHAN" },
|
||||
},
|
||||
|
||||
-- FuchsiaCitySlowpokeSignText: PrintText(_FuchsiaCitySlowpokeSignText),
|
||||
-- then DisplayPokedex SLOWPOKE.
|
||||
TEXT_FUCHSIACITY_SLOWPOKE_SIGN = {
|
||||
{ "show_text", "_FuchsiaCitySlowpokeSignText" },
|
||||
{ "mark_seen", "SLOWPOKE" },
|
||||
{ "push_screen", "DexEntryMenu", "SLOWPOKE" },
|
||||
},
|
||||
|
||||
-- FuchsiaCityLaprasSignText: PrintText(_FuchsiaCityLaprasSignText),
|
||||
-- then DisplayPokedex LAPRAS.
|
||||
TEXT_FUCHSIACITY_LAPRAS_SIGN = {
|
||||
{ "show_text", "_FuchsiaCityLaprasSignText" },
|
||||
{ "mark_seen", "LAPRAS" },
|
||||
{ "push_screen", "DexEntryMenu", "LAPRAS" },
|
||||
},
|
||||
|
||||
-- FuchsiaCityFossilSignText: CheckEvent EVENT_GOT_DOME_FOSSIL /
|
||||
-- CheckEventReuseA EVENT_GOT_HELIX_FOSSIL pick the text and the
|
||||
-- displayed entry; with neither set only the undetermined line prints.
|
||||
TEXT_FUCHSIACITY_FOSSIL_SIGN = {
|
||||
{ "check_flag", "EVENT_GOT_DOME_FOSSIL" }, -- 1
|
||||
{ "jump_if_true", 7 }, -- 2
|
||||
{ "check_flag", "EVENT_GOT_HELIX_FOSSIL" }, -- 3
|
||||
{ "jump_if_true", 11 }, -- 4
|
||||
{ "show_text", "_FuchsiaCityFossilSignUndeterminedText" }, -- 5
|
||||
{ "jump", "end" }, -- 6
|
||||
{ "show_text", "_FuchsiaCityFossilSignOmanyteText" }, -- 7
|
||||
{ "mark_seen", "OMANYTE" }, -- 8
|
||||
{ "push_screen", "DexEntryMenu", "OMANYTE" }, -- 9
|
||||
{ "jump", "end" }, -- 10
|
||||
{ "show_text", "_FuchsiaCityFossilSignKabutoText" }, -- 11
|
||||
{ "mark_seen", "KABUTO" }, -- 12
|
||||
{ "push_screen", "DexEntryMenu", "KABUTO" }, -- 13
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -8,9 +8,9 @@ return {
|
||||
TEXT_LAVENDERMART_COOLTRAINER_M = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_RESCUED_MR_FUJI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_LavenderMartCooltrainerMReviveText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_LavenderMartCooltrainerMNuggetText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -13,9 +13,9 @@ M.MR_FUJIS_HOUSE = {
|
||||
TEXT_MRFUJISHOUSE_SUPER_NERD = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_RESCUED_MR_FUJI" }, -- 2
|
||||
{ "jump_if_true", 5 }, -- 3
|
||||
{ "jump_if_true", 6 }, -- 3
|
||||
{ "show_text", "_MrFujisHouseSuperNerdMrFujiIsntHereText" }, -- 4
|
||||
{ "jump", 6 }, -- 5
|
||||
{ "jump", "end" }, -- 5
|
||||
{ "show_text", "_MrFujisHouseSuperNerdMrFujiHadBeenPrayingText" }, -- 6
|
||||
},
|
||||
|
||||
@@ -25,9 +25,9 @@ M.MR_FUJIS_HOUSE = {
|
||||
TEXT_MRFUJISHOUSE_LITTLE_GIRL = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_RESCUED_MR_FUJI" }, -- 2
|
||||
{ "jump_if_true", 5 }, -- 3
|
||||
{ "jump_if_true", 6 }, -- 3
|
||||
{ "show_text", "_MrFujisHouseLittleGirlThisIsMrFujisHouseText" }, -- 4
|
||||
{ "jump", 6 }, -- 5
|
||||
{ "jump", "end" }, -- 5
|
||||
{ "show_text", "_MrFujisHouseLittleGirlPokemonAreNiceToHugText" }, -- 6
|
||||
},
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ return {
|
||||
TEXT_ROUTE16GATE1F_GUARD = {
|
||||
{ "face_player" },
|
||||
{ "check_item", "BICYCLE" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_Route16Gate1FGuardNoPedestriansAllowedText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_Route16Gate1FGuardCyclingRoadExplanationText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -14,9 +14,9 @@ return {
|
||||
TEXT_ROUTE18GATE1F_GUARD = {
|
||||
{ "face_player" },
|
||||
{ "check_item", "BICYCLE" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_Route18Gate1FGuardYouNeedABicycleText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_Route18Gate1FGuardCyclingRoadUphillText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -12,9 +12,9 @@ return {
|
||||
TEXT_SILPHCO10F_SILPH_WORKER_F = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo10FSilphWorkerFImScaredText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo10FSilphWorkerFQuietAboutMyCryingText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,9 +10,9 @@ return {
|
||||
-- not set: _SilphCo3FSilphWorkerMWhatShouldIDoText
|
||||
TEXT_SILPHCO3F_SILPH_WORKER_M = {
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_SilphCo3FSilphWorkerMWhatShouldIDoText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo3FSilphWorkerMYouSavedUsText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,9 +7,9 @@ return {
|
||||
TEXT_SILPHCO4F_SILPH_WORKER_M = {
|
||||
{"face_player"},
|
||||
{"check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI"},
|
||||
{"jump_if_true", 5},
|
||||
{"jump_if_true", 6},
|
||||
{"show_text", "_SilphCo4FSilphWorkerMImHidingText"},
|
||||
{"jump", 6},
|
||||
{"jump", "end"},
|
||||
{"show_text", "_SilphCo4FSilphWorkerMTeamRocketIsGoneText"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,9 +7,9 @@ return {
|
||||
TEXT_SILPHCO5F_SILPH_WORKER_M = {
|
||||
{"face_player"},
|
||||
{"check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI"},
|
||||
{"jump_if_true", 5},
|
||||
{"jump_if_true", 6},
|
||||
{"show_text", "_SilphCo5FSilphWorkerMThatsYouRightText"},
|
||||
{"jump", 6},
|
||||
{"jump", "end"},
|
||||
{"show_text", "_SilphCo5FSilphWorkerMYoureOurHeroText"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -11,9 +11,9 @@ return {
|
||||
TEXT_SILPHCO6F_SILPH_WORKER_M1 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerM1TookOverTheBuildingText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerM1BackToWorkText" },
|
||||
},
|
||||
|
||||
@@ -21,9 +21,9 @@ return {
|
||||
TEXT_SILPHCO6F_SILPH_WORKER_M2 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerMHelpMePleaseText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerMWeGotEngagedText" },
|
||||
},
|
||||
|
||||
@@ -31,9 +31,9 @@ return {
|
||||
TEXT_SILPHCO6F_SILPH_WORKER_F1 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerF1SuchACowardText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerF1HaveToMarryHimText" },
|
||||
},
|
||||
|
||||
@@ -41,9 +41,9 @@ return {
|
||||
TEXT_SILPHCO6F_SILPH_WORKER_F2 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerF2TeamRocketConquerWorldText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerF2TeamRocketRanText" },
|
||||
},
|
||||
|
||||
@@ -51,9 +51,9 @@ return {
|
||||
TEXT_SILPHCO6F_SILPH_WORKER_M3 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerM3TargetedSilphText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerM3WorkForSilphText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,9 +10,9 @@ return {
|
||||
-- set: _SilphCo7FSilphWorkerM2CancelledMasterBallText
|
||||
TEXT_SILPHCO7F_SILPH_WORKER_M2 = {
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM2AfterTheMasterBallText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM2CancelledMasterBallText" },
|
||||
},
|
||||
|
||||
@@ -22,9 +22,9 @@ return {
|
||||
-- set: _SilphCo7FSilphWorkerM3YouChasedOffTeamRocketText
|
||||
TEXT_SILPHCO7F_SILPH_WORKER_M3 = {
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM3ItWouldBeBadText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM3YouChasedOffTeamRocketText" },
|
||||
},
|
||||
|
||||
@@ -34,9 +34,9 @@ return {
|
||||
-- set: _SilphCo7FSilphWorkerM4SafeAtLastText
|
||||
TEXT_SILPHCO7F_SILPH_WORKER_M4 = {
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM4ItsReallyDangerousHereText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM4SafeAtLastText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,9 +10,9 @@ return {
|
||||
-- set: _SilphCo8FSilphWorkerMThanksForSavingUsText
|
||||
TEXT_SILPHCO8F_SILPH_WORKER_M = {
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_SilphCo8FSilphWorkerMSilphIsFinishedText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo8FSilphWorkerMThanksForSavingUsText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
-- hidden_text_predef spends the facing byte on the tx_pre id, so neither
|
||||
-- tile gates on facing.
|
||||
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Font = require("src.render.Font")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
-- ViridianSchoolBlackboard (engine/events/hidden_events/school_blackboard.asm):
|
||||
@@ -25,33 +26,112 @@ local STATUS_LABELS = {
|
||||
{ " FRZ", "_ViridianBlackboardFrozenText" },
|
||||
}
|
||||
|
||||
-- The headings list is a two-column menu, which src/ui/Menu.lua does not do
|
||||
-- (it stacks one column), so the layout lives here (#591). .blackboardLoop:
|
||||
-- TextBoxBorder at hlcoord 0, 0 with `lb bc, 6, 10` is the 12x8 box,
|
||||
-- StatusAilmentText1 (" SLP"/" PSN"/" PAR") is placed at hlcoord 1, 2 and
|
||||
-- StatusAilmentText2 (" BRN"/" FRZ"/" QUIT") at hlcoord 6, 2. LEFT/RIGHT
|
||||
-- move wTopMenuItemX between those two columns and swap wMenuItemOffset
|
||||
-- between 0 and 3 while leaving wCurrentMenuItem (the row) alone; UP/DOWN
|
||||
-- are not in wMenuWatchedKeys, so they only slide the cursor and loop.
|
||||
local BOARD_LABELS = {}
|
||||
for i, row in ipairs(STATUS_LABELS) do BOARD_LABELS[i] = row[1] end
|
||||
BOARD_LABELS[#BOARD_LABELS + 1] = " QUIT"
|
||||
local BOARD_COL_X = { 1, 6 }
|
||||
local BOARD_ROW_Y = 2
|
||||
local BOARD_ROWS = 3
|
||||
|
||||
local StatusBoard = {}
|
||||
StatusBoard.__index = StatusBoard
|
||||
|
||||
function StatusBoard.new(game, onPick, onQuit)
|
||||
return setmetatable({ game = game, col = 1, row = 1, labels = BOARD_LABELS,
|
||||
onPick = onPick, onQuit = onQuit }, StatusBoard)
|
||||
end
|
||||
|
||||
-- flat index = pokered's wMenuItemOffset (0 or 3) + wCurrentMenuItem (0..2),
|
||||
-- so 1..5 are the statuses in ViridianBlackboardStatusPointers order and 6
|
||||
-- is QUIT
|
||||
function StatusBoard:selection()
|
||||
return (self.col - 1) * BOARD_ROWS + self.row
|
||||
end
|
||||
|
||||
function StatusBoard:update()
|
||||
local input = self.game.input
|
||||
if input:wasPressed("up") then
|
||||
-- wMenuWrappingEnabled is never set here, so both ends are hard stops
|
||||
if self.row > 1 then self.row = self.row - 1 end
|
||||
elseif input:wasPressed("down") then
|
||||
if self.row < BOARD_ROWS then self.row = self.row + 1 end
|
||||
elseif input:wasPressed("left") then
|
||||
self.col = 1
|
||||
elseif input:wasPressed("right") then
|
||||
self.col = 2
|
||||
elseif input:wasPressed("a") or input:wasPressed("b") then
|
||||
-- HandleMenuInput_ (home/window.asm) beeps for the PAD_A | PAD_B branch,
|
||||
-- and B and QUIT share .exitBlackboard
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
local sel = self:selection()
|
||||
if input:wasPressed("b") or sel > #STATUS_LABELS then
|
||||
self.onQuit()
|
||||
else
|
||||
self.onPick(sel)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function StatusBoard:draw()
|
||||
Font.drawBox(0, 0, 12, 8)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, label in ipairs(BOARD_LABELS) do
|
||||
local col = i <= BOARD_ROWS and 1 or 2
|
||||
local row = i - (col - 1) * BOARD_ROWS
|
||||
Font.draw(label, BOARD_COL_X[col] * 8, (BOARD_ROW_Y + row - 1) * 8)
|
||||
end
|
||||
-- wTopMenuItemX equals the column PlaceString started at, so the cursor
|
||||
-- covers the blank each label leads with
|
||||
Font.drawCode(Theme.cursor, BOARD_COL_X[self.col] * 8,
|
||||
(BOARD_ROW_Y + self.row - 1) * 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
local function blackboard(game)
|
||||
local text = game.data.text or {}
|
||||
local items, showMenu, askHeading
|
||||
function showMenu()
|
||||
game.stack:push(Menu.new(game, items,
|
||||
{ tx = 0, ty = 0, tw = 12, th = 8, rowStep = 1 }))
|
||||
local openBoard
|
||||
-- wCurrentMenuItem / wMenuItemOffset are zeroed once, above .blackboardLoop,
|
||||
-- and nothing inside the loop clears them again: after a status blurb
|
||||
-- `jp .blackboardLoop` comes back with the cursor still on the row and
|
||||
-- column the player just picked. One StatusBoard lives for the whole
|
||||
-- reading and is re-pushed each pass, so only entering the blackboard
|
||||
-- resets to the left column / top row (#591).
|
||||
local board
|
||||
-- .blackboardLoop reprints ViridianSchoolBlackboardText2 and only then
|
||||
-- calls HandleMenuInput, so the prompt is on screen for exactly as long as
|
||||
-- the headings list is. That text ends in `done`, not `prompt`
|
||||
-- (data/text/text_2.asm:646), so PrintText returns with the box still up
|
||||
-- and never waits for a button: TextBox opts.stay holds it open under the
|
||||
-- list and these callbacks pop the pair together (#591).
|
||||
local function closeBoard()
|
||||
game.stack:pop() -- the headings list
|
||||
game.stack:pop() -- the held "Which heading" box under it
|
||||
end
|
||||
-- ViridianSchoolBlackboardText2 is reprinted on every .blackboardLoop
|
||||
-- pass, immediately before HandleMenuInput
|
||||
function askHeading()
|
||||
local function pick(i)
|
||||
closeBoard()
|
||||
game.stack:push(TextBox.new(game,
|
||||
text[STATUS_LABELS[i][2]] or STATUS_LABELS[i][1], openBoard))
|
||||
end
|
||||
function openBoard()
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._ViridianSchoolBlackboardText2 or "Which heading do\nyou want to read?",
|
||||
showMenu))
|
||||
nil, { stay = { onShown = function()
|
||||
board = board or StatusBoard.new(game, pick, closeBoard)
|
||||
game.stack:push(board)
|
||||
end } }))
|
||||
end
|
||||
items = {}
|
||||
for i, row in ipairs(STATUS_LABELS) do
|
||||
local label, key = row[1], row[2]
|
||||
items[i] = { label = label, onSelect = function()
|
||||
game.stack:push(TextBox.new(game, text[key] or label, askHeading))
|
||||
end }
|
||||
end
|
||||
-- no onSelect: Menu's own pop closes the box, matching .exitBlackboard
|
||||
items[#items + 1] = { label = " QUIT" }
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._ViridianSchoolBlackboardText1
|
||||
or "The blackboard\ndescribes POKéMON\vSTATUS changes\vduring battles.",
|
||||
askHeading))
|
||||
openBoard))
|
||||
end
|
||||
|
||||
-- ViridianSchoolNotebook (engine/events/hidden_events/school_notebooks.asm):
|
||||
|
||||
@@ -15,6 +15,7 @@ local files = {
|
||||
"data.scripts.flavor.cerulean_trashed_house",
|
||||
"data.scripts.flavor.copycats_house_1f",
|
||||
"data.scripts.flavor.copycats_house_2f",
|
||||
"data.scripts.flavor.fuchsia_city",
|
||||
"data.scripts.flavor.game_corner",
|
||||
"data.scripts.flavor.lavender_cubone_house",
|
||||
"data.scripts.flavor.lavender_mart",
|
||||
|
||||
@@ -48,7 +48,8 @@ end
|
||||
if GameVersion.isYellow() then
|
||||
for _, file in ipairs({ "data.scripts.yellow_gifts",
|
||||
"data.scripts.yellow_jessie_james",
|
||||
"data.scripts.yellow_beach_house" }) do
|
||||
"data.scripts.yellow_beach_house",
|
||||
"data.scripts.yellow_viridian_old_man" }) do
|
||||
for mapId, mod in pairs(require(file)) do
|
||||
MapScripts.attachBase(mapId, mod)
|
||||
end
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
-- takes it ("I'll take this one, then!") and both balls disappear.
|
||||
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
|
||||
-- OaksLabRivalTakePokeBallScript.
|
||||
-- * Leftover ball (after the pick): Oak turns and reads the last-mon
|
||||
-- line instead of re-offering the starter (OaksLabLastMonScript, #601).
|
||||
-- * Rival (object 1): before starter -> "go ahead and choose" once Oak
|
||||
-- has walked you in, else "gramps isn't around" (#218); with
|
||||
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
|
||||
@@ -20,10 +22,10 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
rivalBallX, rivalBall)
|
||||
return {
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 1
|
||||
{ "jump_if_true", 20 }, -- 2
|
||||
{ "jump_if_true", 22 }, -- 2
|
||||
-- no picking until Oak has walked you in (OaksLabScript gating)
|
||||
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
|
||||
{ "jump_if_false", 20 }, -- 4
|
||||
{ "jump_if_false", 25 }, -- 4
|
||||
-- the Pokédex "new species" entry shows before the ask (predef
|
||||
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
|
||||
-- sets the owned bits so ShowPokedexData prints height/weight/text;
|
||||
@@ -31,30 +33,45 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
{ "push_screen", "DexEntryMenu",
|
||||
{ species = species, forceOwned = true } }, -- 5
|
||||
{ "ask", askText }, -- 6
|
||||
{ "jump_if_false", 21 }, -- 7
|
||||
{ "jump_if_false", "end" }, -- 7
|
||||
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
|
||||
-- inside give_pokemon). Show the received text first so the
|
||||
-- nickname prompt follows "you got X", matching Gen1.
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 8
|
||||
{ "give_pokemon", species, 5 }, -- 9
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 10
|
||||
{ "set_flag", choseFlag }, -- 11
|
||||
-- The received text carries sound_get_key_item (OaksLab.asm
|
||||
-- OaksLabReceivedMonText); the jingle plays as the box opens
|
||||
-- (same beat as the Yellow port's starter, #668).
|
||||
{ "play_sound", "Get_Key_Item" }, -- 8
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9
|
||||
{ "give_pokemon", species, 5 }, -- 10
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 11
|
||||
{ "set_flag", choseFlag }, -- 12
|
||||
-- POKé BALLs are not handed out here in the original -- Oak gives
|
||||
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
|
||||
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
|
||||
{ "hide_object", "OAKS_LAB", ownBall }, -- 12
|
||||
{ "hide_object", "OAKS_LAB", ownBall }, -- 13
|
||||
-- the rival walks to the countering ball (around the furniture)
|
||||
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13
|
||||
{ "face_object", 1, "up" }, -- 14
|
||||
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 15
|
||||
{ "hide_object", "OAKS_LAB", rivalBall }, -- 16
|
||||
{ "move_npc_to", 1, rivalBallX, 4 }, -- 14
|
||||
{ "face_object", 1, "up" }, -- 15
|
||||
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16
|
||||
{ "hide_object", "OAKS_LAB", rivalBall }, -- 17
|
||||
{ "play_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item)
|
||||
{ "show_text", "_OaksLabRivalReceivedMonText",
|
||||
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
|
||||
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
|
||||
or "BULBASAUR" } }, -- 17
|
||||
{ "jump", 21 }, -- 18
|
||||
{ "jump", 21 }, -- 19 (spacer)
|
||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 20
|
||||
or "BULBASAUR" } }, -- 19
|
||||
{ "jump", "end" }, -- 20
|
||||
{ "jump", "end" }, -- 21 (spacer)
|
||||
-- a leftover ball after the player's pick: Oak turns to face the
|
||||
-- player and reads the last-mon line instead of re-offering the
|
||||
-- starter (scripts/OaksLab.asm OaksLabSelectedPokeBallScript ->
|
||||
-- OaksLabLastMonScript; #601). The ROM's "#MON" ligature is spelled
|
||||
-- out as Pokémon here.
|
||||
{ "face_object", 5, "down" }, -- 22
|
||||
{ "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 23
|
||||
-- OaksLabLastMonScript ends at TextScriptEnd; the port used to fall
|
||||
-- through into the pre-pick line below (#601 remnant, reported on #600)
|
||||
{ "jump", "end" }, -- 24
|
||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 25
|
||||
}
|
||||
end
|
||||
|
||||
@@ -62,10 +79,22 @@ return {
|
||||
talk = {
|
||||
-- Oak: OaksLabOak1Text. Parcel delivery kicks SCRIPT_OAKSLAB_RIVAL_
|
||||
-- ARRIVES_AT_OAKS_REQUEST + OaksLabOakGivesPokedexScript (rival walk-
|
||||
-- in, full Pokédex speech, rival exit, Route 22 arm). Dex-rating
|
||||
-- (DisplayDexRating) is still skipped.
|
||||
-- in, full Pokédex speech, rival exit, Route 22 arm).
|
||||
TEXT_OAKSLAB_OAK1 = {
|
||||
{ "face_player" },
|
||||
-- OaksLabOak1Text leads with the dex-rating branch (#600): with
|
||||
-- EVENT_PALLET_AFTER_GETTING_POKEBALLS set (converted saves), or
|
||||
-- 2+ species owned once the Pokédex is in hand, Oak asks how it is
|
||||
-- coming and rates it (predef DisplayDexRating). Red keeps the
|
||||
-- GOT_POKEDEX gate that Yellow's copy of this text drops
|
||||
-- (data/scripts/oaks_lab_yellow.lua).
|
||||
{ "check_flag", "EVENT_PALLET_AFTER_GETTING_POKEBALLS" },
|
||||
{ "jump_if_true", "dex_rating" },
|
||||
{ "check_dex_owned", 2 },
|
||||
{ "jump_if_false", "no_rating" },
|
||||
{ "check_flag", "EVENT_GOT_POKEDEX" },
|
||||
{ "jump_if_true", "dex_rating" },
|
||||
{ "label", "no_rating" },
|
||||
{ "check_item", "POKE_BALL" },
|
||||
{ "jump_if_true", "come_see" },
|
||||
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
|
||||
@@ -151,6 +180,14 @@ return {
|
||||
|
||||
{ "label", "come_see" },
|
||||
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
-- .HowIsYourPokedexComingText ends on `prompt` and OaksLabOak1Text
|
||||
-- sets wDoNotWaitForButtonPressAfterDisplayingText, so the seen/owned
|
||||
-- tally follows with no button wait (engine/events/pokedex_rating.asm)
|
||||
{ "label", "dex_rating" },
|
||||
{ "show_text", "_OaksLabOak1HowIsYourPokedexComingText" },
|
||||
{ "dex_rating" },
|
||||
},
|
||||
|
||||
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
|
||||
@@ -245,7 +282,12 @@ return {
|
||||
and y >= 6 then
|
||||
local rival = ow:npcByIndex(1)
|
||||
if not rival then return false end
|
||||
-- OaksLabRivalChallengesPlayerScript swaps in the rival encounter
|
||||
-- fanfare for the taunt/challenge exchange, same as the Yellow port
|
||||
-- (oaks_lab_yellow.lua); it was silently dropped here (#596).
|
||||
local rows = {
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
{ "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1
|
||||
}
|
||||
-- the rival routes to a free cell beside the player
|
||||
@@ -283,8 +325,15 @@ return {
|
||||
table.insert(rows, { "jump_if_false", base + 6 })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
|
||||
-- walk out past the player. The fanfare was dropped here (#683) -- the
|
||||
-- parcel scene above already plays Music_MeetRival on both arrival and
|
||||
-- departure (lines 144-146), and this exit should match (#596).
|
||||
table.insert(rows, { "stop_music" })
|
||||
table.insert(rows, { "play_music", "Music_MeetRival" })
|
||||
table.insert(rows, { "move_npc_to", 1, 4, 11 })
|
||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||
ow.runner:run(rows, { npc = rival })
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -71,8 +71,12 @@ return {
|
||||
{ "show_text", "_OaksLabRivalLeaveItAllToMeText" },
|
||||
{ "set_flag", "EVENT_GOT_POKEDEX" },
|
||||
{ "set_flag", "EVENT_OAK_GOT_PARCEL" },
|
||||
-- OaksLabOakGivesPokedexScript: HideObject TOGGLE_LYING_OLD_MAN /
|
||||
-- ShowObject TOGGLE_OLD_MAN_2 -- Yellow's tutorial old man stands
|
||||
-- on the sleeper's cell (18,9); the Red/Blue walker OLD_MAN at
|
||||
-- (17,5) never appears in Yellow (#617)
|
||||
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" },
|
||||
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" },
|
||||
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN2" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
{ "move_npc_to", RIVAL, 4, 7 },
|
||||
@@ -283,10 +287,12 @@ return {
|
||||
table.insert(rows, { "label", "lost_lab" })
|
||||
table.insert(rows, { "set_field", "rivalStarter", 3 })
|
||||
table.insert(rows, { "label", "exit" })
|
||||
-- OaksLabRivalStartsExitScript: parting shot, walk out past the
|
||||
-- player, restore the lab theme
|
||||
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
|
||||
-- walk out past the player (#683).
|
||||
table.insert(rows, { "wait", 20 })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||
table.insert(rows, { "stop_music" })
|
||||
table.insert(rows, { "play_music", "Music_MeetRival" })
|
||||
table.insert(rows, { "move_npc_to", RIVAL, 4, 11 })
|
||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
-- (.PlayerNextToSafariZoneWorker1CoordsArray). Paying ¥500 hands over
|
||||
-- 30 SAFARI BALLs and starts the 502-step game
|
||||
-- (SafariZoneGateWouldYouLikeToJoinScript: wSafariSteps = 502,
|
||||
-- wNumSafariBalls = SAFARI_BALLS_RECEIVED). Declining walks you back
|
||||
-- so you can't slip past. Returning to the gate ends the game and the
|
||||
-- worker takes the leftover balls back.
|
||||
-- wNumSafariBalls = SAFARI_BALLS_RECEIVED), then auto-walks the player
|
||||
-- up through the north warp into the zone. Declining walks you back
|
||||
-- so you can't slip past. Returning to the gate ends the game, the
|
||||
-- worker takes the leftover balls back and the auto-walk drops you 3
|
||||
-- cells below the warp you came in by (#540).
|
||||
--
|
||||
-- Step/ball bookkeeping lives in src/world/OverworldController.lua
|
||||
-- (safariStep/safariGameOver, from
|
||||
@@ -19,7 +21,33 @@ local FEE = 500
|
||||
local BALLS = 30
|
||||
local STEPS = 502
|
||||
|
||||
local function startGame(game, t, done, balls, introText)
|
||||
-- SafariZoneGateSafariZoneWorker1WouldYouLikeToJoinText .success closes with
|
||||
-- `ld a, PAD_UP / ld c, 3 / SafariZoneEntranceAutoWalk`: paying walks the
|
||||
-- player up out of the gate and through the north warp, it is never left to
|
||||
-- the player. EVENT_IN_SAFARI_ZONE is already set when that walk runs, so
|
||||
-- the two gate steps taken before the warp fires are charged against
|
||||
-- wSafariSteps (home/overworld.asm:307-310) -- which is why the counter
|
||||
-- reads 500/500 on arrival even though the script wrote 502 (#540). The
|
||||
-- port's counter only runs on the nine interior maps (FieldDefaults
|
||||
-- safari.stepMaps, OverworldState:inSafariStepZone), so charge those two
|
||||
-- steps here instead.
|
||||
local function walkIntoZone(game, ow)
|
||||
local p = ow.player
|
||||
-- only from the two trigger cells in front of the worker, which are the
|
||||
-- columns the north warps sit on; a player who paid after TALKING to him
|
||||
-- from somewhere else walks in on their own, as they do today
|
||||
local w = p.cellY == 2 and ow.map:warpAtCell(p.cellX, 0) or nil
|
||||
if not w then return end
|
||||
ow:scriptMove(p, "up", 2, function()
|
||||
local st = game.save.safari
|
||||
if st then st.steps = st.steps - 2 end
|
||||
-- scripted steps skip onStepComplete (and with it CheckWarpsNoCollision),
|
||||
-- so take that warp explicitly once the walk lands on it
|
||||
ow:takeWarp(w.def)
|
||||
end)
|
||||
end
|
||||
|
||||
local function startGame(game, ow, t, done, balls, introText)
|
||||
game.save.safari = { balls = balls or BALLS, steps = STEPS }
|
||||
game.save.safariNags = nil
|
||||
local TextBox = require("src.render.TextBox")
|
||||
@@ -31,7 +59,10 @@ local function startGame(game, t, done, balls, introText)
|
||||
local pa = t._SafariZoneGateSafariZoneWorker1CallYouOnThePAText
|
||||
or "\fWe'll call you on\nthe PA when you\nrun out of time\nor SAFARI BALLs!"
|
||||
local luck = t._SafariZoneGateSafariZoneWorker1GoodLuckText or "Good Luck!"
|
||||
game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, done))
|
||||
game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, function()
|
||||
if done then done() end
|
||||
walkIntoZone(game, ow)
|
||||
end))
|
||||
end
|
||||
|
||||
-- Yellow's soft-lock fix (scripts/SafariZoneGate_2.asm): a player short of
|
||||
@@ -52,7 +83,7 @@ local function yellowLowCost(game, ow, t, done, back)
|
||||
or "\fOh, all right, pay\nme what you have.")
|
||||
.. "\f" .. (t._SafariZoneLowCostText2
|
||||
or "But, I can't give\nyou all 30 BALLs.")
|
||||
startGame(game, t, done, balls, intro)
|
||||
startGame(game, ow, t, done, balls, intro)
|
||||
return
|
||||
end
|
||||
local nag = game.save.safariNags or 0
|
||||
@@ -62,7 +93,7 @@ local function yellowLowCost(game, ow, t, done, back)
|
||||
(t._SafariZoneLowCostText8 or "Read my lips, NO!\nGet it?")
|
||||
.. (t._SafariZoneLowCostText3
|
||||
or "\fYou're persistent,\naren't you?\fOK, you can go in\nfor free, but\njust this once!")
|
||||
startGame(game, t, done, 1, intro)
|
||||
startGame(game, ow, t, done, 1, intro)
|
||||
return
|
||||
end
|
||||
local nags = {
|
||||
@@ -100,7 +131,7 @@ local function joinPrompt(game, ow, done)
|
||||
end
|
||||
else
|
||||
game.save.money = game.save.money - FEE
|
||||
startGame(game, t, done)
|
||||
startGame(game, ow, t, done)
|
||||
end
|
||||
end))
|
||||
end))
|
||||
@@ -135,27 +166,38 @@ M.SAFARI_ZONE_GATE = {
|
||||
-- no walks you back into the zone
|
||||
onEnter = function(game, ow)
|
||||
if not game.save.safari or ow.player.cellY > 1 then return end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local t = game.data.text
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._SafariZoneGateSafariZoneWorker1LeavingEarlyText or "Leaving early?",
|
||||
function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then
|
||||
-- back into the zone through the entrance warp
|
||||
local w = game.data.maps.SAFARI_ZONE_CENTER.warps[1]
|
||||
ow:startWarpTo("SAFARI_ZONE_CENTER", w.x, w.y, "up")
|
||||
return
|
||||
end
|
||||
game.save.safari = nil
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._SafariZoneGateSafariZoneWorker1ReturnSafariBallsText
|
||||
or "Please return any\nSAFARI BALLs you\nhave left.")
|
||||
.. "\f" .. (t._SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText
|
||||
or "Did you get a\ngood haul?\fCome again!")))
|
||||
end))
|
||||
end))
|
||||
-- QUEUED, never pushed: onEnter runs inside the arriving warp's
|
||||
-- Transition midpoint, and Transition:finish pops whatever is on top
|
||||
-- the same frame (Timing.WARP_FADE_IN is 0) -- so a box pushed here is
|
||||
-- swallowed, and on a build where it survived it drew over a screen
|
||||
-- still faded to black (#540). Same contract as M.HALL_OF_FAME in
|
||||
-- data/scripts/story.lua.
|
||||
--
|
||||
-- SafariZoneGateLeavingSafariScript .leaving_early: YES prints the
|
||||
-- return-balls text, faces the player down and runs
|
||||
-- SafariZoneEntranceAutoWalk with `PAD_DOWN, c = 3`, landing on the
|
||||
-- counter row 3 cells below the warp you came in by; NO prints
|
||||
-- "Good Luck!" and walks one step back up through that same warp.
|
||||
local rightSide = ow.player.cellX ~= 3
|
||||
local dest = game.data.maps.SAFARI_ZONE_CENTER.warps[rightSide and 2 or 1]
|
||||
ow:queueScript({
|
||||
{ "ask", "_SafariZoneGateSafariZoneWorker1LeavingEarlyText" },
|
||||
{ "jump_if_false", "stay" },
|
||||
-- the port never reaches SafariZoneGateLeavingSafariScript's own
|
||||
-- GOOD_HAUL_COME_AGAIN branch (safariGameOver warps straight to the
|
||||
-- counter), so the sign-off rides on this path
|
||||
{ "show_text", "_SafariZoneGateSafariZoneWorker1ReturnSafariBallsText" },
|
||||
{ "show_text", "_SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText" },
|
||||
-- no value: set_field assigns nil, which is how save.safari is cleared
|
||||
{ "set_field", "safari" },
|
||||
-- move_player runs through scriptMove, which skips onStepComplete, so
|
||||
-- walking back down past (x,2) cannot re-fire the join trigger
|
||||
{ "move_player", "down", 3 },
|
||||
{ "jump", "end" },
|
||||
{ "label", "stay" },
|
||||
{ "show_text", "_SafariZoneGateSafariZoneWorker1GoodLuckText" },
|
||||
{ "warp", "SAFARI_ZONE_CENTER", dest.x, dest.y, "up" },
|
||||
})
|
||||
end,
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
-- field.seafoam (SEAFOAM_ISLANDS_1F/B1F/B3F holes+holeDestination and
|
||||
-- B3F's pluggedByHolesOn) plus the generic
|
||||
-- OverworldState:boulderIntoHole in src/world/OverworldController.lua;
|
||||
-- no per-map onEnter hook is needed here.
|
||||
-- no per-map onEnter hook is needed for the boulders.
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -23,4 +23,46 @@ M.VERMILION_GYM = {
|
||||
end,
|
||||
}
|
||||
|
||||
-- The PLAYER falling down those same holes (#599). field.seafoam's holes
|
||||
-- carry only the BOULDER's object cell on the floor below (landsAt), not
|
||||
-- the player's landing, so the player's landing is spelled out here: it
|
||||
-- comes from data/maps/special_warps.asm DungeonWarpList/DungeonWarpData,
|
||||
-- which the importer does not extract. Same shape as MANSION_HOLES in
|
||||
-- data/scripts/story6.lua and VICTORY_ROAD_3F.onStep in
|
||||
-- data/scripts/story.lua; CAVERN $22 is a walkable tile, so the fall has
|
||||
-- to be an onStep, not a collision block.
|
||||
--
|
||||
-- Sources: scripts/SeafoamIslands1F.asm Seafoam1HolesCoords (17,6)/(24,6),
|
||||
-- B1F.asm Seafoam2HolesCoords (18,6)/(23,6), B2F.asm Seafoam3HolesCoords
|
||||
-- (19,6)/(22,6), B3F.asm Seafoam4HolesCoords (3,16)/(6,16). Each floor
|
||||
-- sets wDungeonWarpDestinationMap and calls IsPlayerOnDungeonWarp, and
|
||||
-- wCoordIndex picks that floor's DungeonWarpData row. Unconditional in
|
||||
-- the original: a plugged hole still drops the player. The B3F and B4F
|
||||
-- landings are water; setMap's CheckForceBikeOrSurf pass
|
||||
-- (OverworldState:checkForcedMovement) mounts SURF on arrival.
|
||||
local HOLE_FALLS = {
|
||||
SEAFOAM_ISLANDS_1F = { { 17, 6, "SEAFOAM_ISLANDS_B1F", 18, 7 },
|
||||
{ 24, 6, "SEAFOAM_ISLANDS_B1F", 23, 7 } },
|
||||
SEAFOAM_ISLANDS_B1F = { { 18, 6, "SEAFOAM_ISLANDS_B2F", 19, 7 },
|
||||
{ 23, 6, "SEAFOAM_ISLANDS_B2F", 22, 7 } },
|
||||
SEAFOAM_ISLANDS_B2F = { { 19, 6, "SEAFOAM_ISLANDS_B3F", 18, 7 },
|
||||
{ 22, 6, "SEAFOAM_ISLANDS_B3F", 19, 7 } },
|
||||
SEAFOAM_ISLANDS_B3F = { { 3, 16, "SEAFOAM_ISLANDS_B4F", 4, 14 },
|
||||
{ 6, 16, "SEAFOAM_ISLANDS_B4F", 5, 14 } },
|
||||
}
|
||||
|
||||
for mapId, holes in pairs(HOLE_FALLS) do
|
||||
M[mapId] = M[mapId] or {}
|
||||
M[mapId].onStep = function(game, ow, x, y)
|
||||
for _, h in ipairs(holes) do
|
||||
if x == h[1] and y == h[2] then
|
||||
require("src.core.Sound").play(game.data, "Faint_Fall")
|
||||
ow:startWarpTo(h[3], h[4], h[5], ow.player.facing)
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -188,7 +188,12 @@ M.BILLS_HOUSE = {
|
||||
end
|
||||
if ow.player.facing == "down" then
|
||||
-- the player is standing on his straight path: walk around
|
||||
-- (.PokemonWalkAroundPlayerMovement)
|
||||
-- (.PokemonWalkAroundPlayerMovement). BillsHouseScript2 runs
|
||||
-- BillsHousePikachuWatchPlayer first on this branch, so a
|
||||
-- Pikachu that is still following steps clear of Bill's detour
|
||||
-- and turns to watch the player (#455).
|
||||
require("src.world.PikachuFollower")
|
||||
.onBillWalksAroundPlayer(game, ow)
|
||||
ow:scriptMove(npc, "right", 1, function()
|
||||
ow:scriptMove(npc, "up", 2, function()
|
||||
ow:scriptMove(npc, "left", 1, function()
|
||||
@@ -600,12 +605,34 @@ local function snorlaxWake(mapId, objName, beatFlag, wokeUpText, calmedText)
|
||||
}
|
||||
end
|
||||
|
||||
-- A beaten Snorlax is gone for good: Route12/Route16DefaultScript run
|
||||
-- HideObject in the same breath as the battle that sets
|
||||
-- EVENT_BEAT_ROUTEnn_SNORLAX, so "event set, object still on the map" is a
|
||||
-- state the asm cannot produce. Here it can (a mod's world:toggleObject, a
|
||||
-- save edited or migrated from a build older than the flag), and it is a
|
||||
-- dead end: ItemEffects' adjacentSleepingSnorlax refuses to wake a Snorlax
|
||||
-- whose beat flag is set (ItemUsePokeFlute's CheckEvent, engine/items/
|
||||
-- item_effects.asm), so the sleeper sits in the road forever and Cycling
|
||||
-- Road is unreachable (#585). Reconcile the toggle from the flag on every
|
||||
-- entry -- the mirror of SaveData.lua's toggle -> flag backfill, and the
|
||||
-- same repair shape the Silph Co. floors use below.
|
||||
local function hideBeatenSnorlax(mapId, objName, beatFlag)
|
||||
return function(game, ow)
|
||||
if not game.save.flags[beatFlag] then return end
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.hide_object({ game = game, save = game.save, overworld = ow },
|
||||
mapId, objName)
|
||||
end
|
||||
end
|
||||
|
||||
-- snorlaxWake is looked up by ItemEffects.lua/BagMenu.lua (via
|
||||
-- data/scripts/init.lua's M.get) and run when the flute wakes Snorlax;
|
||||
-- objName/beatFlag let ItemEffects find the NPC and check whether it's
|
||||
-- already been beaten before allowing the wake.
|
||||
M.ROUTE_12 = {
|
||||
talk = { TEXT_ROUTE12_SNORLAX = { { "show_text", "_Route12SnorlaxText" } } },
|
||||
onEnter = hideBeatenSnorlax("ROUTE_12", "ROUTE12_SNORLAX",
|
||||
"EVENT_BEAT_ROUTE12_SNORLAX"),
|
||||
snorlaxWake = {
|
||||
objName = "ROUTE12_SNORLAX", beatFlag = "EVENT_BEAT_ROUTE12_SNORLAX",
|
||||
script = snorlaxWake("ROUTE_12", "ROUTE12_SNORLAX", "EVENT_BEAT_ROUTE12_SNORLAX",
|
||||
@@ -614,6 +641,8 @@ M.ROUTE_12 = {
|
||||
}
|
||||
M.ROUTE_16 = {
|
||||
talk = { TEXT_ROUTE16_SNORLAX = { { "show_text", "_Route16Text7" } } },
|
||||
onEnter = hideBeatenSnorlax("ROUTE_16", "ROUTE16_SNORLAX",
|
||||
"EVENT_BEAT_ROUTE16_SNORLAX"),
|
||||
snorlaxWake = {
|
||||
objName = "ROUTE16_SNORLAX", beatFlag = "EVENT_BEAT_ROUTE16_SNORLAX",
|
||||
script = snorlaxWake("ROUTE_16", "ROUTE16_SNORLAX", "EVENT_BEAT_ROUTE16_SNORLAX",
|
||||
@@ -645,16 +674,18 @@ M.SAFARI_ZONE_SECRET_HOUSE = {
|
||||
M.WARDENS_HOUSE = {
|
||||
talk = {
|
||||
TEXT_WARDENSHOUSE_WARDEN = {
|
||||
-- Labelled rather than hand-numbered: the branches here have been
|
||||
-- re-pointed twice now (#535, #645), and every insert used to mean
|
||||
-- renumbering three jumps that had no way of announcing they were stale.
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_HM04" }, -- 2
|
||||
-- #535: previously jumped to the same silent-end target as the
|
||||
-- give-then-thank fallthrough (row 13), so the warden said nothing
|
||||
-- on every visit after the trade. pokered's .got_item branch
|
||||
-- (scripts/WardensHouse.asm) instead prints HM04ExplanationText,
|
||||
-- so route here to the new row 17 that does the same.
|
||||
{ "jump_if_true", 17 }, -- 3
|
||||
-- give-then-thank fallthrough, so the warden said nothing on every
|
||||
-- visit after the trade. pokered's .got_item branch
|
||||
-- (scripts/WardensHouse.asm) instead prints HM04ExplanationText.
|
||||
{ "jump_if_true", "got_hm04" }, -- 3
|
||||
{ "check_item", "GOLD_TEETH" }, -- 4
|
||||
{ "jump_if_false", 15 }, -- 5
|
||||
{ "jump_if_false", "no_teeth" }, -- 5
|
||||
{ "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6
|
||||
{ "take_item", "GOLD_TEETH", 1 }, -- 7
|
||||
{ "set_flag", "EVENT_GAVE_GOLD_TEETH" }, -- 8
|
||||
@@ -663,15 +694,27 @@ M.WARDENS_HOUSE = {
|
||||
{ "give_item", "HM_STRENGTH", 1, false }, -- 10
|
||||
{ "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11
|
||||
{ "set_flag", "EVENT_GOT_HM04" }, -- 12
|
||||
{ "jump", 18 }, -- 13 (already got it this convo; jp .done)
|
||||
{ "jump", 18 }, -- 14 (unused)
|
||||
{ "show_text", "_WardensHouseWardenGibberish1Text" }, -- 15
|
||||
{ "jump", 18 }, -- 16 (#535: skip the new explanation row below)
|
||||
{ "jump", "end" }, -- 13 (jp .done)
|
||||
|
||||
-- #645: WardensHouseWardenText prints Gibberish1, then YesNoChoice,
|
||||
-- and the warden answers the same gibberish either way -- Gibberish2
|
||||
-- on yes, Gibberish3 on no (scripts/WardensHouse.asm). The port
|
||||
-- printed the question and walked off before the answer.
|
||||
{ "label", "no_teeth" }, -- 14
|
||||
{ "ask", "_WardensHouseWardenGibberish1Text" }, -- 15
|
||||
{ "jump_if_true", "gibberish_yes" }, -- 16
|
||||
{ "show_text", "_WardensHouseWardenGibberish3Text" }, -- 17
|
||||
{ "jump", "end" }, -- 18
|
||||
{ "label", "gibberish_yes" }, -- 19
|
||||
{ "show_text", "_WardensHouseWardenGibberish2Text" }, -- 20
|
||||
{ "jump", "end" }, -- 21
|
||||
|
||||
-- #535: pokered .got_item branch (scripts/WardensHouse.asm) --
|
||||
-- printed on every subsequent talk once EVENT_GOT_HM04 is set.
|
||||
-- Text is _WardensHouseWardenHM04ExplanationText (text/WardensHouse.asm):
|
||||
-- HM04 teaches Strength, and hints at the Safari Zone secret house.
|
||||
{ "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 17
|
||||
{ "label", "got_hm04" }, -- 22
|
||||
{ "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 23
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -723,6 +766,28 @@ local function silphRocketsLeave(game, ow, onlyMap)
|
||||
end
|
||||
end
|
||||
|
||||
-- SilphCo11FGiovanniAfterBattleScript (scripts/SilphCo11F.asm) is the whole
|
||||
-- aftermath: DisplayTextID TEXT_SILPHCO11F_GIOVANNI_YOU_RUINED_OUR_PLANS,
|
||||
-- GBFadeOutToBlack, SilphCo11FTeamRocketLeavesScript, Delay3,
|
||||
-- GBFadeInFromBlack, then SetEvent. The port had only the hide pass, so the
|
||||
-- speech never played and every rocket blinked out in front of the player
|
||||
-- (#722). Same hide list as silphRocketsLeave, spelled as script rows so the
|
||||
-- fade can hold over it.
|
||||
local function silphAftermathRows()
|
||||
local rows = {
|
||||
{ "show_text", "_SilphCo11FGiovanniYouRuinedOurPlansText" },
|
||||
{ "fade", "out" },
|
||||
}
|
||||
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
|
||||
for _, name in ipairs(floor[2]) do
|
||||
rows[#rows + 1] = { "hide_object", floor[1], name }
|
||||
end
|
||||
end
|
||||
rows[#rows + 1] = { "wait", 3 } -- Delay3
|
||||
rows[#rows + 1] = { "fade", "in" }
|
||||
return rows
|
||||
end
|
||||
|
||||
M.SILPH_CO_11F = {
|
||||
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
|
||||
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
|
||||
@@ -749,11 +814,15 @@ M.SILPH_CO_11F = {
|
||||
ow:scriptMove(gio, "down", 3, function()
|
||||
gio:facePlayer(ow.player)
|
||||
ow:engageTrainer(gio, function()
|
||||
-- SilphCo11FTeamRocketLeavesScript: every Silph rocket leaves
|
||||
-- after the loss (the street rockets are handled by
|
||||
-- M.SAFFRON_CITY.onEnter in story4.lua).
|
||||
-- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!" speech,
|
||||
-- then SilphCo11FTeamRocketLeavesScript behind a fade so every Silph
|
||||
-- rocket leaves off-screen (the street rockets are handled by
|
||||
-- M.SAFFRON_CITY.onEnter in story4.lua). Queued, not run here: the
|
||||
-- battle's own callbacks are still unwinding, so queueScript starts
|
||||
-- it on the first idle overworld frame -- after the end-battle
|
||||
-- "Arrgh!!" box victories.lua OPP_GIOVANNI#2 pushes (#722).
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
silphRocketsLeave(game, ow)
|
||||
ow:queueScript(silphAftermathRows())
|
||||
end
|
||||
end)
|
||||
end)
|
||||
@@ -899,6 +968,7 @@ M.VICTORY_ROAD_3F = {
|
||||
-- fall is onStep, not a collision block.
|
||||
onStep = function(game, ow, x, y)
|
||||
if x == 23 and y == 15 then
|
||||
require("src.core.Sound").play(game.data, "Faint_Fall")
|
||||
ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing)
|
||||
return true
|
||||
end
|
||||
@@ -937,40 +1007,62 @@ M.VICTORY_ROAD_3F = {
|
||||
local championsRoomRivalScript = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
|
||||
{ "jump_if_true", 25 }, -- 3 past end
|
||||
-- "end" rather than a row number past the tail: this script grew by a row
|
||||
-- when the follow-Oak walk landed (#704), which silently turned the old
|
||||
-- numeric 26 into a jump ONTO the closing HALL_OF_FAME warp instead of past
|
||||
-- it, so a returning champion warped straight into the induction.
|
||||
{ "jump_if_true", "end" }, -- 3
|
||||
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
|
||||
{ "jump_if_false", 25 }, -- 6 past end
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
|
||||
-- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after
|
||||
-- the intro text, before the battle itself (#706); pushBattle's wipe-time
|
||||
-- playBattle("final") then no-ops on the same song, so the theme stays
|
||||
-- continuous into the fight
|
||||
{ "play_music", "Music_FinalBattle" }, -- 5
|
||||
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 6
|
||||
-- losing halts here; the numeric target this replaced pointed at the
|
||||
-- closing warp, which inducted a player who had just lost the fight (#704)
|
||||
{ "jump_if_false", "end" }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 8
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 9
|
||||
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
|
||||
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
|
||||
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
|
||||
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
|
||||
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
|
||||
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 10
|
||||
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
|
||||
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
|
||||
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in
|
||||
{ "play_music", "Music_Cities1", { keep = true } }, -- 10
|
||||
{ "show_text", "_ChampionsRoomOakText" }, -- 11
|
||||
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 12
|
||||
{ "move_npc", 2, "up", 5 }, -- 13 OakEntranceAfterVictoryMovement
|
||||
{ "play_music", "Music_Cities1", { keep = true } }, -- 11
|
||||
{ "show_text", "_ChampionsRoomOakText" }, -- 12
|
||||
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 13
|
||||
{ "move_npc", 2, "up", 5 }, -- 14 OakEntranceAfterVictoryMovement
|
||||
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
|
||||
{ "face_object", 1, "left" }, -- 14
|
||||
{ "face_object", 2, "down" }, -- 15
|
||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 16
|
||||
{ "face_object", 1, "left" }, -- 15
|
||||
{ "face_object", 2, "down" }, -- 16
|
||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 17
|
||||
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
||||
{ "face_object", 2, "right" }, -- 17
|
||||
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 18
|
||||
{ "face_object", 2, "right" }, -- 18
|
||||
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 19
|
||||
-- OakComeWithMeScript: Oak faces down again, then exits up
|
||||
{ "face_object", 2, "down" }, -- 19
|
||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 20
|
||||
{ "move_npc", 2, "up", 2 }, -- 21 OakExitChampionsRoomMovement
|
||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 22
|
||||
{ "face_object", 2, "down" }, -- 20
|
||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 21
|
||||
{ "move_npc", 2, "up", 2 }, -- 22 OakExitChampionsRoomMovement
|
||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 23
|
||||
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement
|
||||
-- (PAD_UP 4, PAD_LEFT 1): the player walks out after Oak instead of the
|
||||
-- screen just fading on the spot (#704). The entrance walk leaves the
|
||||
-- player at (4,3) and both north-wall warps sit on row 0, so the original
|
||||
-- only ever spends three of those simulated steps -- CheckWarpsNoCollision
|
||||
-- takes the HALL_OF_FAME warp the moment the walk lands on (4,0) and the
|
||||
-- trailing UP/LEFT are dropped. Scripted steps ignore collision here just
|
||||
-- as they do in the original (CollisionCheckOnLand skips its checks while
|
||||
-- wSimulatedJoypadStatesIndex is non-zero), so stepping through the
|
||||
-- rival's cell at (4,2) is the ported behavior, not a clip.
|
||||
{ "move_player", "up", 3 }, -- 24
|
||||
-- hand the induction off to the HALL_OF_FAME room (consumed by its
|
||||
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
|
||||
{ "set_field", "pendingHallOfFame", true }, -- 23
|
||||
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 24
|
||||
{ "set_field", "pendingHallOfFame", true }, -- 25
|
||||
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 26
|
||||
}
|
||||
|
||||
M.CHAMPIONS_ROOM = {
|
||||
|
||||
@@ -549,21 +549,57 @@ M.GAME_CORNER = {
|
||||
-- handler is bound to both text ids just below (#552).
|
||||
TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
local t = game.data.text
|
||||
local function line(suffix, fallback)
|
||||
return t["_GameCornerClerk1" .. suffix]
|
||||
or t["_GameCornerClerk" .. suffix]
|
||||
or fallback
|
||||
end
|
||||
-- GameCornerDrawCoinBox (scripts/GameCorner.asm; pokeyellow's copy is
|
||||
-- identical): TextBoxBorder at hlcoord 11,0 with b=5 c=7, a 9x7-tile
|
||||
-- window in the top right holding MONEY at (12,2) over the amount on
|
||||
-- row 3 and COIN at (12,4) over the count on row 5. Both
|
||||
-- PrintBCDNumber calls pass LEADING_ZEROES, whose bit 7 SUPPRESSES
|
||||
-- leading zeroes (home/print_bcd.asm), and neither passes LEFT_ALIGN,
|
||||
-- so both numbers read plain and right-aligned against the inner edge
|
||||
-- at column 18. The asm draws the box before the offer and redraws it
|
||||
-- after the purchase, so it stands for the whole exchange: a draw-only
|
||||
-- state under the dialogue gets that lifetime, since StateStack draws
|
||||
-- every state above the last opaque one and updates only the top
|
||||
-- (src/core/StateStack.lua), and reading save each frame is the
|
||||
-- redraw (#624).
|
||||
local coinBox = { draw = function()
|
||||
Font.drawBox(11, 0, 9, 7)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("MONEY"), 96, 16)
|
||||
local money = ("¥%d"):format(game.save.money or 0)
|
||||
Font.draw(money, 152 - Font.width(money), 24)
|
||||
Font.draw(Strings("COIN"), 96, 32)
|
||||
local coins = ("%d"):format(game.save.coins or 0)
|
||||
Font.draw(coins, 152 - Font.width(coins), 40)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end }
|
||||
game.stack:push(coinBox)
|
||||
-- Every branch below finishes here. A TextBox pops itself before its
|
||||
-- onDone runs, so the coin box is top of the stack again by then and
|
||||
-- this pop takes it down, never someone else's state.
|
||||
local function finish()
|
||||
game.stack:pop()
|
||||
done()
|
||||
end
|
||||
-- YesNoChoice is called with the offer still printed, so the prompt
|
||||
-- has to ride the open text box (opts.choice) instead of being pushed
|
||||
-- after it closes, which is what made the question vanish (#624).
|
||||
game.stack:push(TextBox.new(game,
|
||||
line("DoYouNeedSomeGameCoinsText",
|
||||
"Do you need some\ngame coins?\f¥1000 for 50."), function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
"Do you need some\ngame coins?\f¥1000 for 50."),
|
||||
nil, { choice = function(yes)
|
||||
if not yes then
|
||||
game.stack:push(TextBox.new(game,
|
||||
line("PleaseComePlaySometimeText",
|
||||
"No? Please come\nplay sometime!"), done))
|
||||
"No? Please come\nplay sometime!"), finish))
|
||||
return
|
||||
end
|
||||
-- scripts/GameCorner.asm GameCornerClerk1Text: coins need
|
||||
@@ -571,29 +607,30 @@ M.GAME_CORNER = {
|
||||
if not game.save.inventory.COIN_CASE then
|
||||
game.stack:push(TextBox.new(game,
|
||||
line("DontHaveCoinCaseText",
|
||||
"You don't have a\nCOIN CASE!"), done))
|
||||
"You don't have a\nCOIN CASE!"), finish))
|
||||
return
|
||||
end
|
||||
if (game.save.coins or 0) >= 9990 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
line("CoinCaseIsFullText",
|
||||
"Oops! Your COIN\nCASE is full."), done))
|
||||
"Oops! Your COIN\nCASE is full."), finish))
|
||||
return
|
||||
end
|
||||
if game.save.money < 1000 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
line("CantAffordTheCoinsText",
|
||||
"You can't afford\nthe coins!"), done))
|
||||
"You can't afford\nthe coins!"), finish))
|
||||
return
|
||||
end
|
||||
game.save.money = game.save.money - 1000
|
||||
game.save.coins = math.min(9999, (game.save.coins or 0) + 50)
|
||||
-- the thanks text is the plain _GameCornerClerk1ThanksHereAre50-
|
||||
-- CoinsText; the new count belongs in the coin box the asm
|
||||
-- redraws here, not appended to the line (#624)
|
||||
game.stack:push(TextBox.new(game,
|
||||
line("ThanksHereAre50CoinsText",
|
||||
"Thanks! Here are\nyour 50 coins!")
|
||||
.. ("\fCOINS: %d"):format(game.save.coins), done))
|
||||
end))
|
||||
end))
|
||||
"Thanks! Here are\nyour 50 coins!"), finish))
|
||||
end }))
|
||||
end,
|
||||
},
|
||||
}
|
||||
@@ -606,99 +643,188 @@ M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK =
|
||||
M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK1
|
||||
|
||||
-- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm).
|
||||
-- The six mon prizes differ between Red and Blue; the three TM prizes are
|
||||
-- identical, so they are shared and appended to each version's mon list.
|
||||
-- Each counter owns ONE window of three prizes, not the whole catalogue:
|
||||
-- GetPrizeMenuId (engine/events/prize_menu.asm) subtracts
|
||||
-- TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 from hTextID and indexes
|
||||
-- PrizeDifferentMenuPtrs with the result, so vendor 1 sells
|
||||
-- PrizeMenuMon1Entries, vendor 2 PrizeMenuMon2Entries and vendor 3
|
||||
-- PrizeMenuTMsEntries (#623). The mon windows and their levels differ per
|
||||
-- version; the TM window is identical in all three, so it is shared.
|
||||
local PRIZE_TMS = {
|
||||
{ kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 },
|
||||
{ kind = "item", item = "TM_HYPER_BEAM", cost = 5500 },
|
||||
{ kind = "item", item = "TM_SUBSTITUTE", cost = 7700 },
|
||||
}
|
||||
local RED_PRIZES = {
|
||||
{ kind = "mon", species = "ABRA", level = 9, cost = 180 },
|
||||
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
|
||||
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
|
||||
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
|
||||
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
|
||||
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
|
||||
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
|
||||
local RED_PRIZE_WINDOWS = {
|
||||
{
|
||||
{ kind = "mon", species = "ABRA", level = 9, cost = 180 },
|
||||
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
|
||||
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
|
||||
},
|
||||
{
|
||||
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
|
||||
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
|
||||
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
|
||||
},
|
||||
PRIZE_TMS,
|
||||
}
|
||||
local BLUE_PRIZES = {
|
||||
{ kind = "mon", species = "ABRA", level = 6, cost = 120 },
|
||||
{ kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 },
|
||||
{ kind = "mon", species = "NIDORINO", level = 17, cost = 1200 },
|
||||
{ kind = "mon", species = "PINSIR", level = 20, cost = 2500 },
|
||||
{ kind = "mon", species = "DRATINI", level = 24, cost = 4600 },
|
||||
{ kind = "mon", species = "PORYGON", level = 18, cost = 6500 },
|
||||
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
|
||||
local BLUE_PRIZE_WINDOWS = {
|
||||
{
|
||||
{ kind = "mon", species = "ABRA", level = 6, cost = 120 },
|
||||
{ kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 },
|
||||
{ kind = "mon", species = "NIDORINO", level = 17, cost = 1200 },
|
||||
},
|
||||
{
|
||||
{ kind = "mon", species = "PINSIR", level = 20, cost = 2500 },
|
||||
{ kind = "mon", species = "DRATINI", level = 24, cost = 4600 },
|
||||
{ kind = "mon", species = "PORYGON", level = 18, cost = 6500 },
|
||||
},
|
||||
PRIZE_TMS,
|
||||
}
|
||||
-- Yellow keeps the three windows but restocks both mon counters
|
||||
-- (pokeyellow/data/events/prizes.asm, prize_mon_levels.asm)
|
||||
local YELLOW_PRIZE_WINDOWS = {
|
||||
{
|
||||
{ kind = "mon", species = "ABRA", level = 15, cost = 230 },
|
||||
{ kind = "mon", species = "VULPIX", level = 18, cost = 1000 },
|
||||
{ kind = "mon", species = "WIGGLYTUFF", level = 22, cost = 2680 },
|
||||
},
|
||||
{
|
||||
{ kind = "mon", species = "SCYTHER", level = 30, cost = 6500 },
|
||||
{ kind = "mon", species = "PINSIR", level = 30, cost = 6500 },
|
||||
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
|
||||
},
|
||||
PRIZE_TMS,
|
||||
}
|
||||
|
||||
local function activePrizes()
|
||||
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES
|
||||
local function prizeWindow(n)
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local windows = RED_PRIZE_WINDOWS
|
||||
if GameVersion.isBlue() then
|
||||
windows = BLUE_PRIZE_WINDOWS
|
||||
elseif GameVersion.isYellow() then
|
||||
windows = YELLOW_PRIZE_WINDOWS
|
||||
end
|
||||
return windows[n]
|
||||
end
|
||||
|
||||
-- Prize counters (engine/menus/prize_menu.asm CeladonPrizeMenu; the prize
|
||||
-- Prize counters (engine/events/prize_menu.asm CeladonPrizeMenu; the prize
|
||||
-- list itself is data/events/prizes.asm, prize_mon_levels.asm). Gen1 gates
|
||||
-- the prize window on the COIN CASE: it does IsItemInBag COIN_CASE first, and
|
||||
-- with no case prints RequireCoinCaseText and returns without ever opening a
|
||||
-- window; only with the case does it print ExchangeCoinsForPrizesText and then
|
||||
-- show the prizes. #194: the port used to open the window unconditionally and
|
||||
-- skip both text boxes.
|
||||
local function prizeCounter(game, ow, npc, done)
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Commands = require("src.script.Commands")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
-- IsItemInBag COIN_CASE: without the case, deny and open no window
|
||||
-- (COIN_CASE is a numeric count in save.inventory, nil when absent).
|
||||
if not game.save.inventory.COIN_CASE then
|
||||
-- skip both text boxes. wMaxMenuItem is 3, i.e. this window's three prizes
|
||||
-- plus the NO THANKS row, and HandlePrizeChoice confirms the pick with
|
||||
-- SoYouWantPrizeText + YesNoChoice before any coins move; every branch then
|
||||
-- rets out of CeladonPrizeMenu, so one transaction ends the conversation and
|
||||
-- buying again means talking to the counter again (#623).
|
||||
local function prizeCounter(window)
|
||||
return function(game, ow, npc, done)
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Commands = require("src.script.Commands")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
-- IsItemInBag COIN_CASE: without the case, deny and open no window
|
||||
-- (COIN_CASE is a numeric count in save.inventory, nil when absent).
|
||||
if not game.save.inventory.COIN_CASE then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
|
||||
return
|
||||
end
|
||||
-- ExchangeCoinsForPrizesText plays before the prize window opens.
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
|
||||
return
|
||||
end
|
||||
-- ExchangeCoinsForPrizesText plays before the prize window opens.
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
|
||||
function()
|
||||
local items = {}
|
||||
for _, p in ipairs(activePrizes()) do
|
||||
local label
|
||||
if p.kind == "mon" then
|
||||
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
|
||||
else
|
||||
label = game.data.items[p.item].name
|
||||
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
|
||||
function()
|
||||
local items = {}
|
||||
for _, p in ipairs(prizeWindow(window)) do
|
||||
local label
|
||||
if p.kind == "mon" then
|
||||
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
|
||||
else
|
||||
label = game.data.items[p.item].name
|
||||
end
|
||||
table.insert(items,
|
||||
{ label = label, right = tostring(p.cost), value = p })
|
||||
end
|
||||
table.insert(items,
|
||||
{ label = label, right = tostring(p.cost), value = p })
|
||||
end
|
||||
local list
|
||||
list = ListMenu.new(game, "PRIZES (COINS)", items, {
|
||||
footer = ("COINS %d"):format(game.save.coins or 0),
|
||||
onChoose = function(item)
|
||||
local p = item.value
|
||||
-- NoThanksText (data/events/prizes.asm) sits under the three prizes
|
||||
table.insert(items, { label = "NO THANKS" })
|
||||
local list
|
||||
-- close the window first: every ending in HandlePrizeChoice leaves
|
||||
-- the menu for good, and the closing line belongs over the map
|
||||
local function finish(msg)
|
||||
list:close()
|
||||
game.stack:push(TextBox.new(game, msg, done))
|
||||
end
|
||||
local function buy(p)
|
||||
if (game.save.coins or 0) < p.cost then
|
||||
list.footer = "Not enough coins!"
|
||||
finish(t._SorryNeedMoreCoinsText or "Sorry, you need\nmore coins.")
|
||||
return
|
||||
end
|
||||
-- HasEnoughCoins passed, so hand the prize over first and only
|
||||
-- subtract once it landed: the asm rets before .subtractCoins when
|
||||
-- the bag is full, or when both the party and every box are full
|
||||
local roomless = t._OopsYouDontHaveEnoughRoomText
|
||||
or "Oops! You don't\nhave enough room."
|
||||
if p.kind == "mon" then
|
||||
-- no runner here, so give_pokemon reports through ctx.lastCheck
|
||||
-- and skips the AskName prompt (Commands.give_pokemon)
|
||||
local ctx = { save = game.save, game = game }
|
||||
Commands.give_pokemon(ctx, p.species, p.level)
|
||||
if not ctx.lastCheck then
|
||||
finish(roomless)
|
||||
return
|
||||
end
|
||||
elseif not require("src.inventory.Bag").add(
|
||||
game.save, p.item, 1, game.data) then
|
||||
finish(roomless)
|
||||
return
|
||||
end
|
||||
game.save.coins = game.save.coins - p.cost
|
||||
if p.kind == "mon" then
|
||||
Commands.give_pokemon({ save = game.save, game = game },
|
||||
p.species, p.level)
|
||||
else
|
||||
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
|
||||
end
|
||||
list.footer = ("Got it! COINS %d"):format(game.save.coins)
|
||||
end,
|
||||
onCancel = done,
|
||||
})
|
||||
game.stack:push(list)
|
||||
end))
|
||||
-- no thank-you line: HereYouGoText is unreferenced in the asm,
|
||||
-- which just redraws the coin box (PrintPrizePrice) and returns
|
||||
list:close()
|
||||
done()
|
||||
end
|
||||
list = ListMenu.new(game, "PRIZES (COINS)", items, {
|
||||
footer = ("COINS %d"):format(game.save.coins or 0),
|
||||
onChoose = function(item)
|
||||
local p = item.value
|
||||
if not p then -- NO THANKS is the B exit (cp 3 -> .noChoice)
|
||||
list:close()
|
||||
done()
|
||||
return
|
||||
end
|
||||
local name = (p.kind == "mon")
|
||||
and game.data.pokemon[p.species].name
|
||||
or game.data.items[p.item].name
|
||||
-- SoYouWantPrizeText names the prize out of wNameBuffer, which
|
||||
-- is not one of TextBox's RAM tokens, so fill it in here
|
||||
local ask = (t._SoYouWantPrizeText
|
||||
or "So, you want\n{RAM:wNameBuffer}?")
|
||||
:gsub("{RAM:wNameBuffer}", name)
|
||||
game.stack:push(TextBox.new(game, ask, nil, {
|
||||
choice = function(yes)
|
||||
if not yes then
|
||||
finish(t._OhFineThenText or "Oh, fine then.")
|
||||
return
|
||||
end
|
||||
buy(p)
|
||||
end,
|
||||
}))
|
||||
end,
|
||||
onCancel = done,
|
||||
})
|
||||
game.stack:push(list)
|
||||
end))
|
||||
end
|
||||
end
|
||||
|
||||
M.GAME_CORNER_PRIZE_ROOM = {
|
||||
talk = { -- the three prize counters are bg events
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 = prizeCounter,
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_2 = prizeCounter,
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_3 = prizeCounter,
|
||||
talk = { -- the three prize counters are bg events, one window each
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 = prizeCounter(1),
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_2 = prizeCounter(2),
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_3 = prizeCounter(3),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -176,6 +176,20 @@ M.ROUTE_18_GATE_2F = {
|
||||
{ "face_player" },
|
||||
{ "trade", 6, "EVENT_TRADED_SLOWBRO_FOR_LICKITUNG" }, -- MARC
|
||||
},
|
||||
-- Yellow replaces the youngster with a cook trading SPIKE
|
||||
-- (TANGELA -> PARASECT): pokeyellow/scripts/Route18Gate2F.asm
|
||||
-- Route18Gate2FCookText runs TRADE_FOR_SPIKE, index 6 in the Yellow
|
||||
-- TradeMons table that Data:applyVersionedFieldData swaps in. Red
|
||||
-- maps have no COOK object here and Yellow maps have no YOUNGSTER,
|
||||
-- so each version only ever fires its own row (#651). Both rows
|
||||
-- share the Red-flavoured done flag on purpose: a .sav tracks
|
||||
-- "trade slot 6 completed" in one wCompletedInGameTradeFlags bit
|
||||
-- either version reads, and the save codec maps that bit to this
|
||||
-- flag name (src/save_convert/GenSave.lua EXTRA_FLAG_BITS).
|
||||
TEXT_ROUTE18GATE2F_COOK = {
|
||||
{ "face_player" },
|
||||
{ "trade", 6, "EVENT_TRADED_SLOWBRO_FOR_LICKITUNG" }, -- SPIKE (Yellow)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ local MANSION_HOLES = {
|
||||
M.POKEMON_MANSION_3F.onStep = function(game, ow, x, y)
|
||||
for _, h in ipairs(MANSION_HOLES) do
|
||||
if x == h[1] and y == h[2] then
|
||||
require("src.core.Sound").play(game.data, "Faint_Fall")
|
||||
ow:startWarpTo(h[3], h[4], h[5], ow.player.facing)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -108,8 +108,16 @@ return {
|
||||
"_ViridianGymGiovanniTM27ExplanationText",
|
||||
} },
|
||||
|
||||
-- Silph Co. Giovanni: unlocks the president's Master Ball gift
|
||||
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
-- Silph Co. Giovanni: unlocks the president's Master Ball gift.
|
||||
-- SilphCo11FGiovanniStartBattleScript (scripts/SilphCo11F.asm) hands the
|
||||
-- battle SilphCo10FGiovanniILostAgainText through SaveEndBattleTextPointers,
|
||||
-- but he has no def_trainers header on 11F, so engageTrainer finds no
|
||||
-- header.won to give it -- this chain is the port's stand-in for that loss
|
||||
-- line (#722). The "Blast it all!" speech, the fade and the rockets
|
||||
-- leaving are SilphCo11FGiovanniAfterBattleScript, ported in M.SILPH_CO_11F
|
||||
-- (data/scripts/story.lua).
|
||||
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI",
|
||||
dialogue = { "_SilphCo10FGiovanniILostAgainText" } },
|
||||
|
||||
-- Fighting Dojo Karate Master (scripts/FightingDojo.asm
|
||||
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
-- The Viridian City old-man catch tutorial, Yellow's way
|
||||
-- (pokeyellow scripts/ViridianCity.asm, scripts/ViridianCity_2.asm,
|
||||
-- scripts/OaksLab.asm). Registered on top of the shared tables by
|
||||
-- data/scripts/init.lua on a Yellow boot.
|
||||
--
|
||||
-- Yellow has TWO gambler objects where Red/Blue have one:
|
||||
-- * VIRIDIANCITY_OLD_MAN at (17,5) -- a Red/Blue leftover; its
|
||||
-- toggle stays OFF forever in Yellow, no script ever shows it.
|
||||
-- * VIRIDIANCITY_OLD_MAN2 at (18,9) -- replaces the sleeper the
|
||||
-- moment the Pokédex is given (OaksLabOakGivesPokedexScript:
|
||||
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN_2).
|
||||
--
|
||||
-- This is the tutorial old man. The Red/Blue "Are you in a hurry?"
|
||||
-- yes/no script must NOT run against him: Yellow's
|
||||
-- _ViridianCityOldManHadMyCoffeeNowText is the apology speech ("I've had
|
||||
-- my coffee now ... I'll show you how to catch POKéMON as my apology"),
|
||||
-- and the shared story.lua TEXT_VIRIDIANCITY_OLD_MAN rows hang an
|
||||
-- invented yes/no over it -- YES printed the TimeIsMoney alias
|
||||
-- (_ViridianCityOldManLosingMyTouchText) and NO ran the demo, every
|
||||
-- talk, forever (#617).
|
||||
--
|
||||
-- The real flow (ViridianCityCheckWaitingOldMan + ViridianCityOldMan2Text
|
||||
-- + ViridianCityOldManInitialCatchTrainingScript + ...EndInitial... +
|
||||
-- ViridianCityPostInitialCatchTraining): stepping into (19,9) -- the gap
|
||||
-- east of the sleeper's cell -- faces the old man right and the player
|
||||
-- left, prints the apology, and without any choice runs the demo battle
|
||||
-- (BATTLE_TYPE_OLD_MAN, RATTATA lvl 5), which he FAILS -- the ball shakes
|
||||
-- three times and breaks open. After it, the same text pointer
|
||||
-- now prints _ViridianCityOldManLosingMyTouchText ("That didn't work!
|
||||
-- I must be losing my touch."), the old man walks off (down 6 with the
|
||||
-- player on (19,9), right 1 otherwise, Pikachu nudged out of the way
|
||||
-- first) and TOGGLE_OLD_MAN_2 hides. A direct talk does the same.
|
||||
|
||||
local M = {}
|
||||
|
||||
local OLD_MAN2 = "VIRIDIANCITY_OLD_MAN2"
|
||||
|
||||
-- Capture the FUNCTION, not the table: attachBase stores the module
|
||||
-- table itself, so once this file's onStep is attached the table's slot
|
||||
-- points back here -- delegating through the table would self-recurse.
|
||||
-- story5's VIRIDIAN_CITY.onStep chains story.lua's sleeping-old-man
|
||||
-- gate and its own gym-lock step (same pattern as yellow_jessie_james).
|
||||
local baseViridianStep = require("data.scripts.story5").VIRIDIAN_CITY.onStep
|
||||
|
||||
-- pokeyellow text/ViridianCity.asm, _ViridianCityOldManHadMyCoffeeNowText
|
||||
-- and _ViridianCityOldManLosingMyTouchText, spelled with the extractor's
|
||||
-- markers (line -> \n, cont -> \v, para -> \f)
|
||||
local function text(game)
|
||||
return {
|
||||
apology = game.data.text._ViridianCityOldManHadMyCoffeeNowText
|
||||
or "Ahh, I've had my\ncoffee now and I\vfeel great!\fSure, you can go\n"
|
||||
.. "through!\fI'm sorry I was\nso rude to you!\fI see you're using\n"
|
||||
.. "a POKéDEX.\fI'll show you how\nto catch POKéMON\vas my apology.",
|
||||
losingMyTouch = game.data.text._ViridianCityOldManLosingMyTouchText
|
||||
or "That didn't work!\nI must be losing\vmy touch.\fI've run out of\n"
|
||||
.. "POKé BALLs too.\fI have to get some\nat POKéMON MART.",
|
||||
}
|
||||
end
|
||||
|
||||
-- The row list for the initial-tutorial branch, keyed by where the
|
||||
-- player stands when the battle ends (ViridianCityPostInitialCatchTraining
|
||||
-- reads wXCoord: (19,9) walks the old man down the corridor, anywhere
|
||||
-- else walks him right 1 after moving the follower Pikachu aside).
|
||||
local function oldMan2Rows(game, ow, npc)
|
||||
local rows = {
|
||||
{ "show_text", "_ViridianCityOldManHadMyCoffeeNowText" },
|
||||
-- ViridianCityOldManInitialCatchTrainingScript sets
|
||||
-- EVENT_INITIAL_CATCH_TRAINING before the battle runs, and
|
||||
-- ItemUseBall's .oldManBattle branch turns that event into anim data
|
||||
-- $63: three shakes, then the ball breaks open. The losing-my-touch
|
||||
-- line below only follows a throw that failed (#636).
|
||||
{ "old_man_demo", "fail" },
|
||||
{ "set_flag", "EVENT_COMPLETED_CATCH_TRAINING" },
|
||||
{ "show_text", "_ViridianCityOldManLosingMyTouchText" },
|
||||
}
|
||||
if ow.player and ow.player.cellX == 19 then
|
||||
rows[#rows + 1] =
|
||||
{ "walk_npc", npc.def.index,
|
||||
{ "down", "down", "down", "down", "down", "down" } }
|
||||
else
|
||||
-- ViridianCityMovePikachu (scripts/ViridianCity_2.asm): Pikachu
|
||||
-- steps out of the old man's way before he turns right
|
||||
local PikachuFollower = require("src.world.PikachuFollower")
|
||||
local pika = ow and PikachuFollower.current(ow)
|
||||
if pika then
|
||||
rows[#rows + 1] = { "walk_npc", pika.def.index, { "right" } }
|
||||
end
|
||||
rows[#rows + 1] = { "walk_npc", npc.def.index, { "right" } }
|
||||
end
|
||||
rows[#rows + 1] =
|
||||
{ "hide_object", "VIRIDIAN_CITY", OLD_MAN2 }
|
||||
return rows
|
||||
end
|
||||
|
||||
-- The shared talk handler: TEXT_VIRIDIANCITY_OLD_MAN2's text_asm branch
|
||||
-- (ViridianCityOldMan2Text) on EVENT_COMPLETED_CATCH_TRAINING.
|
||||
local function oldMan2Talk(game, ow, npc, done)
|
||||
if game.save.flags and game.save.flags.EVENT_COMPLETED_CATCH_TRAINING then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, text(game).losingMyTouch, done))
|
||||
return
|
||||
end
|
||||
ow.runner:run(oldMan2Rows(game, ow, npc), { npc = npc, onDone = done })
|
||||
end
|
||||
|
||||
M.VIRIDIAN_CITY = {
|
||||
talk = {
|
||||
TEXT_VIRIDIANCITY_OLD_MAN2 = oldMan2Talk,
|
||||
},
|
||||
|
||||
-- Re-apply the Pokédex swap for a save that already holds the flag but
|
||||
-- was never standing here when it fired (converted .sav imports, same
|
||||
-- shape as story.lua's VIRIDIAN_CITY.onEnter). Yellow shows OLD_MAN2,
|
||||
-- not the Red/Blue OLD_MAN at (17,5), and also puts away a stray
|
||||
-- OLD_MAN a save made by the pre-#617 build left standing.
|
||||
onEnter = function(game, ow)
|
||||
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
||||
return
|
||||
end
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.hide_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY")
|
||||
Commands.hide_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN")
|
||||
Commands.show_object(ctx, "VIRIDIAN_CITY", OLD_MAN2)
|
||||
end,
|
||||
|
||||
-- ViridianCityCheckWaitingOldMan: with the Pokédex held and the
|
||||
-- tutorial undone, (19,9) -- the gap east of the old man, the same
|
||||
-- cell the sleeper used to gate -- faces him right, turns the player
|
||||
-- left and starts the OLD_MAN2 flow with no choice.
|
||||
onStep = function(game, ow, x, y)
|
||||
if baseViridianStep and baseViridianStep(game, ow, x, y) then
|
||||
return true
|
||||
end
|
||||
local flags = game.save.flags
|
||||
if not flags.EVENT_GOT_POKEDEX then return false end
|
||||
if flags.EVENT_COMPLETED_CATCH_TRAINING then return false end
|
||||
if x ~= 19 or y ~= 9 then return false end
|
||||
local man
|
||||
for _, npc in ipairs(ow.npcs) do
|
||||
if npc.def and npc.def.name == OLD_MAN2 then man = npc break end
|
||||
end
|
||||
if not man then return false end
|
||||
man.facing = "right"
|
||||
ow.player.facing = "left"
|
||||
oldMan2Talk(game, ow, man, nil)
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -209,10 +209,15 @@ duration:
|
||||
closes, and apply again after. Fast-forward otherwise runs one peer's
|
||||
queue faster than the peer it is locked to and drains a tournament shot
|
||||
clock faster than the opponent racing it.
|
||||
- **Online play runs vanilla.** Picking ONLINE MATCH or TOURNAMENT with
|
||||
mods enabled offers to switch them all off and relaunch (mods merge at
|
||||
boot, so a restart is the only way). The restart is confirmed, not
|
||||
silent. They stay listed as disabled, ready to switch back on.
|
||||
- **Online play runs vanilla, except for your language.** Picking ONLINE
|
||||
MATCH or TOURNAMENT with mods enabled offers to switch the gameplay ones
|
||||
off and relaunch (mods merge at boot, so a restart is the only way). The
|
||||
restart is confirmed, not silent. They stay listed as disabled, ready to
|
||||
switch back on. A mod that declares itself a translation and provably
|
||||
writes nothing but text stays on: the two games hash the same link
|
||||
surface, so a Spanish install and an English one can battle and trade,
|
||||
each reading the game in its own language and naming the other player's
|
||||
party out of its own text.
|
||||
- **Only a meaningful split ends a match.** The per-turn state signature
|
||||
both peers exchange is split three ways: `actives` and `bench` carry
|
||||
species, HP, status, stat stages, PP and the rest of the party, and a
|
||||
@@ -314,12 +319,18 @@ window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
|
||||
the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
||||
|
||||
The launcher's **Touch Controls** button opens a drag editor: move each
|
||||
button freely, **Disable** to hide the overlay permanently (for
|
||||
controllers / emulation handhelds -- distinct from the temporary
|
||||
gamepad auto-hide), **Reset** for defaults, **Done** to save into
|
||||
`options.lua` as normalized window fractions so rotation keeps the
|
||||
relative placement. In-game, Options → **TOUCH PAD** toggles the same
|
||||
on/off flag without leaving a play session.
|
||||
button freely, resize the whole pad with **-/+** (60% to 160%), **Disable**
|
||||
to hide the overlay permanently (for controllers / emulation handhelds --
|
||||
distinct from the temporary gamepad auto-hide), **Reset** for defaults,
|
||||
**Done** to save into `options.lua` as normalized window fractions so a
|
||||
different screen keeps the relative placement.
|
||||
|
||||
Portrait and landscape are edited and saved separately (#633): the editor
|
||||
follows whichever orientation is on screen, and **Reset** only clears that
|
||||
one, so a layout that works held upright does not have to double as the
|
||||
one used sideways. An `options.lua` from before this split keeps its single
|
||||
layout in both orientations until one of them is edited. In-game, Options →
|
||||
**TOUCH PAD** toggles the same on/off flag without leaving a play session.
|
||||
|
||||
## Translation support
|
||||
|
||||
@@ -382,7 +393,9 @@ semantics - so the two windows read as one app. Six tabs:
|
||||
- **Items**: money, a searchable item picker (replacing the arrows that
|
||||
cycled one id at a time through ~250 items), the configurable bag (20 slots
|
||||
by default), PC storage
|
||||
with no slot cap, and the eight badges as toggle chips.
|
||||
with no slot cap, and the eight badges as toggle chips. The picker, the bag
|
||||
and PC storage all scroll under the mouse wheel, so the whole catalog is
|
||||
reachable one-handed without typing a query.
|
||||
- **Events**: flags, defeated trainers, taken items and per-map object
|
||||
toggles, with a real filter field and a two-column paged grid.
|
||||
- **Map**: any map rendered with the game's own renderer, warps followable,
|
||||
@@ -460,3 +473,38 @@ two buttons. Holding a second key or pad button while the first is still
|
||||
down backs out of the capture without touching a keyboard; Escape still
|
||||
cancels too. SELECT clears one row back to its default, and START resets
|
||||
every binding after a confirmation.
|
||||
|
||||
Controllers a system has no mapping for (common on Linux handhelds and
|
||||
off-brand pads) report bare button numbers rather than names. Those are
|
||||
rebindable on the same screen and show up as JOY1, JOY2 and so on in the
|
||||
controller column. Recognized controllers are read only through their
|
||||
named buttons, so a rebind on those is never shadowed by the factory
|
||||
layout underneath it.
|
||||
|
||||
## Mod profiles (#593)
|
||||
|
||||
The mod manager's PROFILES tab holds named setups. A profile remembers which
|
||||
mods are on, every mod's own options, and which save slot each game version
|
||||
plays, so swapping profiles swaps the whole playthrough and not just the mod
|
||||
list. The setup that existed before profiles shipped becomes PROFILE 1 the
|
||||
first time the manager opens.
|
||||
|
||||
EXPORT.. writes the selected profile to `profiles/<NAME>.g1rmodlist` in the
|
||||
save directory; drop a `.g1rmodlist` someone shared into that folder and
|
||||
IMPORT.. adds it. Imported profiles never overwrite an existing one (a name
|
||||
clash gets a number). Mods the shared profile names but that are not installed
|
||||
are reported when the profile is applied; installing them is still a manual
|
||||
trip through the mods list or Find Mods.
|
||||
|
||||
## Windows: no console windows on launcher actions
|
||||
|
||||
Checking for updates, browsing a mod index, adding a mod repo, installing a
|
||||
mod and picking a ROM all run a host tool (curl, PowerShell) in a child
|
||||
process. On Windows those children used to each open their own console
|
||||
window, so a session could end up buried under half a dozen of them. The
|
||||
game now claims one console for itself at boot and hides it; the children
|
||||
inherit that invisible console and nothing pops up. Nothing else changes:
|
||||
file pickers are ordinary desktop dialogs and still appear normally, and a
|
||||
run started from a terminal (`lovec.exe`, what `scripts\run.ps1` prefers)
|
||||
keeps its terminal and its printed output. Set `POKEPORT_CONSOLE=1` to opt
|
||||
out.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# RFC 0001 — Port Yellow's `IsSurfingPikachuInParty` surf sprite
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `Player.lua`, `FieldDefaults.lua`,
|
||||
`OverworldController.lua`, `RomExtractor.lua`, `PaletteFX.lua`. Tools:
|
||||
`build_rom_data.py`, `extract/sprites.py`, `make_rom_manifest.py`,
|
||||
`make_yellow_manifest.py`. Tests: `parity_surfing_pikachu_sprite.lua`,
|
||||
`mod_world_tests.lua`.
|
||||
|
||||
**Regeneration required.** The manifest and sprite sheet update by
|
||||
re-running `make_yellow_manifest.py` against a `pret/pokeyellow`
|
||||
checkout, then re-importing the Yellow ROM.
|
||||
|
||||
## Motivation
|
||||
|
||||
Yellow's `IsSurfingPikachuInParty` + `LoadSurfingPlayerSpriteGraphics2`
|
||||
(`home/map_objects.asm`, `home/overworld.asm`) swap the player's
|
||||
overworld sheet to `SurfingPikachuSprite` (`gfx/sprites/
|
||||
surfing_pikachu.2bpp`, a 16×96 walk sheet — not the minigame sheets)
|
||||
when the party mon that knows SURF is a Pikachu. The recomp misses this
|
||||
in two places:
|
||||
|
||||
1. **Extraction.** `SurfingPikachuSprite` is not in
|
||||
`SpriteSheetPointerTable` — loaded by its own `ld de,` like
|
||||
`RedBikeSprite`. The extractor never sees it, and the symbol is not
|
||||
in the Yellow manifest.
|
||||
2. **Engine rule.** `field.playerSprites.surf` is one static
|
||||
(`SPRITE_SEEL`), cached at boot. No seam for "swap when the SURF-mon
|
||||
is a Pikachu."
|
||||
|
||||
## The decision it extends
|
||||
|
||||
No prior D-number. Extends the surf-field-move port in
|
||||
`docs/behavior-porting-notes.md` (the `IsSurfingAllowed` exact port)
|
||||
with the player-sprite swap vanilla runs alongside it.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible, additive-only.
|
||||
|
||||
### `field.playerSprites.surfPikachu`
|
||||
|
||||
New optional key alongside `walk`/`surf`/`bike`/`fly`, defaults to
|
||||
`SPRITE_SURFING_PIKACHU`. Guarded in `Player.new` so before extraction
|
||||
lands the ride keeps the Seel — no plain on-water Pikachu.
|
||||
|
||||
### `Player.surfPikachuSprite`
|
||||
|
||||
`Player.new` builds a second `SpriteRenderer` when the field resolves.
|
||||
`pose()` picks it when `surfing and surfingPikachu`.
|
||||
|
||||
### `Player.surfingPikachu` (runtime)
|
||||
|
||||
Runtime-only boolean (not persisted); re-derived so a party change
|
||||
between save and load is honored.
|
||||
|
||||
### `OverworldState:syncSurfingPikachu()`
|
||||
|
||||
Sets `player.surfingPikachu` from `partyKnows("SURF")`. Called at every
|
||||
surf-state toggle: trySurf, dismount, flyTo, beginTeleportOut,
|
||||
warpToHealPoint, forced-surf tile, setMap boot-restore.
|
||||
|
||||
### Importer — `SPRITE_SURFING_PIKACHU`
|
||||
|
||||
`make_yellow_manifest.py` adds `SurfingPikachuSprite` to
|
||||
`YELLOW_EXTRA_SYMBOLS`. `make_rom_manifest.py`'s `sprite_metadata()`
|
||||
gains a `surfPikachu` entry (guarded, so Red/Blue unchanged).
|
||||
`RomExtractor.extractSprites` + `build_rom_data.py` + `extract/sprites.py`
|
||||
each gain a parallel extract mirroring `RedBikeSprite`.
|
||||
|
||||
### `PaletteFX.spriteObp`
|
||||
|
||||
`SurfingPikachuSprite` joins `RedBikeSprite` in the no-bracket-index
|
||||
special case, wearing the player's OBP palette so it colors in GBC mode.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing.** `surf` still defaults to `SPRITE_SEEL`; `surfPikachu`
|
||||
only resolves on a Yellow import after regeneration. No manifest or
|
||||
`mod.save` shape changes. An eligibility hook that swaps a rental
|
||||
SURF-mon still drives the sprite pick via `partyKnows`.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod** (`mod_world_tests.lua`): `surf == "SPRITE_SEEL"`,
|
||||
`surfPikachu == "SPRITE_SURFING_PIKACHU"` seeded at boot. The 19229-check
|
||||
`world & maps v2` suite stays green.
|
||||
- **Mod-API** (`parity_surfing_pikachu_sprite.lua`): `syncSurfingPikachu`
|
||||
+ `Player:pose` across four party shapes (12/12). The existing
|
||||
`parity_cinnabar_east_surf.lua` (24/24) stays green.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. Additive: a new `field.playerSprites` key, a new
|
||||
runtime flag, a new engine method, a new sprite id.
|
||||
@@ -0,0 +1,382 @@
|
||||
# Timing parity with the Game Boy
|
||||
|
||||
The port's clock is faithful: `src/core/FixedStep.lua` advances game logic in
|
||||
whole 1/60 s steps off wall-clock `dt`, the default speed multiplier is 1x
|
||||
(`src/core/GameSpeed.lua:22`), and audio runs on its own real-time accumulator
|
||||
so fast-forward cannot pitch it. Almost nothing in `src/` is seconds-based.
|
||||
|
||||
What diverges is the **frame budget of composed sequences**. The original
|
||||
spends a large fraction of its running time inside `DelayFrames` calls that
|
||||
produce no visible change - the pause after a page break, the beat before a
|
||||
status move resolves, the drain of an HP bar one point at a time. Those are
|
||||
invisible in a screenshot and easy to drop when porting behavior rather than
|
||||
timing. Dropping them is why the port reads as faster and snappier than
|
||||
hardware even though every individual animation is correct.
|
||||
|
||||
This document is the specification: what each sequence costs on hardware, and
|
||||
where that number comes from.
|
||||
|
||||
## Method
|
||||
|
||||
`tools/scan_pokered_delays.ps1` walks the disassembly and reports every
|
||||
frame-consuming wait with its enclosing routine label:
|
||||
|
||||
| Kind | Meaning | Frames |
|
||||
| --- | --- | --- |
|
||||
| `DelayFrames` | `ld c, N` + `call DelayFrames` (`home/delay.asm:1`) | N |
|
||||
| `DelayFrame` | one vblank wait (`home/vblank.asm:92`) | 1 |
|
||||
| `Delay3` | `home/palettes.asm:14`, three frames for a full bg-map update | 3 |
|
||||
| `Fade` | the `GBFade*` helpers, expanded to their totals below | 24 or 32 |
|
||||
|
||||
Current inventory against `pokered-master`: **450 sites** - 181 `DelayFrames`,
|
||||
164 `Delay3`, 65 `DelayFrame`, 31 `GBFade*`, and 9 `DelayFrames` calls whose
|
||||
count is computed at runtime.
|
||||
|
||||
The four fades all live in `home/fade.asm` and are loops of
|
||||
`ld c, 8 / call DelayFrames`:
|
||||
|
||||
| Routine | Iterations | Frames | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| `GBFadeInFromBlack` | 4 | **32** | `home/fade.asm:21` |
|
||||
| `GBFadeOutToBlack` | 4 | **32** | `home/fade.asm:43` |
|
||||
| `GBFadeOutToWhite` | 3 | **24** | `home/fade.asm:26` |
|
||||
| `GBFadeInFromWhite` | 3 | **24** | `home/fade.asm:48` |
|
||||
|
||||
## The metric
|
||||
|
||||
For each catalog entry, `delta = |port - truth| / truth`; the entry passes at
|
||||
`delta <= 0.05`. The headline number is the **exposure-weighted** pass rate,
|
||||
weighting each sequence by how often it occurs in ordinary play - a 30-frame
|
||||
error on every page of dialogue matters more than a 30-frame error in the Hall
|
||||
of Fame. Weights are in the tier column: T1 sequences recur constantly, T2 are
|
||||
frequent, T3 are set pieces seen once or twice per playthrough.
|
||||
|
||||
## Tier 1 - constant exposure
|
||||
|
||||
These recur every few seconds of play and dominate perceived pacing. Both
|
||||
sides are verified.
|
||||
|
||||
### Overworld and transitions
|
||||
|
||||
| Sequence | Hardware | Source | Port | Port source | Delta |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Overworld loop iteration | 2 frames (two `DelayFrame`) | `home/overworld.asm:41-44` | 1 step | `src/core/Game.lua:174` | see note |
|
||||
| Warp / door: fade out | **32** | `home/overworld.asm:703` -> `GBFadeOutToBlack` | 32 | `src/render/Transition.lua` | **fixed** |
|
||||
| Warp / door: fade in | **0** (map is drawn under blacked palettes, no fade) | `home/overworld.asm:690-703` | 0 | `src/render/Transition.lua` | **fixed** |
|
||||
| Return to overworld after a battle | **10** hold, then `GBFadeInFromWhite` **24** | `home/overworld.asm:351-352`, `:22`, `:749-753` | 10 + 24 | `Transition.battleReturn` | **fixed** |
|
||||
| Special warp entry (fly / teleport / dungeon) | `Delay3` + `GBFadeInFromWhite` = **27** | `engine/overworld/player_animations.asm:5-7` | - | - | unmeasured |
|
||||
| Dungeon-warp arrival hold | **50** | `engine/overworld/player_animations.asm:43` | - | - | unmeasured |
|
||||
| Player step (walk) | 16 | 8 loop iterations x 2 frames | 16 | `src/world/Player.lua:14` | **ok** |
|
||||
| Turn in place | 2 | one extra loop pass | 2 | `src/world/Player.lua:18` | **ok** |
|
||||
|
||||
Note on the overworld loop: `OverworldLoop` calls `DelayFrame` and then falls
|
||||
through to `OverworldLoopLessDelay`, which calls it again - so a full pass
|
||||
costs 2 frames, and input is sampled every other frame. The port steps logic
|
||||
and samples input every frame. This does not change walking speed (the 2-frame
|
||||
loop moves 2 px, giving the same 16 frames per tile) but it does halve input
|
||||
latency versus hardware. Flagged rather than "wrong": matching it exactly would
|
||||
make the port feel less responsive than the original does on a modern display,
|
||||
and it is the one place where a deliberate divergence is defensible.
|
||||
|
||||
### Text
|
||||
|
||||
The typewriter cadence itself is already correct - 1/3/5 frames per character
|
||||
from `TextSpeedOptionData`, implemented at `src/render/TextBox.lua:267`. The
|
||||
gaps around it are missing.
|
||||
|
||||
| Sequence | Hardware | Source | Port | Port source | Delta |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `<CONT>` line scroll (after the A press) | `ProtectedDelay3` 3 + 2x `ScrollTextUpOneLine` 5 = **13** | `home/text.asm:262-277`, `:283-307` | 13 | `src/render/TextBox.lua` | **fixed** |
|
||||
| `<PARA>` paragraph break | `ProtectedDelay3` 3 + clear + **20** = **23** | `home/text.asm:230-243` | 23 | `src/render/TextBox.lua` | **fixed** |
|
||||
| Page break (`PageChar`) | 3 + **20** = **23** | `home/text.asm:245-260` | 23 | `src/render/TextBox.lua` | **fixed** |
|
||||
| `TextCommand_PAUSE` | **30** | `home/text.asm:500` | - | - | unmeasured |
|
||||
| `TextCommand_DOTS` | **10** per dot | `home/text.asm:576` | - | - | unmeasured |
|
||||
|
||||
The three ProtectedDelay3 frames are a *pre*-input hold: the arrow is already
|
||||
up and the button is ignored, because `ManualTextScroll` only starts watching
|
||||
the joypad after the delay returns. Mashing A through a long conversation
|
||||
therefore cannot go faster than 3 frames per line on hardware, and now cannot
|
||||
here either. `PromptText` (`home/text.asm:209-217`) has the same shape, so a
|
||||
finished page holds three frames before it can be dismissed too.
|
||||
|
||||
**Battle text is a second, separate engine.** `BattleState` types its own
|
||||
messages rather than going through `src/render/TextBox.lua`, so none of the
|
||||
fixes above reached it and it had drifted further than the overworld box:
|
||||
|
||||
| Sequence | Hardware | Source | Was | Now |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Per character | 1 glyph per `wOptions & $f` frames (1/3/5, default **3**) | `home/print_text.asm:4-45` | 2 glyphs **per frame**, option ignored | 3 |
|
||||
| Per character, A or B held | **1** frame | `print_text.asm:27-36` | 2 glyphs per frame | 1 |
|
||||
| `<CONT>` pre-input hold | **3** | `home/text.asm:263-267` | 0 | 3 |
|
||||
| `<CONT>` scroll after the press | **10** | `home/text.asm:280-305` | 0 | 10 |
|
||||
| Finished page, pre-input hold | **3** | `home/text.asm:213-217` | 0 | 3 |
|
||||
|
||||
At the default text speed the battle typewriter was running **six times**
|
||||
hardware speed, which is most of why battle text read as a blur, and it
|
||||
ignored the OPTION text-speed setting entirely.
|
||||
|
||||
`ScrollTextUpOneLine` is `ld b, 5` of `DelayFrame` (`home/text.asm:301-305`)
|
||||
and its own comment notes it is "always called twice in a row", so a CONT
|
||||
scroll blocks for 10 frames. The port's `scrollPx` slide at
|
||||
`src/render/TextBox.lua:305-307` is a cosmetic 8 px at 2 px/frame running in
|
||||
`draw()`, not on the logic step, and it does not gate the typewriter.
|
||||
|
||||
### Menus
|
||||
|
||||
| Sequence | Hardware | Source | Port | Port source | Delta |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Yes/no answer (either option) | **15** | `engine/menus/text_box.asm:322-323`, `:333-334` | 15 | `src/ui/ChoiceBox.lua` | **fixed** |
|
||||
| List menu open (bag, PC, party-as-list) | **10** | `home/list_menu.asm:55-56` | 0 | `src/ui/ListMenu.lua` | **-100%, open** |
|
||||
| List menu redraw per input | `Delay3` = **3** | `home/list_menu.asm:64` | 0 | - | **-100%, open** |
|
||||
| Field move from the party menu | `Delay3` = **3** | `engine/menus/start_sub_menus.asm:4,27,175-203` | 7 (white flash) | `src/render/Transition.lua:8` | see note |
|
||||
| Teleport from the party menu | **60** + `Delay3` | `engine/menus/start_sub_menus.asm:224-225` | - | - | unmeasured |
|
||||
|
||||
The port's 7-frame `white_flash` models `GBPalWhiteOutWithDelay3` plus the
|
||||
screen-tile restore, which is a defensible reading of the same sequence; it is
|
||||
listed here to be reconciled against the exact path rather than treated as a
|
||||
bug.
|
||||
|
||||
### Battle entry
|
||||
|
||||
The wipe into a battle and the silhouette slide behind it. Numbers here come
|
||||
from **pokered-c** (`C:\Users\Anthony\pokered`), whose `battle_transition.c`
|
||||
derives each wipe from `battle_transitions.asm` and then corrects it against a
|
||||
live side-by-side with the ROM. Where that project's measured value and a
|
||||
naive reading of the asm disagree, the measured value wins - see the inward
|
||||
spiral below.
|
||||
|
||||
| Sequence | Hardware | Was | Now |
|
||||
| --- | --- | --- | --- |
|
||||
| DoubleCircle wipe (wild, weak) | 10 x 3 = **30** | 40 | 30 |
|
||||
| Circle wipe (wild, strong) | 20 x 3 = **60** | 40 | 60 |
|
||||
| Spiral outward (trainer, strong) | 360 fills / 3 per frame = **120** | 40 | 120 |
|
||||
| Spiral inward (trainer, weak) | 7 tiles per `Delay3` = **~150** | 40 | 156 |
|
||||
| HStripes (dungeon wild, weak) | 20 x 3 = **60** | 24 | 60 |
|
||||
| VStripes (dungeon wild, strong) | 18 x 3 = **54** | 24 | 54 |
|
||||
| Shrink (dungeon trainer, weak) | 9 x 6 = **54** | 24 | 54 |
|
||||
| Split (dungeon trainer, strong) | 9 x 6 = **54** | 24 | 54 |
|
||||
| Flash before the circle wipes | 12 x 2 x 3 = **72** | 72 | 72 |
|
||||
| Black hold before the battle draws | not stated by pokered; ~30 floor, calibrated **60** | 30 | 60 |
|
||||
| Silhouette slide in | 144 px at 2 px/frame = **72** | 40 (160 px at 4 px/frame) | 72 |
|
||||
| Trainer intro, before balls + text | `WaitForSoundToFinish` + `DelayFrames 20` | 0 | sfx wait + 20 |
|
||||
|
||||
The trainer intro's sound is `SFX_Silph_Scope`, extracted here as
|
||||
`Trainer_Appeared` (`tools/rom_manifest.json` `audio.sfxHeaders`, bank 8 /
|
||||
`$42bb`). It was being extracted and never played by anything. It now plays
|
||||
into a clear window: `PrintBeginningBattleText .trainerBattle` does
|
||||
`PlaySound` then `WaitForSoundToFinish`, which **blocks**, and only then
|
||||
`DelayFrames 20` before `DrawAllPokeballs` and the text. `BattleState`'s
|
||||
message queue grew a `waitSound` row for that, since `WaitForSoundToFinish`
|
||||
waits on the sound actually stopping rather than on a fixed frame count.
|
||||
|
||||
**Scripted battles were skipping the transition entirely.** `BattleTransition`
|
||||
runs from `DoBattleTransitionAndInitBattleVariables`, which both
|
||||
`InitBattleCommon` (`core.asm:6680`, trainers) and `InitWildBattle` (`:6699`)
|
||||
call unconditionally - every battle on hardware enters through a wipe. In the
|
||||
port only `OverworldState:pushBattle` built one, and `Commands.start_battle`
|
||||
pushed the `BattleState` straight onto the stack. The trainer-*sight* path
|
||||
went through `pushBattle`, but every **script-driven** battle did not: gym
|
||||
leaders, the rival, Giovanni, and every scripted wild encounter cut straight
|
||||
to the battle screen with no transition at all. The catch tutorial
|
||||
(`old_man_demo`) had the same gap; `InitWildBattle` has no
|
||||
`BATTLE_TYPE_OLD_MAN` special case, so it gets a wipe too.
|
||||
|
||||
**Beyond 160x144.** Both halves of the transition used to stop at the classic
|
||||
letterbox. The flash filled the 160x144 UI canvas, and the wipe handed the
|
||||
surrounding window a generic centre-out square cascade
|
||||
(`Renderer:drawBattleCascade`) regardless of which of the eight styles was
|
||||
running - so at any zoom a spiral read as "a spiral in a box, with something
|
||||
else happening around it".
|
||||
|
||||
- The flash is a palette write (`rBGP`), and a palette register tints every
|
||||
pixel the LCD shows; there is no "outside the screen" for it to miss. It is
|
||||
now published to the renderer as a screen-space veil and painted over the
|
||||
finished composite, so it covers the whole surface at any zoom.
|
||||
- The spiral and circle walks are now generated for whatever grid the window
|
||||
works out to (`BattleTransition.gridOrder`), and the area outside the
|
||||
letterbox is filled in that order instead of the square cascade. The
|
||||
authentic 20x18 builders still own the letterbox itself, overrun and all,
|
||||
so nothing changes at 1x. The generic builders are deliberately *not* the
|
||||
ROM's walk - out there the hardware has no behaviour to be faithful to,
|
||||
only a shape to continue.
|
||||
- `shrink`, `split` and the two stripe styles are plain geometry rather than
|
||||
a tile order, so they keep the cascade for now. Extending them is
|
||||
rectangles, not a walk, and has not been done.
|
||||
|
||||
Note that the **flash is wild-only**: `BattleTransition_FlashScreen` is called
|
||||
from `BattleTransition_Circle` (`:585`) and `BattleTransition_DoubleCircle`
|
||||
(`:628`) and nowhere else. A trainer battle's transition is the spiral -
|
||||
inward against a weaker foe, outward against a stronger one
|
||||
(`wBattleTransitionSpiralDirection`, `:119-126`) - with no flash in front of
|
||||
it. The port's `flash` mapping was already correct; the transition simply
|
||||
never ran for those battles.
|
||||
|
||||
Two of these deserve their reasoning recorded:
|
||||
|
||||
**The inward spiral** writes one tile per iteration and calls
|
||||
`BattleTransition_TransferDelay3` every seventh tile. That helper is not a
|
||||
one-frame transfer - it is `ld a,1 / ldh [hAutoBGTransferEnabled] / call
|
||||
Delay3 / xor a / ldh [...]` (`battle_transitions.asm:619`), so the cadence is
|
||||
7 tiles per **three** frames. Reading it as one frame runs the whole wipe 3x
|
||||
too fast, ~46 frames against the ROM's ~150. pokered-c hit that exact bug and
|
||||
caught it on a live comparison.
|
||||
|
||||
**The black hold** is not a number pokered states anywhere; it is incidental
|
||||
load cost that a modern port does not pay. The derivable floor is ~13 frames
|
||||
(`LoadHpBarAndStatusTilePatterns` 4, `LoadHudTilePatterns` 2, `ClearScreen`'s
|
||||
`Delay3` 3, the `DisableLCD` LY wait 1, `Delay3` after `EnableLCD` 3), but the
|
||||
two sprite decompressors (`UncompressSpriteFromDE` for the 7x7 front pic and
|
||||
`LoadPlayerBackPic`'s uncompress + `ScaleSpriteByTwo`) are bit-level RLE/delta
|
||||
decoders that cannot be cycle-counted from the asm at all. So the derivation
|
||||
bottoms out around 25-30 with an unbounded remainder. pokered-c set 60 by ear
|
||||
against the real ROM and marked it ~95% right rather than frame-matched. The
|
||||
credible range is 30-60; it should not be "corrected" down toward the floor on
|
||||
the strength of the derivation, because the omitted decompressors are exactly
|
||||
the unbounded part. Pinning it exactly wants a frame-by-frame capture.
|
||||
|
||||
### Battle turns
|
||||
|
||||
| Sequence | Hardware | Source | Port | Port source | Delta |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Player HP bar drain of D HP over P pixels | **D + 2P + 6** | `engine/gfx/hp_bar.asm:81-135`, `:140-159`, `:234` | D + 2P + 6 | `src/battle/BattleState.lua` `stepHPDrain` | **fixed** |
|
||||
| Enemy HP bar drain over P pixels | **2P + 5** (no per-HP frame) | same, gated at `:207-209` | 2P + 5 | same | **fixed** |
|
||||
| Status move / missed move beat | **30** | `engine/battle/core.asm:3145,3158,3185-3186`; enemy `:5588` | 30 | `EffectRegistry` `missBeat`, `BattleState:performMove` | **fixed** |
|
||||
| Applying anim type 1, vertical shake b=8 | **48** | `animations.asm:500-503` | 48 | `BattleState:applyHitFx` | ok |
|
||||
| Applying anim type 2, fast horizontal b=8 | **72** | `animations.asm:505-508` | 72 | same | ok |
|
||||
| Applying anim type 3, slow horizontal 6,2 | **48** | `animations.asm:510-512,526-549` | 48 | same | ok |
|
||||
| Applying anim type 4, `AnimationBlinkMon` | **60** | `animations.asm:514-516`, `:1360-1376` | 60 | same | **fixed** |
|
||||
| Applying anim type 5, fast horizontal b=2 | **18** | `animations.asm:518-521` | 18 | same | ok |
|
||||
| Applying anim type 6, slow horizontal 3,2 | **24** | `animations.asm:523-525` | 24 | same | ok |
|
||||
| Faint slide-down | **14** (`PIC_HEIGHT` x `DelayFrames 2`) | `engine/battle/core.asm:1186-1222` | 14 | `BattleState:onFaint` | **fixed** |
|
||||
| Before every move animation | `Delay3` = **3** | `engine/battle/core.asm:6635-6640` | 3 | `BattleState:updateQueue` | **fixed** |
|
||||
| Battle start, after enemy send-out | **40** | `engine/battle/core.asm:152-156` | 40 | `BattleState:enter` | **fixed** |
|
||||
| Poison / burn / leech-seed tick | **20** | `engine/battle/core.asm:529-530` | 20 | `src/battle/BattleState.lua:1817` | **ok** |
|
||||
| Switch player mon | **50** | `engine/battle/core.asm:2421-2422` | 50 | `src/battle/BattleState.lua:1666-1668` | **ok** |
|
||||
| Post-hit hold (crit text or not) | **20** | `engine/battle/core.asm:3798-3814` | 20 | `EffectRegistry.runDamaging` | **fixed** |
|
||||
| Fainted mon slide-down | **2** per row | `engine/battle/core.asm:1216-1217` | - | - | unmeasured |
|
||||
| Trainer pic slide off | **2** per column | `engine/battle/core.asm:1267-1268` | - | - | unmeasured |
|
||||
| Trainer battle victory | **40** | `engine/battle/core.asm:940-941` | - | - | unmeasured |
|
||||
| Player blackout | **40** | `engine/battle/core.asm:1143-1144` | - | - | unmeasured |
|
||||
| No moves left (Struggle) | **60** | `engine/battle/core.asm:2753-2754` | - | - | unmeasured |
|
||||
| Send-out animation | `Delay3` + **4** + **5** | `engine/battle/core.asm:6814-6830` | - | - | unmeasured |
|
||||
|
||||
The HP bar was the single largest battle divergence. `UpdateHPBar` steps **one
|
||||
HP point per loop iteration**; each iteration pays 1 frame in
|
||||
`UpdateHPBar_PrintHPNumber` whenever `wHPBarType != 0` - the player's own HUD
|
||||
and the party menu, but not the enemy HUD - plus 2 frames for each pixel the
|
||||
bar actually moves. The tail (`.animateHPBarDone`, `:132-135`) prints the
|
||||
number once more, animates one last pixel and falls into `Delay3`, so it costs
|
||||
6 frames player-side and 5 enemy-side.
|
||||
|
||||
A 150 HP mon losing everything therefore costs 150 + 96 + 6 = **252 frames
|
||||
(4.2 s)** on the player's HUD, against 96 + 5 = 101 on the enemy's. The port
|
||||
used a flat `maxHP/96` - the enemy-side rate applied to both sides - and ran
|
||||
that same drain in 96 frames (1.6 s).
|
||||
|
||||
## Tier 2 - frequent
|
||||
|
||||
Status-effect failures (`engine/battle/effects.asm:161,1162,1205`) hold **50**
|
||||
frames each. `SwitchAndTeleportEffect` uses 50 and 20
|
||||
(`:834-901`). Evolution holds **50** then **40**
|
||||
(`engine/pokemon/evos_moves.asm:123,155`). The healing machine
|
||||
(`engine/overworld/healing_machine.asm`), item effects
|
||||
(`engine/items/item_effects.asm`, 259 frames across 8 sites), and the fishing
|
||||
animation (**10** then **100**, `engine/overworld/player_animations.asm:380,399`)
|
||||
are all in this tier.
|
||||
|
||||
## Tier 3 - set pieces
|
||||
|
||||
Highest total budgets in the scan, all seen rarely:
|
||||
`engine/movie/hall_of_fame.asm` (529 frames / 7 sites),
|
||||
`engine/link/cable_club.asm` (507 / 12), `engine/movie/credits.asm` (502 / 7),
|
||||
`engine/movie/trade.asm` (494 / 19), `engine/movie/intro.asm` (317 / 7),
|
||||
`engine/menus/main_menu.asm` (271 / 15), `engine/menus/save.asm` (250 / 3),
|
||||
`engine/battle/end_of_battle.asm` (200 / 1, the link-battle win/lose string).
|
||||
Several of these already have faithful implementations - see
|
||||
`src/ui/IntroMovie.lua`, `src/ui/Credits.lua`, `src/ui/HallOfFame.lua`, whose
|
||||
constants cite their asm sources directly.
|
||||
|
||||
## Status
|
||||
|
||||
Closed, and locked by `tests/engine/timing_parity.lua`:
|
||||
|
||||
1. **Text page and CONT breaks** - were 0 frames where hardware spends 13-23,
|
||||
on every page of every dialogue in the game. Now exact, including the
|
||||
three-frame pre-input hold that swallows a mashed A.
|
||||
2. **Player HP bar drain** - was ~2.5x too fast on a typical mon. Now steps
|
||||
one HP point at a time at the hardware rate, with the enemy HUD correctly
|
||||
cheaper than the player's.
|
||||
3. **Warp fade** - was a symmetric 12/12; now 32 out and no fade in, which is
|
||||
both the right duration and the right shape.
|
||||
4. **Yes/no answer** - was 0 frames where hardware spends 15, with the cursor
|
||||
snapping to NO on B for the duration as `.choseSecondMenuItem` does.
|
||||
|
||||
5. **Battle entry** - every wipe ran at a flat 40/24 against budgets of 30-156,
|
||||
the silhouette slide was 40 frames against 72, the black hold was half
|
||||
what pokered-c calibrated, and the trainer intro's 20-frame gap before the
|
||||
balls and text was missing entirely. This was the single most compressed
|
||||
stretch in the game.
|
||||
|
||||
6. **Battle turns** - the type-4 blink ran at 20 frames against 60. That is
|
||||
the applying animation for every plain damaging move the player uses, so
|
||||
it was the single most-repeated timing error in the game. The 30-frame
|
||||
beat that hardware spends on every status move and every miss was missing
|
||||
entirely. The faint slide, unusually, ran *slower* than hardware (30
|
||||
against 14).
|
||||
|
||||
Two things worth recording about that last batch, because both contradict a
|
||||
plausible reading:
|
||||
|
||||
- **`PrintCriticalOHKOText`'s 20-frame hold is not conditional.** The
|
||||
"no critical hit" early-out at `core.asm:3799` jumps to `.done`, and
|
||||
`.done` *is* the `ld c, 20 / jp DelayFrames`. Every landed hit pays it,
|
||||
which is where the beat before "It's super effective!" comes from.
|
||||
- **`StartBattle`'s 40-frame hold is not conditional either.** The `call nz`
|
||||
at `core.asm:154` gates only `EnemySendOutFirstMon`; the `DelayFrames 40`
|
||||
under it runs for wild battles too, and it lands *between* the enemy's
|
||||
send-out and `.playerSendOutFirstMon` (`:166`) rather than at the end of
|
||||
the intro.
|
||||
|
||||
Trainer victory (24-frame scroll-in + 40-frame hold) and the ball shake
|
||||
(`SFX_TINK` + `DelayFrames 40` per rock) were already correct -
|
||||
`BattleState.lua`'s `wait = 64` and `AnimPlayer.lua`'s `emit(40)`. Note that
|
||||
pokered-c's `BUI_TRAINER_VICTORY_SLIDE` comment calls the scroll-in 14
|
||||
frames; `_ScrollTrainerPicAfterBattle` is 6 loop passes of `DelayFrames 4`,
|
||||
so 24 is right and this port already had it.
|
||||
|
||||
**Leaving a battle was a cut, not a fade.** `EnterMap` checks
|
||||
`BIT_BATTLE_OVER_OR_BLACKOUT` and calls `MapEntryAfterBattle`
|
||||
(`home/overworld.asm:22`), which is `GBFadeInFromWhite` - so the map fades up
|
||||
from white over 24 frames, behind the 10-frame hold at `:351-352`. The port
|
||||
popped the battle and the overworld was simply there. `Transition.battleReturn`
|
||||
supplies both halves, and steps the veil in three palette stages of 8 frames
|
||||
rather than tweening it, because `GBFadeIncCommon` writes a palette and holds
|
||||
it with `ld c, 8 / call DelayFrames` (`home/fade.asm:30-41`) three times over.
|
||||
|
||||
It is wrapped around `battle.onFinish` in `OverworldState:pushBattle` - the
|
||||
one funnel every battle goes through - rather than living in `afterBattle`.
|
||||
That placement matters: a script-driven **win** defers `afterBattle` into
|
||||
`ctx.afterScript` so an evolution screen cannot be buried under the trainer's
|
||||
follow-up text (`Commands.start_battle`), and a fade inside `afterBattle`
|
||||
inherited that deferral. On a rival battle it fired after the post-battle
|
||||
dialogue *and* the rival's walk-off, rather than when the battle ended.
|
||||
|
||||
The rest of `onFinish` runs as the fade's `onDone`, which is also the hardware
|
||||
order: `MapEntryAfterBattle` fades the map back in and only then does the map
|
||||
script run. The overworld is frozen meanwhile - `StateStack` updates the top
|
||||
state only - so nothing moves under the white.
|
||||
|
||||
Hardware skips the fade on a dark map (`wMapPalOffset` nonzero takes the
|
||||
`LoadGBPal` branch at `:754`). This port has no `wMapPalOffset` equivalent -
|
||||
no map needs FLASH to be lit - so that branch is unreachable here;
|
||||
`battleReturn` accepts `opts.instant` for it if that ever changes.
|
||||
|
||||
Still open, hardware number confirmed but not yet wired:
|
||||
|
||||
- List menu open (10) and per-input redraw (`Delay3`).
|
||||
- Every battle-table row marked "unmeasured" above - the status/miss beat (30)
|
||||
is the highest-exposure of them, since it is paid on every status move and
|
||||
every miss.
|
||||
|
||||
Entries marked "unmeasured" have confirmed hardware numbers but the port side
|
||||
has not been traced; they are remaining work, not known-good.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Mike Freno
|
||||
|
||||
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
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
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.
|
||||
@@ -0,0 +1,188 @@
|
||||
-- modules/Behavior.lua
|
||||
--
|
||||
-- Base module for the pluggable behavior system that drives the Behavior &
|
||||
-- Mode Unification refactor.
|
||||
--
|
||||
-- A *behavior* is a small, stateless table produced by `Behavior.new(spec)`
|
||||
-- that implements a fixed lifecycle hook set. Concrete behaviors (Clickable,
|
||||
-- Scrollable, TextEditable, Selectable, ...) each live in their own module and
|
||||
-- are attached to an Element. The Element's `update`/`draw`/save-restore paths
|
||||
-- iterate `element.behaviors` and dispatch to the appropriate hooks, replacing
|
||||
-- the swarm of `if self.scrollable` / immediate-mode-branch checks previously
|
||||
-- hard-coded in Element.lua.
|
||||
--
|
||||
-- Element.new iterates a registry of behavior prototypes and auto-attaches
|
||||
-- whichever return true from `shouldAttach(props)`. Element therefore never
|
||||
-- needs to know what an individual behavior does — only that it conforms to
|
||||
-- this interface.
|
||||
--
|
||||
-- Design constraints (locked — tasks 02-13 depend on this API):
|
||||
-- * Pure Lua — NO `love` import, NO dependency on utils/Color/Units/ErrorHandler.
|
||||
-- Stays fully stub-testable standalone (see testing/__tests__/behavior_test.lua).
|
||||
-- * Minimal interface — exactly 6 lifecycle hooks + a `shouldAttach` predicate.
|
||||
-- Do NOT add hooks "just in case"; new capabilities become new behaviors,
|
||||
-- not new hooks. Extending HOOK_NAMES is an architectural decision that must
|
||||
-- be mirrored by every concrete behavior.
|
||||
-- * Immutable instances — behavior tables are produced once and treated as
|
||||
-- read-only. Per-element runtime state lives on the element (or a subsystem
|
||||
-- the behavior attaches), NEVER on the behavior instance itself, so a single
|
||||
-- behavior instance can be shared across many elements.
|
||||
--
|
||||
-- Lifecycle hook contract (each receives the owning element as first argument):
|
||||
-- onAttach(element) — called once when the behavior is attached
|
||||
-- (element fully constructed). Allocate
|
||||
-- subsystems / register listeners here.
|
||||
-- onDetach(element) — called once when the behavior is detached
|
||||
-- (element destroyed / mode switch). Tear
|
||||
-- down anything onAttach created.
|
||||
-- onUpdate(element, dt) — called every frame from Element:update.
|
||||
-- onDraw(element, ctx) — called every frame from Element:draw; `ctx`
|
||||
-- is the draw context (viewport transform,
|
||||
-- scissor state, theme renderer, ...).
|
||||
-- saveState(element) -> state — called during Element save-state; returns
|
||||
-- a serializable snapshot (or nil) so the
|
||||
-- behavior's runtime state survives the
|
||||
-- immediate-mode recreation cycle.
|
||||
-- restoreState(element, state) — called after reconstruction with the
|
||||
-- snapshot previously returned by saveState.
|
||||
--
|
||||
-- shouldAttach(props) -> boolean — class-level predicate (not a hook): given
|
||||
-- an element's props table, return true if
|
||||
-- this behavior should be auto-attached.
|
||||
-- Defaults to false (opt-in).
|
||||
|
||||
--- A behavior instance: a frozen table of lifecycle hooks + a shouldAttach
|
||||
--- predicate. All hooks are always present (custom override or no-op default).
|
||||
---@class Behavior
|
||||
---@field onAttach fun(element:table)
|
||||
---@field onDetach fun(element:table)
|
||||
---@field onUpdate fun(element:table, dt:number)
|
||||
---@field onDraw fun(element:table, ctx:table)
|
||||
---@field saveState fun(element:table):any
|
||||
---@field restoreState fun(element:table, state:any)
|
||||
---@field shouldAttach fun(props:table):boolean
|
||||
|
||||
local Behavior = {}
|
||||
|
||||
-- The fixed, ordered lifecycle hook set. Order is preserved so downstream tasks
|
||||
-- (Element behavior iteration) can rely on a deterministic dispatch sequence.
|
||||
-- HOOK_NAMES is intentionally NOT extended casually — see file header.
|
||||
Behavior.HOOK_NAMES = {
|
||||
"onAttach",
|
||||
"onDetach",
|
||||
"onUpdate",
|
||||
"onDraw",
|
||||
"saveState",
|
||||
"restoreState",
|
||||
}
|
||||
|
||||
-- Allowlist of spec keys accepted by Behavior.new. Anything else is rejected so
|
||||
-- a typo (e.g. `onUpdat`) surfaces immediately instead of silently no-op'ing.
|
||||
-- Hook keys (HOOK_NAMES + shouldAttach) MUST be functions; metadata keys
|
||||
-- (drawLayer) may hold any value.
|
||||
local ALLOWED_KEYS = {
|
||||
onAttach = true,
|
||||
onDetach = true,
|
||||
onUpdate = true,
|
||||
onDraw = true,
|
||||
saveState = true,
|
||||
restoreState = true,
|
||||
shouldAttach = true,
|
||||
drawLayer = true,
|
||||
}
|
||||
|
||||
-- Spec keys whose values are NOT required to be functions (passive metadata
|
||||
-- consumed by dispatch sites, e.g. Element:draw's pre/post-children split).
|
||||
local NON_FUNCTION_KEYS = {
|
||||
drawLayer = true,
|
||||
}
|
||||
|
||||
-- Default no-op hook. Behaviors override only the hooks they need; every other
|
||||
-- hook resolves to this so dispatch sites never have to nil-check.
|
||||
local function noop() end
|
||||
|
||||
-- Default shouldAttach predicate: never auto-attach unless the behavior opts in
|
||||
-- by providing its own predicate. This is the safe default — a behavior with no
|
||||
-- opinion about which elements it applies to stays inert in the auto-attach
|
||||
-- pass (it can still be attached explicitly by name in a later task).
|
||||
local function defaultShouldAttach()
|
||||
return false
|
||||
end
|
||||
|
||||
-- Module-level default predicate exposed for callers/tests that want to
|
||||
-- reference the base default directly without constructing an instance.
|
||||
Behavior.shouldAttach = defaultShouldAttach
|
||||
|
||||
--- Factory: create a frozen behavior instance from a spec table.
|
||||
---
|
||||
--- `spec` is a table whose keys may be any subset of the 6 lifecycle hook names
|
||||
--- plus `shouldAttach`; each value (when present) must be a function. The
|
||||
--- returned table contains every lifecycle hook (custom override OR no-op) and
|
||||
--- a `shouldAttach` predicate (custom OR always-false default), so dispatch
|
||||
--- sites can call any hook unconditionally without nil-checking.
|
||||
---
|
||||
--- Unknown spec keys and non-function values raise an error immediately so
|
||||
--- mistakes fail fast at construction rather than as silent no-ops later.
|
||||
---
|
||||
---@param spec table|nil spec table overriding select hooks / shouldAttach
|
||||
---@return Behavior
|
||||
function Behavior.new(spec)
|
||||
spec = spec or {}
|
||||
|
||||
-- Validate spec keys up front so typos surface here, not as silent no-ops.
|
||||
for key, value in pairs(spec) do
|
||||
if not ALLOWED_KEYS[key] then
|
||||
error(string.format("Behavior.new: unknown spec key '%s'", tostring(key)), 2)
|
||||
end
|
||||
if not NON_FUNCTION_KEYS[key] and type(value) ~= "function" then
|
||||
error(string.format("Behavior.new: spec key '%s' must be a function, got %s", tostring(key), type(value)), 2)
|
||||
end
|
||||
end
|
||||
|
||||
local instance = {}
|
||||
|
||||
-- Populate every lifecycle hook: custom override when provided, no-op default
|
||||
-- otherwise. Guarantees `instance.hook` is always callable.
|
||||
for _, hook in ipairs(Behavior.HOOK_NAMES) do
|
||||
instance[hook] = spec[hook] or noop
|
||||
end
|
||||
|
||||
-- shouldAttach defaults to always-false; behaviors opt in by supplying one.
|
||||
instance.shouldAttach = spec.shouldAttach or defaultShouldAttach
|
||||
|
||||
-- drawLayer: optional metadata field (default nil = "background"/pre-children).
|
||||
-- Dispatch sites (Element:draw) use it to split rendering into pre-children
|
||||
-- (background layers) and post-children (overlay layers, e.g. scrollbars).
|
||||
instance.drawLayer = spec.drawLayer
|
||||
|
||||
-- Freeze: prevent adding new fields. Behavior instances are shared, stateless
|
||||
-- objects; runtime state belongs on the element, never on the behavior.
|
||||
-- (Reassigning an existing hook is still possible via direct index write —
|
||||
-- Lua metatables cannot intercept that — but the freeze communicates intent
|
||||
-- and catches accidental field additions.)
|
||||
local mt = {
|
||||
__newindex = function(_, key)
|
||||
error(string.format("Behavior: behavior instances are immutable (cannot set '%s')", tostring(key)), 2)
|
||||
end,
|
||||
--- Mark the metatable so consumers can detect a Behavior instance.
|
||||
---@return string
|
||||
__tostring = function()
|
||||
return "Behavior"
|
||||
end,
|
||||
__metatable = "Behavior",
|
||||
}
|
||||
setmetatable(instance, mt)
|
||||
|
||||
return instance
|
||||
end
|
||||
|
||||
--- Type guard: returns true if `value` is a Behavior instance produced by
|
||||
--- `Behavior.new`. Used by Element's attach path to validate registry entries
|
||||
--- without depending on identity.
|
||||
---@param value any
|
||||
---@return boolean
|
||||
function Behavior.isBehavior(value)
|
||||
return type(value) == "table" and getmetatable(value) == "Behavior"
|
||||
end
|
||||
|
||||
return Behavior
|
||||
@@ -0,0 +1,686 @@
|
||||
-- Lua 5.2+ compatibility for unpack
|
||||
local unpack = table.unpack or unpack
|
||||
|
||||
-- Warning cache to prevent duplicate warnings for the same element
|
||||
local warningCache = {}
|
||||
|
||||
local Cache = {
|
||||
canvases = {},
|
||||
quads = {},
|
||||
blurInstances = {}, -- Cache blur instances by quality
|
||||
blurredCanvases = {}, -- Cache pre-blurred canvases for immediate mode
|
||||
MAX_CANVAS_SIZE = 20,
|
||||
MAX_QUAD_SIZE = 20,
|
||||
MAX_BLURRED_CANVAS_CACHE = 50, -- Maximum cached blurred canvases
|
||||
RADIUS_THRESHOLD = 0.5, -- Skip blur below this radius
|
||||
LARGE_BLUR_THRESHOLD = 250 * 250, -- Warn if blur area exceeds this (250x250px)
|
||||
}
|
||||
|
||||
--- Round canvas size to nearest bucket for better reuse
|
||||
---@param size number Size to bucket
|
||||
---@return number bucketSize Bucketed size
|
||||
local function bucketSize(size)
|
||||
if size <= 128 then
|
||||
return math.ceil(size / 32) * 32
|
||||
elseif size <= 512 then
|
||||
return math.ceil(size / 64) * 64
|
||||
elseif size <= 1024 then
|
||||
return math.ceil(size / 128) * 128
|
||||
else
|
||||
return math.ceil(size / 256) * 256
|
||||
end
|
||||
end
|
||||
|
||||
--- Get or create a canvas from cache
|
||||
---@param width number Canvas width
|
||||
---@param height number Canvas height
|
||||
---@return love.Canvas canvas The cached or new canvas
|
||||
function Cache.getCanvas(width, height)
|
||||
-- Use bucketed sizes for better cache reuse
|
||||
local bucketedWidth = bucketSize(width)
|
||||
local bucketedHeight = bucketSize(height)
|
||||
local key = string.format("%dx%d", bucketedWidth, bucketedHeight)
|
||||
|
||||
if not Cache.canvases[key] then
|
||||
Cache.canvases[key] = {}
|
||||
end
|
||||
|
||||
local cache = Cache.canvases[key]
|
||||
|
||||
for i, entry in ipairs(cache) do
|
||||
if not entry.inUse then
|
||||
entry.inUse = true
|
||||
return entry.canvas
|
||||
end
|
||||
end
|
||||
|
||||
local canvas = love.graphics.newCanvas(bucketedWidth, bucketedHeight)
|
||||
table.insert(cache, { canvas = canvas, inUse = true })
|
||||
|
||||
if #cache > Cache.MAX_CANVAS_SIZE then
|
||||
local removed = table.remove(cache, 1)
|
||||
if removed and removed.canvas then
|
||||
removed.canvas:release()
|
||||
end
|
||||
end
|
||||
|
||||
return canvas
|
||||
end
|
||||
|
||||
--- Release a canvas back to the cache
|
||||
---@param canvas love.Canvas Canvas to release
|
||||
function Cache.releaseCanvas(canvas)
|
||||
for _, sizeCache in pairs(Cache.canvases) do
|
||||
for _, entry in ipairs(sizeCache) do
|
||||
if entry.canvas == canvas then
|
||||
entry.inUse = false
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Get or create a quad from cache
|
||||
---@param x number X position
|
||||
---@param y number Y position
|
||||
---@param width number Quad width
|
||||
---@param height number Quad height
|
||||
---@param sw number Source width
|
||||
---@param sh number Source height
|
||||
---@return love.Quad quad The cached or new quad
|
||||
function Cache.getQuad(x, y, width, height, sw, sh)
|
||||
local key = string.format("%d,%d,%d,%d,%d,%d", x, y, width, height, sw, sh)
|
||||
|
||||
if not Cache.quads[key] then
|
||||
Cache.quads[key] = {}
|
||||
end
|
||||
|
||||
local cache = Cache.quads[key]
|
||||
|
||||
for i, entry in ipairs(cache) do
|
||||
if not entry.inUse then
|
||||
entry.inUse = true
|
||||
return entry.quad
|
||||
end
|
||||
end
|
||||
|
||||
local quad = love.graphics.newQuad(x, y, width, height, sw, sh)
|
||||
table.insert(cache, { quad = quad, inUse = true })
|
||||
|
||||
if #cache > Cache.MAX_QUAD_SIZE then
|
||||
table.remove(cache, 1)
|
||||
end
|
||||
|
||||
return quad
|
||||
end
|
||||
|
||||
--- Release a quad back to the cache
|
||||
---@param quad love.Quad Quad to release
|
||||
function Cache.releaseQuad(quad)
|
||||
for _, keyCache in pairs(Cache.quads) do
|
||||
for _, entry in ipairs(keyCache) do
|
||||
if entry.quad == quad then
|
||||
entry.inUse = false
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Generate cache key for blurred canvas
|
||||
---@param elementId string Element ID
|
||||
---@param x number X position
|
||||
---@param y number Y position
|
||||
---@param width number Width
|
||||
---@param height number Height
|
||||
---@param radius number Blur radius
|
||||
---@param quality number Blur quality
|
||||
---@param isBackdrop boolean Whether this is backdrop blur
|
||||
---@return string key Cache key
|
||||
function Cache.generateBlurCacheKey(elementId, x, y, width, height, radius, quality, isBackdrop)
|
||||
return string.format(
|
||||
"%s:%d:%d:%d:%d:%.1f:%d:%s",
|
||||
elementId,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
radius,
|
||||
quality,
|
||||
tostring(isBackdrop)
|
||||
)
|
||||
end
|
||||
|
||||
--- Get cached blurred canvas
|
||||
---@param key string Cache key
|
||||
---@return love.Canvas|nil canvas Cached canvas or nil
|
||||
function Cache.getBlurredCanvas(key)
|
||||
local entry = Cache.blurredCanvases[key]
|
||||
if entry then
|
||||
entry.lastUsed = os.time()
|
||||
return entry.canvas
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Store blurred canvas in cache
|
||||
---@param key string Cache key
|
||||
---@param canvas love.Canvas Canvas to cache
|
||||
function Cache.setBlurredCanvas(key, canvas)
|
||||
-- Limit cache size
|
||||
local count = 0
|
||||
for _ in pairs(Cache.blurredCanvases) do
|
||||
count = count + 1
|
||||
end
|
||||
|
||||
if count >= Cache.MAX_BLURRED_CANVAS_CACHE then
|
||||
-- Remove oldest entry
|
||||
local oldestKey = nil
|
||||
local oldestTime = math.huge
|
||||
for k, v in pairs(Cache.blurredCanvases) do
|
||||
if v.lastUsed < oldestTime then
|
||||
oldestTime = v.lastUsed
|
||||
oldestKey = k
|
||||
end
|
||||
end
|
||||
|
||||
if oldestKey then
|
||||
if Cache.blurredCanvases[oldestKey].canvas then
|
||||
Cache.blurredCanvases[oldestKey].canvas:release()
|
||||
end
|
||||
Cache.blurredCanvases[oldestKey] = nil
|
||||
end
|
||||
end
|
||||
|
||||
Cache.blurredCanvases[key] = {
|
||||
canvas = canvas,
|
||||
lastUsed = os.time(),
|
||||
}
|
||||
end
|
||||
|
||||
--- Clear blurred canvas cache for specific element
|
||||
---@param elementId string Element ID to clear cache for
|
||||
function Cache.clearBlurredCanvasesForElement(elementId)
|
||||
for key, entry in pairs(Cache.blurredCanvases) do
|
||||
if key:match("^" .. elementId .. ":") then
|
||||
if entry.canvas then
|
||||
entry.canvas:release()
|
||||
end
|
||||
Cache.blurredCanvases[key] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Clear all caches
|
||||
function Cache.clear()
|
||||
-- Release all blurred canvases
|
||||
for _, entry in pairs(Cache.blurredCanvases) do
|
||||
if entry.canvas then
|
||||
entry.canvas:release()
|
||||
end
|
||||
end
|
||||
|
||||
Cache.canvases = {}
|
||||
Cache.quads = {}
|
||||
Cache.blurInstances = {}
|
||||
Cache.blurredCanvases = {}
|
||||
warningCache = {} -- Clear warning cache on cache clear
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- SHADER BUILDER
|
||||
-- ============================================================================
|
||||
|
||||
local ShaderBuilder = {}
|
||||
|
||||
--- Build Gaussian blur shader with given parameters
|
||||
---@param taps number Number of samples (must be odd, >= 3)
|
||||
---@param offset number Offset value
|
||||
---@param offsetType string "weighted" or "center"
|
||||
---@param sigma number Sigma value for Gaussian distribution
|
||||
---@return love.Shader shader The compiled blur shader
|
||||
function ShaderBuilder.build(taps, offset, offsetType, sigma)
|
||||
taps = math.floor(taps)
|
||||
sigma = sigma >= 1 and sigma or (taps - 1) * offset / 6
|
||||
sigma = math.max(sigma, 1)
|
||||
|
||||
local steps = (taps + 1) / 2
|
||||
|
||||
local gOffsets = {}
|
||||
local gWeights = {}
|
||||
for i = 1, steps do
|
||||
gOffsets[i] = offset * (i - 1)
|
||||
gWeights[i] = math.exp(-0.5 * (gOffsets[i] - 0) ^ 2 * 1 / sigma ^ 2)
|
||||
end
|
||||
|
||||
local offsets = {}
|
||||
local weights = {}
|
||||
for i = #gWeights, 2, -2 do
|
||||
local oA, oB = gOffsets[i], gOffsets[i - 1]
|
||||
local wA, wB = gWeights[i], gWeights[i - 1]
|
||||
wB = oB == 0 and wB / 2 or wB
|
||||
local weight = wA + wB
|
||||
offsets[#offsets + 1] = offsetType == "center" and (oA + oB) / 2 or (oA * wA + oB * wB) / weight
|
||||
weights[#weights + 1] = weight
|
||||
end
|
||||
|
||||
local code = {
|
||||
[[
|
||||
extern vec2 direction;
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {]],
|
||||
}
|
||||
|
||||
local norm = 0
|
||||
if #gWeights % 2 == 0 then
|
||||
code[#code + 1] = "vec4 c = vec4( 0.0 );"
|
||||
else
|
||||
local weight = gWeights[1]
|
||||
norm = norm + weight
|
||||
code[#code + 1] = string.format("vec4 c = %f * texture2D(tex, tc);", weight)
|
||||
end
|
||||
|
||||
local template = "c += %f * ( texture2D(tex, tc + %f * direction)+ texture2D(tex, tc - %f * direction));\n"
|
||||
for i = 1, #offsets do
|
||||
local offset = offsets[i]
|
||||
local weight = weights[i]
|
||||
norm = norm + weight * 2
|
||||
code[#code + 1] = string.format(template, weight, offset, offset)
|
||||
end
|
||||
code[#code + 1] = string.format("return c * vec4(%f) * color; }", 1 / norm)
|
||||
|
||||
local shaderCode = table.concat(code)
|
||||
return love.graphics.newShader(shaderCode)
|
||||
end
|
||||
|
||||
--- Get or create a blur instance from cache
|
||||
---@param quality number Quality level (1-10)
|
||||
---@return table blurData Cached blur data {shader, taps}
|
||||
function Cache.getBlurInstance(quality)
|
||||
if not Cache.blurInstances[quality] then
|
||||
local taps = 3 + (quality - 1) * 1.5
|
||||
taps = math.floor(taps)
|
||||
if taps % 2 == 0 then
|
||||
taps = taps + 1
|
||||
end
|
||||
|
||||
local shader = ShaderBuilder.build(taps, 1.0, "weighted", -1)
|
||||
Cache.blurInstances[quality] = {
|
||||
shader = shader,
|
||||
taps = taps,
|
||||
}
|
||||
end
|
||||
|
||||
return Cache.blurInstances[quality]
|
||||
end
|
||||
|
||||
---@class BlurProps
|
||||
---@field quality number? Quality level (1-10, default: 5)
|
||||
|
||||
---@class Blur
|
||||
---@field shader love.Shader The blur shader
|
||||
---@field quality number Quality level (1-10)
|
||||
---@field taps number Number of shader taps
|
||||
---@field _ErrorHandler table? Reference to ErrorHandler module
|
||||
local Blur = {}
|
||||
Blur.__index = Blur
|
||||
|
||||
--- Check if we should warn about large blur area in immediate mode
|
||||
---@param elementId string|nil Element ID for caching warnings
|
||||
---@param width number Blur area width
|
||||
---@param height number Blur area height
|
||||
---@param blurType string "content" or "backdrop"
|
||||
local function checkLargeBlurWarning(elementId, width, height, blurType)
|
||||
-- Skip if no ErrorHandler available
|
||||
if not Blur._ErrorHandler then
|
||||
return
|
||||
end
|
||||
|
||||
-- Skip if not in immediate mode
|
||||
if not Blur._blurOptimizations then
|
||||
return
|
||||
end
|
||||
|
||||
-- Calculate blur area
|
||||
local area = width * height
|
||||
|
||||
-- Skip if area is below threshold
|
||||
if area <= Cache.LARGE_BLUR_THRESHOLD then
|
||||
return
|
||||
end
|
||||
|
||||
-- Generate warning key (use elementId if available, otherwise use dimensions)
|
||||
local warningKey = elementId or string.format("%dx%d:%s", width, height, blurType)
|
||||
|
||||
-- Skip if already warned for this element/area
|
||||
if warningCache[warningKey] then
|
||||
return
|
||||
end
|
||||
|
||||
-- Mark as warned
|
||||
warningCache[warningKey] = true
|
||||
|
||||
-- Issue warning
|
||||
local message =
|
||||
string.format("Large %s blur area detected (%dx%d = %d pixels) in immediate mode", blurType, width, height, area)
|
||||
|
||||
local suggestion =
|
||||
"Consider using retained mode for this component to avoid recreating blur effects every frame. Large blur operations are expensive and can cause performance issues in immediate mode."
|
||||
|
||||
Blur._ErrorHandler:warn("Blur", "PERF_003", {
|
||||
area = string.format("%.0fx%.0f", width or 0, height or 0),
|
||||
})
|
||||
end
|
||||
|
||||
--- Create a new blur effect instance
|
||||
---@param props BlurProps? Blur configuration
|
||||
---@return Blur blur The new blur instance
|
||||
function Blur.new(props)
|
||||
props = props or {}
|
||||
|
||||
local quality = props.quality or 5
|
||||
quality = math.max(1, math.min(10, quality))
|
||||
|
||||
-- Get cached blur instance for this quality level
|
||||
local blurData = Cache.getBlurInstance(quality)
|
||||
|
||||
local self = setmetatable({}, Blur)
|
||||
self.shader = blurData.shader
|
||||
self.quality = quality
|
||||
self.taps = blurData.taps
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
--- Apply blur to a region of the screen
|
||||
---@param radius number Blur radius in pixels
|
||||
---@param x number X position
|
||||
---@param y number Y position
|
||||
---@param width number Width of region
|
||||
---@param height number Height of region
|
||||
---@param drawFunc function Function to draw content to be blurred
|
||||
function Blur:applyToRegion(radius, x, y, width, height, drawFunc)
|
||||
if type(drawFunc) ~= "function" then
|
||||
if Blur._ErrorHandler then
|
||||
Blur._ErrorHandler:warn("Blur", "BLUR_001")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if radius <= 0 or width <= 0 or height <= 0 then
|
||||
drawFunc()
|
||||
return
|
||||
end
|
||||
|
||||
-- Early exit for very low radius (optimization)
|
||||
if radius < Cache.RADIUS_THRESHOLD then
|
||||
drawFunc()
|
||||
return
|
||||
end
|
||||
|
||||
-- Check for large blur area in immediate mode
|
||||
checkLargeBlurWarning(nil, width, height, "content")
|
||||
|
||||
-- Calculate offset multiplier based on radius and quality
|
||||
-- Higher quality = more samples = smaller steps for same radius
|
||||
local offsetMultiplier = radius / self.quality
|
||||
|
||||
local canvas1 = Cache.getCanvas(width, height)
|
||||
local canvas2 = Cache.getCanvas(width, height)
|
||||
|
||||
local prevCanvas = love.graphics.getCanvas()
|
||||
local prevShader = love.graphics.getShader()
|
||||
local prevColor = { love.graphics.getColor() }
|
||||
local prevBlendMode = love.graphics.getBlendMode()
|
||||
|
||||
love.graphics.setCanvas(canvas1)
|
||||
love.graphics.clear()
|
||||
love.graphics.push()
|
||||
love.graphics.origin()
|
||||
love.graphics.translate(-x, -y)
|
||||
drawFunc()
|
||||
love.graphics.pop()
|
||||
|
||||
love.graphics.setShader(self.shader)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setBlendMode("alpha", "premultiplied")
|
||||
|
||||
-- Single pass with radius-controlled offset
|
||||
love.graphics.setCanvas(canvas2)
|
||||
love.graphics.clear()
|
||||
self.shader:send("direction", { offsetMultiplier / width, 0 })
|
||||
love.graphics.draw(canvas1, 0, 0)
|
||||
|
||||
love.graphics.setCanvas(canvas1)
|
||||
love.graphics.clear()
|
||||
self.shader:send("direction", { 0, offsetMultiplier / height })
|
||||
love.graphics.draw(canvas2, 0, 0)
|
||||
|
||||
love.graphics.setCanvas(prevCanvas)
|
||||
love.graphics.setShader()
|
||||
love.graphics.setBlendMode(prevBlendMode)
|
||||
love.graphics.draw(canvas1, x, y)
|
||||
|
||||
love.graphics.setShader(prevShader)
|
||||
love.graphics.setColor(unpack(prevColor))
|
||||
|
||||
Cache.releaseCanvas(canvas1)
|
||||
Cache.releaseCanvas(canvas2)
|
||||
end
|
||||
|
||||
--- Apply backdrop blur effect (blur content behind a region)
|
||||
---@param radius number Blur radius in pixels
|
||||
---@param x number X position
|
||||
---@param y number Y position
|
||||
---@param width number Width of region
|
||||
---@param height number Height of region
|
||||
---@param backdropCanvas love.Canvas Canvas containing the backdrop content
|
||||
function Blur:applyBackdrop(radius, x, y, width, height, backdropCanvas)
|
||||
if not backdropCanvas then
|
||||
if Blur._ErrorHandler then
|
||||
Blur._ErrorHandler:warn("Blur", "BLUR_002")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if radius <= 0 or width <= 0 or height <= 0 then
|
||||
return
|
||||
end
|
||||
|
||||
-- Early exit for very low radius (optimization)
|
||||
if radius < Cache.RADIUS_THRESHOLD then
|
||||
return
|
||||
end
|
||||
|
||||
-- Calculate offset multiplier based on radius and quality
|
||||
local offsetMultiplier = radius / self.quality
|
||||
|
||||
local canvas1 = Cache.getCanvas(width, height)
|
||||
local canvas2 = Cache.getCanvas(width, height)
|
||||
|
||||
local prevCanvas = love.graphics.getCanvas()
|
||||
local prevShader = love.graphics.getShader()
|
||||
local prevColor = { love.graphics.getColor() }
|
||||
local prevBlendMode = love.graphics.getBlendMode()
|
||||
|
||||
love.graphics.setCanvas(canvas1)
|
||||
love.graphics.clear()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setBlendMode("alpha", "premultiplied")
|
||||
|
||||
local backdropWidth, backdropHeight = backdropCanvas:getDimensions()
|
||||
local quad = Cache.getQuad(x, y, width, height, backdropWidth, backdropHeight)
|
||||
love.graphics.draw(backdropCanvas, quad, 0, 0)
|
||||
|
||||
love.graphics.setShader(self.shader)
|
||||
|
||||
-- Single pass with radius-controlled offset
|
||||
love.graphics.setCanvas(canvas2)
|
||||
love.graphics.clear()
|
||||
self.shader:send("direction", { offsetMultiplier / width, 0 })
|
||||
love.graphics.draw(canvas1, 0, 0)
|
||||
|
||||
love.graphics.setCanvas(canvas1)
|
||||
love.graphics.clear()
|
||||
self.shader:send("direction", { 0, offsetMultiplier / height })
|
||||
love.graphics.draw(canvas2, 0, 0)
|
||||
|
||||
love.graphics.setCanvas(prevCanvas)
|
||||
love.graphics.setShader()
|
||||
love.graphics.setBlendMode(prevBlendMode)
|
||||
love.graphics.draw(canvas1, x, y)
|
||||
|
||||
love.graphics.setShader(prevShader)
|
||||
love.graphics.setColor(unpack(prevColor))
|
||||
|
||||
Cache.releaseCanvas(canvas1)
|
||||
Cache.releaseCanvas(canvas2)
|
||||
Cache.releaseQuad(quad)
|
||||
end
|
||||
|
||||
--- Get the current quality level
|
||||
---@return number quality Quality level (1-10)
|
||||
function Blur:getQuality()
|
||||
return self.quality
|
||||
end
|
||||
|
||||
--- Get the number of shader taps
|
||||
---@return number taps Number of shader taps
|
||||
function Blur:getTaps()
|
||||
return self.taps
|
||||
end
|
||||
|
||||
--- Clear all caches (call on window resize or memory cleanup)
|
||||
function Blur.clearCache()
|
||||
Cache.clear()
|
||||
end
|
||||
|
||||
--- Apply backdrop blur with caching support
|
||||
---@param radius number Blur radius in pixels
|
||||
---@param x number X position
|
||||
---@param y number Y position
|
||||
---@param width number Width of region
|
||||
---@param height number Height of region
|
||||
---@param backdropCanvas love.Canvas Canvas containing the backdrop content
|
||||
---@param elementId string|nil Element ID for caching (nil disables caching)
|
||||
function Blur:applyBackdropCached(radius, x, y, width, height, backdropCanvas, elementId)
|
||||
-- If caching is disabled or no element ID, fall back to regular apply
|
||||
if not Blur._blurOptimizations or not elementId then
|
||||
return self:applyBackdrop(radius, x, y, width, height, backdropCanvas)
|
||||
end
|
||||
|
||||
-- Generate cache key
|
||||
local cacheKey = Cache.generateBlurCacheKey(elementId, x, y, width, height, radius, self.quality, true)
|
||||
|
||||
-- Check cache
|
||||
local cachedCanvas = Cache.getBlurredCanvas(cacheKey)
|
||||
if cachedCanvas then
|
||||
-- Draw cached blur
|
||||
local prevCanvas = love.graphics.getCanvas()
|
||||
local prevShader = love.graphics.getShader()
|
||||
local prevColor = { love.graphics.getColor() }
|
||||
local prevBlendMode = love.graphics.getBlendMode()
|
||||
|
||||
love.graphics.setCanvas(prevCanvas)
|
||||
love.graphics.setShader()
|
||||
love.graphics.setBlendMode(prevBlendMode)
|
||||
love.graphics.draw(cachedCanvas, x, y)
|
||||
|
||||
love.graphics.setShader(prevShader)
|
||||
love.graphics.setColor(unpack(prevColor))
|
||||
return
|
||||
end
|
||||
|
||||
-- Not cached, render and cache
|
||||
if not backdropCanvas then
|
||||
if Blur._ErrorHandler then
|
||||
Blur._ErrorHandler:warn("Blur", "BLUR_002")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if radius <= 0 or width <= 0 or height <= 0 then
|
||||
return
|
||||
end
|
||||
|
||||
-- Early exit for very low radius (optimization)
|
||||
if radius < Cache.RADIUS_THRESHOLD then
|
||||
return
|
||||
end
|
||||
|
||||
-- Check for large blur area in immediate mode
|
||||
checkLargeBlurWarning(elementId, width, height, "backdrop")
|
||||
|
||||
-- Calculate offset multiplier based on radius and quality
|
||||
local offsetMultiplier = radius / self.quality
|
||||
|
||||
local canvas1 = Cache.getCanvas(width, height)
|
||||
local canvas2 = Cache.getCanvas(width, height)
|
||||
|
||||
local prevCanvas = love.graphics.getCanvas()
|
||||
local prevShader = love.graphics.getShader()
|
||||
local prevColor = { love.graphics.getColor() }
|
||||
local prevBlendMode = love.graphics.getBlendMode()
|
||||
|
||||
love.graphics.setCanvas(canvas1)
|
||||
love.graphics.clear()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setBlendMode("alpha", "premultiplied")
|
||||
|
||||
local backdropWidth, backdropHeight = backdropCanvas:getDimensions()
|
||||
local quad = Cache.getQuad(x, y, width, height, backdropWidth, backdropHeight)
|
||||
love.graphics.draw(backdropCanvas, quad, 0, 0)
|
||||
|
||||
love.graphics.setShader(self.shader)
|
||||
|
||||
-- Single pass with radius-controlled offset
|
||||
love.graphics.setCanvas(canvas2)
|
||||
love.graphics.clear()
|
||||
self.shader:send("direction", { offsetMultiplier / width, 0 })
|
||||
love.graphics.draw(canvas1, 0, 0)
|
||||
|
||||
love.graphics.setCanvas(canvas1)
|
||||
love.graphics.clear()
|
||||
self.shader:send("direction", { 0, offsetMultiplier / height })
|
||||
love.graphics.draw(canvas2, 0, 0)
|
||||
|
||||
-- Cache the result
|
||||
local cachedResult = love.graphics.newCanvas(width, height)
|
||||
love.graphics.setCanvas(cachedResult)
|
||||
love.graphics.clear()
|
||||
love.graphics.setShader()
|
||||
love.graphics.setBlendMode("alpha", "premultiplied")
|
||||
love.graphics.draw(canvas1, 0, 0)
|
||||
Cache.setBlurredCanvas(cacheKey, cachedResult)
|
||||
|
||||
love.graphics.setCanvas(prevCanvas)
|
||||
love.graphics.setShader()
|
||||
love.graphics.setBlendMode(prevBlendMode)
|
||||
love.graphics.draw(canvas1, x, y)
|
||||
|
||||
love.graphics.setShader(prevShader)
|
||||
love.graphics.setColor(unpack(prevColor))
|
||||
|
||||
Cache.releaseCanvas(canvas1)
|
||||
Cache.releaseCanvas(canvas2)
|
||||
Cache.releaseQuad(quad)
|
||||
end
|
||||
|
||||
--- Clear blur cache for specific element
|
||||
---@param elementId string Element ID
|
||||
function Blur.clearElementCache(elementId)
|
||||
Cache.clearBlurredCanvasesForElement(elementId)
|
||||
end
|
||||
|
||||
--- Initialize Blur module with dependencies
|
||||
---@param deps table Dependencies: { ErrorHandler = ErrorHandler?, immediateModeOptimizations = boolean? }
|
||||
function Blur.init(deps)
|
||||
if type(deps) == "table" then
|
||||
Blur._ErrorHandler = deps.ErrorHandler
|
||||
Blur._blurOptimizations = deps.immediateModeOptimizations or false
|
||||
end
|
||||
end
|
||||
|
||||
Blur.Cache = Cache
|
||||
Blur.ShaderBuilder = ShaderBuilder
|
||||
|
||||
return Blur
|
||||
@@ -0,0 +1,385 @@
|
||||
--- Utility module for parsing and evaluating CSS-like calc() expressions
|
||||
--- Supports arithmetic operations (+, -, *, /) with mixed units (px, %, vw, vh)
|
||||
---@class Calc
|
||||
local Calc = {}
|
||||
|
||||
--- Initialize Calc module with dependencies
|
||||
---@param deps CalcDependencies Dependencies: { ErrorHandler = ErrorHandler? }
|
||||
function Calc.init(deps)
|
||||
Calc._ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
|
||||
--- Token types for lexical analysis
|
||||
local TokenType = {
|
||||
NUMBER = "NUMBER",
|
||||
UNIT = "UNIT",
|
||||
PLUS = "PLUS",
|
||||
MINUS = "MINUS",
|
||||
MULTIPLY = "MULTIPLY",
|
||||
DIVIDE = "DIVIDE",
|
||||
LPAREN = "LPAREN",
|
||||
RPAREN = "RPAREN",
|
||||
EOF = "EOF",
|
||||
}
|
||||
|
||||
--- Tokenize a calc expression string into tokens
|
||||
---@param expr string The expression to tokenize (e.g., "50% - 10vw")
|
||||
---@return CalcToken[]? tokens Array of tokens with type, value, unit
|
||||
---@return string? error Error message if tokenization fails
|
||||
local function tokenize(expr)
|
||||
local tokens = {}
|
||||
local i = 1
|
||||
local len = #expr
|
||||
|
||||
while i <= len do
|
||||
local char = expr:sub(i, i)
|
||||
|
||||
-- Skip whitespace
|
||||
if char:match("%s") then
|
||||
i = i + 1
|
||||
-- Number (including decimals, but NOT negative - handled separately below)
|
||||
elseif char:match("%d") or (char == "." and expr:sub(i + 1, i + 1):match("%d")) then
|
||||
local numStr = ""
|
||||
|
||||
-- Parse integer and decimal parts
|
||||
while i <= len and (expr:sub(i, i):match("%d") or expr:sub(i, i) == ".") do
|
||||
numStr = numStr .. expr:sub(i, i)
|
||||
i = i + 1
|
||||
end
|
||||
|
||||
local num = tonumber(numStr)
|
||||
if not num then
|
||||
return nil, "Invalid number: " .. numStr
|
||||
end
|
||||
|
||||
-- Check for unit following the number
|
||||
local unitStr = ""
|
||||
while i <= len and expr:sub(i, i):match("[%a%%]") do
|
||||
unitStr = unitStr .. expr:sub(i, i)
|
||||
i = i + 1
|
||||
end
|
||||
|
||||
-- Default to px if no unit
|
||||
if unitStr == "" then
|
||||
unitStr = "px"
|
||||
end
|
||||
|
||||
-- Validate unit
|
||||
local validUnits = { px = true, ["%"] = true, vw = true, vh = true }
|
||||
if not validUnits[unitStr] then
|
||||
return nil, "Invalid unit: " .. unitStr
|
||||
end
|
||||
|
||||
table.insert(tokens, {
|
||||
type = TokenType.NUMBER,
|
||||
value = num,
|
||||
unit = unitStr,
|
||||
})
|
||||
-- Operators
|
||||
elseif char == "+" then
|
||||
table.insert(tokens, { type = TokenType.PLUS })
|
||||
i = i + 1
|
||||
elseif char == "-" then
|
||||
-- Check if this is a negative number or subtraction
|
||||
-- It's a negative number if previous token is an operator or opening paren
|
||||
local prevToken = tokens[#tokens]
|
||||
if
|
||||
not prevToken
|
||||
or prevToken.type == TokenType.PLUS
|
||||
or prevToken.type == TokenType.MINUS
|
||||
or prevToken.type == TokenType.MULTIPLY
|
||||
or prevToken.type == TokenType.DIVIDE
|
||||
or prevToken.type == TokenType.LPAREN
|
||||
then
|
||||
-- This is a negative number, continue to number parsing
|
||||
local numStr = "-"
|
||||
i = i + 1
|
||||
|
||||
-- Parse integer and decimal parts
|
||||
while i <= len and (expr:sub(i, i):match("%d") or expr:sub(i, i) == ".") do
|
||||
numStr = numStr .. expr:sub(i, i)
|
||||
i = i + 1
|
||||
end
|
||||
|
||||
local num = tonumber(numStr)
|
||||
if not num then
|
||||
return nil, "Invalid number: " .. numStr
|
||||
end
|
||||
|
||||
-- Check for unit following the number
|
||||
local unitStr = ""
|
||||
while i <= len and expr:sub(i, i):match("[%a%%]") do
|
||||
unitStr = unitStr .. expr:sub(i, i)
|
||||
i = i + 1
|
||||
end
|
||||
|
||||
-- Default to px if no unit
|
||||
if unitStr == "" then
|
||||
unitStr = "px"
|
||||
end
|
||||
|
||||
-- Validate unit
|
||||
local validUnits = { px = true, ["%"] = true, vw = true, vh = true }
|
||||
if not validUnits[unitStr] then
|
||||
return nil, "Invalid unit: " .. unitStr
|
||||
end
|
||||
|
||||
table.insert(tokens, {
|
||||
type = TokenType.NUMBER,
|
||||
value = num,
|
||||
unit = unitStr,
|
||||
})
|
||||
else
|
||||
-- This is subtraction operator
|
||||
table.insert(tokens, { type = TokenType.MINUS })
|
||||
i = i + 1
|
||||
end
|
||||
elseif char == "*" then
|
||||
table.insert(tokens, { type = TokenType.MULTIPLY })
|
||||
i = i + 1
|
||||
elseif char == "/" then
|
||||
table.insert(tokens, { type = TokenType.DIVIDE })
|
||||
i = i + 1
|
||||
elseif char == "(" then
|
||||
table.insert(tokens, { type = TokenType.LPAREN })
|
||||
i = i + 1
|
||||
elseif char == ")" then
|
||||
table.insert(tokens, { type = TokenType.RPAREN })
|
||||
i = i + 1
|
||||
else
|
||||
return nil, "Unexpected character: " .. char
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(tokens, { type = TokenType.EOF })
|
||||
return tokens
|
||||
end
|
||||
|
||||
--- Parser for calc expressions using recursive descent
|
||||
---@class Parser
|
||||
---@field tokens CalcToken[] Array of tokens
|
||||
---@field pos number Current token position
|
||||
local Parser = {}
|
||||
Parser.__index = Parser
|
||||
|
||||
--- Create a new parser
|
||||
---@param tokens CalcToken[] Array of tokens
|
||||
---@return Parser
|
||||
function Parser.new(tokens)
|
||||
local self = setmetatable({}, Parser)
|
||||
self.tokens = tokens
|
||||
self.pos = 1
|
||||
return self
|
||||
end
|
||||
|
||||
--- Get current token
|
||||
---@return CalcToken token Current token
|
||||
function Parser:current()
|
||||
return self.tokens[self.pos]
|
||||
end
|
||||
|
||||
--- Advance to next token
|
||||
function Parser:advance()
|
||||
self.pos = self.pos + 1
|
||||
end
|
||||
|
||||
--- Parse expression (handles + and -)
|
||||
---@return CalcASTNode ast Abstract syntax tree node
|
||||
function Parser:parseExpression()
|
||||
local left = self:parseTerm()
|
||||
|
||||
while self:current().type == TokenType.PLUS or self:current().type == TokenType.MINUS do
|
||||
local op = self:current().type
|
||||
self:advance()
|
||||
local right = self:parseTerm()
|
||||
left = {
|
||||
type = op == TokenType.PLUS and "add" or "subtract",
|
||||
left = left,
|
||||
right = right,
|
||||
}
|
||||
end
|
||||
|
||||
return left
|
||||
end
|
||||
|
||||
--- Parse term (handles * and /)
|
||||
---@return CalcASTNode ast Abstract syntax tree node
|
||||
function Parser:parseTerm()
|
||||
local left = self:parseFactor()
|
||||
|
||||
while self:current().type == TokenType.MULTIPLY or self:current().type == TokenType.DIVIDE do
|
||||
local op = self:current().type
|
||||
self:advance()
|
||||
local right = self:parseFactor()
|
||||
left = {
|
||||
type = op == TokenType.MULTIPLY and "multiply" or "divide",
|
||||
left = left,
|
||||
right = right,
|
||||
}
|
||||
end
|
||||
|
||||
return left
|
||||
end
|
||||
|
||||
--- Parse factor (handles numbers and parentheses)
|
||||
---@return CalcASTNode ast Abstract syntax tree node
|
||||
function Parser:parseFactor()
|
||||
local token = self:current()
|
||||
|
||||
if token.type == TokenType.NUMBER then
|
||||
self:advance()
|
||||
return {
|
||||
type = "number",
|
||||
value = token.value,
|
||||
unit = token.unit,
|
||||
}
|
||||
elseif token.type == TokenType.LPAREN then
|
||||
self:advance()
|
||||
local expr = self:parseExpression()
|
||||
if self:current().type ~= TokenType.RPAREN then
|
||||
error("Expected closing parenthesis")
|
||||
end
|
||||
self:advance()
|
||||
return expr
|
||||
else
|
||||
error("Unexpected token: " .. token.type)
|
||||
end
|
||||
end
|
||||
|
||||
--- Parse the tokens into an AST
|
||||
---@return CalcASTNode ast Abstract syntax tree
|
||||
function Parser:parse()
|
||||
local ast = self:parseExpression()
|
||||
if self:current().type ~= TokenType.EOF then
|
||||
error("Unexpected tokens after expression")
|
||||
end
|
||||
return ast
|
||||
end
|
||||
|
||||
--- Create a calc expression object that can be resolved later
|
||||
--- This is the main API function that users call
|
||||
---@param expr string The calc expression (e.g., "50% - 10vw")
|
||||
---@return CalcObject calcObject A calc expression object with AST
|
||||
function Calc.new(expr)
|
||||
-- Tokenize
|
||||
local tokens, err = tokenize(expr)
|
||||
if not tokens then
|
||||
if Calc._ErrorHandler then
|
||||
Calc._ErrorHandler:warn("Calc", "VAL_006", {
|
||||
expression = expr,
|
||||
error = err,
|
||||
})
|
||||
end
|
||||
-- Return a fallback calc object that resolves to 0
|
||||
return {
|
||||
_isCalc = true,
|
||||
_expr = expr,
|
||||
_ast = nil,
|
||||
_error = err,
|
||||
}
|
||||
end
|
||||
|
||||
-- Parse
|
||||
local parser = Parser.new(tokens)
|
||||
local success, ast = pcall(function()
|
||||
return parser:parse()
|
||||
end)
|
||||
|
||||
if not success then
|
||||
if Calc._ErrorHandler then
|
||||
Calc._ErrorHandler:warn("Calc", "VAL_006", {
|
||||
expression = expr,
|
||||
error = ast, -- ast contains error message on failure
|
||||
})
|
||||
end
|
||||
-- Return a fallback calc object that resolves to 0
|
||||
return {
|
||||
_isCalc = true,
|
||||
_expr = expr,
|
||||
_ast = nil,
|
||||
_error = ast,
|
||||
}
|
||||
end
|
||||
|
||||
return {
|
||||
_isCalc = true,
|
||||
_expr = expr,
|
||||
_ast = ast,
|
||||
}
|
||||
end
|
||||
|
||||
--- Check if a value is a calc expression
|
||||
---@param value any The value to check
|
||||
---@return boolean isCalc True if value is a calc expression
|
||||
function Calc.isCalc(value)
|
||||
return type(value) == "table" and value._isCalc == true
|
||||
end
|
||||
|
||||
--- Resolve a calc expression to pixel value
|
||||
---@param calcObj CalcObject The calc expression object
|
||||
---@param viewportWidth number Viewport width in pixels
|
||||
---@param viewportHeight number Viewport height in pixels
|
||||
---@param parentSize number? Parent dimension for percentage units
|
||||
---@return number resolvedValue Resolved pixel value
|
||||
function Calc.resolve(calcObj, viewportWidth, viewportHeight, parentSize)
|
||||
if not calcObj._ast then
|
||||
-- Error during parsing, return 0
|
||||
return 0
|
||||
end
|
||||
|
||||
--- Evaluate AST node recursively
|
||||
---@param node table AST node
|
||||
---@return number value Evaluated value in pixels
|
||||
local function evaluate(node)
|
||||
if node.type == "number" then
|
||||
-- Convert unit to pixels
|
||||
local value = node.value
|
||||
local unit = node.unit
|
||||
|
||||
if unit == "px" then
|
||||
return value
|
||||
elseif unit == "%" then
|
||||
if not parentSize then
|
||||
if Calc._ErrorHandler then
|
||||
Calc._ErrorHandler:warn("Calc", "LAY_003", {
|
||||
unit = "%",
|
||||
issue = "parent dimension not available",
|
||||
})
|
||||
end
|
||||
return 0
|
||||
end
|
||||
return (value / 100) * parentSize
|
||||
elseif unit == "vw" then
|
||||
return (value / 100) * viewportWidth
|
||||
elseif unit == "vh" then
|
||||
return (value / 100) * viewportHeight
|
||||
else
|
||||
return 0
|
||||
end
|
||||
elseif node.type == "add" then
|
||||
return evaluate(node.left) + evaluate(node.right)
|
||||
elseif node.type == "subtract" then
|
||||
return evaluate(node.left) - evaluate(node.right)
|
||||
elseif node.type == "multiply" then
|
||||
return evaluate(node.left) * evaluate(node.right)
|
||||
elseif node.type == "divide" then
|
||||
local divisor = evaluate(node.right)
|
||||
if divisor == 0 then
|
||||
if Calc._ErrorHandler then
|
||||
Calc._ErrorHandler:warn("Calc", "VAL_006", {
|
||||
expression = calcObj._expr,
|
||||
error = "Division by zero",
|
||||
})
|
||||
end
|
||||
return 0
|
||||
end
|
||||
return evaluate(node.left) / divisor
|
||||
else
|
||||
return 0
|
||||
end
|
||||
end
|
||||
|
||||
return evaluate(calcObj._ast)
|
||||
end
|
||||
|
||||
return Calc
|
||||
@@ -0,0 +1,346 @@
|
||||
---@class Color
|
||||
local Color = {}
|
||||
Color.__index = Color
|
||||
|
||||
--- Initialize module with shared dependencies
|
||||
---@param deps table Dependencies {ErrorHandler}
|
||||
function Color.init(deps)
|
||||
if type(deps) == "table" then
|
||||
Color._ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
end
|
||||
|
||||
--- Build type-safe color objects with automatic validation and clamping
|
||||
--- Use this to avoid invalid color values and ensure consistent LÖVE-compatible colors (0-1 range)
|
||||
---@param r number? Red component (0-1), defaults to 0
|
||||
---@param g number? Green component (0-1), defaults to 0
|
||||
---@param b number? Blue component (0-1), defaults to 0
|
||||
---@param a number? Alpha component (0-1), defaults to 1
|
||||
---@return Color color The new color instance
|
||||
function Color.new(r, g, b, a)
|
||||
-- Sanitize and clamp color components
|
||||
local _, sanitizedR = Color.validateColorChannel(r or 0, 1)
|
||||
local _, sanitizedG = Color.validateColorChannel(g or 0, 1)
|
||||
local _, sanitizedB = Color.validateColorChannel(b or 0, 1)
|
||||
local _, sanitizedA = Color.validateColorChannel(a or 1, 1)
|
||||
|
||||
-- FFI structs don't support metatables/methods without wrapping
|
||||
-- The wrapping overhead negates the FFI benefits
|
||||
local self = setmetatable({}, Color)
|
||||
self.r = sanitizedR or 0
|
||||
self.g = sanitizedG or 0
|
||||
self.b = sanitizedB or 0
|
||||
self.a = sanitizedA or 1
|
||||
return self
|
||||
end
|
||||
|
||||
--- Extract individual color channels for use with love.graphics.setColor()
|
||||
--- Use this to pass colors to LÖVE's rendering functions
|
||||
---@return number r Red component (0-1)
|
||||
---@return number g Green component (0-1)
|
||||
---@return number b Blue component (0-1)
|
||||
---@return number a Alpha component (0-1)
|
||||
function Color:toRGBA()
|
||||
return self.r, self.g, self.b, self.a
|
||||
end
|
||||
|
||||
--- Parse CSS-style hex colors into Color objects for designer-friendly workflows
|
||||
--- Use this to work with colors from design tools that export hex values
|
||||
---@param hexWithTag string Hex color string (e.g. "#RRGGBB" or "#RRGGBBAA")
|
||||
---@return Color color The parsed color (returns white on error with warning)
|
||||
function Color.fromHex(hexWithTag)
|
||||
-- Validate input type
|
||||
if type(hexWithTag) ~= "string" then
|
||||
Color._ErrorHandler:warn("Color", "VAL_004", {
|
||||
input = tostring(hexWithTag),
|
||||
issue = "not a string",
|
||||
fallback = "white (#FFFFFF)",
|
||||
})
|
||||
return Color.new(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
local hex = hexWithTag:gsub("#", "")
|
||||
if #hex == 6 then
|
||||
local r = tonumber("0x" .. hex:sub(1, 2))
|
||||
local g = tonumber("0x" .. hex:sub(3, 4))
|
||||
local b = tonumber("0x" .. hex:sub(5, 6))
|
||||
if not r or not g or not b then
|
||||
Color._ErrorHandler:warn("Color", "VAL_004", {
|
||||
input = hexWithTag,
|
||||
issue = "invalid hex digits",
|
||||
fallback = "white (#FFFFFF)",
|
||||
})
|
||||
return Color.new(1, 1, 1, 1) -- Return white as fallback
|
||||
end
|
||||
return Color.new(r / 255, g / 255, b / 255, 1)
|
||||
elseif #hex == 8 then
|
||||
local r = tonumber("0x" .. hex:sub(1, 2))
|
||||
local g = tonumber("0x" .. hex:sub(3, 4))
|
||||
local b = tonumber("0x" .. hex:sub(5, 6))
|
||||
local a = tonumber("0x" .. hex:sub(7, 8))
|
||||
if not r or not g or not b or not a then
|
||||
Color._ErrorHandler:warn("Color", "VAL_004", {
|
||||
input = hexWithTag,
|
||||
issue = "invalid hex digits",
|
||||
fallback = "white (#FFFFFFFF)",
|
||||
})
|
||||
return Color.new(1, 1, 1, 1) -- Return white as fallback
|
||||
end
|
||||
return Color.new(r / 255, g / 255, b / 255, a / 255)
|
||||
else
|
||||
Color._ErrorHandler:warn("Color", "VAL_004", {
|
||||
input = hexWithTag,
|
||||
expected = "#RRGGBB or #RRGGBBAA",
|
||||
hexLength = #hex,
|
||||
fallback = "white (#FFFFFF)",
|
||||
})
|
||||
return Color.new(1, 1, 1, 1) -- Return white as fallback
|
||||
end
|
||||
end
|
||||
|
||||
--- Verify and sanitize individual color components to prevent rendering errors
|
||||
--- Use this to safely process user input or external color data
|
||||
---@param value any Value to validate
|
||||
---@param max number? Maximum value (255 for 0-255 range, 1 for 0-1 range), defaults to 1
|
||||
---@return boolean valid True if valid
|
||||
---@return number? clamped Clamped value in 0-1 range, nil if invalid
|
||||
function Color.validateColorChannel(value, max)
|
||||
max = max or 1
|
||||
|
||||
if type(value) ~= "number" then
|
||||
return false, nil
|
||||
end
|
||||
|
||||
-- Check for NaN
|
||||
if value ~= value then
|
||||
return false, nil
|
||||
end
|
||||
|
||||
-- Check for Infinity
|
||||
if value == math.huge or value == -math.huge then
|
||||
return false, nil
|
||||
end
|
||||
|
||||
-- Normalize to 0-1 range
|
||||
local normalized = value
|
||||
if max == 255 then
|
||||
normalized = value / 255
|
||||
end
|
||||
|
||||
-- Clamp to valid range
|
||||
normalized = math.max(0, math.min(1, normalized))
|
||||
|
||||
return true, normalized
|
||||
end
|
||||
|
||||
--- Validate hex color format
|
||||
---@param hex string Hex color string (with or without #)
|
||||
---@return boolean valid True if valid format
|
||||
---@return string? error Error message if invalid, nil if valid
|
||||
function Color.validateHexColor(hex)
|
||||
if type(hex) ~= "string" then
|
||||
return false, "Hex color must be a string"
|
||||
end
|
||||
|
||||
-- Remove # prefix
|
||||
local cleanHex = hex:gsub("^#", "")
|
||||
|
||||
-- Check length (3, 6, or 8 characters)
|
||||
if #cleanHex ~= 3 and #cleanHex ~= 6 and #cleanHex ~= 8 then
|
||||
return false, string.format("Invalid hex length: %d. Expected 3, 6, or 8 characters", #cleanHex)
|
||||
end
|
||||
|
||||
-- Check for valid hex characters
|
||||
if not cleanHex:match("^[0-9A-Fa-f]+$") then
|
||||
return false, "Invalid hex characters. Use only 0-9, A-F"
|
||||
end
|
||||
|
||||
return true, nil
|
||||
end
|
||||
|
||||
--- Validate RGB/RGBA color values
|
||||
---@param r number Red component
|
||||
---@param g number Green component
|
||||
---@param b number Blue component
|
||||
---@param a number? Alpha component (optional, defaults to max)
|
||||
---@param max number? Maximum value (255 or 1), defaults to 1
|
||||
---@return boolean valid True if valid
|
||||
---@return string? error Error message if invalid, nil if valid
|
||||
function Color.validateRGBColor(r, g, b, a, max)
|
||||
max = max or 1
|
||||
a = a or max
|
||||
|
||||
local rValid = Color.validateColorChannel(r, max)
|
||||
local gValid = Color.validateColorChannel(g, max)
|
||||
local bValid = Color.validateColorChannel(b, max)
|
||||
local aValid = Color.validateColorChannel(a, max)
|
||||
|
||||
if not rValid then
|
||||
return false, string.format("Invalid red channel: %s", tostring(r))
|
||||
end
|
||||
if not gValid then
|
||||
return false, string.format("Invalid green channel: %s", tostring(g))
|
||||
end
|
||||
if not bValid then
|
||||
return false, string.format("Invalid blue channel: %s", tostring(b))
|
||||
end
|
||||
if not aValid then
|
||||
return false, string.format("Invalid alpha channel: %s", tostring(a))
|
||||
end
|
||||
|
||||
return true, nil
|
||||
end
|
||||
|
||||
--- Check if a value is a valid color format
|
||||
---@param value any Value to check
|
||||
---@return string? format Format type ("hex", "named", "table"), nil if invalid
|
||||
function Color.isValidColorFormat(value)
|
||||
local valueType = type(value)
|
||||
|
||||
-- Check for hex string
|
||||
if valueType == "string" then
|
||||
if value:match("^#?[0-9A-Fa-f]+$") then
|
||||
local valid = Color.validateHexColor(value)
|
||||
if valid then
|
||||
return "hex"
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Check for table format
|
||||
if valueType == "table" then
|
||||
-- Check for Color instance
|
||||
if getmetatable(value) == Color then
|
||||
return "table"
|
||||
end
|
||||
|
||||
-- Check for array format {r, g, b, a}
|
||||
if value[1] and value[2] and value[3] then
|
||||
local valid = Color.validateRGBColor(value[1], value[2], value[3], value[4])
|
||||
if valid then
|
||||
return "table"
|
||||
end
|
||||
end
|
||||
|
||||
-- Check for named format {r=, g=, b=, a=}
|
||||
if value.r and value.g and value.b then
|
||||
local valid = Color.validateRGBColor(value.r, value.g, value.b, value.a)
|
||||
if valid then
|
||||
return "table"
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Convert any color format to a valid Color object with graceful fallbacks
|
||||
--- Use this to robustly handle colors from any source without crashes
|
||||
---@param value any Color value to sanitize (hex, named, table, or Color instance)
|
||||
---@param default Color? Default color if invalid (defaults to black)
|
||||
---@return Color color Sanitized color instance (guaranteed non-nil)
|
||||
function Color.sanitizeColor(value, default)
|
||||
default = default or Color.new(0, 0, 0, 1)
|
||||
|
||||
local format = Color.isValidColorFormat(value)
|
||||
|
||||
if not format then
|
||||
return default
|
||||
end
|
||||
|
||||
-- Handle hex format
|
||||
if format == "hex" then
|
||||
local cleanHex = value:gsub("^#", "")
|
||||
|
||||
-- Expand 3-digit hex to 6-digit
|
||||
if #cleanHex == 3 then
|
||||
cleanHex = cleanHex:gsub("(.)", "%1%1")
|
||||
end
|
||||
|
||||
-- Try to parse
|
||||
local success, result = pcall(Color.fromHex, "#" .. cleanHex)
|
||||
if success then
|
||||
return result
|
||||
else
|
||||
return default
|
||||
end
|
||||
end
|
||||
|
||||
if format == "table" then
|
||||
-- Color instance
|
||||
if getmetatable(value) == Color then
|
||||
return value
|
||||
end
|
||||
|
||||
-- Array format
|
||||
if value[1] then
|
||||
local _, r = Color.validateColorChannel(value[1], 1)
|
||||
local _, g = Color.validateColorChannel(value[2], 1)
|
||||
local _, b = Color.validateColorChannel(value[3], 1)
|
||||
local _, a = Color.validateColorChannel(value[4] or 1, 1)
|
||||
|
||||
if r and g and b and a then
|
||||
return Color.new(r, g, b, a)
|
||||
end
|
||||
end
|
||||
|
||||
-- Named format
|
||||
if value.r then
|
||||
local _, r = Color.validateColorChannel(value.r, 1)
|
||||
local _, g = Color.validateColorChannel(value.g, 1)
|
||||
local _, b = Color.validateColorChannel(value.b, 1)
|
||||
local _, a = Color.validateColorChannel(value.a or 1, 1)
|
||||
|
||||
if r and g and b and a then
|
||||
return Color.new(r, g, b, a)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return default
|
||||
end
|
||||
|
||||
--- Universally convert any color format (hex, named, table) into a Color object
|
||||
--- Use this as your main color input handler to accept flexible color specifications
|
||||
---@param value any Color value (hex string, named color, table, or Color instance)
|
||||
---@return Color color Parsed color instance (defaults to black on error)
|
||||
function Color.parse(value)
|
||||
return Color.sanitizeColor(value, Color.new(0, 0, 0, 1))
|
||||
end
|
||||
|
||||
--- Smoothly transition between two colors for animations and gradients
|
||||
--- Use this to create color-based animations without manual channel calculations
|
||||
---@param colorA Color Starting color
|
||||
---@param colorB Color Ending color
|
||||
---@param t number Interpolation factor (0-1)
|
||||
---@return Color color Interpolated color
|
||||
function Color.lerp(colorA, colorB, t)
|
||||
-- Sanitize inputs
|
||||
if type(colorA) ~= "table" or getmetatable(colorA) ~= Color then
|
||||
colorA = Color.new(0, 0, 0, 1)
|
||||
end
|
||||
if type(colorB) ~= "table" or getmetatable(colorB) ~= Color then
|
||||
colorB = Color.new(0, 0, 0, 1)
|
||||
end
|
||||
if type(t) ~= "number" or t ~= t or t == math.huge or t == -math.huge then
|
||||
t = 0
|
||||
end
|
||||
|
||||
-- Clamp t to 0-1 range
|
||||
t = math.max(0, math.min(1, t))
|
||||
|
||||
-- Linear interpolation for each channel
|
||||
local oneMinusT = 1 - t
|
||||
local r = colorA.r * oneMinusT + colorB.r * t
|
||||
local g = colorA.g * oneMinusT + colorB.g * t
|
||||
local b = colorA.b * oneMinusT + colorB.b * t
|
||||
local a = colorA.a * oneMinusT + colorB.a * t
|
||||
|
||||
return Color.new(r, g, b, a)
|
||||
end
|
||||
|
||||
return Color
|
||||
@@ -0,0 +1,596 @@
|
||||
---@class Context
|
||||
local modulePath = (...):match("(.-)[^%.]+$")
|
||||
local ZIndex = require(modulePath .. "ZIndex")
|
||||
local Element = require(modulePath .. "Element")
|
||||
local Context = {
|
||||
topElements = {},
|
||||
-- Base scale configuration
|
||||
baseScale = nil, -- {width: number, height: number}
|
||||
-- Current scale factors
|
||||
scaleFactors = { x = 1.0, y = 1.0 },
|
||||
defaultTheme = nil,
|
||||
_focusedElement = nil,
|
||||
_focusedElementId = nil, -- Stable id used to rehydrate focus across immediate-mode frames
|
||||
_activeEventElement = nil,
|
||||
_cachedViewport = { width = 0, height = 0 },
|
||||
-- Immediate mode state
|
||||
_immediateMode = false,
|
||||
_frameNumber = 0,
|
||||
_currentFrameElements = {},
|
||||
_immediateModeState = nil, -- Will be initialized if immediate mode is enabled
|
||||
_frameStarted = false,
|
||||
_autoBeganFrame = false,
|
||||
-- Z-index ordered element tracking for immediate mode
|
||||
_zIndexOrderedElements = {}, -- Array of elements sorted by z-index (lowest to highest)
|
||||
-- Focus management guard
|
||||
_settingFocus = false,
|
||||
-- Hook called whenever focus changes: function(element) or nil
|
||||
_onFocusChanged = nil,
|
||||
|
||||
-- Navigation state
|
||||
_navigationContext = {
|
||||
lastFocusedElement = nil, -- For returning from modals
|
||||
navigationMode = "sequential", -- "sequential" or "directional"
|
||||
containerElement = nil, -- Current navigation container
|
||||
},
|
||||
|
||||
initialized = false,
|
||||
|
||||
-- Expose internal hit-testing helpers for unit testing only.
|
||||
-- These are populated below after their local definitions. They are NOT part
|
||||
-- of the public API and must not be relied on by callers; they exist so the
|
||||
-- shared hit-test core (the single place display:none guarding lives) can be
|
||||
-- exercised directly by the test suite. Subsequent unified-event-routing
|
||||
-- tasks consume these locals through the mode-agnostic query functions.
|
||||
_test = {
|
||||
pointHitsElement = nil,
|
||||
elementHasScrollableOverflow = nil,
|
||||
},
|
||||
|
||||
-- Debug draw overlay
|
||||
_debugDraw = false,
|
||||
_debugDrawKey = nil,
|
||||
|
||||
-- Initialization state tracking
|
||||
---@type "uninitialized"|"initializing"|"ready"
|
||||
_initState = "uninitialized",
|
||||
---@type table[] Queue of {props: ElementProps, callback: function(element)|nil}
|
||||
_initQueue = {},
|
||||
|
||||
-- Per-frame cache for findInteractiveAtPosition so Clickable.onUpdate's
|
||||
-- per-element call (unified-event-routing task 05) doesn't re-walk the tree
|
||||
-- + realloc + sort for every interactive element sharing the same cursor.
|
||||
-- Invalidated explicitly by Context.clearInteractiveCache() at the start of
|
||||
-- each flexlove.update (both modes) and in clearFrameElements (immediate
|
||||
-- mid-frame rebuild). It also self-invalidates when the topElements table
|
||||
-- reference changes (tests replace it per-case; immediate-mode beginFrame
|
||||
-- reassigns it each frame), so direct callers that never go through
|
||||
-- flexlove.update still see fresh results across tree swaps.
|
||||
_interactiveLookupCache = {
|
||||
valid = false,
|
||||
x = nil,
|
||||
y = nil,
|
||||
result = nil,
|
||||
topElementsRef = nil,
|
||||
frameNumber = -1,
|
||||
},
|
||||
}
|
||||
|
||||
--- Check if a point hits an element, accounting for scroll offsets and display:none.
|
||||
--- All mode-agnostic query functions use this as their single hit-test entry point,
|
||||
--- ensuring fixes like display:none guarding apply everywhere.
|
||||
---
|
||||
--- This is the single canonical place where `element.display == false` short-
|
||||
--- circuits hit testing. Parent-chain clipping/scroll-offset accumulation is
|
||||
--- the caller's responsibility: callers walk the parent chain (using
|
||||
--- `elementHasScrollableOverflow` to decide which ancestors clip) and pass the
|
||||
--- accumulated scroll offset in here. Keeping the parent walk outside this core
|
||||
--- lets retained-mode (recursive tree descent) and immediate-mode (flat
|
||||
--- z-index list) callers share the exact same primitive bounds/display logic.
|
||||
---@param element Element
|
||||
---@param mx number Screen X coordinate
|
||||
---@param my number Screen Y coordinate
|
||||
---@param scrollOffsetX number? Accumulated scroll offset from parent chain
|
||||
---@param scrollOffsetY number? Accumulated scroll offset from parent chain
|
||||
---@return boolean hits
|
||||
local function pointHitsElement(element, mx, my, scrollOffsetX, scrollOffsetY)
|
||||
scrollOffsetX = scrollOffsetX or 0
|
||||
scrollOffsetY = scrollOffsetY or 0
|
||||
|
||||
-- Skip display:none elements entirely
|
||||
if element.display == false then
|
||||
return false
|
||||
end
|
||||
|
||||
local bx = element.x
|
||||
local by = element.y
|
||||
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
|
||||
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
|
||||
|
||||
local adjustedX = mx + scrollOffsetX
|
||||
local adjustedY = my + scrollOffsetY
|
||||
|
||||
return adjustedX >= bx and adjustedX <= bx + bw and adjustedY >= by and adjustedY <= by + bh
|
||||
end
|
||||
|
||||
--- Check if an element has scrollable/clipped overflow (for scroll offset accumulation).
|
||||
--- Returns true for `scroll`, `auto`, and `hidden` on either axis. These are the
|
||||
--- overflow values that clip/translate descendant content and therefore require
|
||||
--- scroll-offset compensation when hit testing descendants.
|
||||
---@param element Element
|
||||
---@return boolean
|
||||
local function elementHasScrollableOverflow(element)
|
||||
local overflowX = element.overflowX or element.overflow
|
||||
local overflowY = element.overflowY or element.overflow
|
||||
return overflowX == "scroll"
|
||||
or overflowX == "auto"
|
||||
or overflowY == "scroll"
|
||||
or overflowY == "auto"
|
||||
or overflowX == "hidden"
|
||||
or overflowY == "hidden"
|
||||
end
|
||||
|
||||
-- Expose the two core helpers for unit testing only (see Context._test above).
|
||||
Context._test.pointHitsElement = pointHitsElement
|
||||
Context._test.elementHasScrollableOverflow = elementHasScrollableOverflow
|
||||
|
||||
-- Public exposure of the canonical hit-test primitive so other modules
|
||||
-- (e.g. FlexLove's `getElementAtPosition` / `_getTouchElementAtPosition`
|
||||
-- tree walks) can share the single implementation of bounds + display:none
|
||||
-- guarding instead of duplicating the `display == false` check inline.
|
||||
-- This keeps "display == false" in exactly one place for hit-testing.
|
||||
Context.pointHitsElement = pointHitsElement
|
||||
Context.elementHasScrollableOverflow = elementHasScrollableOverflow
|
||||
|
||||
--- Find the first scrollable element at a screen position, regardless of mode.
|
||||
--- This is the mode-agnostic successor to the two duplicated scrollable lookups
|
||||
--- that previously lived inline in `flexlove.wheelmoved`:
|
||||
--- * immediate mode — walked `Context._zIndexOrderedElements` in reverse and
|
||||
--- re-implemented bounds + parent-chain clipping + scroll-offset math; and
|
||||
--- * retained mode — recursed through `Context.topElements` with a private
|
||||
--- `findScrollableAtPosition(elements, x, y)` helper.
|
||||
--- Both paths now collapse into this single function, which routes every
|
||||
--- hit test through `pointHitsElement` (the single place `display == false`
|
||||
--- is guarded) and every scroll-offset decision through
|
||||
--- `elementHasScrollableOverflow`. As a result display:none elements are never
|
||||
--- returned in either mode, fixing the latent bug where the immediate-mode
|
||||
--- path's `isPointInElement` did not skip display:none elements.
|
||||
---
|
||||
--- The retained-mode branch intentionally mirrors the original
|
||||
--- `findScrollableAtPosition` helper's tree walk (deepest scrollable wins,
|
||||
--- children checked before self) but is upgraded to thread accumulated scroll
|
||||
--- offsets through `pointHitsElement` so nested scrolled containers are tested
|
||||
--- against their visible position. The original helper is removed once
|
||||
--- `flexlove.wheelmoved` is rerouted onto this function in task 04.
|
||||
---@param x number Screen X coordinate
|
||||
---@param y number Screen Y coordinate
|
||||
---@return Element|nil The scrollable element, or nil
|
||||
function Context.findScrollableAtPosition(x, y)
|
||||
if Context.isImmediateMode() then
|
||||
-- Immediate mode: iterate the z-index ordered list (reverse order =
|
||||
-- topmost first). pointHitsElement supplies the bounds + display guard.
|
||||
for i = #Context._zIndexOrderedElements, 1, -1 do
|
||||
local element = Context._zIndexOrderedElements[i]
|
||||
if pointHitsElement(element, x, y) then
|
||||
local overflowX = element.overflowX or element.overflow
|
||||
local overflowY = element.overflowY or element.overflow
|
||||
if
|
||||
(overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto")
|
||||
and (element._overflowX or element._overflowY)
|
||||
then
|
||||
return element
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
else
|
||||
-- Retained mode: recursive tree walk from topElements. Children are
|
||||
-- checked before self (deepest scrollable wins); accumulated scroll
|
||||
-- offsets are threaded through pointHitsElement so descendants of
|
||||
-- scrolled containers are hit-tested against their translated position.
|
||||
local function findInTree(elements, scrollOffsetX, scrollOffsetY)
|
||||
scrollOffsetX = scrollOffsetX or 0
|
||||
scrollOffsetY = scrollOffsetY or 0
|
||||
for i = #elements, 1, -1 do
|
||||
local element = elements[i]
|
||||
if pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then
|
||||
if #element.children > 0 then
|
||||
local childScrollOffsetX = scrollOffsetX
|
||||
local childScrollOffsetY = scrollOffsetY
|
||||
if elementHasScrollableOverflow(element) then
|
||||
childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0)
|
||||
childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0)
|
||||
end
|
||||
local childResult = findInTree(element.children, childScrollOffsetX, childScrollOffsetY)
|
||||
if childResult then
|
||||
return childResult
|
||||
end
|
||||
end
|
||||
-- No descendant was scrollable — check self.
|
||||
local overflowX = element.overflowX or element.overflow
|
||||
local overflowY = element.overflowY or element.overflow
|
||||
if
|
||||
(overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto")
|
||||
and (element._overflowX or element._overflowY)
|
||||
then
|
||||
return element
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
return findInTree(Context.topElements)
|
||||
end
|
||||
end
|
||||
|
||||
--- Check whether immediate mode is active.
|
||||
--- This is the single canonical accessor for the mode flag consumed throughout
|
||||
--- the framework. Mode-aware branches elsewhere call this instead of reading
|
||||
--- `Context._immediateMode` directly, so the literal mode flag only appears
|
||||
--- here (its definition) and in StateManager (its mirrored storage) — never
|
||||
--- scattered across Element / behaviors / managers (behavior-mode-unification
|
||||
--- task 11).
|
||||
---@return boolean
|
||||
function Context.isImmediateMode()
|
||||
return Context._immediateMode
|
||||
end
|
||||
|
||||
---@return number, number -- scaleX, scaleY
|
||||
function Context.getScaleFactors()
|
||||
return Context.scaleFactors.x, Context.scaleFactors.y
|
||||
end
|
||||
|
||||
--- Register an element in the z-index ordered tree (for immediate mode)
|
||||
---@param element Element The element to register
|
||||
function Context.registerElement(element)
|
||||
if not Context.isImmediateMode() then
|
||||
return
|
||||
end
|
||||
|
||||
table.insert(Context._zIndexOrderedElements, element)
|
||||
end
|
||||
|
||||
function Context.clearFrameElements()
|
||||
Context._zIndexOrderedElements = {}
|
||||
Context.clearInteractiveCache()
|
||||
end
|
||||
|
||||
--- Compute the composite z-index key for an element.
|
||||
--- rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ
|
||||
---
|
||||
--- ROOT_WEIGHT (10^10) gives the top-level ancestor's z-index 10 digits of significance.
|
||||
--- DEPTH_WEIGHT (10^3) gives nesting depth 3 digits, ensuring children always sort above
|
||||
--- their ancestors. The element's own z (capped to ±999 by ZIndex.clamp) fits within the
|
||||
--- remaining 3 digits without interfering with the depth component.
|
||||
---
|
||||
--- These weights assume |z| <= ZIndex.MAX_Z and practical tree depths (< 10^7), which
|
||||
--- keeps the composite key well within Lua's exact integer range (2^53 ≈ 9 × 10^15).
|
||||
---
|
||||
--- This is the SINGLE canonical z-index ordering function, used by both
|
||||
--- sortElementsByZIndex (the immediate-mode flat list sort) and
|
||||
--- findInteractiveAtPosition (the mode-agnostic occlusion sort). Keeping them
|
||||
--- on the same key ensures the interactive topmost element matches the visual
|
||||
--- draw order — a button in a z=50 MainMenu window must occlude a button in a
|
||||
--- z=0 BottomBar even when both buttons default to own z=0.
|
||||
local function getEffectiveZIndex(elem)
|
||||
local ownZ = elem.z or 0
|
||||
local rootZ = ownZ
|
||||
local depth = 0
|
||||
local current = elem.parent
|
||||
while current do
|
||||
rootZ = current.z or 0
|
||||
depth = depth + 1
|
||||
current = current.parent
|
||||
end
|
||||
return rootZ * ZIndex.ROOT_WEIGHT + depth * ZIndex.DEPTH_WEIGHT + ownZ
|
||||
end
|
||||
|
||||
-- Public exposure so FlexLove.getElementAtPosition shares the single
|
||||
-- implementation instead of duplicating the parent-chain walk as a closure.
|
||||
Context.getEffectiveZIndex = getEffectiveZIndex
|
||||
|
||||
--- Sort elements by z-index (called after all elements are registered)
|
||||
function Context.sortElementsByZIndex()
|
||||
-- Precompute the composite key ONCE per element so the sort comparator is a
|
||||
-- pure table lookup (O(1)) instead of re-walking the parent chain on every
|
||||
-- O(N log N) comparison. This function runs every frame in immediate mode.
|
||||
local elements = Context._zIndexOrderedElements
|
||||
local zIndices = {}
|
||||
for i = 1, #elements do
|
||||
zIndices[elements[i]] = getEffectiveZIndex(elements[i])
|
||||
end
|
||||
table.sort(elements, function(a, b)
|
||||
return zIndices[a] < zIndices[b]
|
||||
end)
|
||||
end
|
||||
|
||||
--- Find the topmost interactive element at a screen position, regardless of mode.
|
||||
--- Replaces the former immediate-mode-only `Context.getTopElementAt()` (removed
|
||||
--- in unified-event-routing task 05) and the retained-mode `_activeEventElement`
|
||||
--- mechanism — both are now funneled through this single entry point.
|
||||
---
|
||||
--- In immediate mode this replaces Context.getTopElementAt() (which only worked
|
||||
--- in immediate mode). In retained mode this provides the same role as the
|
||||
--- _activeEventElement set by flexlove.getElementAtPosition().
|
||||
---
|
||||
--- An element is "interactive" if it has an onEvent handler, themeComponent, or is editable.
|
||||
---@param x number Screen X coordinate
|
||||
---@param y number Screen Y coordinate
|
||||
---@return Element|nil The topmost interactive element, or nil
|
||||
function Context.findInteractiveAtPosition(x, y)
|
||||
-- Per-frame cache: Clickable.onUpdate runs this for every interactive
|
||||
-- element under the same cursor, but the result for a given (x,y) is
|
||||
-- identical across all of them within a single update pass. Returning a
|
||||
-- cached element restores the old 1x/frame cost of the _activeEventElement
|
||||
-- mechanism that task 05 replaced. Cache auto-invalidates when the
|
||||
-- topElements table reference changes (so tests and mid-frame rebuilds get
|
||||
-- fresh results) and is cleared explicitly per-frame in flexlove.update.
|
||||
local cache = Context._interactiveLookupCache
|
||||
if
|
||||
cache.valid
|
||||
and cache.x == x
|
||||
and cache.y == y
|
||||
and cache.topElementsRef == Context.topElements
|
||||
and cache.frameNumber == Context._frameNumber
|
||||
then
|
||||
return cache.result
|
||||
end
|
||||
|
||||
local interactiveCandidates = {}
|
||||
|
||||
local function collectInteractive(element, scrollOffsetX, scrollOffsetY)
|
||||
scrollOffsetX = scrollOffsetX or 0
|
||||
scrollOffsetY = scrollOffsetY or 0
|
||||
|
||||
if not pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then
|
||||
return
|
||||
end
|
||||
|
||||
-- Check if this element is interactive
|
||||
if element.onEvent or element.themeComponent or element.editable then
|
||||
table.insert(interactiveCandidates, element)
|
||||
end
|
||||
|
||||
-- Recurse into children with accumulated scroll offset
|
||||
local childScrollOffsetX = scrollOffsetX
|
||||
local childScrollOffsetY = scrollOffsetY
|
||||
if elementHasScrollableOverflow(element) then
|
||||
childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0)
|
||||
childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0)
|
||||
end
|
||||
|
||||
for _, child in ipairs(element.children) do
|
||||
collectInteractive(child, childScrollOffsetX, childScrollOffsetY)
|
||||
end
|
||||
end
|
||||
|
||||
-- Always traverse the tree (works in both modes — topElements exists always)
|
||||
for _, element in ipairs(Context.topElements) do
|
||||
collectInteractive(element)
|
||||
end
|
||||
|
||||
-- Sort by composite z-index descending — topmost wins. The composite key
|
||||
-- (rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ) matches the ordering
|
||||
-- used by sortElementsByZIndex / _zIndexOrderedElements, so the interactive
|
||||
-- topmost element matches the visual draw order. This is critical for the
|
||||
-- game's multi-window layout: a button inside a z=50 MainMenu window must
|
||||
-- occlude a button inside a z=0 BottomBar even when both buttons default to
|
||||
-- own z=0. Sorting by own-z alone (the original implementation) couldn't
|
||||
-- distinguish them, so the wrong window's button could win, leaving the
|
||||
-- visible button's isActiveElement=false and clicks/hover dead.
|
||||
local zIndices = {}
|
||||
for _, el in ipairs(interactiveCandidates) do
|
||||
zIndices[el] = getEffectiveZIndex(el)
|
||||
end
|
||||
table.sort(interactiveCandidates, function(a, b)
|
||||
return zIndices[a] > zIndices[b]
|
||||
end)
|
||||
|
||||
local result = interactiveCandidates[1]
|
||||
|
||||
cache.x = x
|
||||
cache.y = y
|
||||
cache.result = result
|
||||
cache.topElementsRef = Context.topElements
|
||||
cache.frameNumber = Context._frameNumber
|
||||
cache.valid = true
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
--- Invalidate the per-frame `findInteractiveAtPosition` cache.
|
||||
--- Called once at the top of `flexlove.update` (the natural per-frame boundary
|
||||
--- in both modes) and from `clearFrameElements` (immediate-mode mid-frame
|
||||
--- rebuild). After invalidation the next lookup recomputes fresh.
|
||||
function Context.clearInteractiveCache()
|
||||
local cache = Context._interactiveLookupCache
|
||||
cache.valid = false
|
||||
cache.x = nil
|
||||
cache.y = nil
|
||||
cache.result = nil
|
||||
cache.topElementsRef = nil
|
||||
cache.frameNumber = -1
|
||||
end
|
||||
|
||||
--- Set the focused element (centralizes focus management)
|
||||
--- Automatically blurs the previously focused element if different
|
||||
---@param element Element|nil The element to focus (nil to clear focus)
|
||||
function Context.setFocused(element)
|
||||
if Context._focusedElement == element then
|
||||
return -- Already focused
|
||||
end
|
||||
|
||||
-- Prevent re-entry during focus change
|
||||
if Context._settingFocus then
|
||||
return
|
||||
end
|
||||
Context._settingFocus = true
|
||||
|
||||
-- Save reference to previously focused element before updating
|
||||
local oldFocusedElement = Context._focusedElement
|
||||
|
||||
-- Blur previously focused element
|
||||
if oldFocusedElement and oldFocusedElement ~= element then
|
||||
if oldFocusedElement._textEditor then
|
||||
oldFocusedElement._textEditor:blur(oldFocusedElement)
|
||||
end
|
||||
end
|
||||
|
||||
-- Set new focused element and persist its id for immediate-mode rehydration
|
||||
Context._focusedElement = element
|
||||
Context._focusedElementId = element and (element.id ~= "" and element.id or nil) or nil
|
||||
|
||||
-- Notify any registered focus change hook (e.g. FocusIndicator)
|
||||
if Context._onFocusChanged then
|
||||
Context._onFocusChanged(element)
|
||||
end
|
||||
|
||||
-- Focus the new element's text editor if it has one
|
||||
if element and element._textEditor then
|
||||
element._textEditor._focused = true
|
||||
end
|
||||
|
||||
Context._settingFocus = false
|
||||
end
|
||||
|
||||
--- Recursively search for an element by id in an element tree
|
||||
---@param root Element The root element to start searching from
|
||||
---@param targetId string The id to search for
|
||||
---@return Element|nil The element with the matching id, or nil if not found
|
||||
local function findElementById(root, targetId)
|
||||
if root.id == targetId then
|
||||
return root
|
||||
end
|
||||
for _, child in ipairs(root.children or {}) do
|
||||
local found = findElementById(child, targetId)
|
||||
if found then
|
||||
return found
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Rehydrate _focusedElement from _focusedElementId by scanning live elements.
|
||||
--- Called at the start of getFocused() in immediate mode so stale references
|
||||
--- are always replaced with the current-frame object before use.
|
||||
function Context._rehydrateFocus()
|
||||
if not Context._focusedElementId then
|
||||
Context._focusedElement = nil
|
||||
return
|
||||
end
|
||||
|
||||
-- First, try a fast linear search through all registered elements
|
||||
for _, elem in ipairs(Context._zIndexOrderedElements) do
|
||||
if elem.id == Context._focusedElementId then
|
||||
Context._focusedElement = elem
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- If not found, recursively search from top-level elements
|
||||
-- This handles cases where elements may not be in _zIndexOrderedElements
|
||||
for _, topLevel in ipairs(Context.topElements or {}) do
|
||||
local found = findElementById(topLevel, Context._focusedElementId)
|
||||
if found then
|
||||
Context._focusedElement = found
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- Element with that id is not present this frame (e.g. screen changed)
|
||||
Context._focusedElement = nil
|
||||
end
|
||||
|
||||
--- Get the currently focused element
|
||||
---@return Element|nil The focused element, or nil if none
|
||||
function Context.getFocused()
|
||||
if Context.isImmediateMode() then
|
||||
Context._rehydrateFocus()
|
||||
end
|
||||
return Context._focusedElement
|
||||
end
|
||||
|
||||
--- Clear focus from any element
|
||||
function Context.clearFocus()
|
||||
Context._focusedElementId = nil
|
||||
Context.setFocused(nil)
|
||||
end
|
||||
|
||||
--- Get all focusable elements in tab order, regardless of mode.
|
||||
--- In immediate mode this extracts from _zIndexOrderedElements (flat, z-sorted).
|
||||
--- In retained mode it walks the element tree (DOM order).
|
||||
--- In both modes, display:none elements are excluded.
|
||||
---@return table<Element> List of focusable elements in tab order
|
||||
function Context.getFocusableElements()
|
||||
local focusable = {}
|
||||
|
||||
local function isFocusable(elem)
|
||||
if elem.display == false then
|
||||
return false
|
||||
end
|
||||
-- Use Element:isFocusable() for consistent behavior
|
||||
return Element.isFocusable(elem)
|
||||
end
|
||||
|
||||
local function collectFromTree(elements)
|
||||
for _, elem in ipairs(elements) do
|
||||
if isFocusable(elem) then
|
||||
table.insert(focusable, elem)
|
||||
end
|
||||
if #elem.children > 0 then
|
||||
collectFromTree(elem.children)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if Context._immediateMode then
|
||||
-- Immediate mode: _zIndexOrderedElements is already in z-index order (lowest first),
|
||||
-- which approximates tab order for most UIs.
|
||||
for _, elem in ipairs(Context._zIndexOrderedElements) do
|
||||
if isFocusable(elem) then
|
||||
table.insert(focusable, elem)
|
||||
end
|
||||
end
|
||||
else
|
||||
-- Retained mode: walk the top element trees in DOM order
|
||||
collectFromTree(Context.topElements)
|
||||
end
|
||||
|
||||
return focusable
|
||||
end
|
||||
|
||||
-- ====================
|
||||
-- Navigation Context
|
||||
-- ====================
|
||||
|
||||
--- Push current focus onto stack (for modals/dialogs)
|
||||
---@param element Element?
|
||||
function Context.pushFocusStack(element)
|
||||
Context._navigationContext.lastFocusedElement = Context._focusedElement
|
||||
if element then
|
||||
Context.setFocused(element)
|
||||
end
|
||||
end
|
||||
|
||||
--- Pop focus from stack (return from modal)
|
||||
---@return Element?
|
||||
function Context.popFocusStack()
|
||||
local previous = Context._navigationContext.lastFocusedElement
|
||||
Context._navigationContext.lastFocusedElement = nil
|
||||
Context.setFocused(previous)
|
||||
return previous
|
||||
end
|
||||
|
||||
--- Set navigation container (scope for tab navigation)
|
||||
---@param element Element?
|
||||
function Context.setNavigationContainer(element)
|
||||
Context._navigationContext.containerElement = element
|
||||
end
|
||||
|
||||
--- Get navigation container
|
||||
---@return Element?
|
||||
function Context.getNavigationContainer()
|
||||
return Context._navigationContext.containerElement
|
||||
end
|
||||
|
||||
return Context
|
||||
@@ -0,0 +1,171 @@
|
||||
-- Layout, flex, text, image, and ARIA enums used across FlexLove.
|
||||
-- Extracted from utils so utils stays under its LOC budget; re-exported as
|
||||
-- `utils.enums` for backward compatibility.
|
||||
|
||||
local enums = {
|
||||
---@enum TextAlign
|
||||
TextAlign = { START = "start", CENTER = "center", END = "end", JUSTIFY = "justify" },
|
||||
---@enum TextAlignVertical
|
||||
TextAlignVertical = { START = "start", CENTER = "center", END = "end" },
|
||||
---@enum Positioning
|
||||
Positioning = { ABSOLUTE = "absolute", RELATIVE = "relative", FLEX = "flex", GRID = "grid" },
|
||||
---@enum FlexDirection
|
||||
FlexDirection = {
|
||||
HORIZONTAL = "horizontal",
|
||||
VERTICAL = "vertical",
|
||||
ROW = "row",
|
||||
COLUMN = "column",
|
||||
HORIZONTAL_REVERSE = "horizontal-reverse",
|
||||
VERTICAL_REVERSE = "vertical-reverse",
|
||||
ROW_REVERSE = "row-reverse",
|
||||
COLUMN_REVERSE = "column-reverse",
|
||||
},
|
||||
---@enum JustifyContent
|
||||
JustifyContent = {
|
||||
FLEX_START = "flex-start",
|
||||
CENTER = "center",
|
||||
SPACE_AROUND = "space-around",
|
||||
FLEX_END = "flex-end",
|
||||
SPACE_EVENLY = "space-evenly",
|
||||
SPACE_BETWEEN = "space-between",
|
||||
},
|
||||
---@enum JustifySelf
|
||||
JustifySelf = {
|
||||
AUTO = "auto",
|
||||
FLEX_START = "flex-start",
|
||||
CENTER = "center",
|
||||
FLEX_END = "flex-end",
|
||||
SPACE_AROUND = "space-around",
|
||||
SPACE_EVENLY = "space-evenly",
|
||||
SPACE_BETWEEN = "space-between",
|
||||
},
|
||||
---@enum AlignItems
|
||||
AlignItems = {
|
||||
STRETCH = "stretch",
|
||||
FLEX_START = "flex-start",
|
||||
FLEX_END = "flex-end",
|
||||
CENTER = "center",
|
||||
BASELINE = "baseline",
|
||||
},
|
||||
---@enum AlignSelf
|
||||
AlignSelf = {
|
||||
AUTO = "auto",
|
||||
STRETCH = "stretch",
|
||||
FLEX_START = "flex-start",
|
||||
FLEX_END = "flex-end",
|
||||
CENTER = "center",
|
||||
BASELINE = "baseline",
|
||||
},
|
||||
---@enum AlignContent
|
||||
AlignContent = {
|
||||
STRETCH = "stretch",
|
||||
FLEX_START = "flex-start",
|
||||
FLEX_END = "flex-end",
|
||||
CENTER = "center",
|
||||
SPACE_BETWEEN = "space-between",
|
||||
SPACE_AROUND = "space-around",
|
||||
},
|
||||
---@enum FlexWrap
|
||||
FlexWrap = { NOWRAP = "nowrap", WRAP = "wrap", WRAP_REVERSE = "wrap-reverse" },
|
||||
---@enum TextSize
|
||||
TextSize = {
|
||||
XXS = "xxs",
|
||||
XS = "xs",
|
||||
SM = "sm",
|
||||
MD = "md",
|
||||
LG = "lg",
|
||||
XL = "xl",
|
||||
XXL = "xxl",
|
||||
XL3 = "3xl",
|
||||
XL4 = "4xl",
|
||||
},
|
||||
---@enum ImageRepeat
|
||||
ImageRepeat = {
|
||||
NO_REPEAT = "no-repeat",
|
||||
REPEAT = "repeat",
|
||||
REPEAT_X = "repeat-x",
|
||||
REPEAT_Y = "repeat-y",
|
||||
SPACE = "space",
|
||||
ROUND = "round",
|
||||
},
|
||||
|
||||
---@enum ARIA Role (accessibility roles for screen readers)
|
||||
ARIA = {
|
||||
-- Widget roles
|
||||
BUTTON = "button",
|
||||
CHECKBOX = "checkbox",
|
||||
LINK = "link",
|
||||
MENUITEM = "menuitem",
|
||||
MENUITEMCHECKBOX = "menuitemcheckbox",
|
||||
MENUITEMRADIO = "menuitemradio",
|
||||
PROGRESSBAR = "progressbar",
|
||||
RADIO = "radio",
|
||||
SCROLLBAR = "scrollbar",
|
||||
SLIDER = "slider",
|
||||
SPINBUTTON = "spinbutton",
|
||||
SWITCH = "switch",
|
||||
TAB = "tab",
|
||||
TABLIST = "tablist",
|
||||
TABPANEL = "tabpanel",
|
||||
TEXTBOX = "textbox",
|
||||
TOOLTIP = "tooltip",
|
||||
TREEITEM = "treeitem",
|
||||
COMBOBOX = "combobox",
|
||||
GRID = "grid",
|
||||
GRIDCELL = "gridcell",
|
||||
LISTBOX = "listbox",
|
||||
LISTITEM = "listitem",
|
||||
MENU = "menu",
|
||||
MENUBAR = "menubar",
|
||||
TREE = "tree",
|
||||
TREEGRID = "treegrid",
|
||||
WINDOW = "window",
|
||||
DIALOG = "dialog",
|
||||
ALERTDIALOG = "alertdialog",
|
||||
|
||||
-- Landmark roles
|
||||
BANNER = "banner",
|
||||
COMPLEMENTARY = "complementary",
|
||||
CONTENTINFO = "contentinfo",
|
||||
FORM = "form",
|
||||
MAIN = "main",
|
||||
NAVIGATION = "navigation",
|
||||
REGION = "region",
|
||||
SEARCH = "search",
|
||||
|
||||
-- Live region roles
|
||||
ALERT = "alert",
|
||||
LOG = "log",
|
||||
MARQUEE = "marquee",
|
||||
STATUS = "status",
|
||||
TIMERTIME = "timer",
|
||||
|
||||
-- Document structure roles
|
||||
ARTICLE = "article",
|
||||
BLOCKQUOTEBLOCKQUOTE = "blockquote",
|
||||
CAPTION = "caption",
|
||||
CODE = "code",
|
||||
DEFINITION = "definition",
|
||||
DELETED = "deletion",
|
||||
DIRECTORY = "directory",
|
||||
DIVISION = "division",
|
||||
EMphasis = "emphasis",
|
||||
HEADING = "heading",
|
||||
INSERTED = "insertion",
|
||||
LIST = "list",
|
||||
MARK = "mark",
|
||||
MATH = "math",
|
||||
NONE = "none",
|
||||
PARAGRAPH = "paragraph",
|
||||
PRESENTATION = "presentation",
|
||||
SEPARATOR = "separator",
|
||||
STRONG = "strong",
|
||||
SUBSCRIPT = "subscript",
|
||||
SUPERSCRIPT = "superscript",
|
||||
TERM = "term",
|
||||
TIME = "time",
|
||||
VARIABLE = "variable",
|
||||
},
|
||||
}
|
||||
|
||||
return { enums = enums }
|
||||
@@ -0,0 +1,843 @@
|
||||
---@class EventHandler
|
||||
---@field onEvent fun(element:Element, event:InputEvent)?
|
||||
---@field onEventDeferred boolean?
|
||||
---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Touch-specific callback
|
||||
---@field onTouchEventDeferred boolean? -- Whether onTouchEvent is deferred
|
||||
---@field onGesture fun(element:Element, gesture:table)? -- Gesture callback
|
||||
---@field onGestureDeferred boolean? -- Whether onGesture is deferred
|
||||
---@field touchEnabled boolean -- Whether touch events are processed (default: true)
|
||||
---@field multiTouchEnabled boolean -- Whether multi-touch is supported (default: false)
|
||||
---@field _pressed table<number, boolean>
|
||||
---@field _lastClickTime number?
|
||||
---@field _lastClickButton number?
|
||||
---@field _clickCount number
|
||||
---@field _dragStartX table<number, number>
|
||||
---@field _dragStartY table<number, number>
|
||||
---@field _lastMouseX table<number, number>
|
||||
---@field _lastMouseY table<number, number>
|
||||
---@field _touches table<string, table> -- Multi-touch state per touch ID
|
||||
---@field _touchStartPositions table<string, table> -- Touch start positions
|
||||
---@field _lastTouchPositions table<string, table> -- Last touch positions for delta
|
||||
---@field _touchHistory table<string, table> -- Touch position history for gestures (last 5)
|
||||
---@field _hovered boolean
|
||||
---@field _scrollbarPressHandled boolean
|
||||
---@field _InputEvent table
|
||||
---@field _utils table
|
||||
---@field _Performance Performance? Performance module dependency
|
||||
---@field _ErrorHandler ErrorHandler
|
||||
local EventHandler = {}
|
||||
EventHandler.__index = EventHandler
|
||||
|
||||
--- Initialize module with shared dependencies
|
||||
---@param deps table Dependencies {Performance, ErrorHandler, InputEvent, Context, utils}
|
||||
function EventHandler.init(deps)
|
||||
EventHandler._Performance = deps.Performance
|
||||
EventHandler._ErrorHandler = deps.ErrorHandler
|
||||
EventHandler._InputEvent = deps.InputEvent
|
||||
EventHandler._utils = deps.utils
|
||||
EventHandler._Context = deps.Context
|
||||
end
|
||||
|
||||
---@param config table Configuration options
|
||||
---@return EventHandler
|
||||
function EventHandler.new(config)
|
||||
config = config or {}
|
||||
local self = setmetatable({}, EventHandler)
|
||||
|
||||
self.onEvent = config.onEvent
|
||||
self.onEventDeferred = config.onEventDeferred
|
||||
self.onTouchEvent = config.onTouchEvent
|
||||
self.onTouchEventDeferred = config.onTouchEventDeferred or false
|
||||
self.onGesture = config.onGesture
|
||||
self.onGestureDeferred = config.onGestureDeferred or false
|
||||
self.touchEnabled = config.touchEnabled ~= false -- Default true
|
||||
self.multiTouchEnabled = config.multiTouchEnabled or false -- Default false
|
||||
|
||||
self._pressed = config._pressed or {}
|
||||
|
||||
self._lastClickTime = config._lastClickTime
|
||||
self._lastClickButton = config._lastClickButton
|
||||
self._clickCount = config._clickCount or 0
|
||||
|
||||
-- FocusIndicator reference (set after initialization)
|
||||
self._FocusIndicator = nil
|
||||
|
||||
self._dragStartX = config._dragStartX or {}
|
||||
self._dragStartY = config._dragStartY or {}
|
||||
self._lastMouseX = config._lastMouseX or {}
|
||||
self._lastMouseY = config._lastMouseY or {}
|
||||
|
||||
-- Multi-touch tracking
|
||||
self._touches = config._touches or {}
|
||||
self._touchStartPositions = config._touchStartPositions or {}
|
||||
self._lastTouchPositions = config._lastTouchPositions or {}
|
||||
self._touchHistory = config._touchHistory or {}
|
||||
|
||||
self._hovered = config._hovered or false
|
||||
|
||||
self._scrollbarPressHandled = false
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
--- Get state for persistence (for immediate mode)
|
||||
---@return table State data
|
||||
function EventHandler:getState()
|
||||
return {
|
||||
_pressed = self._pressed,
|
||||
_lastClickTime = self._lastClickTime,
|
||||
_lastClickButton = self._lastClickButton,
|
||||
_clickCount = self._clickCount,
|
||||
_dragStartX = self._dragStartX,
|
||||
_dragStartY = self._dragStartY,
|
||||
_lastMouseX = self._lastMouseX,
|
||||
_lastMouseY = self._lastMouseY,
|
||||
_touches = self._touches,
|
||||
_touchStartPositions = self._touchStartPositions,
|
||||
_lastTouchPositions = self._lastTouchPositions,
|
||||
_touchHistory = self._touchHistory,
|
||||
_hovered = self._hovered,
|
||||
}
|
||||
end
|
||||
|
||||
--- Restore state from persistence (for immediate mode)
|
||||
---@param state table State data
|
||||
function EventHandler:setState(state)
|
||||
if not state then
|
||||
return
|
||||
end
|
||||
|
||||
self._pressed = state._pressed or {}
|
||||
self._lastClickTime = state._lastClickTime
|
||||
self._lastClickButton = state._lastClickButton
|
||||
self._clickCount = state._clickCount or 0
|
||||
self._dragStartX = state._dragStartX or {}
|
||||
self._dragStartY = state._dragStartY or {}
|
||||
self._lastMouseX = state._lastMouseX or {}
|
||||
self._lastMouseY = state._lastMouseY or {}
|
||||
self._touches = state._touches or {}
|
||||
self._touchStartPositions = state._touchStartPositions or {}
|
||||
self._lastTouchPositions = state._lastTouchPositions or {}
|
||||
self._touchHistory = state._touchHistory or {}
|
||||
self._hovered = state._hovered or false
|
||||
end
|
||||
|
||||
--- Process mouse button events in the update cycle
|
||||
---@param element Element The parent element
|
||||
---@param mx number Mouse X position
|
||||
---@param my number Mouse Y position
|
||||
---@param isHovering boolean Whether mouse is over element
|
||||
---@param isActiveElement boolean Whether this is the top element at mouse position
|
||||
function EventHandler:processMouseEvents(element, mx, my, isHovering, isActiveElement)
|
||||
-- Start performance timing
|
||||
-- Performance accessed via EventHandler._Performance
|
||||
if EventHandler._Performance and EventHandler._Performance.enabled then
|
||||
EventHandler._Performance:startTimer("event_mouse")
|
||||
end
|
||||
|
||||
-- Check if currently dragging (allows drag continuation even if occluded)
|
||||
local isDragging = false
|
||||
for _, button in ipairs({ 1, 2, 3 }) do
|
||||
if self._pressed[button] and love.mouse.isDown(button) then
|
||||
isDragging = true
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
-- Check if any button is currently pressed (tracked state)
|
||||
local hasTrackedPress = false
|
||||
for _, button in ipairs({ 1, 2, 3 }) do
|
||||
if self._pressed[button] then
|
||||
hasTrackedPress = true
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
-- Can only process events if we have handler, element is enabled, and is active or dragging or has tracked press
|
||||
-- Read onEvent from element (source of truth), fallback to handler cache for backwards compat
|
||||
local canProcessEvents = (
|
||||
element.onEvent
|
||||
or self.onEvent
|
||||
or element.editable
|
||||
or element._selectState
|
||||
or element.selectOption
|
||||
)
|
||||
and element.visibility ~= "hidden"
|
||||
and not element.disabled
|
||||
and (isActiveElement or isDragging or hasTrackedPress)
|
||||
|
||||
if not canProcessEvents then
|
||||
-- If not hovering and no buttons are physically pressed, reset all pressed states
|
||||
-- This ensures the pressed state is cleared when mouse leaves without button held
|
||||
if not isHovering and not isDragging then
|
||||
for _, button in ipairs({ 1, 2, 3 }) do
|
||||
if self._pressed[button] and not love.mouse.isDown(button) then
|
||||
self._pressed[button] = false
|
||||
self._dragStartX[button] = nil
|
||||
self._dragStartY[button] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Track hover state changes even when events can't be processed
|
||||
-- Fire synthetic unhover when element becomes disabled while hovered
|
||||
if element.disabled and self._hovered then
|
||||
self._hovered = false
|
||||
if element.onEvent or self.onEvent then
|
||||
local modifiers = EventHandler._utils.getModifiers()
|
||||
local unhoverEvent = EventHandler._InputEvent.new({
|
||||
type = "unhover",
|
||||
button = 0,
|
||||
x = mx,
|
||||
y = my,
|
||||
modifiers = modifiers,
|
||||
clickCount = 0,
|
||||
})
|
||||
self:_invokeCallback(element, unhoverEvent)
|
||||
end
|
||||
elseif self._hovered and not isHovering then
|
||||
self._hovered = false
|
||||
if element.onEvent or self.onEvent then
|
||||
local modifiers = EventHandler._utils.getModifiers()
|
||||
local unhoverEvent = EventHandler._InputEvent.new({
|
||||
type = "unhover",
|
||||
button = 0,
|
||||
x = mx,
|
||||
y = my,
|
||||
modifiers = modifiers,
|
||||
clickCount = 0,
|
||||
})
|
||||
self:_invokeCallback(element, unhoverEvent)
|
||||
end
|
||||
end
|
||||
|
||||
if EventHandler._Performance and EventHandler._Performance.enabled then
|
||||
EventHandler._Performance:stopTimer("event_mouse")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- Track hover state changes and fire hover/unhover events BEFORE button processing
|
||||
-- This ensures hover fires before press when mouse first enters element
|
||||
local wasHovered = self._hovered
|
||||
local isHoveringAndActive = isHovering and isActiveElement
|
||||
|
||||
if isHoveringAndActive and not wasHovered then
|
||||
-- Just started hovering - fire hover event
|
||||
self._hovered = true
|
||||
local modifiers = EventHandler._utils.getModifiers()
|
||||
local hoverEvent = EventHandler._InputEvent.new({
|
||||
type = "hover",
|
||||
button = 0,
|
||||
x = mx,
|
||||
y = my,
|
||||
modifiers = modifiers,
|
||||
clickCount = 0,
|
||||
})
|
||||
self:_invokeCallback(element, hoverEvent)
|
||||
elseif not isHoveringAndActive and wasHovered then
|
||||
-- Just stopped hovering - fire unhover event
|
||||
self._hovered = false
|
||||
local modifiers = EventHandler._utils.getModifiers()
|
||||
local unhoverEvent = EventHandler._InputEvent.new({
|
||||
type = "unhover",
|
||||
button = 0,
|
||||
x = mx,
|
||||
y = my,
|
||||
modifiers = modifiers,
|
||||
clickCount = 0,
|
||||
})
|
||||
self:_invokeCallback(element, unhoverEvent)
|
||||
end
|
||||
|
||||
-- Process all three mouse buttons
|
||||
local buttons = { 1, 2, 3 } -- left, right, middle
|
||||
|
||||
for _, button in ipairs(buttons) do
|
||||
-- Check if this button was tracked as pressed
|
||||
local wasPressed = self._pressed[button]
|
||||
local isPhysicallyPressed = love.mouse.isDown(button)
|
||||
|
||||
if isHovering or isDragging or wasPressed then
|
||||
if isPhysicallyPressed then
|
||||
-- Button is pressed down
|
||||
if not wasPressed then
|
||||
-- Just pressed - fire press event (only if hovering)
|
||||
if isHovering then
|
||||
self:_handleMousePress(element, mx, my, button)
|
||||
end
|
||||
else
|
||||
-- Button is still pressed - check for drag
|
||||
self:_handleMouseDrag(element, mx, my, button, isHovering)
|
||||
end
|
||||
elseif wasPressed then
|
||||
-- Button was just released
|
||||
-- Only fire click and release events if mouse is still hovering AND element is active
|
||||
-- (not occluded by another element)
|
||||
if isHovering and isActiveElement then
|
||||
self:_handleMouseRelease(element, mx, my, button)
|
||||
else
|
||||
-- Mouse left before release OR element is occluded - just clear the pressed state without firing events
|
||||
self._pressed[button] = false
|
||||
self._dragStartX[button] = nil
|
||||
self._dragStartY[button] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- After processing events, reset pressed states for buttons that are no longer held
|
||||
-- This handles the case where mouse leaves while button is held, then released
|
||||
if not isHovering and not isDragging then
|
||||
for _, button in ipairs({ 1, 2, 3 }) do
|
||||
if self._pressed[button] and not love.mouse.isDown(button) then
|
||||
self._pressed[button] = false
|
||||
self._dragStartX[button] = nil
|
||||
self._dragStartY[button] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Stop performance timing
|
||||
if EventHandler._Performance and EventHandler._Performance.enabled then
|
||||
EventHandler._Performance:stopTimer("event_mouse")
|
||||
end
|
||||
end
|
||||
|
||||
--- Handle mouse button press
|
||||
---@param element Element The parent element
|
||||
---@param mx number Mouse X position
|
||||
---@param my number Mouse Y position
|
||||
---@param button number Mouse button (1=left, 2=right, 3=middle)
|
||||
function EventHandler:_handleMousePress(element, mx, my, button)
|
||||
-- Check if press is on scrollbar first (skip if already handled)
|
||||
if button == 1 and not self._scrollbarPressHandled and element._handleScrollbarPress then
|
||||
if element:_handleScrollbarPress(mx, my, button) then
|
||||
-- Scrollbar consumed the event, mark as pressed to prevent onEvent
|
||||
self._pressed[button] = true
|
||||
self._scrollbarPressHandled = true
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- Fire press event
|
||||
local modifiers = EventHandler._utils.getModifiers()
|
||||
local pressEvent = EventHandler._InputEvent.new({
|
||||
type = "press",
|
||||
button = button,
|
||||
x = mx,
|
||||
y = my,
|
||||
modifiers = modifiers,
|
||||
clickCount = 1,
|
||||
})
|
||||
self:_invokeCallback(element, pressEvent)
|
||||
|
||||
self._pressed[button] = true
|
||||
|
||||
-- On left click, set keyboard focus to any focusable element (not just editable).
|
||||
-- Clear the focus indicator since mouse navigation doesn't use it.
|
||||
local isFocusable
|
||||
if type(element.isFocusable) == "function" then
|
||||
isFocusable = element:isFocusable()
|
||||
else
|
||||
isFocusable = (element.editable == true)
|
||||
or (type(element.onEvent) == "function")
|
||||
or element._selectState ~= nil
|
||||
or element.selectOption ~= nil
|
||||
end
|
||||
|
||||
if button == 1 and EventHandler._Context and isFocusable then
|
||||
EventHandler._Context.setFocused(element)
|
||||
-- Hide focus indicator - it's only for keyboard navigation
|
||||
if EventHandler._FocusIndicator then
|
||||
EventHandler._FocusIndicator.setFocused(nil)
|
||||
end
|
||||
end
|
||||
-- Set mouse down position for text selection on left click
|
||||
if button == 1 and element._textEditor then
|
||||
element._mouseDownPosition = element._textEditor:mouseToTextPosition(element, mx, my)
|
||||
element._textDragOccurred = false -- Reset drag flag on press
|
||||
end
|
||||
|
||||
-- Record drag start position per button
|
||||
self._dragStartX[button] = mx
|
||||
self._dragStartY[button] = my
|
||||
self._lastMouseX[button] = mx
|
||||
self._lastMouseY[button] = my
|
||||
end
|
||||
|
||||
--- Handle mouse drag (while button is pressed and mouse moves)
|
||||
---@param element Element The parent element
|
||||
---@param mx number Mouse X position
|
||||
---@param my number Mouse Y position
|
||||
---@param button number Mouse button
|
||||
---@param isHovering boolean Whether mouse is over element
|
||||
function EventHandler:_handleMouseDrag(element, mx, my, button, isHovering)
|
||||
local lastX = self._lastMouseX[button] or mx
|
||||
local lastY = self._lastMouseY[button] or my
|
||||
|
||||
if lastX ~= mx or lastY ~= my then
|
||||
-- Handle scrollbar drag if scrollbar was pressed
|
||||
if button == 1 and self._scrollbarPressHandled and element._handleScrollbarDrag then
|
||||
element:_handleScrollbarDrag(mx, my)
|
||||
self._lastMouseX[button] = mx
|
||||
self._lastMouseY[button] = my
|
||||
return -- Don't process other drag events while dragging scrollbar
|
||||
end
|
||||
|
||||
-- Mouse has moved - fire drag event only if still hovering
|
||||
if isHovering then
|
||||
local modifiers = EventHandler._utils.getModifiers()
|
||||
local dx = mx - self._dragStartX[button]
|
||||
local dy = my - self._dragStartY[button]
|
||||
|
||||
local dragEvent = EventHandler._InputEvent.new({
|
||||
type = "drag",
|
||||
button = button,
|
||||
x = mx,
|
||||
y = my,
|
||||
dx = dx,
|
||||
dy = dy,
|
||||
modifiers = modifiers,
|
||||
clickCount = 1,
|
||||
})
|
||||
self:_invokeCallback(element, dragEvent)
|
||||
end
|
||||
|
||||
-- Handle text selection drag for editable elements
|
||||
if button == 1 and element.editable and element._focused and element._handleTextDrag then
|
||||
element:_handleTextDrag(mx, my)
|
||||
end
|
||||
|
||||
-- Update last known position for this button
|
||||
self._lastMouseX[button] = mx
|
||||
self._lastMouseY[button] = my
|
||||
end
|
||||
end
|
||||
|
||||
--- Handle mouse button release
|
||||
---@param mx number Mouse X position
|
||||
---@param my number Mouse Y position
|
||||
---@param button number Mouse button
|
||||
function EventHandler:_handleMouseRelease(element, mx, my, button)
|
||||
local currentTime = love.timer.getTime()
|
||||
local modifiers = EventHandler._utils.getModifiers()
|
||||
|
||||
-- Handle scrollbar release if scrollbar was pressed
|
||||
if button == 1 and self._scrollbarPressHandled and element._handleScrollbarRelease then
|
||||
element:_handleScrollbarRelease(button)
|
||||
self._scrollbarPressHandled = false -- Reset flag
|
||||
self._pressed[button] = false
|
||||
self._dragStartX[button] = nil
|
||||
self._dragStartY[button] = nil
|
||||
return -- Don't process click events for scrollbar release
|
||||
end
|
||||
|
||||
-- Determine click count (double-click detection)
|
||||
local clickCount
|
||||
local doubleClickThreshold = 0.3 -- 300ms for double-click
|
||||
|
||||
if
|
||||
self._lastClickTime
|
||||
and self._lastClickButton == button
|
||||
and (currentTime - self._lastClickTime) < doubleClickThreshold
|
||||
then
|
||||
clickCount = self._clickCount + 1
|
||||
else
|
||||
clickCount = 1
|
||||
end
|
||||
|
||||
self._clickCount = clickCount
|
||||
self._lastClickTime = currentTime
|
||||
self._lastClickButton = button
|
||||
|
||||
-- Determine event type based on button
|
||||
local eventType = "click"
|
||||
if button == 2 then
|
||||
eventType = "rightclick"
|
||||
elseif button == 3 then
|
||||
eventType = "middleclick"
|
||||
end
|
||||
|
||||
-- Fire click event
|
||||
local clickEvent = EventHandler._InputEvent.new({
|
||||
type = eventType,
|
||||
button = button,
|
||||
x = mx,
|
||||
y = my,
|
||||
modifiers = modifiers,
|
||||
clickCount = clickCount,
|
||||
})
|
||||
self:_invokeCallback(element, clickEvent)
|
||||
|
||||
self._pressed[button] = false
|
||||
|
||||
-- Clean up drag tracking
|
||||
self._dragStartX[button] = nil
|
||||
self._dragStartY[button] = nil
|
||||
|
||||
-- Clean up text selection drag tracking
|
||||
if button == 1 then
|
||||
element._mouseDownPosition = nil
|
||||
end
|
||||
|
||||
-- Focus editable elements on left click
|
||||
if button == 1 and element.editable then
|
||||
-- Only focus if not already focused (to avoid moving cursor to end)
|
||||
local wasFocused = element:isFocused()
|
||||
if not wasFocused then
|
||||
element:focus()
|
||||
end
|
||||
|
||||
-- Handle text click for cursor positioning and word selection
|
||||
-- Only process click if no text drag occurred (to preserve drag selection)
|
||||
if element._handleTextClick and not element._textDragOccurred then
|
||||
element:_handleTextClick(mx, my, clickCount)
|
||||
end
|
||||
|
||||
-- Reset drag flag after release
|
||||
element._textDragOccurred = false
|
||||
end
|
||||
|
||||
-- Fire release event
|
||||
local releaseEvent = EventHandler._InputEvent.new({
|
||||
type = "release",
|
||||
button = button,
|
||||
x = mx,
|
||||
y = my,
|
||||
modifiers = modifiers,
|
||||
clickCount = clickCount,
|
||||
})
|
||||
self:_invokeCallback(element, releaseEvent)
|
||||
|
||||
if button == 1 and element._handleSelectRelease then
|
||||
element:_handleSelectRelease()
|
||||
end
|
||||
end
|
||||
|
||||
--- Process touch events in the update cycle
|
||||
---@param element Element The parent element
|
||||
function EventHandler:processTouchEvents(element)
|
||||
-- Start performance timing
|
||||
if EventHandler._Performance and EventHandler._Performance.enabled then
|
||||
EventHandler._Performance:startTimer("event_touch")
|
||||
end
|
||||
|
||||
-- Check if element can process events
|
||||
local canProcessEvents = (
|
||||
element.onEvent
|
||||
or self.onEvent
|
||||
or element.onTouchEvent
|
||||
or self.onTouchEvent
|
||||
or element.editable
|
||||
)
|
||||
and not element.disabled
|
||||
and self.touchEnabled
|
||||
|
||||
if not canProcessEvents then
|
||||
if EventHandler._Performance and EventHandler._Performance.enabled then
|
||||
EventHandler._Performance:stopTimer("event_touch")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local bx = element.x
|
||||
local by = element.y
|
||||
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
|
||||
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
|
||||
|
||||
-- Get current active touches from LÖVE
|
||||
local activeTouches = {}
|
||||
local touches = love.touch.getTouches()
|
||||
for _, id in ipairs(touches) do
|
||||
activeTouches[tostring(id)] = true
|
||||
end
|
||||
|
||||
-- Count active tracked touches for multi-touch filtering
|
||||
local trackedTouchCount = 0
|
||||
for _ in pairs(self._touches) do
|
||||
trackedTouchCount = trackedTouchCount + 1
|
||||
end
|
||||
|
||||
-- Process active touches
|
||||
for _, id in ipairs(touches) do
|
||||
local touchId = tostring(id)
|
||||
local tx, ty = love.touch.getPosition(id)
|
||||
local pressure = 1.0 -- LÖVE doesn't provide pressure by default
|
||||
|
||||
-- Check if touch is within element bounds
|
||||
local isInside = tx >= bx and tx <= bx + bw and ty >= by and ty <= by + bh
|
||||
|
||||
if isInside then
|
||||
if not self._touches[touchId] then
|
||||
-- Multi-touch filtering: reject new touches when multiTouchEnabled=false
|
||||
-- and we already have an active touch
|
||||
if self.multiTouchEnabled or trackedTouchCount == 0 then
|
||||
-- New touch began
|
||||
self:_handleTouchBegan(element, touchId, tx, ty, pressure)
|
||||
trackedTouchCount = trackedTouchCount + 1
|
||||
end
|
||||
else
|
||||
-- Touch moved
|
||||
self:_handleTouchMoved(element, touchId, tx, ty, pressure)
|
||||
end
|
||||
elseif self._touches[touchId] then
|
||||
-- Touch moved outside or ended
|
||||
if activeTouches[touchId] then
|
||||
-- Still active but outside - fire moved event
|
||||
self:_handleTouchMoved(element, touchId, tx, ty, pressure)
|
||||
else
|
||||
-- Touch ended
|
||||
self:_handleTouchEnded(element, touchId, tx, ty, pressure)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Check for ended touches (touches that were tracked but are no longer active)
|
||||
for touchId, _ in pairs(self._touches) do
|
||||
if not activeTouches[touchId] then
|
||||
-- Touch ended or cancelled
|
||||
local lastPos = self._lastTouchPositions[touchId]
|
||||
if lastPos then
|
||||
self:_handleTouchEnded(element, touchId, lastPos.x, lastPos.y, 1.0)
|
||||
else
|
||||
-- Cleanup orphaned touch
|
||||
self:_cleanupTouch(touchId)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Stop performance timing
|
||||
if EventHandler._Performance and EventHandler._Performance.enabled then
|
||||
EventHandler._Performance:stopTimer("event_touch")
|
||||
end
|
||||
end
|
||||
|
||||
--- Handle touch began event
|
||||
---@param element Element The parent element
|
||||
---@param touchId string Touch identifier
|
||||
---@param x number Touch X position
|
||||
---@param y number Touch Y position
|
||||
---@param pressure number Touch pressure (0-1)
|
||||
function EventHandler:_handleTouchBegan(element, touchId, x, y, pressure)
|
||||
-- Create touch state
|
||||
self._touches[touchId] = {
|
||||
x = x,
|
||||
y = y,
|
||||
pressure = pressure,
|
||||
timestamp = love.timer.getTime(),
|
||||
phase = "began",
|
||||
}
|
||||
|
||||
-- Record start position
|
||||
self._touchStartPositions[touchId] = { x = x, y = y }
|
||||
self._lastTouchPositions[touchId] = { x = x, y = y }
|
||||
|
||||
-- Initialize touch history
|
||||
self._touchHistory[touchId] = { { x = x, y = y, timestamp = love.timer.getTime() } }
|
||||
|
||||
-- Create and fire touch press event
|
||||
local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "began", pressure)
|
||||
touchEvent.type = "touchpress"
|
||||
touchEvent.dx = 0
|
||||
touchEvent.dy = 0
|
||||
self:_invokeCallback(element, touchEvent)
|
||||
self:_invokeTouchCallback(element, touchEvent)
|
||||
end
|
||||
|
||||
--- Handle touch moved event
|
||||
---@param element Element The parent element
|
||||
---@param touchId string Touch identifier
|
||||
---@param x number Touch X position
|
||||
---@param y number Touch Y position
|
||||
---@param pressure number Touch pressure (0-1)
|
||||
function EventHandler:_handleTouchMoved(element, touchId, x, y, pressure)
|
||||
local touchState = self._touches[touchId]
|
||||
|
||||
if not touchState then
|
||||
-- Touch not tracked, ignore
|
||||
return
|
||||
end
|
||||
|
||||
local lastPos = self._lastTouchPositions[touchId]
|
||||
if not lastPos or lastPos.x ~= x or lastPos.y ~= y then
|
||||
-- Touch position changed
|
||||
local startPos = self._touchStartPositions[touchId]
|
||||
local dx = x - startPos.x
|
||||
local dy = y - startPos.y
|
||||
|
||||
-- Update touch state
|
||||
touchState.x = x
|
||||
touchState.y = y
|
||||
touchState.pressure = pressure
|
||||
touchState.phase = "moved"
|
||||
|
||||
-- Update last position
|
||||
self._lastTouchPositions[touchId] = { x = x, y = y }
|
||||
|
||||
-- Add to touch history (keep last 5 positions)
|
||||
local history = self._touchHistory[touchId] or {}
|
||||
table.insert(history, { x = x, y = y, timestamp = love.timer.getTime() })
|
||||
if #history > 5 then
|
||||
table.remove(history, 1)
|
||||
end
|
||||
self._touchHistory[touchId] = history
|
||||
|
||||
-- Create and fire touch move event
|
||||
local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "moved", pressure)
|
||||
touchEvent.type = "touchmove"
|
||||
touchEvent.dx = dx
|
||||
touchEvent.dy = dy
|
||||
self:_invokeCallback(element, touchEvent)
|
||||
self:_invokeTouchCallback(element, touchEvent)
|
||||
end
|
||||
end
|
||||
|
||||
--- Handle touch ended event
|
||||
---@param element Element The parent element
|
||||
---@param touchId string Touch identifier
|
||||
---@param x number Touch X position
|
||||
---@param y number Touch Y position
|
||||
---@param pressure number Touch pressure (0-1)
|
||||
function EventHandler:_handleTouchEnded(element, touchId, x, y, pressure)
|
||||
local touchState = self._touches[touchId]
|
||||
|
||||
if not touchState then
|
||||
-- Touch not tracked, ignore
|
||||
return
|
||||
end
|
||||
|
||||
local startPos = self._touchStartPositions[touchId]
|
||||
local dx = x - startPos.x
|
||||
local dy = y - startPos.y
|
||||
|
||||
-- Create and fire touch release event
|
||||
local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "ended", pressure)
|
||||
touchEvent.type = "touchrelease"
|
||||
touchEvent.dx = dx
|
||||
touchEvent.dy = dy
|
||||
self:_invokeCallback(element, touchEvent)
|
||||
self:_invokeTouchCallback(element, touchEvent)
|
||||
|
||||
-- Cleanup touch state
|
||||
self:_cleanupTouch(touchId)
|
||||
end
|
||||
|
||||
--- Cleanup touch state
|
||||
---@param touchId string Touch ID
|
||||
function EventHandler:_cleanupTouch(touchId)
|
||||
self._touches[touchId] = nil
|
||||
self._touchStartPositions[touchId] = nil
|
||||
self._lastTouchPositions[touchId] = nil
|
||||
self._touchHistory[touchId] = nil
|
||||
end
|
||||
|
||||
--- Get active touches on this element
|
||||
---@return table<string, table> Active touches
|
||||
function EventHandler:getActiveTouches()
|
||||
return self._touches
|
||||
end
|
||||
|
||||
--- Reset scrollbar press flag (called each frame)
|
||||
function EventHandler:resetScrollbarPressFlag()
|
||||
self._scrollbarPressHandled = false
|
||||
end
|
||||
|
||||
--- Check if any mouse button is pressed
|
||||
---@return boolean True if any button is pressed
|
||||
function EventHandler:isAnyButtonPressed()
|
||||
for _, pressed in pairs(self._pressed) do
|
||||
if pressed then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
--- Check if a specific button is pressed
|
||||
---@param button number Mouse button (1=left, 2=right, 3=middle)
|
||||
---@return boolean True if button is pressed
|
||||
function EventHandler:isButtonPressed(button)
|
||||
return self._pressed[button] == true
|
||||
end
|
||||
|
||||
--- Invoke the onEvent callback, optionally deferring it if onEventDeferred is true
|
||||
---@param element Element The element that triggered the event
|
||||
---@param event InputEvent The event data
|
||||
function EventHandler:_invokeCallback(element, event)
|
||||
-- Read onEvent from element (source of truth), fallback to handler cache for backwards compat
|
||||
local callback = element.onEvent or self.onEvent
|
||||
if not callback then
|
||||
return
|
||||
end
|
||||
|
||||
if self.onEventDeferred then
|
||||
-- Get FlexLove module to defer the callback
|
||||
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
|
||||
if FlexLove and FlexLove.deferCallback then
|
||||
FlexLove.deferCallback(function()
|
||||
callback(element, event)
|
||||
end)
|
||||
else
|
||||
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
|
||||
eventType = event.type,
|
||||
})
|
||||
end
|
||||
else
|
||||
callback(element, event)
|
||||
end
|
||||
end
|
||||
|
||||
--- Invoke the onTouchEvent callback, optionally deferring it
|
||||
---@param element Element The element that triggered the event
|
||||
---@param event InputEvent The touch event data
|
||||
function EventHandler:_invokeTouchCallback(element, event)
|
||||
-- Read onTouchEvent from element (source of truth), fallback to handler cache for backwards compat
|
||||
local callback = element.onTouchEvent or self.onTouchEvent
|
||||
if not callback then
|
||||
return
|
||||
end
|
||||
|
||||
if self.onTouchEventDeferred then
|
||||
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
|
||||
if FlexLove and FlexLove.deferCallback then
|
||||
FlexLove.deferCallback(function()
|
||||
callback(element, event)
|
||||
end)
|
||||
else
|
||||
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
|
||||
eventType = event.type,
|
||||
})
|
||||
end
|
||||
else
|
||||
callback(element, event)
|
||||
end
|
||||
end
|
||||
|
||||
--- Invoke the onGesture callback, optionally deferring it
|
||||
---@param element Element The element that triggered the event
|
||||
---@param gesture table The gesture data from GestureRecognizer
|
||||
function EventHandler:_invokeGestureCallback(element, gesture)
|
||||
-- Read onGesture from element (source of truth), fallback to handler cache for backwards compat
|
||||
local callback = element.onGesture or self.onGesture
|
||||
if not callback then
|
||||
return
|
||||
end
|
||||
|
||||
if self.onGestureDeferred then
|
||||
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
|
||||
if FlexLove and FlexLove.deferCallback then
|
||||
FlexLove.deferCallback(function()
|
||||
callback(element, gesture)
|
||||
end)
|
||||
else
|
||||
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
|
||||
gestureType = gesture.type,
|
||||
})
|
||||
end
|
||||
else
|
||||
callback(element, gesture)
|
||||
end
|
||||
end
|
||||
|
||||
return EventHandler
|
||||
@@ -0,0 +1,232 @@
|
||||
local packageName = ... or "FocusIndicator"
|
||||
local modulePath = packageName:match("(.-)[^%.]+$")
|
||||
|
||||
local function req(name)
|
||||
return require(modulePath .. name)
|
||||
end
|
||||
|
||||
local FocusIndicator = {}
|
||||
|
||||
--- Configuration
|
||||
---@type KeyboardNavigationFocusIndicatorConfig
|
||||
FocusIndicator.config = {
|
||||
enabled = true,
|
||||
|
||||
--- Custom draw function to override default rendering
|
||||
---@type function|nil
|
||||
--- Called with: element, bounds, style - return true to skip default drawing
|
||||
draw = nil,
|
||||
|
||||
-- Appearance
|
||||
color = { 0.2, 0.6, 1.0, 0.8 }, -- Blue with 80% opacity
|
||||
lineWidth = 2,
|
||||
inset = -3, -- Negative value extends beyond element
|
||||
borderRadius = 4,
|
||||
|
||||
-- Animation
|
||||
animationDuration = 0.15, -- Seconds for focus animation
|
||||
pulseEnabled = false, -- Enable pulsing animation
|
||||
pulseDuration = 1.0, -- Seconds per pulse cycle
|
||||
pulseScaleMin = 0.95, -- Minimum scale during pulse
|
||||
pulseScaleMax = 1.05, -- Maximum scale during pulse
|
||||
}
|
||||
|
||||
--- State
|
||||
FocusIndicator._focusedElement = nil
|
||||
FocusIndicator._animationProgress = 0
|
||||
FocusIndicator._pulsePhase = 0
|
||||
FocusIndicator._hidden = true
|
||||
FocusIndicator._deps = nil
|
||||
|
||||
--- Initialize FocusIndicator module
|
||||
---@param deps table Dependencies table containing Context and Color modules
|
||||
---@field deps.Context table Context module for getting focused element
|
||||
---@field deps.Color table Color module for color manipulation
|
||||
function FocusIndicator.init(deps)
|
||||
FocusIndicator._deps = deps
|
||||
FocusIndicator._Context = deps.Context
|
||||
FocusIndicator._Color = deps.Color
|
||||
end
|
||||
|
||||
--- Update animation state for entrance and pulse effects
|
||||
---@param dt number Delta time in seconds since last frame
|
||||
function FocusIndicator:update(dt)
|
||||
if not FocusIndicator.config.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
-- Update focus entrance animation
|
||||
if FocusIndicator._animationProgress < 1 then
|
||||
FocusIndicator._animationProgress =
|
||||
math.min(1, FocusIndicator._animationProgress + (dt / FocusIndicator.config.animationDuration))
|
||||
end
|
||||
|
||||
-- Update pulse animation
|
||||
if FocusIndicator.config.pulseEnabled then
|
||||
FocusIndicator._pulsePhase = (FocusIndicator._pulsePhase + dt) % FocusIndicator.config.pulseDuration
|
||||
end
|
||||
end
|
||||
|
||||
--- Set the focused element to render indicator around
|
||||
---@param element Element? The element to show focus indicator around, or nil to hide
|
||||
function FocusIndicator.setFocused(element)
|
||||
FocusIndicator._focusedElement = element
|
||||
FocusIndicator._hidden = element == nil
|
||||
-- Reset animation when focus changes
|
||||
if element then
|
||||
FocusIndicator._animationProgress = 0
|
||||
end
|
||||
end
|
||||
|
||||
--- Get the current scale factor for animations
|
||||
--- Combines entrance scale (0.8 to 1.0) with optional pulse scale
|
||||
---@return number Scale factor (typically 0.8-1.05 range)
|
||||
function FocusIndicator:getScale()
|
||||
local scale = 1
|
||||
|
||||
-- Apply entrance animation (scale up from 0.8)
|
||||
local entranceScale = 0.8 + (0.2 * FocusIndicator._animationProgress)
|
||||
scale = scale * entranceScale
|
||||
|
||||
-- Apply pulse animation
|
||||
if FocusIndicator.config.pulseEnabled then
|
||||
local pulseProgress = FocusIndicator._pulsePhase / FocusIndicator.config.pulseDuration
|
||||
-- Smooth sine wave pulse
|
||||
local pulseScale = FocusIndicator.config.pulseScaleMin
|
||||
+ (FocusIndicator.config.pulseScaleMax - FocusIndicator.config.pulseScaleMin)
|
||||
* (0.5 + 0.5 * math.sin(2 * math.pi * pulseProgress))
|
||||
scale = scale * pulseScale
|
||||
end
|
||||
|
||||
return scale
|
||||
end
|
||||
|
||||
--- Get the current opacity for the indicator
|
||||
--- Applies entrance animation fade-in to the configured alpha
|
||||
---@return number Alpha value (0-1 range)
|
||||
function FocusIndicator:getOpacity()
|
||||
-- Fade in on focus
|
||||
return FocusIndicator.config.color[4] * FocusIndicator._animationProgress
|
||||
end
|
||||
|
||||
--- Draw the focus indicator around the focused element
|
||||
--- Renders a rounded rectangle border, or calls custom draw function if configured
|
||||
--- Should be called from within love.draw() after all elements are drawn
|
||||
function FocusIndicator:draw()
|
||||
if not FocusIndicator.config.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
if FocusIndicator._hidden then
|
||||
return
|
||||
end
|
||||
|
||||
-- In immediate mode the stored element reference is stale (recreated every frame).
|
||||
-- Always resolve through Context so we get the live object with up-to-date positions.
|
||||
local element
|
||||
if FocusIndicator._Context then
|
||||
element = FocusIndicator._Context.getFocused()
|
||||
else
|
||||
element = FocusIndicator._focusedElement
|
||||
end
|
||||
|
||||
if not element then
|
||||
return
|
||||
end
|
||||
|
||||
-- Get element dimensions (use border-box size which includes padding)
|
||||
local x = element.x or 0
|
||||
local y = element.y or 0
|
||||
local w = element._borderBoxWidth
|
||||
or (element.width + (element.padding and (element.padding.left + element.padding.right) or 0))
|
||||
local h = element._borderBoxHeight
|
||||
or (element.height + (element.padding and (element.padding.top + element.padding.bottom) or 0))
|
||||
|
||||
if w == 0 or h == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
-- Calculate indicator dimensions with inset and scale
|
||||
local inset = FocusIndicator.config.inset
|
||||
local scale = self:getScale()
|
||||
|
||||
local indicatorX = x + inset
|
||||
local indicatorY = y + inset
|
||||
local indicatorW = w - 2 * inset
|
||||
local indicatorH = h - 2 * inset
|
||||
|
||||
-- Center the scale around the element
|
||||
local offsetX = (indicatorW * (1 - scale)) / 2
|
||||
local offsetY = (indicatorH * (1 - scale)) / 2
|
||||
|
||||
indicatorX = indicatorX + offsetX
|
||||
indicatorY = indicatorY + offsetY
|
||||
indicatorW = indicatorW * scale
|
||||
indicatorH = indicatorH * scale
|
||||
|
||||
-- Get color with animated opacity
|
||||
local r, g, b = FocusIndicator.config.color[1], FocusIndicator.config.color[2], FocusIndicator.config.color[3]
|
||||
local a = self:getOpacity()
|
||||
|
||||
-- Build style table for custom draw callback
|
||||
local bounds = {
|
||||
x = indicatorX,
|
||||
y = indicatorY,
|
||||
width = indicatorW,
|
||||
height = indicatorH,
|
||||
}
|
||||
|
||||
local style = {
|
||||
color = { r = r, g = g, b = b, a = a },
|
||||
lineWidth = FocusIndicator.config.lineWidth,
|
||||
borderRadius = FocusIndicator.config.borderRadius,
|
||||
scale = scale,
|
||||
opacity = a,
|
||||
}
|
||||
|
||||
-- Check for custom draw callback
|
||||
if FocusIndicator.config.draw then
|
||||
local skipDefault = FocusIndicator.config.draw(element, bounds, style)
|
||||
if skipDefault then
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- Save current love.graphics state
|
||||
local prevBlend, prevAlphaMode = love.graphics.getBlendMode()
|
||||
local prevR, prevG, prevB, prevA = love.graphics.getColor()
|
||||
local prevLineWidth = love.graphics.getLineWidth()
|
||||
|
||||
-- Set blend mode for transparency
|
||||
love.graphics.setBlendMode("alpha")
|
||||
|
||||
-- Draw rounded rectangle border
|
||||
love.graphics.setColor(r, g, b, a)
|
||||
love.graphics.setLineWidth(FocusIndicator.config.lineWidth)
|
||||
|
||||
-- Draw the rounded rectangle border
|
||||
local borderRadius = FocusIndicator.config.borderRadius
|
||||
love.graphics.rectangle("line", indicatorX, indicatorY, indicatorW, indicatorH, borderRadius)
|
||||
|
||||
-- Restore love.graphics state
|
||||
love.graphics.setBlendMode(prevBlend, prevAlphaMode)
|
||||
love.graphics.setColor(prevR, prevG, prevB, prevA)
|
||||
love.graphics.setLineWidth(prevLineWidth)
|
||||
end
|
||||
|
||||
--- Set the indicator color
|
||||
---@param r number Red component (0-1 range)
|
||||
---@param g number Green component (0-1 range)
|
||||
---@param b number Blue component (0-1 range)
|
||||
---@param a number|nil Alpha component (0-1 range), defaults to current alpha if omitted
|
||||
function FocusIndicator.setColor(r, g, b, a)
|
||||
FocusIndicator.config.color = { r, g, b, a or FocusIndicator.config.color[4] }
|
||||
end
|
||||
|
||||
--- Set the stroke width for the indicator border
|
||||
---@param width number Line width in pixels
|
||||
function FocusIndicator.setLineWidth(width)
|
||||
FocusIndicator.config.lineWidth = width
|
||||
end
|
||||
|
||||
return FocusIndicator
|
||||
@@ -0,0 +1,269 @@
|
||||
local modulePath = (...):match("(.-)[^%.]+$")
|
||||
local function req(name)
|
||||
return require(modulePath .. name)
|
||||
end
|
||||
|
||||
-- Font cache with LRU eviction, font resolution, and cache management.
|
||||
-- `ErrorHandler` and `resolveImagePath` are injected via init() to avoid
|
||||
-- a cross-import into utils (utils re-exports the cache via aliases).
|
||||
|
||||
-- Font cache with LRU eviction
|
||||
local FONT_CACHE = {}
|
||||
local FONT_CACHE_MAX_SIZE = 50
|
||||
local FONT_CACHE_STATS = {
|
||||
hits = 0,
|
||||
misses = 0,
|
||||
evictions = 0,
|
||||
size = 0,
|
||||
}
|
||||
|
||||
local ErrorHandler = nil
|
||||
local resolveImagePath = nil
|
||||
|
||||
--- Initialize dependencies
|
||||
---@param deps table Dependencies: { ErrorHandler = ErrorHandler, resolveImagePath = function }
|
||||
local function init(deps)
|
||||
if type(deps) == "table" then
|
||||
ErrorHandler = deps.ErrorHandler
|
||||
resolveImagePath = deps.resolveImagePath
|
||||
end
|
||||
end
|
||||
|
||||
-- LRU tracking: each entry has {font, lastUsed, accessCount}
|
||||
local function updateCacheAccess(cacheKey)
|
||||
local entry = FONT_CACHE[cacheKey]
|
||||
if entry then
|
||||
entry.lastUsed = love.timer.getTime()
|
||||
entry.accessCount = entry.accessCount + 1
|
||||
end
|
||||
end
|
||||
|
||||
local function evictLRU()
|
||||
local oldestKey = nil
|
||||
local oldestTime = math.huge
|
||||
|
||||
for key, entry in pairs(FONT_CACHE) do
|
||||
-- Skip methods (get, getFont) - only evict cache entries (tables with lastUsed)
|
||||
if type(entry) == "table" and entry.lastUsed then
|
||||
if entry.lastUsed < oldestTime then
|
||||
oldestTime = entry.lastUsed
|
||||
oldestKey = key
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if oldestKey then
|
||||
FONT_CACHE[oldestKey] = nil
|
||||
FONT_CACHE_STATS.evictions = FONT_CACHE_STATS.evictions + 1
|
||||
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size - 1
|
||||
end
|
||||
end
|
||||
|
||||
--- Create or get a font from cache
|
||||
---@param size number
|
||||
---@param fontPath string?
|
||||
---@return love.Font
|
||||
function FONT_CACHE.get(size, fontPath)
|
||||
-- Bucket font sizes for better cache reuse (reduces unique cache entries)
|
||||
-- Small sizes (< 20): round to nearest 2
|
||||
-- Medium sizes (20-40): round to nearest 4
|
||||
-- Large sizes (> 40): round to nearest 8
|
||||
if size < 20 then
|
||||
size = math.floor((size + 1) / 2) * 2
|
||||
elseif size < 40 then
|
||||
size = math.floor((size + 2) / 4) * 4
|
||||
else
|
||||
size = math.floor((size + 4) / 8) * 8
|
||||
end
|
||||
|
||||
local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size))
|
||||
|
||||
if FONT_CACHE[cacheKey] then
|
||||
-- Cache hit
|
||||
FONT_CACHE_STATS.hits = FONT_CACHE_STATS.hits + 1
|
||||
updateCacheAccess(cacheKey)
|
||||
return FONT_CACHE[cacheKey].font
|
||||
end
|
||||
|
||||
-- Cache miss
|
||||
FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1
|
||||
|
||||
local font
|
||||
if fontPath then
|
||||
local resolvedPath = resolveImagePath(fontPath)
|
||||
local success, result = pcall(love.graphics.newFont, resolvedPath, size)
|
||||
if success then
|
||||
font = result
|
||||
else
|
||||
if ErrorHandler then
|
||||
ErrorHandler:warn("utils", "RES_004", {
|
||||
resourceType = "font",
|
||||
path = fontPath,
|
||||
})
|
||||
end
|
||||
font = love.graphics.newFont(size)
|
||||
end
|
||||
else
|
||||
font = love.graphics.newFont(size)
|
||||
end
|
||||
|
||||
-- Add to cache with LRU metadata
|
||||
FONT_CACHE[cacheKey] = {
|
||||
font = font,
|
||||
lastUsed = love.timer.getTime(),
|
||||
accessCount = 1,
|
||||
}
|
||||
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size + 1
|
||||
|
||||
-- Evict if cache is full
|
||||
if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then
|
||||
evictLRU()
|
||||
end
|
||||
|
||||
return font
|
||||
end
|
||||
|
||||
--- Get font for text size (cached)
|
||||
---@param textSize number?
|
||||
---@param fontPath string?
|
||||
---@return love.Font
|
||||
function FONT_CACHE.getFont(textSize, fontPath)
|
||||
if textSize then
|
||||
return FONT_CACHE.get(textSize, fontPath)
|
||||
else
|
||||
return love.graphics.getFont()
|
||||
end
|
||||
end
|
||||
|
||||
-- Font resolution utilities
|
||||
|
||||
--- Resolve font path from fontFamily and theme
|
||||
---@param fontFamily string? Font family name or direct path
|
||||
---@param themeComponent string? Theme component name
|
||||
---@param themeManager table? ThemeManager instance
|
||||
---@return string? Resolved font path or nil
|
||||
local function resolveFontPath(fontFamily, themeComponent, themeManager)
|
||||
if fontFamily then
|
||||
-- Check if fontFamily is a theme font name
|
||||
local themeToUse = themeManager and themeManager:getTheme()
|
||||
if themeToUse and themeToUse.fonts and themeToUse.fonts[fontFamily] then
|
||||
return themeToUse.fonts[fontFamily]
|
||||
else
|
||||
-- Treat as direct path to font file
|
||||
return fontFamily
|
||||
end
|
||||
elseif themeComponent and themeManager then
|
||||
-- If using themeComponent but no fontFamily specified, check for default font in theme
|
||||
return themeManager:getDefaultFontFamily()
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Get font for element (resolves from theme or fontFamily)
|
||||
---@param textSize number? Text size in pixels
|
||||
---@param fontFamily string? Font family name or direct path
|
||||
---@param themeComponent string? Theme component name
|
||||
---@param themeManager table? ThemeManager instance
|
||||
---@return love.Font
|
||||
local function getFont(textSize, fontFamily, themeComponent, themeManager)
|
||||
local fontPath = resolveFontPath(fontFamily, themeComponent, themeManager)
|
||||
return FONT_CACHE.getFont(textSize, fontPath)
|
||||
end
|
||||
|
||||
-- Font cache management
|
||||
|
||||
--- Get font cache statistics
|
||||
---@return table stats {hits, misses, evictions, size, hitRate}
|
||||
local function getFontCacheStats()
|
||||
local total = FONT_CACHE_STATS.hits + FONT_CACHE_STATS.misses
|
||||
local hitRate = total > 0 and (FONT_CACHE_STATS.hits / total) or 0
|
||||
return {
|
||||
hits = FONT_CACHE_STATS.hits,
|
||||
misses = FONT_CACHE_STATS.misses,
|
||||
evictions = FONT_CACHE_STATS.evictions,
|
||||
size = FONT_CACHE_STATS.size,
|
||||
hitRate = hitRate,
|
||||
}
|
||||
end
|
||||
|
||||
--- Set maximum font cache size
|
||||
---@param maxSize number Maximum number of fonts to cache
|
||||
local function setFontCacheSize(maxSize)
|
||||
FONT_CACHE_MAX_SIZE = math.max(1, maxSize)
|
||||
|
||||
-- Evict entries if cache is now over limit
|
||||
while FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE do
|
||||
evictLRU()
|
||||
end
|
||||
end
|
||||
|
||||
--- Clear font cache
|
||||
local function clearFontCache()
|
||||
-- Clear cache entries but preserve methods (get, getFont)
|
||||
for key, entry in pairs(FONT_CACHE) do
|
||||
if type(entry) == "table" and entry.lastUsed then
|
||||
FONT_CACHE[key] = nil
|
||||
end
|
||||
end
|
||||
FONT_CACHE_STATS.size = 0
|
||||
FONT_CACHE_STATS.evictions = 0
|
||||
end
|
||||
|
||||
--- Preload font at multiple sizes
|
||||
---@param fontPath string? Path to font file (nil for default font)
|
||||
---@param sizes table Array of font sizes to preload
|
||||
local function preloadFont(fontPath, sizes)
|
||||
for _, size in ipairs(sizes) do
|
||||
-- Round size to reduce cache entries
|
||||
size = math.floor(size + 0.5)
|
||||
|
||||
local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size))
|
||||
|
||||
if not FONT_CACHE[cacheKey] then
|
||||
local font
|
||||
if fontPath then
|
||||
local resolvedPath = resolveImagePath(fontPath)
|
||||
local success, result = pcall(love.graphics.newFont, resolvedPath, size)
|
||||
if success then
|
||||
font = result
|
||||
else
|
||||
font = love.graphics.newFont(size)
|
||||
end
|
||||
else
|
||||
font = love.graphics.newFont(size)
|
||||
end
|
||||
|
||||
FONT_CACHE[cacheKey] = {
|
||||
font = font,
|
||||
lastUsed = love.timer.getTime(),
|
||||
accessCount = 1,
|
||||
}
|
||||
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size + 1
|
||||
FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1
|
||||
|
||||
-- Evict if cache is full
|
||||
if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then
|
||||
evictLRU()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Reset font cache statistics
|
||||
local function resetFontCacheStats()
|
||||
FONT_CACHE_STATS.hits = 0
|
||||
FONT_CACHE_STATS.misses = 0
|
||||
FONT_CACHE_STATS.evictions = 0
|
||||
end
|
||||
|
||||
return {
|
||||
FONT_CACHE = FONT_CACHE,
|
||||
init = init,
|
||||
resolveFontPath = resolveFontPath,
|
||||
getFont = getFont,
|
||||
getFontCacheStats = getFontCacheStats,
|
||||
setFontCacheSize = setFontCacheSize,
|
||||
clearFontCache = clearFontCache,
|
||||
preloadFont = preloadFont,
|
||||
resetFontCacheStats = resetFontCacheStats,
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
---@class GestureRecognizer
|
||||
---@field _touches table<string, table> -- Current touch states
|
||||
---@field _gestureStates table -- Active gesture states
|
||||
---@field _config table -- Gesture configuration (thresholds, etc.)
|
||||
---@field _InputEvent table
|
||||
---@field _utils table
|
||||
local GestureRecognizer = {}
|
||||
GestureRecognizer.__index = GestureRecognizer
|
||||
|
||||
-- Gesture types enum
|
||||
local GestureType = {
|
||||
TAP = "tap",
|
||||
DOUBLE_TAP = "double_tap",
|
||||
LONG_PRESS = "long_press",
|
||||
SWIPE = "swipe",
|
||||
PAN = "pan",
|
||||
PINCH = "pinch",
|
||||
ROTATE = "rotate",
|
||||
}
|
||||
|
||||
-- Gesture states
|
||||
local GestureState = {
|
||||
POSSIBLE = "possible",
|
||||
BEGAN = "began",
|
||||
CHANGED = "changed",
|
||||
ENDED = "ended",
|
||||
CANCELLED = "cancelled",
|
||||
FAILED = "failed",
|
||||
}
|
||||
|
||||
-- Default configuration
|
||||
local defaultConfig = {
|
||||
-- Tap gesture
|
||||
tapMaxDuration = 0.3, -- seconds
|
||||
tapMaxMovement = 10, -- pixels
|
||||
|
||||
-- Double-tap gesture
|
||||
doubleTapInterval = 0.3, -- seconds between taps
|
||||
|
||||
-- Long-press gesture
|
||||
longPressMinDuration = 0.5, -- seconds
|
||||
longPressMaxMovement = 10, -- pixels
|
||||
|
||||
-- Swipe gesture
|
||||
swipeMinDistance = 50, -- pixels
|
||||
swipeMaxDuration = 0.2, -- seconds
|
||||
swipeMinVelocity = 200, -- pixels per second
|
||||
|
||||
-- Pan gesture
|
||||
panMinMovement = 5, -- pixels to start pan
|
||||
|
||||
-- Pinch gesture
|
||||
pinchMinScaleChange = 0.1, -- 10% scale change
|
||||
|
||||
-- Rotate gesture
|
||||
rotateMinAngleChange = 5, -- degrees
|
||||
}
|
||||
|
||||
--- Create a new GestureRecognizer instance
|
||||
---@param config table? Optional configuration options
|
||||
---@param deps table Dependencies {InputEvent, utils}
|
||||
---@return GestureRecognizer
|
||||
function GestureRecognizer.new(config, deps)
|
||||
config = config or {}
|
||||
|
||||
local self = setmetatable({}, GestureRecognizer)
|
||||
|
||||
self._InputEvent = deps.InputEvent
|
||||
self._utils = deps.utils
|
||||
|
||||
-- Merge configuration with defaults
|
||||
self._config = {}
|
||||
for key, value in pairs(defaultConfig) do
|
||||
self._config[key] = config[key] or value
|
||||
end
|
||||
|
||||
self._touches = {}
|
||||
self._gestureStates = {
|
||||
tap = nil,
|
||||
doubleTap = { lastTapTime = 0, tapCount = 0 },
|
||||
longPress = {},
|
||||
swipe = {},
|
||||
pan = {},
|
||||
pinch = {},
|
||||
rotate = {},
|
||||
}
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
--- Update gesture recognizer with touch event
|
||||
---@param event InputEvent Touch event
|
||||
function GestureRecognizer:processTouchEvent(event)
|
||||
if not event.touchId then
|
||||
return nil
|
||||
end
|
||||
|
||||
local touchId = event.touchId
|
||||
local gestures = {}
|
||||
|
||||
-- Update touch state
|
||||
if event.type == "touchpress" then
|
||||
self._touches[touchId] = {
|
||||
startX = event.x,
|
||||
startY = event.y,
|
||||
x = event.x,
|
||||
y = event.y,
|
||||
startTime = event.timestamp,
|
||||
lastTime = event.timestamp,
|
||||
phase = "began",
|
||||
}
|
||||
|
||||
-- Initialize gesture detection
|
||||
self:_detectTapBegan(touchId, event)
|
||||
self:_detectLongPressBegan(touchId, event)
|
||||
elseif event.type == "touchmove" then
|
||||
local touch = self._touches[touchId]
|
||||
if touch then
|
||||
touch.x = event.x
|
||||
touch.y = event.y
|
||||
touch.lastTime = event.timestamp
|
||||
touch.phase = "moved"
|
||||
|
||||
-- Update gesture detection
|
||||
local panGesture = self:_detectPan(touchId, event)
|
||||
if panGesture then
|
||||
table.insert(gestures, panGesture)
|
||||
end
|
||||
local swipeGesture = self:_detectSwipe(touchId, event)
|
||||
if swipeGesture then
|
||||
table.insert(gestures, swipeGesture)
|
||||
end
|
||||
|
||||
-- Multi-touch gestures
|
||||
if self:_getTouchCount() >= 2 then
|
||||
local pinchGesture = self:_detectPinch(event)
|
||||
if pinchGesture then
|
||||
table.insert(gestures, pinchGesture)
|
||||
end
|
||||
local rotateGesture = self:_detectRotate(event)
|
||||
if rotateGesture then
|
||||
table.insert(gestures, rotateGesture)
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif event.type == "touchrelease" then
|
||||
local touch = self._touches[touchId]
|
||||
if touch then
|
||||
touch.phase = "ended"
|
||||
|
||||
-- Finalize gesture detection
|
||||
local tapGesture = self:_detectTapEnded(touchId, event)
|
||||
if tapGesture then
|
||||
table.insert(gestures, tapGesture)
|
||||
end
|
||||
local swipeGesture = self:_detectSwipeEnded(touchId, event)
|
||||
if swipeGesture then
|
||||
table.insert(gestures, swipeGesture)
|
||||
end
|
||||
local panGesture = self:_detectPanEnded(touchId, event)
|
||||
if panGesture then
|
||||
table.insert(gestures, panGesture)
|
||||
end
|
||||
|
||||
-- Cleanup touch
|
||||
self._touches[touchId] = nil
|
||||
end
|
||||
elseif event.type == "touchcancel" then
|
||||
-- Cancel all active gestures for this touch
|
||||
self._touches[touchId] = nil
|
||||
self:_cancelAllGestures()
|
||||
end
|
||||
|
||||
return #gestures > 0 and gestures or nil
|
||||
end
|
||||
|
||||
--- Get number of active touches
|
||||
---@return number
|
||||
function GestureRecognizer:_getTouchCount()
|
||||
local count = 0
|
||||
for _ in pairs(self._touches) do
|
||||
count = count + 1
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
--- Detect tap gesture began
|
||||
---@param touchId string
|
||||
---@param event InputEvent
|
||||
function GestureRecognizer:_detectTapBegan(touchId, event)
|
||||
-- Tap detection happens on touch end
|
||||
-- Just record the touch for now
|
||||
end
|
||||
|
||||
--- Detect tap gesture ended
|
||||
---@param touchId string
|
||||
---@param event InputEvent
|
||||
function GestureRecognizer:_detectTapEnded(touchId, event)
|
||||
local touch = self._touches[touchId]
|
||||
if not touch then
|
||||
return
|
||||
end
|
||||
|
||||
local duration = event.timestamp - touch.startTime
|
||||
local dx = event.x - touch.startX
|
||||
local dy = event.y - touch.startY
|
||||
local distance = math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
-- Check if it's a valid tap
|
||||
if duration < self._config.tapMaxDuration and distance < self._config.tapMaxMovement then
|
||||
local currentTime = event.timestamp
|
||||
local doubleTapState = self._gestureStates.doubleTap
|
||||
|
||||
-- Check for double-tap
|
||||
if currentTime - doubleTapState.lastTapTime < self._config.doubleTapInterval then
|
||||
doubleTapState.tapCount = doubleTapState.tapCount + 1
|
||||
|
||||
if doubleTapState.tapCount >= 2 then
|
||||
-- Fire double-tap gesture
|
||||
return {
|
||||
type = GestureType.DOUBLE_TAP,
|
||||
state = GestureState.ENDED,
|
||||
x = event.x,
|
||||
y = event.y,
|
||||
timestamp = event.timestamp,
|
||||
}
|
||||
end
|
||||
else
|
||||
doubleTapState.tapCount = 1
|
||||
end
|
||||
|
||||
doubleTapState.lastTapTime = currentTime
|
||||
|
||||
-- Fire tap gesture
|
||||
return {
|
||||
type = GestureType.TAP,
|
||||
state = GestureState.ENDED,
|
||||
x = event.x,
|
||||
y = event.y,
|
||||
timestamp = event.timestamp,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
--- Detect long-press gesture began
|
||||
---@param touchId string
|
||||
---@param event InputEvent
|
||||
function GestureRecognizer:_detectLongPressBegan(touchId, event)
|
||||
-- Long-press detection happens continuously during touch
|
||||
self._gestureStates.longPress[touchId] = {
|
||||
startX = event.x,
|
||||
startY = event.y,
|
||||
startTime = event.timestamp,
|
||||
triggered = false,
|
||||
}
|
||||
end
|
||||
|
||||
--- Detect pan gesture
|
||||
---@param touchId string
|
||||
---@param event InputEvent
|
||||
---@return table? Gesture event
|
||||
function GestureRecognizer:_detectPan(touchId, event)
|
||||
local touch = self._touches[touchId]
|
||||
if not touch then
|
||||
return nil
|
||||
end
|
||||
|
||||
local dx = event.x - touch.startX
|
||||
local dy = event.y - touch.startY
|
||||
local distance = math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
local panState = self._gestureStates.pan[touchId]
|
||||
|
||||
if not panState then
|
||||
-- Check if pan should begin
|
||||
if distance >= self._config.panMinMovement then
|
||||
self._gestureStates.pan[touchId] = {
|
||||
active = true,
|
||||
lastX = touch.startX,
|
||||
lastY = touch.startY,
|
||||
}
|
||||
panState = self._gestureStates.pan[touchId]
|
||||
|
||||
return {
|
||||
type = GestureType.PAN,
|
||||
state = GestureState.BEGAN,
|
||||
x = event.x,
|
||||
y = event.y,
|
||||
dx = dx,
|
||||
dy = dy,
|
||||
timestamp = event.timestamp,
|
||||
}
|
||||
end
|
||||
else
|
||||
-- Pan is active, fire changed event
|
||||
local panDx = event.x - panState.lastX
|
||||
local panDy = event.y - panState.lastY
|
||||
|
||||
panState.lastX = event.x
|
||||
panState.lastY = event.y
|
||||
|
||||
return {
|
||||
type = GestureType.PAN,
|
||||
state = GestureState.CHANGED,
|
||||
x = event.x,
|
||||
y = event.y,
|
||||
dx = panDx,
|
||||
dy = panDy,
|
||||
totalDx = dx,
|
||||
totalDy = dy,
|
||||
timestamp = event.timestamp,
|
||||
}
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Detect pan ended
|
||||
---@param touchId string
|
||||
---@param event InputEvent
|
||||
---@return table? Gesture event
|
||||
function GestureRecognizer:_detectPanEnded(touchId, event)
|
||||
local panState = self._gestureStates.pan[touchId]
|
||||
if panState and panState.active then
|
||||
self._gestureStates.pan[touchId] = nil
|
||||
|
||||
local touch = self._touches[touchId]
|
||||
local dx = event.x - touch.startX
|
||||
local dy = event.y - touch.startY
|
||||
|
||||
return {
|
||||
type = GestureType.PAN,
|
||||
state = GestureState.ENDED,
|
||||
x = event.x,
|
||||
y = event.y,
|
||||
dx = dx,
|
||||
dy = dy,
|
||||
timestamp = event.timestamp,
|
||||
}
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Detect swipe gesture
|
||||
---@param touchId string
|
||||
---@param event InputEvent
|
||||
function GestureRecognizer:_detectSwipe(touchId, event)
|
||||
-- Swipe detection happens on touch end
|
||||
end
|
||||
|
||||
--- Detect swipe ended
|
||||
---@param touchId string
|
||||
---@param event InputEvent
|
||||
---@return table? Gesture event
|
||||
function GestureRecognizer:_detectSwipeEnded(touchId, event)
|
||||
local touch = self._touches[touchId]
|
||||
if not touch then
|
||||
return nil
|
||||
end
|
||||
|
||||
local duration = event.timestamp - touch.startTime
|
||||
local dx = event.x - touch.startX
|
||||
local dy = event.y - touch.startY
|
||||
local distance = math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
-- Check if it's a valid swipe
|
||||
if distance >= self._config.swipeMinDistance and duration <= self._config.swipeMaxDuration then
|
||||
local velocity = distance / duration
|
||||
|
||||
if velocity >= self._config.swipeMinVelocity then
|
||||
-- Determine swipe direction
|
||||
local angle = math.atan2(dy, dx)
|
||||
local direction = "right"
|
||||
|
||||
if angle >= -math.pi / 4 and angle < math.pi / 4 then
|
||||
direction = "right"
|
||||
elseif angle >= math.pi / 4 and angle < 3 * math.pi / 4 then
|
||||
direction = "down"
|
||||
elseif angle >= -3 * math.pi / 4 and angle < -math.pi / 4 then
|
||||
direction = "up"
|
||||
else
|
||||
direction = "left"
|
||||
end
|
||||
|
||||
return {
|
||||
type = GestureType.SWIPE,
|
||||
state = GestureState.ENDED,
|
||||
x = event.x,
|
||||
y = event.y,
|
||||
dx = dx,
|
||||
dy = dy,
|
||||
direction = direction,
|
||||
velocity = velocity,
|
||||
timestamp = event.timestamp,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Detect pinch gesture
|
||||
---@param event InputEvent
|
||||
---@return table? Gesture event
|
||||
function GestureRecognizer:_detectPinch(event)
|
||||
-- Get two touches for pinch
|
||||
local touches = {}
|
||||
for touchId, touch in pairs(self._touches) do
|
||||
table.insert(touches, { id = touchId, touch = touch })
|
||||
if #touches >= 2 then
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if #touches < 2 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local t1 = touches[1].touch
|
||||
local t2 = touches[2].touch
|
||||
|
||||
-- Calculate current distance
|
||||
local currentDx = t2.x - t1.x
|
||||
local currentDy = t2.y - t1.y
|
||||
local currentDistance = math.sqrt(currentDx * currentDx + currentDy * currentDy)
|
||||
|
||||
-- Calculate initial distance
|
||||
local initialDx = t2.startX - t1.startX
|
||||
local initialDy = t2.startY - t1.startY
|
||||
local initialDistance = math.sqrt(initialDx * initialDx + initialDy * initialDy)
|
||||
|
||||
if initialDistance == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Calculate scale
|
||||
local scale = currentDistance / initialDistance
|
||||
local pinchState = self._gestureStates.pinch
|
||||
|
||||
if not pinchState.active then
|
||||
-- Check if pinch should begin
|
||||
if math.abs(scale - 1.0) >= self._config.pinchMinScaleChange then
|
||||
pinchState.active = true
|
||||
pinchState.initialScale = scale
|
||||
pinchState.lastScale = scale
|
||||
|
||||
-- Calculate center point
|
||||
local centerX = (t1.x + t2.x) / 2
|
||||
local centerY = (t1.y + t2.y) / 2
|
||||
|
||||
return {
|
||||
type = GestureType.PINCH,
|
||||
state = GestureState.BEGAN,
|
||||
scale = scale,
|
||||
centerX = centerX,
|
||||
centerY = centerY,
|
||||
timestamp = event.timestamp,
|
||||
}
|
||||
end
|
||||
else
|
||||
-- Pinch is active, fire changed event
|
||||
local centerX = (t1.x + t2.x) / 2
|
||||
local centerY = (t1.y + t2.y) / 2
|
||||
|
||||
local scaleChange = scale - pinchState.lastScale
|
||||
pinchState.lastScale = scale
|
||||
|
||||
return {
|
||||
type = GestureType.PINCH,
|
||||
state = GestureState.CHANGED,
|
||||
scale = scale,
|
||||
scaleChange = scaleChange,
|
||||
centerX = centerX,
|
||||
centerY = centerY,
|
||||
timestamp = event.timestamp,
|
||||
}
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Detect rotate gesture
|
||||
---@param event InputEvent
|
||||
---@return table? Gesture event
|
||||
function GestureRecognizer:_detectRotate(event)
|
||||
-- Get two touches for rotation
|
||||
local touches = {}
|
||||
for touchId, touch in pairs(self._touches) do
|
||||
table.insert(touches, { id = touchId, touch = touch })
|
||||
if #touches >= 2 then
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if #touches < 2 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local t1 = touches[1].touch
|
||||
local t2 = touches[2].touch
|
||||
|
||||
-- Calculate current angle
|
||||
local currentAngle = math.atan2(t2.y - t1.y, t2.x - t1.x)
|
||||
|
||||
-- Calculate initial angle
|
||||
local initialAngle = math.atan2(t2.startY - t1.startY, t2.startX - t1.startX)
|
||||
|
||||
-- Calculate rotation (in degrees)
|
||||
local rotation = (currentAngle - initialAngle) * 180 / math.pi
|
||||
|
||||
local rotateState = self._gestureStates.rotate
|
||||
|
||||
if not rotateState.active then
|
||||
-- Check if rotation should begin
|
||||
if math.abs(rotation) >= self._config.rotateMinAngleChange then
|
||||
rotateState.active = true
|
||||
rotateState.initialRotation = rotation
|
||||
rotateState.lastRotation = rotation
|
||||
|
||||
-- Calculate center point
|
||||
local centerX = (t1.x + t2.x) / 2
|
||||
local centerY = (t1.y + t2.y) / 2
|
||||
|
||||
return {
|
||||
type = GestureType.ROTATE,
|
||||
state = GestureState.BEGAN,
|
||||
rotation = rotation,
|
||||
centerX = centerX,
|
||||
centerY = centerY,
|
||||
timestamp = event.timestamp,
|
||||
}
|
||||
end
|
||||
else
|
||||
-- Rotation is active, fire changed event
|
||||
local centerX = (t1.x + t2.x) / 2
|
||||
local centerY = (t1.y + t2.y) / 2
|
||||
|
||||
local rotationChange = rotation - rotateState.lastRotation
|
||||
rotateState.lastRotation = rotation
|
||||
|
||||
return {
|
||||
type = GestureType.ROTATE,
|
||||
state = GestureState.CHANGED,
|
||||
rotation = rotation,
|
||||
rotationChange = rotationChange,
|
||||
centerX = centerX,
|
||||
centerY = centerY,
|
||||
timestamp = event.timestamp,
|
||||
}
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Cancel all active gestures
|
||||
function GestureRecognizer:_cancelAllGestures()
|
||||
for gestureType, state in pairs(self._gestureStates) do
|
||||
if type(state) == "table" and state.active then
|
||||
state.active = false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Reset gesture recognizer state
|
||||
function GestureRecognizer:reset()
|
||||
self._touches = {}
|
||||
self._gestureStates = {
|
||||
tap = nil,
|
||||
doubleTap = { lastTapTime = 0, tapCount = 0 },
|
||||
longPress = {},
|
||||
swipe = {},
|
||||
pan = {},
|
||||
pinch = { active = false },
|
||||
rotate = { active = false },
|
||||
}
|
||||
end
|
||||
|
||||
-- Export gesture types and states
|
||||
GestureRecognizer.GestureType = GestureType
|
||||
GestureRecognizer.GestureState = GestureState
|
||||
|
||||
return GestureRecognizer
|
||||
@@ -0,0 +1,336 @@
|
||||
local modulePath = (...):match("(.-)[^%.]+$")
|
||||
local utils = require(modulePath .. "utils")
|
||||
local enums = utils.enums
|
||||
local Units = require(modulePath .. "Units")
|
||||
|
||||
local Positioning = enums.Positioning
|
||||
local AlignItems = enums.AlignItems
|
||||
|
||||
--- Grid layout with variable column widths / row heights
|
||||
--- Supports px, %, fr, auto, vw, vh, and calc track sizes
|
||||
local Grid = {}
|
||||
|
||||
--- Parse a single track spec into {type, value}
|
||||
--- Uses the Units pipeline for standard CSS units (px, %, vw, vh, calc).
|
||||
--- Grid-specific types (fr, auto) are handled directly.
|
||||
---@param spec number|string Track specification: number (px), string ("100px", "50%", "10vw", "1fr", "auto")
|
||||
---@param availableSize number Container size for % resolution
|
||||
---@param viewportWidth number Viewport width for vw resolution
|
||||
---@param viewportHeight number Viewport height for vh resolution
|
||||
---@return table {type: "px"|"fr"|"auto", value: number}
|
||||
function Grid._parseTrack(spec, availableSize, viewportWidth, viewportHeight)
|
||||
-- Handle calc objects (tables with _isCalc flag from FlexLove.calc())
|
||||
if type(spec) == "table" then
|
||||
local resolved = Units.resolve(spec, "calc", viewportWidth, viewportHeight, availableSize)
|
||||
return { type = "px", value = resolved }
|
||||
end
|
||||
|
||||
if type(spec) == "number" then
|
||||
return { type = "px", value = spec }
|
||||
end
|
||||
|
||||
if type(spec) == "string" then
|
||||
if spec == "auto" then
|
||||
return { type = "auto", value = 0 }
|
||||
end
|
||||
|
||||
-- Check for fr unit (grid-specific, not in Units pipeline)
|
||||
local numStr, unit = spec:match("^([%-]?[%d%.]+)(.*)$")
|
||||
if numStr and unit == "fr" then
|
||||
local num = tonumber(numStr)
|
||||
if num then
|
||||
return { type = "fr", value = num }
|
||||
end
|
||||
end
|
||||
|
||||
-- Delegate all other units to the Units pipeline (px, %, vw, vh, calc)
|
||||
local parsedVal, parsedUnit = Units.parse(spec)
|
||||
local resolved = Units.resolve(parsedVal, parsedUnit, viewportWidth, viewportHeight, availableSize)
|
||||
return { type = "px", value = resolved }
|
||||
end
|
||||
|
||||
-- Default: 1fr
|
||||
return { type = "fr", value = 1 }
|
||||
end
|
||||
|
||||
--- Build track list from gridColumns/gridRows or fall back to equal 1fr tracks
|
||||
---@param spec number|table? Track count (number = equal 1fr tracks) or array of track specs (e.g., {"1fr", "2fr", "100px"})
|
||||
---@param availableSize number Container size for % resolution
|
||||
---@param viewportWidth number Viewport width for vw resolution
|
||||
---@param viewportHeight number Viewport height for vh resolution
|
||||
---@return table Array of {type, value} track descriptors
|
||||
function Grid._buildTracks(spec, availableSize, viewportWidth, viewportHeight)
|
||||
if type(spec) == "table" and #spec > 0 then
|
||||
local tracks = {}
|
||||
for i, s in ipairs(spec) do
|
||||
tracks[i] = Grid._parseTrack(s, availableSize, viewportWidth, viewportHeight)
|
||||
end
|
||||
return tracks
|
||||
end
|
||||
-- Fallback: equal 1fr tracks
|
||||
local count = (type(spec) == "number" and spec > 0) and spec or 1
|
||||
local tracks = {}
|
||||
for i = 1, count do
|
||||
tracks[i] = { type = "fr", value = 1 }
|
||||
end
|
||||
return tracks
|
||||
end
|
||||
|
||||
--- Measure intrinsic content sizes for auto tracks
|
||||
--- Maps children to their tracks and computes each child's max-content contribution.
|
||||
--- For children with explicit dimensions (units unit ~= "auto"), uses the original
|
||||
--- explicit size. For auto-sized children, uses calculated content size.
|
||||
--- Stores the max per auto track. Matches CSS Grid auto sizing where tracks size
|
||||
--- to the max-content contribution of their grid items.
|
||||
---@param tracks table Array of {type, value} track descriptors
|
||||
---@param children table Array of grid child elements
|
||||
---@param axis "width"|"height" Dimension axis to measure
|
||||
function Grid._measureAutoTracks(tracks, children, axis)
|
||||
local trackSizes = {}
|
||||
local numTracks = #tracks
|
||||
|
||||
for i, child in ipairs(children) do
|
||||
local index = i - 1
|
||||
local trackIdx = (index % numTracks) + 1
|
||||
|
||||
local intrinsicSize
|
||||
if axis == "width" then
|
||||
local unit = child.units and child.units.width and child.units.width.unit
|
||||
if unit and unit ~= "auto" then
|
||||
-- Explicit width: use original value + padding (not stretched border-box)
|
||||
intrinsicSize = (child.units.width.value or 0) + child.padding.left + child.padding.right
|
||||
else
|
||||
-- Auto-sized: use calculated content size
|
||||
intrinsicSize = child:calculateAutoWidth()
|
||||
end
|
||||
else
|
||||
local unit = child.units and child.units.height and child.units.height.unit
|
||||
if unit and unit ~= "auto" then
|
||||
intrinsicSize = (child.units.height.value or 0) + child.padding.top + child.padding.bottom
|
||||
else
|
||||
intrinsicSize = child:calculateAutoHeight()
|
||||
end
|
||||
end
|
||||
|
||||
if intrinsicSize > 0 then
|
||||
trackSizes[trackIdx] = math.max(trackSizes[trackIdx] or 0, intrinsicSize)
|
||||
end
|
||||
end
|
||||
|
||||
-- Apply measured sizes to auto tracks
|
||||
for i, track in ipairs(tracks) do
|
||||
if track.type == "auto" and trackSizes[i] then
|
||||
track.value = trackSizes[i]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Resolve track sizes: auto (content) first, then px (fixed), then fr (remaining)
|
||||
--- CSS Grid algorithm:
|
||||
--- 1. auto tracks size to their content (max-content) — measured by _measureAutoTracks
|
||||
--- 2. px tracks consume their fixed size
|
||||
--- 3. fr tracks consume remaining free space proportionally
|
||||
--- 4. If no fr tracks exist, auto tracks share remaining space equally
|
||||
--- Mutates tracks in-place, converting all to {type="px", value=number}
|
||||
---@param tracks table Array of {type, value} track descriptors
|
||||
---@param availableSize number Total space available for tracks
|
||||
---@param gap number Gap between tracks
|
||||
function Grid._resolveTracks(tracks, availableSize, gap)
|
||||
local count = #tracks
|
||||
local totalGaps = (count > 1 and (count - 1) * gap) or 0
|
||||
local remaining = math.max(0, availableSize - totalGaps)
|
||||
|
||||
-- Pass 1: Treat auto tracks as fixed (content-measured) and subtract
|
||||
for _, track in ipairs(tracks) do
|
||||
if track.type == "px" then
|
||||
remaining = remaining - track.value
|
||||
elseif track.type == "auto" then
|
||||
remaining = remaining - math.max(0, track.value)
|
||||
end
|
||||
end
|
||||
|
||||
remaining = math.max(0, remaining)
|
||||
|
||||
-- Pass 2: Count fr shares
|
||||
local totalFr = 0
|
||||
local autoCount = 0
|
||||
for _, track in ipairs(tracks) do
|
||||
if track.type == "fr" then
|
||||
totalFr = totalFr + track.value
|
||||
elseif track.type == "auto" then
|
||||
autoCount = autoCount + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- Pass 3: Distribute remaining space
|
||||
if totalFr > 0 then
|
||||
-- fr tracks consume all remaining free space
|
||||
local frUnit = remaining / totalFr
|
||||
for _, track in ipairs(tracks) do
|
||||
if track.type == "fr" then
|
||||
track.value = frUnit * track.value
|
||||
track.type = "px"
|
||||
end
|
||||
end
|
||||
elseif autoCount > 0 then
|
||||
-- No fr tracks: auto tracks share remaining space equally (grow beyond content)
|
||||
local extraPerAuto = math.max(0, remaining) / autoCount
|
||||
for _, track in ipairs(tracks) do
|
||||
if track.type == "auto" then
|
||||
track.value = track.value + extraPerAuto
|
||||
track.type = "px"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Layout grid items within a grid container
|
||||
--- Supports variable column widths and row heights via gridColumns/gridRows (number or track specs)
|
||||
--- Falls back to equal-sized 1fr tracks when nil
|
||||
---@param element Element -- Grid container element
|
||||
function Grid.layoutGridItems(element)
|
||||
-- Calculate space reserved by absolutely positioned siblings
|
||||
local reservedLeft = 0
|
||||
local reservedRight = 0
|
||||
local reservedTop = 0
|
||||
local reservedBottom = 0
|
||||
|
||||
for _, child in ipairs(element.children) do
|
||||
-- Only consider absolutely positioned children with explicit positioning and display != false
|
||||
if child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute and child.display ~= false then
|
||||
-- BORDER-BOX MODEL: Use border-box dimensions for space calculations
|
||||
local childBorderBoxWidth = child:getBorderBoxWidth()
|
||||
local childBorderBoxHeight = child:getBorderBoxHeight()
|
||||
|
||||
if child.left then
|
||||
reservedLeft = math.max(reservedLeft, child.left + childBorderBoxWidth)
|
||||
end
|
||||
if child.right then
|
||||
reservedRight = math.max(reservedRight, child.right + childBorderBoxWidth)
|
||||
end
|
||||
if child.top then
|
||||
reservedTop = math.max(reservedTop, child.top + childBorderBoxHeight)
|
||||
end
|
||||
if child.bottom then
|
||||
reservedBottom = math.max(reservedBottom, child.bottom + childBorderBoxHeight)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Calculate available space (accounting for padding and reserved space)
|
||||
-- BORDER-BOX MODEL: element.width and element.height are already content dimensions
|
||||
local availableWidth = math.max(0, element.width - reservedLeft - reservedRight)
|
||||
local availableHeight = math.max(0, element.height - reservedTop - reservedBottom)
|
||||
|
||||
-- Get gaps
|
||||
local columnGap = element.columnGap or 0
|
||||
local rowGap = element.rowGap or 0
|
||||
|
||||
-- Collect grid children (exclude explicitly absolute and display=false)
|
||||
local gridChildren = {}
|
||||
for _, child in ipairs(element.children) do
|
||||
if not (child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute) and child.display ~= false then
|
||||
table.insert(gridChildren, child)
|
||||
end
|
||||
end
|
||||
|
||||
-- Get viewport dimensions for unit resolution (vw, vh, %)
|
||||
local vpw, vph = Units.getViewport()
|
||||
|
||||
-- Build tracks, measure auto tracks by content, then resolve sizes
|
||||
local colTracks = Grid._buildTracks(element.gridColumns, availableWidth, vpw, vph)
|
||||
local rowTracks = Grid._buildTracks(element.gridRows, availableHeight, vpw, vph)
|
||||
|
||||
Grid._measureAutoTracks(colTracks, gridChildren, "width")
|
||||
Grid._measureAutoTracks(rowTracks, gridChildren, "height")
|
||||
|
||||
Grid._resolveTracks(colTracks, availableWidth, columnGap)
|
||||
Grid._resolveTracks(rowTracks, availableHeight, rowGap)
|
||||
|
||||
-- Compute column start positions (for positioning)
|
||||
local colStarts = {}
|
||||
local currentX = element.x + element.padding.left + reservedLeft
|
||||
for col = 1, #colTracks do
|
||||
colStarts[col] = currentX
|
||||
currentX = currentX + colTracks[col].value + columnGap
|
||||
end
|
||||
|
||||
local rowStarts = {}
|
||||
local currentY = element.y + element.padding.top + reservedTop
|
||||
for row = 1, #rowTracks do
|
||||
rowStarts[row] = currentY
|
||||
currentY = currentY + rowTracks[row].value + rowGap
|
||||
end
|
||||
|
||||
local effectiveAlignItems = element.alignItems or AlignItems.STRETCH
|
||||
|
||||
for i, child in ipairs(gridChildren) do
|
||||
-- Calculate row and column (0-indexed for calculation)
|
||||
local index = i - 1
|
||||
local col = index % #colTracks
|
||||
local row = math.floor(index / #colTracks)
|
||||
|
||||
if row >= #rowTracks then
|
||||
break
|
||||
end
|
||||
|
||||
-- Get resolved cell position and size
|
||||
local colIdx = col + 1
|
||||
local rowIdx = row + 1
|
||||
local cellX = colStarts[colIdx]
|
||||
local cellY = rowStarts[rowIdx]
|
||||
local cellWidth = colTracks[colIdx].value
|
||||
local cellHeight = rowTracks[rowIdx].value
|
||||
|
||||
-- Apply alignment within grid cell (default to stretch)
|
||||
-- BORDER-BOX MODEL: Set border-box dimensions, content area adjusts automatically
|
||||
if effectiveAlignItems == AlignItems.STRETCH or effectiveAlignItems == "stretch" then
|
||||
child.x = cellX
|
||||
child.y = cellY
|
||||
child._borderBoxWidth = cellWidth
|
||||
child._borderBoxHeight = cellHeight
|
||||
child.width = math.max(0, cellWidth - child.padding.left - child.padding.right)
|
||||
child.height = math.max(0, cellHeight - child.padding.top - child.padding.bottom)
|
||||
-- Disable auto-sizing when stretched by grid
|
||||
child.autosizing.width = false
|
||||
child.autosizing.height = false
|
||||
elseif effectiveAlignItems == AlignItems.CENTER or effectiveAlignItems == "center" then
|
||||
local childBorderBoxWidth = child:getBorderBoxWidth()
|
||||
local childBorderBoxHeight = child:getBorderBoxHeight()
|
||||
child.x = cellX + (cellWidth - childBorderBoxWidth) / 2
|
||||
child.y = cellY + (cellHeight - childBorderBoxHeight) / 2
|
||||
elseif
|
||||
effectiveAlignItems == AlignItems.FLEX_START
|
||||
or effectiveAlignItems == "flex-start"
|
||||
or effectiveAlignItems == "start"
|
||||
then
|
||||
child.x = cellX
|
||||
child.y = cellY
|
||||
elseif
|
||||
effectiveAlignItems == AlignItems.FLEX_END
|
||||
or effectiveAlignItems == "flex-end"
|
||||
or effectiveAlignItems == "end"
|
||||
then
|
||||
local childBorderBoxWidth = child:getBorderBoxWidth()
|
||||
local childBorderBoxHeight = child:getBorderBoxHeight()
|
||||
child.x = cellX + cellWidth - childBorderBoxWidth
|
||||
child.y = cellY + cellHeight - childBorderBoxHeight
|
||||
else
|
||||
child.x = cellX
|
||||
child.y = cellY
|
||||
child._borderBoxWidth = cellWidth
|
||||
child._borderBoxHeight = cellHeight
|
||||
child.width = math.max(0, cellWidth - child.padding.left - child.padding.right)
|
||||
child.height = math.max(0, cellHeight - child.padding.top - child.padding.bottom)
|
||||
-- Disable auto-sizing when stretched by grid
|
||||
child.autosizing.width = false
|
||||
child.autosizing.height = false
|
||||
end
|
||||
|
||||
if #child.children > 0 then
|
||||
child:layoutChildren()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return Grid
|
||||
@@ -0,0 +1,160 @@
|
||||
local modulePath = (...):match("(.-)[^%.]+$")
|
||||
local function req(name)
|
||||
return require(modulePath .. name)
|
||||
end
|
||||
|
||||
local utils = req("utils")
|
||||
|
||||
-- ErrorHandler will be injected via init
|
||||
local ErrorHandler = nil
|
||||
|
||||
---@class ImageCache
|
||||
---@field _cache table<string, {image: love.Image, imageData: love.ImageData?}>
|
||||
local ImageCache = {}
|
||||
ImageCache._cache = {}
|
||||
|
||||
--- Initialize ImageCache with dependencies
|
||||
---@param deps table Dependencies table with ErrorHandler
|
||||
function ImageCache.init(deps)
|
||||
if deps and deps.ErrorHandler then
|
||||
ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
end
|
||||
|
||||
--- Load an image from file path with caching
|
||||
--- Returns cached image if already loaded, otherwise loads and caches it
|
||||
---@param imagePath string -- Path to image file
|
||||
---@param loadImageData boolean? -- Optional: also load ImageData for pixel access (default: false)
|
||||
---@return love.Image|nil -- Image object or nil on error
|
||||
---@return string|nil -- Error message if loading failed
|
||||
function ImageCache.load(imagePath, loadImageData)
|
||||
if not imagePath or type(imagePath) ~= "string" or imagePath == "" then
|
||||
return nil, "Invalid image path: path must be a non-empty string"
|
||||
end
|
||||
|
||||
local normalizedPath = utils.normalizePath(imagePath)
|
||||
|
||||
if ImageCache._cache[normalizedPath] then
|
||||
return ImageCache._cache[normalizedPath].image, nil
|
||||
end
|
||||
|
||||
local success, imageOrError = pcall(love.graphics.newImage, normalizedPath)
|
||||
if not success then
|
||||
if ErrorHandler then
|
||||
ErrorHandler:warn("ImageCache", "RES_004", {
|
||||
resourceType = "image",
|
||||
path = imagePath,
|
||||
error = tostring(imageOrError),
|
||||
})
|
||||
end
|
||||
return nil, string.format("Failed to load image '%s': %s", imagePath, tostring(imageOrError))
|
||||
end
|
||||
|
||||
local image = imageOrError
|
||||
local imgData = nil
|
||||
|
||||
if loadImageData then
|
||||
local dataSuccess, dataOrError = pcall(love.image.newImageData, normalizedPath)
|
||||
if dataSuccess then
|
||||
imgData = dataOrError
|
||||
elseif ErrorHandler then
|
||||
ErrorHandler:warn("ImageCache", "RES_004", {
|
||||
resourceType = "image data",
|
||||
path = imagePath,
|
||||
error = tostring(dataOrError),
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
ImageCache._cache[normalizedPath] = {
|
||||
image = image,
|
||||
imageData = imgData,
|
||||
}
|
||||
|
||||
return image, nil
|
||||
end
|
||||
|
||||
--- Get a cached image without loading
|
||||
---@param imagePath string -- Path to image file
|
||||
---@return love.Image|nil -- Cached image or nil if not found
|
||||
function ImageCache.get(imagePath)
|
||||
if not imagePath or type(imagePath) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local normalizedPath = utils.normalizePath(imagePath)
|
||||
local cached = ImageCache._cache[normalizedPath]
|
||||
return cached and cached.image or nil
|
||||
end
|
||||
|
||||
--- Get cached ImageData for an image
|
||||
---@param imagePath string -- Path to image file
|
||||
---@return love.ImageData|nil -- Cached ImageData or nil if not found
|
||||
function ImageCache.getImageData(imagePath)
|
||||
if not imagePath or type(imagePath) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local normalizedPath = utils.normalizePath(imagePath)
|
||||
local cached = ImageCache._cache[normalizedPath]
|
||||
return cached and cached.imageData or nil
|
||||
end
|
||||
|
||||
--- Remove a specific image from cache
|
||||
---@param imagePath string -- Path to image file to remove
|
||||
---@return boolean -- True if image was removed, false if not found
|
||||
function ImageCache.remove(imagePath)
|
||||
if not imagePath or type(imagePath) ~= "string" then
|
||||
return false
|
||||
end
|
||||
|
||||
local normalizedPath = utils.normalizePath(imagePath)
|
||||
if ImageCache._cache[normalizedPath] then
|
||||
local cached = ImageCache._cache[normalizedPath]
|
||||
if cached.image then
|
||||
cached.image:release()
|
||||
end
|
||||
if cached.imageData then
|
||||
cached.imageData:release()
|
||||
end
|
||||
ImageCache._cache[normalizedPath] = nil
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
--- Clear all cached images
|
||||
function ImageCache.clear()
|
||||
for path, cached in pairs(ImageCache._cache) do
|
||||
if cached.image then
|
||||
cached.image:release()
|
||||
end
|
||||
if cached.imageData then
|
||||
cached.imageData:release()
|
||||
end
|
||||
end
|
||||
ImageCache._cache = {}
|
||||
end
|
||||
|
||||
--- Get cache statistics
|
||||
---@return {count: number, memoryEstimate: number} -- Cache stats
|
||||
function ImageCache.getStats()
|
||||
local count = 0
|
||||
local memoryEstimate = 0
|
||||
|
||||
for path, cached in pairs(ImageCache._cache) do
|
||||
count = count + 1
|
||||
if cached.image then
|
||||
local w, h = cached.image:getDimensions()
|
||||
-- Estimate: 4 bytes per pixel (RGBA)
|
||||
memoryEstimate = memoryEstimate + (w * h * 4)
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
count = count,
|
||||
memoryEstimate = memoryEstimate,
|
||||
}
|
||||
end
|
||||
|
||||
return ImageCache
|
||||
@@ -0,0 +1,380 @@
|
||||
---@class ImageRenderer
|
||||
local ImageRenderer = {}
|
||||
|
||||
-- ErrorHandler and utils will be injected via init
|
||||
local ErrorHandler = nil
|
||||
local utils = nil
|
||||
|
||||
--- Initialize ImageRenderer with dependencies
|
||||
---@param deps table Dependencies table with ErrorHandler and utils
|
||||
function ImageRenderer.init(deps)
|
||||
if deps and deps.ErrorHandler then
|
||||
ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
if deps and deps.utils then
|
||||
utils = deps.utils
|
||||
end
|
||||
end
|
||||
|
||||
--- Calculate rendering parameters for object-fit modes
|
||||
--- Returns source and destination rectangles for rendering
|
||||
---@param imageWidth number -- Natural width of the image
|
||||
---@param imageHeight number -- Natural height of the image
|
||||
---@param boundsWidth number -- Width of the bounds to fit within
|
||||
---@param boundsHeight number -- Height of the bounds to fit within
|
||||
---@param fitMode string? -- One of: "fill", "contain", "cover", "scale-down", "none" (default: "fill")
|
||||
---@param objectPosition string? -- Position like "center center", "top left", "50% 50%" (default: "center center")
|
||||
---@return {sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number, scaleX: number, scaleY: number}
|
||||
function ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, fitMode, objectPosition)
|
||||
fitMode = fitMode or "fill"
|
||||
objectPosition = objectPosition or "center center"
|
||||
|
||||
if imageWidth <= 0 or imageHeight <= 0 or boundsWidth <= 0 or boundsHeight <= 0 then
|
||||
ErrorHandler:error("ImageRenderer", "VAL_002", {
|
||||
imageWidth = imageWidth,
|
||||
imageHeight = imageHeight,
|
||||
boundsWidth = boundsWidth,
|
||||
boundsHeight = boundsHeight,
|
||||
})
|
||||
end
|
||||
|
||||
local result = {
|
||||
sx = 0, -- Source X
|
||||
sy = 0, -- Source Y
|
||||
sw = imageWidth, -- Source width
|
||||
sh = imageHeight, -- Source height
|
||||
dx = 0, -- Destination X
|
||||
dy = 0, -- Destination Y
|
||||
dw = boundsWidth, -- Destination width
|
||||
dh = boundsHeight, -- Destination height
|
||||
scaleX = 1, -- Scale factor X
|
||||
scaleY = 1, -- Scale factor Y
|
||||
}
|
||||
|
||||
if fitMode == "fill" then
|
||||
-- Stretch to fill bounds (may distort)
|
||||
result.scaleX = boundsWidth / imageWidth
|
||||
result.scaleY = boundsHeight / imageHeight
|
||||
result.dw = boundsWidth
|
||||
result.dh = boundsHeight
|
||||
elseif fitMode == "contain" then
|
||||
-- Scale to fit within bounds (preserves aspect ratio)
|
||||
local scale = math.min(boundsWidth / imageWidth, boundsHeight / imageHeight)
|
||||
result.scaleX = scale
|
||||
result.scaleY = scale
|
||||
result.dw = imageWidth * scale
|
||||
result.dh = imageHeight * scale
|
||||
|
||||
-- Apply object-position for letterbox alignment
|
||||
local posX, posY = ImageRenderer._parsePosition(objectPosition)
|
||||
result.dx = (boundsWidth - result.dw) * posX
|
||||
result.dy = (boundsHeight - result.dh) * posY
|
||||
elseif fitMode == "cover" then
|
||||
-- Scale to cover bounds (preserves aspect ratio, may crop)
|
||||
local scale = math.max(boundsWidth / imageWidth, boundsHeight / imageHeight)
|
||||
result.scaleX = scale
|
||||
result.scaleY = scale
|
||||
|
||||
local scaledWidth = imageWidth * scale
|
||||
local scaledHeight = imageHeight * scale
|
||||
|
||||
-- Apply object-position for crop alignment
|
||||
local posX, posY = ImageRenderer._parsePosition(objectPosition)
|
||||
|
||||
-- Calculate which part of the scaled image to show
|
||||
local cropX = (scaledWidth - boundsWidth) * posX
|
||||
local cropY = (scaledHeight - boundsHeight) * posY
|
||||
|
||||
-- Convert back to source coordinates
|
||||
result.sx = cropX / scale
|
||||
result.sy = cropY / scale
|
||||
result.sw = boundsWidth / scale
|
||||
result.sh = boundsHeight / scale
|
||||
|
||||
result.dx = 0
|
||||
result.dy = 0
|
||||
result.dw = boundsWidth
|
||||
result.dh = boundsHeight
|
||||
elseif fitMode == "none" then
|
||||
-- Use natural size (no scaling)
|
||||
result.scaleX = 1
|
||||
result.scaleY = 1
|
||||
result.dw = imageWidth
|
||||
result.dh = imageHeight
|
||||
|
||||
-- Apply object-position
|
||||
local posX, posY = ImageRenderer._parsePosition(objectPosition)
|
||||
result.dx = (boundsWidth - imageWidth) * posX
|
||||
result.dy = (boundsHeight - imageHeight) * posY
|
||||
elseif fitMode == "scale-down" then
|
||||
-- Use none or contain, whichever is smaller
|
||||
if imageWidth <= boundsWidth and imageHeight <= boundsHeight then
|
||||
-- Image fits naturally, use "none"
|
||||
return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "none", objectPosition)
|
||||
else
|
||||
-- Image too large, use "contain"
|
||||
return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "contain", objectPosition)
|
||||
end
|
||||
else
|
||||
ErrorHandler:warn("ImageRenderer", "VAL_007", {
|
||||
fitMode = fitMode,
|
||||
fallback = "fill",
|
||||
})
|
||||
-- Use 'fill' as fallback
|
||||
return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "fill", objectPosition)
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
--- Parse object-position string into normalized coordinates (0-1)
|
||||
--- Supports keywords (center, top, bottom, left, right) and percentages
|
||||
---@param position string -- Position string like "center center", "top left", "50% 50%"
|
||||
---@return number, number -- Normalized X and Y positions (0-1)
|
||||
function ImageRenderer._parsePosition(position)
|
||||
if not position or type(position) ~= "string" then
|
||||
return 0.5, 0.5 -- Default to center
|
||||
end
|
||||
|
||||
-- Split into X and Y components
|
||||
local parts = {}
|
||||
for part in position:gmatch("%S+") do
|
||||
table.insert(parts, part:lower())
|
||||
end
|
||||
|
||||
-- If only one value, use it for both axes (with special handling)
|
||||
if #parts == 1 then
|
||||
local val = parts[1]
|
||||
if val == "left" or val == "right" then
|
||||
parts = { val, "center" }
|
||||
elseif val == "top" or val == "bottom" then
|
||||
parts = { "center", val }
|
||||
else
|
||||
parts = { val, val }
|
||||
end
|
||||
elseif #parts == 0 then
|
||||
return 0.5, 0.5 -- Default to center
|
||||
end
|
||||
|
||||
local function parseValue(val)
|
||||
-- Handle keywords
|
||||
if val == "center" then
|
||||
return 0.5
|
||||
elseif val == "left" or val == "top" then
|
||||
return 0
|
||||
elseif val == "right" or val == "bottom" then
|
||||
return 1
|
||||
end
|
||||
|
||||
-- Handle percentages
|
||||
local percent = val:match("^([%d%.]+)%%$")
|
||||
if percent then
|
||||
return tonumber(percent) / 100
|
||||
end
|
||||
|
||||
-- Handle plain numbers (treat as percentage)
|
||||
local num = tonumber(val)
|
||||
if num then
|
||||
return num / 100
|
||||
end
|
||||
|
||||
-- Invalid value, default to center
|
||||
return 0.5
|
||||
end
|
||||
|
||||
local x = parseValue(parts[1])
|
||||
local y = parseValue(parts[2] or parts[1])
|
||||
|
||||
-- Clamp to 0-1 range
|
||||
x = math.max(0, math.min(1, x))
|
||||
y = math.max(0, math.min(1, y))
|
||||
|
||||
return x, y
|
||||
end
|
||||
|
||||
--- Draw an image with specified object-fit mode
|
||||
---@param image love.Image -- Image to draw
|
||||
---@param x number -- X position of bounds
|
||||
---@param y number -- Y position of bounds
|
||||
---@param width number -- Width of bounds
|
||||
---@param height number -- Height of bounds
|
||||
---@param fitMode string? -- Object-fit mode (default: "fill")
|
||||
---@param objectPosition string? -- Object-position (default: "center center")
|
||||
---@param opacity number? -- Opacity 0-1 (default: 1)
|
||||
---@param tintColor Color? -- Color to tint the image (default: white/no tint)
|
||||
function ImageRenderer.draw(image, x, y, width, height, fitMode, objectPosition, opacity, tintColor)
|
||||
if not image then
|
||||
return -- Nothing to draw
|
||||
end
|
||||
|
||||
opacity = opacity or 1
|
||||
fitMode = fitMode or "fill"
|
||||
objectPosition = objectPosition or "center center"
|
||||
|
||||
local imgWidth, imgHeight = image:getDimensions()
|
||||
local params = ImageRenderer.calculateFit(imgWidth, imgHeight, width, height, fitMode, objectPosition)
|
||||
|
||||
-- Save current color
|
||||
local r, g, b, a = love.graphics.getColor()
|
||||
|
||||
-- Apply opacity and tint
|
||||
if tintColor then
|
||||
love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity)
|
||||
else
|
||||
love.graphics.setColor(1, 1, 1, opacity)
|
||||
end
|
||||
|
||||
-- Draw image
|
||||
if params.sx ~= 0 or params.sy ~= 0 or params.sw ~= imgWidth or params.sh ~= imgHeight then
|
||||
-- Need to use a quad for cropping
|
||||
local quad = love.graphics.newQuad(params.sx, params.sy, params.sw, params.sh, imgWidth, imgHeight)
|
||||
love.graphics.draw(image, quad, x + params.dx, y + params.dy, 0, params.dw / params.sw, params.dh / params.sh)
|
||||
else
|
||||
-- Simple draw with scaling
|
||||
love.graphics.draw(image, x + params.dx, y + params.dy, 0, params.scaleX, params.scaleY)
|
||||
end
|
||||
|
||||
-- Restore color
|
||||
love.graphics.setColor(r, g, b, a)
|
||||
end
|
||||
|
||||
--- Draw an image with tiling/repeat mode
|
||||
---@param image love.Image -- Image to draw
|
||||
---@param x number -- X position of bounds
|
||||
---@param y number -- Y position of bounds
|
||||
---@param width number -- Width of bounds
|
||||
---@param height number -- Height of bounds
|
||||
---@param repeatMode string? -- Repeat mode: "repeat", "repeat-x", "repeat-y", "no-repeat", "space", "round" (default: "no-repeat")
|
||||
---@param opacity number? -- Opacity 0-1 (default: 1)
|
||||
---@param tintColor Color? -- Color to tint the image (default: white/no tint)
|
||||
function ImageRenderer.drawTiled(image, x, y, width, height, repeatMode, opacity, tintColor)
|
||||
if not image then
|
||||
return -- Nothing to draw
|
||||
end
|
||||
|
||||
opacity = opacity or 1
|
||||
repeatMode = repeatMode or "no-repeat"
|
||||
|
||||
local imgWidth, imgHeight = image:getDimensions()
|
||||
|
||||
-- Save current color
|
||||
local r, g, b, a = love.graphics.getColor()
|
||||
|
||||
-- Apply opacity and tint
|
||||
if tintColor then
|
||||
love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity)
|
||||
else
|
||||
love.graphics.setColor(1, 1, 1, opacity)
|
||||
end
|
||||
|
||||
if repeatMode == "no-repeat" then
|
||||
-- Just draw once, no tiling
|
||||
love.graphics.draw(image, x, y)
|
||||
elseif repeatMode == "repeat" then
|
||||
-- Tile in both directions
|
||||
local tilesX = math.ceil(width / imgWidth)
|
||||
local tilesY = math.ceil(height / imgHeight)
|
||||
|
||||
for tileY = 0, tilesY - 1 do
|
||||
for tileX = 0, tilesX - 1 do
|
||||
local drawX = x + (tileX * imgWidth)
|
||||
local drawY = y + (tileY * imgHeight)
|
||||
|
||||
-- Calculate how much of the tile to draw (for partial tiles at edges)
|
||||
local drawWidth = math.min(imgWidth, width - (tileX * imgWidth))
|
||||
local drawHeight = math.min(imgHeight, height - (tileY * imgHeight))
|
||||
|
||||
if drawWidth < imgWidth or drawHeight < imgHeight then
|
||||
-- Use quad for partial tile
|
||||
local quad = love.graphics.newQuad(0, 0, drawWidth, drawHeight, imgWidth, imgHeight)
|
||||
love.graphics.draw(image, quad, drawX, drawY)
|
||||
else
|
||||
-- Draw full tile
|
||||
love.graphics.draw(image, drawX, drawY)
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif repeatMode == "repeat-x" then
|
||||
-- Tile horizontally only
|
||||
local tilesX = math.ceil(width / imgWidth)
|
||||
|
||||
for tileX = 0, tilesX - 1 do
|
||||
local drawX = x + (tileX * imgWidth)
|
||||
local drawWidth = math.min(imgWidth, width - (tileX * imgWidth))
|
||||
|
||||
if drawWidth < imgWidth then
|
||||
-- Use quad for partial tile
|
||||
local quad = love.graphics.newQuad(0, 0, drawWidth, imgHeight, imgWidth, imgHeight)
|
||||
love.graphics.draw(image, quad, drawX, y)
|
||||
else
|
||||
-- Draw full tile
|
||||
love.graphics.draw(image, drawX, y)
|
||||
end
|
||||
end
|
||||
elseif repeatMode == "repeat-y" then
|
||||
-- Tile vertically only
|
||||
local tilesY = math.ceil(height / imgHeight)
|
||||
|
||||
for tileY = 0, tilesY - 1 do
|
||||
local drawY = y + (tileY * imgHeight)
|
||||
local drawHeight = math.min(imgHeight, height - (tileY * imgHeight))
|
||||
|
||||
if drawHeight < imgHeight then
|
||||
-- Use quad for partial tile
|
||||
local quad = love.graphics.newQuad(0, 0, imgWidth, drawHeight, imgWidth, imgHeight)
|
||||
love.graphics.draw(image, quad, x, drawY)
|
||||
else
|
||||
-- Draw full tile
|
||||
love.graphics.draw(image, x, drawY)
|
||||
end
|
||||
end
|
||||
elseif repeatMode == "space" then
|
||||
-- Distribute tiles with even spacing
|
||||
local tilesX = math.floor(width / imgWidth)
|
||||
local tilesY = math.floor(height / imgHeight)
|
||||
|
||||
if tilesX < 1 then
|
||||
tilesX = 1
|
||||
end
|
||||
if tilesY < 1 then
|
||||
tilesY = 1
|
||||
end
|
||||
|
||||
local spaceX = tilesX > 1 and (width - (tilesX * imgWidth)) / (tilesX - 1) or 0
|
||||
local spaceY = tilesY > 1 and (height - (tilesY * imgHeight)) / (tilesY - 1) or 0
|
||||
|
||||
for tileY = 0, tilesY - 1 do
|
||||
for tileX = 0, tilesX - 1 do
|
||||
local drawX = x + (tileX * (imgWidth + spaceX))
|
||||
local drawY = y + (tileY * (imgHeight + spaceY))
|
||||
love.graphics.draw(image, drawX, drawY)
|
||||
end
|
||||
end
|
||||
elseif repeatMode == "round" then
|
||||
-- Scale tiles to fit bounds exactly
|
||||
local tilesX = math.max(1, utils.round(width / imgWidth))
|
||||
local tilesY = math.max(1, utils.round(height / imgHeight))
|
||||
|
||||
local scaleX = width / (tilesX * imgWidth)
|
||||
local scaleY = height / (tilesY * imgHeight)
|
||||
|
||||
for tileY = 0, tilesY - 1 do
|
||||
for tileX = 0, tilesX - 1 do
|
||||
local drawX = x + (tileX * imgWidth * scaleX)
|
||||
local drawY = y + (tileY * imgHeight * scaleY)
|
||||
love.graphics.draw(image, drawX, drawY, 0, scaleX, scaleY)
|
||||
end
|
||||
end
|
||||
else
|
||||
ErrorHandler:warn("ImageRenderer", "VAL_007", {
|
||||
repeatMode = repeatMode,
|
||||
fallback = "no-repeat",
|
||||
})
|
||||
love.graphics.draw(image, x, y)
|
||||
end
|
||||
|
||||
-- Restore color
|
||||
love.graphics.setColor(r, g, b, a)
|
||||
end
|
||||
|
||||
return ImageRenderer
|
||||
@@ -0,0 +1,174 @@
|
||||
-- ====================
|
||||
-- ImageScaler
|
||||
-- ====================
|
||||
|
||||
local ImageScaler = {}
|
||||
|
||||
-- ErrorHandler will be injected via init
|
||||
local ErrorHandler = nil
|
||||
|
||||
--- Initialize ImageScaler with dependencies
|
||||
---@param deps table Dependencies table with ErrorHandler
|
||||
function ImageScaler.init(deps)
|
||||
if deps and deps.ErrorHandler then
|
||||
ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
end
|
||||
|
||||
--- Scale an ImageData region using nearest-neighbor sampling
|
||||
--- Produces sharp, pixelated scaling - ideal for pixel art
|
||||
---@param sourceImageData love.ImageData -- Source image data
|
||||
---@param srcX number -- Source region X (0-based)
|
||||
---@param srcY number -- Source region Y (0-based)
|
||||
---@param srcW number -- Source region width
|
||||
---@param srcH number -- Source region height
|
||||
---@param destW number -- Destination width
|
||||
---@param destH number -- Destination height
|
||||
---@return love.ImageData -- Scaled image data
|
||||
function ImageScaler.scaleNearest(sourceImageData, srcX, srcY, srcW, srcH, destW, destH)
|
||||
if not sourceImageData then
|
||||
ErrorHandler:error("ImageScaler", "VAL_001", {
|
||||
parameter = "sourceImageData",
|
||||
})
|
||||
end
|
||||
|
||||
if srcW <= 0 or srcH <= 0 or destW <= 0 or destH <= 0 then
|
||||
ErrorHandler:warn("ImageScaler", "VAL_002", {
|
||||
srcW = srcW,
|
||||
srcH = srcH,
|
||||
destW = destW,
|
||||
destH = destH,
|
||||
fallback = "1x1 transparent image",
|
||||
})
|
||||
-- Return a minimal 1x1 transparent image as fallback
|
||||
local fallbackImageData = love.image.newImageData(1, 1)
|
||||
fallbackImageData:setPixel(0, 0, 0, 0, 0, 0)
|
||||
return fallbackImageData
|
||||
end
|
||||
|
||||
-- Create destination ImageData
|
||||
local destImageData = love.image.newImageData(destW, destH)
|
||||
|
||||
-- Calculate scale ratios (cached outside loops for performance)
|
||||
local scaleX = srcW / destW
|
||||
local scaleY = srcH / destH
|
||||
|
||||
-- Nearest-neighbor sampling
|
||||
for destY = 0, destH - 1 do
|
||||
for destX = 0, destW - 1 do
|
||||
-- Calculate source pixel coordinates using floor (nearest-neighbor)
|
||||
local srcPixelX = math.floor(destX * scaleX) + srcX
|
||||
local srcPixelY = math.floor(destY * scaleY) + srcY
|
||||
|
||||
-- Clamp to source bounds (safety check)
|
||||
srcPixelX = math.min(srcPixelX, srcX + srcW - 1)
|
||||
srcPixelY = math.min(srcPixelY, srcY + srcH - 1)
|
||||
|
||||
-- Sample source pixel
|
||||
local r, g, b, a = sourceImageData:getPixel(srcPixelX, srcPixelY)
|
||||
|
||||
-- Write to destination
|
||||
destImageData:setPixel(destX, destY, r, g, b, a)
|
||||
end
|
||||
end
|
||||
|
||||
return destImageData
|
||||
end
|
||||
|
||||
--- Linear interpolation helper
|
||||
--- Blends between two values based on interpolation factor
|
||||
---@param a number -- Start value
|
||||
---@param b number -- End value
|
||||
---@param t number -- Interpolation factor [0, 1]
|
||||
---@return number -- Interpolated value
|
||||
local function lerp(a, b, t)
|
||||
return a + (b - a) * t
|
||||
end
|
||||
|
||||
--- Scale an ImageData region using bilinear interpolation
|
||||
--- Produces smooth, filtered scaling - ideal for high-quality upscaling
|
||||
---@param sourceImageData love.ImageData -- Source image data
|
||||
---@param srcX number -- Source region X (0-based)
|
||||
---@param srcY number -- Source region Y (0-based)
|
||||
---@param srcW number -- Source region width
|
||||
---@param srcH number -- Source region height
|
||||
---@param destW number -- Destination width
|
||||
---@param destH number -- Destination height
|
||||
---@return love.ImageData -- Scaled image data
|
||||
function ImageScaler.scaleBilinear(sourceImageData, srcX, srcY, srcW, srcH, destW, destH)
|
||||
if not sourceImageData then
|
||||
ErrorHandler:error("ImageScaler", "VAL_001", {
|
||||
parameter = "sourceImageData",
|
||||
})
|
||||
end
|
||||
|
||||
if srcW <= 0 or srcH <= 0 or destW <= 0 or destH <= 0 then
|
||||
ErrorHandler:warn("ImageScaler", "VAL_002", {
|
||||
srcW = srcW,
|
||||
srcH = srcH,
|
||||
destW = destW,
|
||||
destH = destH,
|
||||
fallback = "1x1 transparent image",
|
||||
})
|
||||
-- Return a minimal 1x1 transparent image as fallback
|
||||
local fallbackImageData = love.image.newImageData(1, 1)
|
||||
fallbackImageData:setPixel(0, 0, 0, 0, 0, 0)
|
||||
return fallbackImageData
|
||||
end
|
||||
|
||||
-- Create destination ImageData
|
||||
local destImageData = love.image.newImageData(destW, destH)
|
||||
|
||||
-- Calculate scale ratios
|
||||
local scaleX = srcW / destW
|
||||
local scaleY = srcH / destH
|
||||
|
||||
-- Bilinear interpolation
|
||||
for destY = 0, destH - 1 do
|
||||
for destX = 0, destW - 1 do
|
||||
-- Calculate fractional source position
|
||||
local srcXf = destX * scaleX
|
||||
local srcYf = destY * scaleY
|
||||
|
||||
-- Get integer coordinates for 2x2 sampling grid
|
||||
local x0 = math.floor(srcXf)
|
||||
local y0 = math.floor(srcYf)
|
||||
local x1 = math.min(x0 + 1, srcW - 1)
|
||||
local y1 = math.min(y0 + 1, srcH - 1)
|
||||
|
||||
-- Get fractional parts for interpolation
|
||||
local fx = srcXf - x0
|
||||
local fy = srcYf - y0
|
||||
|
||||
-- Sample 4 neighboring pixels (with source offset)
|
||||
local r00, g00, b00, a00 = sourceImageData:getPixel(srcX + x0, srcY + y0)
|
||||
local r10, g10, b10, a10 = sourceImageData:getPixel(srcX + x1, srcY + y0)
|
||||
local r01, g01, b01, a01 = sourceImageData:getPixel(srcX + x0, srcY + y1)
|
||||
local r11, g11, b11, a11 = sourceImageData:getPixel(srcX + x1, srcY + y1)
|
||||
|
||||
-- Interpolate horizontally (top and bottom rows)
|
||||
local rTop = lerp(r00, r10, fx)
|
||||
local gTop = lerp(g00, g10, fx)
|
||||
local bTop = lerp(b00, b10, fx)
|
||||
local aTop = lerp(a00, a10, fx)
|
||||
|
||||
local rBottom = lerp(r01, r11, fx)
|
||||
local gBottom = lerp(g01, g11, fx)
|
||||
local bBottom = lerp(b01, b11, fx)
|
||||
local aBottom = lerp(a01, a11, fx)
|
||||
|
||||
-- Interpolate vertically (final result)
|
||||
local r = lerp(rTop, rBottom, fy)
|
||||
local g = lerp(gTop, gBottom, fy)
|
||||
local b = lerp(bTop, bBottom, fy)
|
||||
local a = lerp(aTop, aBottom, fy)
|
||||
|
||||
-- Write to destination
|
||||
destImageData:setPixel(destX, destY, r, g, b, a)
|
||||
end
|
||||
end
|
||||
|
||||
return destImageData
|
||||
end
|
||||
|
||||
return ImageScaler
|
||||
@@ -0,0 +1,88 @@
|
||||
---@class InputEvent
|
||||
---@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag"|"hover"|"unhover"|"touchpress"|"touchmove"|"touchrelease"|"touchcancel"
|
||||
---@field button number -- Mouse button: 1 (left), 2 (right), 3 (middle)
|
||||
---@field x number -- Mouse/Touch X position
|
||||
---@field y number -- Mouse/Touch Y position
|
||||
---@field dx number? -- Delta X from drag/touch start (only for drag/touch events)
|
||||
---@field dy number? -- Delta Y from drag/touch start (only for drag/touch events)
|
||||
---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean}
|
||||
---@field clickCount number -- Number of clicks (for double/triple click detection)
|
||||
---@field timestamp number -- Time when event occurred
|
||||
---@field touchId string? -- Touch identifier (for multi-touch)
|
||||
---@field pressure number? -- Touch pressure (0-1, defaults to 1.0)
|
||||
---@field phase string? -- Touch phase: "began", "moved", "ended", "cancelled"
|
||||
local InputEvent = {}
|
||||
InputEvent.__index = InputEvent
|
||||
|
||||
---@class InputEventProps
|
||||
---@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag"|"hover"|"unhover"|"touchpress"|"touchmove"|"touchrelease"|"touchcancel"
|
||||
---@field button number
|
||||
---@field x number
|
||||
---@field y number
|
||||
---@field dx number?
|
||||
---@field dy number?
|
||||
---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean}
|
||||
---@field clickCount number?
|
||||
---@field timestamp number?
|
||||
---@field touchId string?
|
||||
---@field pressure number?
|
||||
---@field phase string?
|
||||
|
||||
--- Create a new input event
|
||||
---@param props InputEventProps
|
||||
---@return InputEvent
|
||||
function InputEvent.new(props)
|
||||
local self = setmetatable({}, InputEvent)
|
||||
self.type = props.type
|
||||
self.button = props.button
|
||||
self.x = props.x
|
||||
self.y = props.y
|
||||
self.dx = props.dx
|
||||
self.dy = props.dy
|
||||
self.modifiers = props.modifiers
|
||||
self.clickCount = props.clickCount or 1
|
||||
self.timestamp = props.timestamp or love.timer.getTime()
|
||||
|
||||
-- Touch-specific properties
|
||||
self.touchId = props.touchId
|
||||
self.pressure = props.pressure or 1.0
|
||||
self.phase = props.phase
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
--- Create an InputEvent from LÖVE touch data
|
||||
---@param id userdata Touch ID from LÖVE
|
||||
---@param x number Touch X position
|
||||
---@param y number Touch Y position
|
||||
---@param phase string Touch phase: "began", "moved", "ended", "cancelled"
|
||||
---@param pressure number? Touch pressure (0-1, defaults to 1.0)
|
||||
---@return InputEvent
|
||||
function InputEvent.fromTouch(id, x, y, phase, pressure)
|
||||
local touchIdStr = tostring(id)
|
||||
local eventType = "touchpress"
|
||||
if phase == "moved" then
|
||||
eventType = "touchmove"
|
||||
elseif phase == "ended" then
|
||||
eventType = "touchrelease"
|
||||
elseif phase == "cancelled" then
|
||||
eventType = "touchcancel"
|
||||
end
|
||||
|
||||
return InputEvent.new({
|
||||
type = eventType,
|
||||
button = 1, -- Treat touch as left button
|
||||
x = x,
|
||||
y = y,
|
||||
dx = 0,
|
||||
dy = 0,
|
||||
modifiers = { shift = false, ctrl = false, alt = false, super = false },
|
||||
clickCount = 1,
|
||||
timestamp = love.timer.getTime(),
|
||||
touchId = touchIdStr,
|
||||
pressure = pressure or 1.0,
|
||||
phase = phase,
|
||||
})
|
||||
end
|
||||
|
||||
return InputEvent
|
||||
@@ -0,0 +1,748 @@
|
||||
local packageName = ... or "KeyboardNavigation"
|
||||
local modulePath = packageName:match("(.-)[^%.]+$")
|
||||
|
||||
local function req(name)
|
||||
return require(modulePath .. name)
|
||||
end
|
||||
|
||||
---@class KeyboardNavigation
|
||||
---@field config KeyboardNavigationConfig
|
||||
local KeyboardNavigation = {
|
||||
|
||||
config = {
|
||||
-- Global settings
|
||||
enabled = true,
|
||||
debugMode = false,
|
||||
|
||||
-- Key bindings
|
||||
keys = {
|
||||
next = "tab",
|
||||
previous = "shifttab",
|
||||
up = "up",
|
||||
down = "down",
|
||||
left = "left",
|
||||
right = "right",
|
||||
activate = { "return", "space" },
|
||||
dismiss = "escape",
|
||||
toggleDebug = "f12",
|
||||
inspect = "i",
|
||||
},
|
||||
|
||||
-- Navigation behavior
|
||||
wrapAround = true,
|
||||
directionalNavigation = true,
|
||||
focusVisible = true,
|
||||
autofocusOnCreate = false,
|
||||
|
||||
--- Drop focus after pressing Enter/Space to activate an element
|
||||
--- When false, focus remains on the element after activation
|
||||
dropFocusOnSelection = true,
|
||||
|
||||
-- Developer tools
|
||||
developerTools = {
|
||||
enabled = true,
|
||||
showProperties = true,
|
||||
highlightColor = { 1, 0.8, 0, 0.5 },
|
||||
},
|
||||
|
||||
-- Focus indicator style
|
||||
focusIndicator = {
|
||||
color = { 0.2, 0.6, 1.0, 0.8 },
|
||||
lineWidth = 2,
|
||||
inset = -3,
|
||||
borderRadius = 4,
|
||||
animationDuration = 0.15,
|
||||
},
|
||||
},
|
||||
|
||||
-- State
|
||||
_navigationStack = {},
|
||||
_lastNavigationTime = 0,
|
||||
_inspectMode = false,
|
||||
_deps = nil,
|
||||
|
||||
-- Spatial index for directional navigation (performance optimization)
|
||||
_spatialIndex = {
|
||||
enabled = false,
|
||||
cellSize = 100, -- Grid cell size in pixels
|
||||
grid = {}, -- Grid storing element references
|
||||
elementPositions = {}, -- Cache of element positions {element = {x, y, w, h}}
|
||||
lastUpdateFrame = 0,
|
||||
},
|
||||
}
|
||||
|
||||
--- Initialize KeyboardNavigation module
|
||||
---@param deps table {Context, Element, ErrorHandler, utils, InputEvent}
|
||||
function KeyboardNavigation.init(deps)
|
||||
-- Validate required dependencies
|
||||
local required = { Context = true, Element = true, ErrorHandler = true, utils = true, InputEvent = true }
|
||||
for depName, _ in pairs(required) do
|
||||
if not deps[depName] then
|
||||
error(string.format("KeyboardNavigation.init: Missing required dependency: %s", depName))
|
||||
end
|
||||
end
|
||||
|
||||
KeyboardNavigation._deps = deps
|
||||
KeyboardNavigation._ErrorHandler = deps.ErrorHandler
|
||||
KeyboardNavigation._InputEvent = deps.InputEvent
|
||||
KeyboardNavigation._Context = deps.Context
|
||||
KeyboardNavigation._Element = deps.Element
|
||||
KeyboardNavigation._utils = deps.utils
|
||||
end
|
||||
|
||||
--- Handle keyboard press for navigation
|
||||
---@param key string
|
||||
---@param scancode string
|
||||
---@param isrepeat boolean
|
||||
---@return boolean handled
|
||||
function KeyboardNavigation:handleKeyPress(key, scancode, isrepeat)
|
||||
if not KeyboardNavigation._Context then
|
||||
return false
|
||||
end
|
||||
|
||||
-- Debug logging
|
||||
if KeyboardNavigation.config.debugMode then
|
||||
print(
|
||||
string.format(
|
||||
"[KeyboardNavigation] Key pressed: %s (scancode: %s, repeat: %s)",
|
||||
key,
|
||||
scancode,
|
||||
tostring(isrepeat)
|
||||
)
|
||||
)
|
||||
print(string.format("[KeyboardNavigation] Enabled: %s", tostring(KeyboardNavigation.config.enabled)))
|
||||
end
|
||||
|
||||
local config = KeyboardNavigation.config
|
||||
local keys = config.keys
|
||||
|
||||
-- Check for activation keys
|
||||
for _, activateKey in ipairs(keys.activate) do
|
||||
if key == activateKey then
|
||||
return self:activateElement()
|
||||
end
|
||||
end
|
||||
|
||||
-- Check for dismiss key
|
||||
if key == keys.dismiss then
|
||||
return self:dismissElement()
|
||||
end
|
||||
|
||||
-- Check for next/previous navigation
|
||||
-- Tab with shift held = previous; Tab without shift = next
|
||||
if key == keys.next then
|
||||
if love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift") then
|
||||
return self:previousFocusable()
|
||||
end
|
||||
return self:nextFocusable()
|
||||
end
|
||||
|
||||
if key == keys.previous then
|
||||
return self:previousFocusable()
|
||||
end
|
||||
|
||||
-- Check for directional navigation
|
||||
if config.directionalNavigation then
|
||||
if key == keys.up then
|
||||
return self:navigateDirectional("up")
|
||||
elseif key == keys.down then
|
||||
return self:navigateDirectional("down")
|
||||
elseif key == keys.left then
|
||||
return self:navigateDirectional("left")
|
||||
elseif key == keys.right then
|
||||
return self:navigateDirectional("right")
|
||||
end
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--- Find next focusable element in the focusable list
|
||||
---@param focusableList table<Element> List of focusable elements in tab order
|
||||
---@param current Element? Currently focused element
|
||||
---@return Element?
|
||||
function KeyboardNavigation:_findNextInList(focusableList, current)
|
||||
local currentIndex = 0
|
||||
if current then
|
||||
for i, elem in ipairs(focusableList) do
|
||||
if elem.id == current.id then
|
||||
currentIndex = i
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Search forward
|
||||
if currentIndex < #focusableList then
|
||||
return focusableList[currentIndex + 1]
|
||||
end
|
||||
|
||||
-- Wrap around if enabled
|
||||
if KeyboardNavigation.config.wrapAround and #focusableList > 0 then
|
||||
return focusableList[1]
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Get the focusable element list scoped to the navigation container
|
||||
---@return Element[]
|
||||
function KeyboardNavigation:_getScopedFocusableList()
|
||||
local Context = KeyboardNavigation._Context
|
||||
local container = Context.getNavigationContainer()
|
||||
if container then
|
||||
return container:getFocusableChildren()
|
||||
end
|
||||
return Context.getFocusableElements()
|
||||
end
|
||||
|
||||
--- Navigate to next focusable element (Tab)
|
||||
---@return boolean success
|
||||
function KeyboardNavigation:nextFocusable()
|
||||
local Context = KeyboardNavigation._Context
|
||||
|
||||
local current = Context.getFocused()
|
||||
if KeyboardNavigation.config.debugMode then
|
||||
print(
|
||||
string.format("[KeyboardNavigation] Tab pressed - Current focus: %s", tostring(current and current.id or "nil"))
|
||||
)
|
||||
end
|
||||
|
||||
local focusableList = self:_getScopedFocusableList()
|
||||
local nextElem = self:_findNextInList(focusableList, current)
|
||||
|
||||
if nextElem then
|
||||
self:_focusElement(nextElem)
|
||||
return true
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--- Find previous focusable element in the focusable list
|
||||
---@param focusableList table<Element> List of focusable elements in tab order
|
||||
---@param current Element? Currently focused element
|
||||
---@return Element?
|
||||
function KeyboardNavigation:_findPreviousInList(focusableList, current)
|
||||
local currentIndex = #focusableList + 1
|
||||
if current then
|
||||
for i, elem in ipairs(focusableList) do
|
||||
if elem.id == current.id then
|
||||
currentIndex = i
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Search backward
|
||||
if currentIndex - 1 >= 1 then
|
||||
return focusableList[currentIndex - 1]
|
||||
end
|
||||
|
||||
-- Wrap around if enabled
|
||||
if KeyboardNavigation.config.wrapAround and #focusableList > 0 then
|
||||
return focusableList[#focusableList]
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Navigate to previous focusable element (Shift+Tab)
|
||||
---@return boolean success
|
||||
function KeyboardNavigation:previousFocusable()
|
||||
local Context = KeyboardNavigation._Context
|
||||
|
||||
local current = Context.getFocused()
|
||||
|
||||
local focusableList = self:_getScopedFocusableList()
|
||||
local prevElem = self:_findPreviousInList(focusableList, current)
|
||||
|
||||
if prevElem then
|
||||
self:_focusElement(prevElem)
|
||||
return true
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--- Navigate using arrow keys
|
||||
---@param direction "up"|"down"|"left"|"right"
|
||||
---@return boolean success
|
||||
function KeyboardNavigation:navigateDirectional(direction)
|
||||
local Context = KeyboardNavigation._Context
|
||||
local current = Context.getFocused()
|
||||
|
||||
if not current then
|
||||
return false
|
||||
end
|
||||
|
||||
local nextElem = KeyboardNavigation:_findDirectionalNeighbor(current, direction)
|
||||
|
||||
if nextElem then
|
||||
self:_focusElement(nextElem)
|
||||
return true
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--- Find closest focusable element in the given direction
|
||||
---@param current Element
|
||||
---@param direction "up"|"down"|"left"|"right"
|
||||
---@return Element?
|
||||
function KeyboardNavigation:_findDirectionalNeighbor(current, direction)
|
||||
-- Try spatial index first if enabled
|
||||
if KeyboardNavigation._spatialIndex.enabled then
|
||||
local spatialResult = self:_findDirectionalNeighborSpatial(current, direction)
|
||||
if spatialResult then
|
||||
return spatialResult
|
||||
end
|
||||
end
|
||||
|
||||
-- Collect all focusable elements visible this frame
|
||||
local Context = KeyboardNavigation._Context
|
||||
local focusable = {}
|
||||
|
||||
local function collectFocusable(elem)
|
||||
if elem:isFocusable() and elem ~= current then
|
||||
table.insert(focusable, elem)
|
||||
end
|
||||
for _, child in ipairs(elem.children) do
|
||||
collectFocusable(child)
|
||||
end
|
||||
end
|
||||
|
||||
-- Mode-agnostic: collect from Context's focusable list
|
||||
local allFocusable = Context.getFocusableElements()
|
||||
for _, elem in ipairs(allFocusable) do
|
||||
if elem ~= current then
|
||||
table.insert(focusable, elem)
|
||||
end
|
||||
end
|
||||
|
||||
if #focusable == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local currentRect = {
|
||||
x = current.x,
|
||||
y = current.y,
|
||||
width = current.width or 0,
|
||||
height = current.height or 0,
|
||||
}
|
||||
|
||||
local closest = nil
|
||||
local closestDistance = math.huge
|
||||
|
||||
for _, elem in ipairs(focusable) do
|
||||
local elemRect = {
|
||||
x = elem.x,
|
||||
y = elem.y,
|
||||
width = elem.width or 0,
|
||||
height = elem.height or 0,
|
||||
}
|
||||
|
||||
local distance, isInDirection = self:_calculateDirectionalDistance(currentRect, elemRect, direction)
|
||||
|
||||
if isInDirection and distance < closestDistance then
|
||||
closest = elem
|
||||
closestDistance = distance
|
||||
end
|
||||
end
|
||||
|
||||
-- If no element found in exact direction, try with looser criteria
|
||||
if not closest then
|
||||
closest = self:_findClosestInDirection(current, focusable, direction)
|
||||
end
|
||||
|
||||
return closest
|
||||
end
|
||||
|
||||
--- Calculate distance and direction between elements
|
||||
---@param from table {x, y, width, height}
|
||||
---@param to table {x, y, width, height}
|
||||
---@param direction string
|
||||
---@return number distance, boolean isInDirection
|
||||
function KeyboardNavigation:_calculateDirectionalDistance(from, to, direction)
|
||||
-- Calculate bounding box edges
|
||||
local fromLeft = from.x
|
||||
local fromRight = from.x + from.width
|
||||
local fromTop = from.y
|
||||
local fromBottom = from.y + from.height
|
||||
|
||||
local toLeft = to.x
|
||||
local toRight = to.x + to.width
|
||||
local toTop = to.y
|
||||
local toBottom = to.y + to.height
|
||||
|
||||
local distance = math.huge
|
||||
local isInDirection = false
|
||||
|
||||
if direction == "up" then
|
||||
if toBottom < fromTop then
|
||||
isInDirection = true
|
||||
distance = fromTop - toBottom
|
||||
end
|
||||
elseif direction == "down" then
|
||||
if toTop > fromBottom then
|
||||
isInDirection = true
|
||||
distance = toTop - fromBottom
|
||||
end
|
||||
elseif direction == "left" then
|
||||
if toRight < fromLeft then
|
||||
isInDirection = true
|
||||
distance = fromLeft - toRight
|
||||
end
|
||||
elseif direction == "right" then
|
||||
if toLeft > fromRight then
|
||||
isInDirection = true
|
||||
distance = toLeft - fromRight
|
||||
end
|
||||
end
|
||||
|
||||
return distance, isInDirection
|
||||
end
|
||||
|
||||
--- Find closest element in direction using center-to-center distance
|
||||
---@param current Element
|
||||
---@param focusable Element[]
|
||||
---@param direction string
|
||||
---@return Element?
|
||||
function KeyboardNavigation:_findClosestInDirection(current, focusable, direction)
|
||||
local currentCenterX = current.x + (current.width or 0) / 2
|
||||
local currentCenterY = current.y + (current.height or 0) / 2
|
||||
|
||||
local closest = nil
|
||||
local closestDistance = math.huge
|
||||
|
||||
for _, elem in ipairs(focusable) do
|
||||
if elem ~= current then
|
||||
local elemCenterX = elem.x + (elem.width or 0) / 2
|
||||
local elemCenterY = elem.y + (elem.height or 0) / 2
|
||||
|
||||
local dx = elemCenterX - currentCenterX
|
||||
local dy = elemCenterY - currentCenterY
|
||||
|
||||
-- Check if element is generally in the right direction
|
||||
local isInDirection = false
|
||||
|
||||
if direction == "up" and dy < 0 then
|
||||
isInDirection = true
|
||||
elseif direction == "down" and dy > 0 then
|
||||
isInDirection = true
|
||||
elseif direction == "left" and dx < 0 then
|
||||
isInDirection = true
|
||||
elseif direction == "right" and dx > 0 then
|
||||
isInDirection = true
|
||||
end
|
||||
|
||||
if isInDirection then
|
||||
local distance = math.sqrt(dx * dx + dy * dy)
|
||||
if distance < closestDistance then
|
||||
closest = elem
|
||||
closestDistance = distance
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return closest
|
||||
end
|
||||
|
||||
--- Focus an element
|
||||
---@param element Element
|
||||
function KeyboardNavigation:_focusElement(element)
|
||||
local Context = KeyboardNavigation._Context
|
||||
|
||||
if element and element:isFocusable() then
|
||||
if KeyboardNavigation.config.debugMode then
|
||||
print(
|
||||
string.format(
|
||||
"[KeyboardNavigation] Focusing element: %s (id: %s)",
|
||||
element.themeComponent or "unknown",
|
||||
tostring(element.id)
|
||||
)
|
||||
)
|
||||
end
|
||||
Context.setFocused(element)
|
||||
|
||||
-- Update focus indicator
|
||||
if KeyboardNavigation.FocusIndicator then
|
||||
KeyboardNavigation.FocusIndicator.setFocused(element)
|
||||
end
|
||||
|
||||
-- Call onFocus callback if it exists
|
||||
if element.onFocus then
|
||||
local success, err = pcall(function()
|
||||
if element.onFocusDeferred then
|
||||
table.insert(Context._deferredCallbacks or {}, function()
|
||||
element:onFocus(element)
|
||||
end)
|
||||
else
|
||||
element:onFocus(element)
|
||||
end
|
||||
end)
|
||||
|
||||
if not success then
|
||||
KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_001", {
|
||||
elementId = element.id or "unknown",
|
||||
error = tostring(err),
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@return boolean
|
||||
function KeyboardNavigation:_shouldDropFocusOnSelection(element)
|
||||
if element and element.dropFocusOnSelection ~= nil then
|
||||
return element.dropFocusOnSelection == true
|
||||
end
|
||||
|
||||
return KeyboardNavigation.config.dropFocusOnSelection == true
|
||||
end
|
||||
|
||||
--- Activate currently focused element
|
||||
---@return boolean success
|
||||
function KeyboardNavigation:activateElement()
|
||||
local Context = KeyboardNavigation._Context
|
||||
local focused = Context.getFocused()
|
||||
|
||||
if not focused then
|
||||
return false
|
||||
end
|
||||
|
||||
if focused.disabled then
|
||||
return false
|
||||
end
|
||||
|
||||
-- Fire press and release events
|
||||
if focused.onEvent then
|
||||
local modifiers = KeyboardNavigation._utils.getModifiers()
|
||||
local pressEvent = KeyboardNavigation._InputEvent.new({
|
||||
type = "press",
|
||||
button = 1,
|
||||
x = focused.x,
|
||||
y = focused.y,
|
||||
modifiers = modifiers,
|
||||
clickCount = 1,
|
||||
})
|
||||
|
||||
local releaseEvent = KeyboardNavigation._InputEvent.new({
|
||||
type = "release",
|
||||
button = 1,
|
||||
x = focused.x,
|
||||
y = focused.y,
|
||||
modifiers = modifiers,
|
||||
clickCount = 1,
|
||||
})
|
||||
|
||||
local success, err = pcall(function()
|
||||
focused.onEvent(focused, pressEvent)
|
||||
focused.onEvent(focused, releaseEvent)
|
||||
end)
|
||||
|
||||
if not success then
|
||||
KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_002", {
|
||||
elementId = focused.id or "unknown",
|
||||
error = tostring(err),
|
||||
})
|
||||
end
|
||||
|
||||
-- Drop focus after selection based on per-element override or global config.
|
||||
if KeyboardNavigation:_shouldDropFocusOnSelection(focused) then
|
||||
Context.clearFocus()
|
||||
if KeyboardNavigation.FocusIndicator then
|
||||
KeyboardNavigation.FocusIndicator.setFocused(nil)
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--- Dismiss currently focused element
|
||||
---@return boolean success
|
||||
function KeyboardNavigation:dismissElement()
|
||||
local Context = KeyboardNavigation._Context
|
||||
local focused = Context.getFocused()
|
||||
|
||||
if not focused then
|
||||
return false
|
||||
end
|
||||
|
||||
-- Check if element has a dismiss handler
|
||||
if focused.onDismiss then
|
||||
local success, err = pcall(function()
|
||||
if focused.onDismissDeferred then
|
||||
table.insert(Context._deferredCallbacks or {}, function()
|
||||
focused:onDismiss(focused)
|
||||
end)
|
||||
else
|
||||
focused:onDismiss(focused)
|
||||
end
|
||||
end)
|
||||
|
||||
if not success then
|
||||
KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_003", {
|
||||
elementId = focused.id or "unknown",
|
||||
error = tostring(err),
|
||||
})
|
||||
end
|
||||
|
||||
return true -- Handler took care of dismissal
|
||||
end
|
||||
|
||||
-- Default behavior: blur the element (only if no onDismiss handler)
|
||||
Context.clearFocus()
|
||||
return true
|
||||
end
|
||||
|
||||
--- Update keyboard navigation (for animations, etc.)
|
||||
---@param dt number
|
||||
function KeyboardNavigation:update(dt)
|
||||
-- Update focus indicator if it exists
|
||||
if KeyboardNavigation.FocusIndicator then
|
||||
KeyboardNavigation.FocusIndicator:update(dt)
|
||||
end
|
||||
end
|
||||
|
||||
--- Push current focus onto stack (for modals/dialogs)
|
||||
--- Saves current focus and sets new focus to the given element
|
||||
---@param element Element? The element to focus (e.g., modal dialog)
|
||||
function KeyboardNavigation:pushFocus(element)
|
||||
local Context = KeyboardNavigation._Context
|
||||
|
||||
table.insert(KeyboardNavigation._navigationStack, Context.getFocused())
|
||||
Context.pushFocusStack(element)
|
||||
end
|
||||
|
||||
--- Pop focus from stack (return from modal)
|
||||
--- Restores previously focused element from the stack
|
||||
---@return Element? The previously focused element, or nil if stack was empty
|
||||
function KeyboardNavigation:popFocus()
|
||||
local Context = KeyboardNavigation._Context
|
||||
|
||||
local previous = Context.popFocusStack()
|
||||
if #KeyboardNavigation._navigationStack > 0 then
|
||||
previous = table.remove(KeyboardNavigation._navigationStack)
|
||||
end
|
||||
|
||||
return previous
|
||||
end
|
||||
|
||||
-- ====================
|
||||
-- Spatial Index (Performance Optimization)
|
||||
-- ====================
|
||||
|
||||
--- Enable spatial index for faster directional navigation
|
||||
---@param enabled boolean
|
||||
function KeyboardNavigation.enableSpatialIndex(enabled)
|
||||
KeyboardNavigation._spatialIndex.enabled = enabled
|
||||
if not enabled then
|
||||
KeyboardNavigation:_clearSpatialIndex()
|
||||
end
|
||||
end
|
||||
|
||||
--- Clear spatial index
|
||||
function KeyboardNavigation:_clearSpatialIndex()
|
||||
KeyboardNavigation._spatialIndex.grid = {}
|
||||
KeyboardNavigation._spatialIndex.elementPositions = {}
|
||||
end
|
||||
|
||||
--- Find directional neighbor using spatial index
|
||||
---@param current Element
|
||||
---@param direction "up"|"down"|"left"|"right"
|
||||
---@return Element?
|
||||
function KeyboardNavigation:_findDirectionalNeighborSpatial(current, direction)
|
||||
local index = KeyboardNavigation._spatialIndex
|
||||
local cellSize = index.cellSize
|
||||
|
||||
-- Get current element's grid position
|
||||
local currentPos = index.elementPositions[current]
|
||||
if not currentPos then
|
||||
return nil
|
||||
end
|
||||
|
||||
local centerX = currentPos.x + currentPos.w / 2
|
||||
local centerY = currentPos.y + currentPos.h / 2
|
||||
local currentCellX = math.floor(centerX / cellSize)
|
||||
local currentCellY = math.floor(centerY / cellSize)
|
||||
|
||||
-- Search in direction, expanding outward
|
||||
local maxSearchRadius = 20 -- Maximum cells to search
|
||||
local visited = {}
|
||||
|
||||
for radius = 1, maxSearchRadius do
|
||||
local candidates = {}
|
||||
|
||||
-- Get cells in the search ring
|
||||
if direction == "up" then
|
||||
table.insert(candidates, { currentCellX, currentCellY - radius })
|
||||
if radius > 1 then
|
||||
table.insert(candidates, { currentCellX - 1, currentCellY - radius })
|
||||
table.insert(candidates, { currentCellX + 1, currentCellY - radius })
|
||||
end
|
||||
elseif direction == "down" then
|
||||
table.insert(candidates, { currentCellX, currentCellY + radius })
|
||||
if radius > 1 then
|
||||
table.insert(candidates, { currentCellX - 1, currentCellY + radius })
|
||||
table.insert(candidates, { currentCellX + 1, currentCellY + radius })
|
||||
end
|
||||
elseif direction == "left" then
|
||||
table.insert(candidates, { currentCellX - radius, currentCellY })
|
||||
if radius > 1 then
|
||||
table.insert(candidates, { currentCellX - radius, currentCellY - 1 })
|
||||
table.insert(candidates, { currentCellX - radius, currentCellY + 1 })
|
||||
end
|
||||
elseif direction == "right" then
|
||||
table.insert(candidates, { currentCellX + radius, currentCellY })
|
||||
if radius > 1 then
|
||||
table.insert(candidates, { currentCellX + radius, currentCellY - 1 })
|
||||
table.insert(candidates, { currentCellX + radius, currentCellY + 1 })
|
||||
end
|
||||
end
|
||||
|
||||
-- Check each candidate cell
|
||||
for _, cell in ipairs(candidates) do
|
||||
local cellKey = string.format("%d,%d", cell[1], cell[2])
|
||||
local cellElements = index.grid[cellKey]
|
||||
|
||||
if cellElements then
|
||||
for _, elem in ipairs(cellElements) do
|
||||
if elem ~= current and not visited[elem] then
|
||||
visited[elem] = true
|
||||
local elemPos = index.elementPositions[elem]
|
||||
if elemPos then
|
||||
local elemCenterX = elemPos.x + elemPos.w / 2
|
||||
local elemCenterY = elemPos.y + elemPos.h / 2
|
||||
|
||||
-- Check if element is in the correct direction
|
||||
local isInDirection = false
|
||||
if direction == "up" and elemCenterY < centerY then
|
||||
isInDirection = true
|
||||
elseif direction == "down" and elemCenterY > centerY then
|
||||
isInDirection = true
|
||||
elseif direction == "left" and elemCenterX < centerX then
|
||||
isInDirection = true
|
||||
elseif direction == "right" and elemCenterX > centerX then
|
||||
isInDirection = true
|
||||
end
|
||||
|
||||
if isInDirection then
|
||||
return elem
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
return KeyboardNavigation
|
||||
@@ -0,0 +1,697 @@
|
||||
---@class MemoryScanner
|
||||
---@field _StateManager table
|
||||
---@field _Context table
|
||||
---@field _ImageCache table
|
||||
---@field _ErrorHandler table
|
||||
local MemoryScanner = {}
|
||||
|
||||
---Initialize MemoryScanner with dependencies
|
||||
---@param deps {StateManager: table, Context: table, ImageCache: table, ErrorHandler: table}
|
||||
function MemoryScanner.init(deps)
|
||||
MemoryScanner._StateManager = deps.StateManager
|
||||
MemoryScanner._Context = deps.Context
|
||||
MemoryScanner._ImageCache = deps.ImageCache
|
||||
MemoryScanner._ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
|
||||
---Count items in a table
|
||||
---@param tbl table
|
||||
---@return number
|
||||
local function countTable(tbl)
|
||||
local count = 0
|
||||
for _ in pairs(tbl) do
|
||||
count = count + 1
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
---Calculate memory size estimate for a table (recursive)
|
||||
---@param tbl table
|
||||
---@param visited table? Tracking table to prevent circular references
|
||||
---@param depth number? Current recursion depth
|
||||
---@return number bytes Estimated memory usage in bytes
|
||||
local function estimateTableSize(tbl, visited, depth)
|
||||
if type(tbl) ~= "table" then
|
||||
return 0
|
||||
end
|
||||
|
||||
visited = visited or {}
|
||||
depth = depth or 0
|
||||
|
||||
-- Limit recursion depth to prevent stack overflow
|
||||
if depth > 10 then
|
||||
return 0
|
||||
end
|
||||
|
||||
-- Check for circular references
|
||||
if visited[tbl] then
|
||||
return 0
|
||||
end
|
||||
visited[tbl] = true
|
||||
|
||||
local size = 40 -- Base table overhead (approximate)
|
||||
|
||||
for k, v in pairs(tbl) do
|
||||
-- Key size
|
||||
if type(k) == "string" then
|
||||
size = size + #k + 24 -- String overhead
|
||||
elseif type(k) == "number" then
|
||||
size = size + 8
|
||||
else
|
||||
size = size + 8 -- Reference
|
||||
end
|
||||
|
||||
-- Value size
|
||||
if type(v) == "string" then
|
||||
size = size + #v + 24
|
||||
elseif type(v) == "number" then
|
||||
size = size + 8
|
||||
elseif type(v) == "boolean" then
|
||||
size = size + 4
|
||||
elseif type(v) == "table" then
|
||||
size = size + estimateTableSize(v, visited, depth + 1)
|
||||
elseif type(v) == "function" then
|
||||
size = size + 16 -- Function reference
|
||||
else
|
||||
size = size + 8 -- Other references
|
||||
end
|
||||
end
|
||||
|
||||
return size
|
||||
end
|
||||
|
||||
---Scan StateManager for memory issues
|
||||
---@return table report Detailed report of StateManager memory usage
|
||||
function MemoryScanner.scanStateManager()
|
||||
local report = {
|
||||
stateCount = 0,
|
||||
stateStoreSize = 0,
|
||||
metadataSize = 0,
|
||||
callSiteCounterSize = 0,
|
||||
orphanedStates = {},
|
||||
staleStates = {},
|
||||
largeStates = {},
|
||||
issues = {},
|
||||
}
|
||||
|
||||
if not MemoryScanner._StateManager then
|
||||
table.insert(report.issues, {
|
||||
severity = "error",
|
||||
message = "StateManager not initialized",
|
||||
})
|
||||
return report
|
||||
end
|
||||
|
||||
local internal = MemoryScanner._StateManager._getInternalState()
|
||||
local stateStore = internal.stateStore
|
||||
local stateMetadata = internal.stateMetadata
|
||||
local callSiteCounters = internal.callSiteCounters
|
||||
local currentFrame = MemoryScanner._StateManager.getFrameNumber()
|
||||
|
||||
-- Count states
|
||||
report.stateCount = countTable(stateStore)
|
||||
|
||||
-- Estimate sizes
|
||||
report.stateStoreSize = estimateTableSize(stateStore)
|
||||
report.metadataSize = estimateTableSize(stateMetadata)
|
||||
report.callSiteCounterSize = estimateTableSize(callSiteCounters)
|
||||
|
||||
-- Check for orphaned states (metadata without state)
|
||||
for id, _ in pairs(stateMetadata) do
|
||||
if not stateStore[id] then
|
||||
table.insert(report.orphanedStates, id)
|
||||
end
|
||||
end
|
||||
|
||||
-- Check for stale states (not accessed in many frames)
|
||||
local staleThreshold = 120 -- 2 seconds at 60fps
|
||||
for id, meta in pairs(stateMetadata) do
|
||||
local framesSinceAccess = currentFrame - meta.lastFrame
|
||||
if framesSinceAccess > staleThreshold then
|
||||
table.insert(report.staleStates, {
|
||||
id = id,
|
||||
framesSinceAccess = framesSinceAccess,
|
||||
createdFrame = meta.createdFrame,
|
||||
accessCount = meta.accessCount,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- Check for large states (may indicate memory bloat)
|
||||
for id, state in pairs(stateStore) do
|
||||
local stateSize = estimateTableSize(state)
|
||||
if stateSize > 1024 then -- More than 1KB
|
||||
table.insert(report.largeStates, {
|
||||
id = id,
|
||||
size = stateSize,
|
||||
keyCount = countTable(state),
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- Check callSiteCounters (should be near 0 after frame cleanup)
|
||||
local callSiteCount = countTable(callSiteCounters)
|
||||
if callSiteCount > 100 then
|
||||
table.insert(report.issues, {
|
||||
severity = "warning",
|
||||
message = string.format("callSiteCounters has %d entries (expected near 0)", callSiteCount),
|
||||
suggestion = "incrementFrame() may not be called properly, or counters aren't being reset",
|
||||
})
|
||||
end
|
||||
|
||||
-- Check for excessive state count
|
||||
if report.stateCount > 500 then
|
||||
table.insert(report.issues, {
|
||||
severity = "warning",
|
||||
message = string.format("High state count: %d states", report.stateCount),
|
||||
suggestion = "Consider reducing element count or implementing more aggressive cleanup",
|
||||
})
|
||||
end
|
||||
|
||||
-- Check for orphaned states
|
||||
if #report.orphanedStates > 0 then
|
||||
table.insert(report.issues, {
|
||||
severity = "error",
|
||||
message = string.format("Found %d orphaned states (metadata without state)", #report.orphanedStates),
|
||||
suggestion = "This indicates a bug in state management - metadata should be cleaned up with state",
|
||||
})
|
||||
end
|
||||
|
||||
-- Check for stale states
|
||||
if #report.staleStates > 10 then
|
||||
table.insert(report.issues, {
|
||||
severity = "warning",
|
||||
message = string.format("Found %d stale states (not accessed in 2+ seconds)", #report.staleStates),
|
||||
suggestion = "Cleanup may not be aggressive enough - consider reducing stateRetentionFrames",
|
||||
})
|
||||
end
|
||||
|
||||
return report
|
||||
end
|
||||
|
||||
---Scan Context for memory issues
|
||||
---@return table report Detailed report of Context memory usage
|
||||
function MemoryScanner.scanContext()
|
||||
local report = {
|
||||
topElementCount = 0,
|
||||
zIndexElementCount = 0,
|
||||
frameElementCount = 0,
|
||||
issues = {},
|
||||
}
|
||||
|
||||
if not MemoryScanner._Context then
|
||||
table.insert(report.issues, {
|
||||
severity = "error",
|
||||
message = "Context not initialized",
|
||||
})
|
||||
return report
|
||||
end
|
||||
|
||||
-- Count elements
|
||||
report.topElementCount = #MemoryScanner._Context.topElements
|
||||
report.zIndexElementCount = #MemoryScanner._Context._zIndexOrderedElements
|
||||
report.frameElementCount = #MemoryScanner._Context._currentFrameElements
|
||||
|
||||
-- Check for stale z-index elements (should be cleared each frame)
|
||||
if MemoryScanner._Context.isImmediateMode() then
|
||||
-- In immediate mode, _zIndexOrderedElements should be cleared at frame start
|
||||
-- If it has elements outside of frame rendering, that's a leak
|
||||
if not MemoryScanner._Context._frameStarted and report.zIndexElementCount > 0 then
|
||||
table.insert(report.issues, {
|
||||
severity = "warning",
|
||||
message = string.format("Z-index array has %d elements outside of frame", report.zIndexElementCount),
|
||||
suggestion = "clearFrameElements() may not be called properly in beginFrame()",
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- Check for excessive element count
|
||||
if report.topElementCount > 100 then
|
||||
table.insert(report.issues, {
|
||||
severity = "info",
|
||||
message = string.format("High top-level element count: %d", report.topElementCount),
|
||||
suggestion = "Consider consolidating elements or using fewer top-level containers",
|
||||
})
|
||||
end
|
||||
|
||||
return report
|
||||
end
|
||||
|
||||
---Scan ImageCache for memory issues
|
||||
---@return table report Detailed report of ImageCache memory usage
|
||||
function MemoryScanner.scanImageCache()
|
||||
local report = {
|
||||
imageCount = 0,
|
||||
estimatedMemory = 0,
|
||||
issues = {},
|
||||
}
|
||||
|
||||
if not MemoryScanner._ImageCache then
|
||||
table.insert(report.issues, {
|
||||
severity = "error",
|
||||
message = "ImageCache not initialized",
|
||||
})
|
||||
return report
|
||||
end
|
||||
|
||||
local stats = MemoryScanner._ImageCache.getStats()
|
||||
report.imageCount = stats.count
|
||||
report.estimatedMemory = stats.memoryEstimate
|
||||
|
||||
-- Check for excessive memory usage (>100MB)
|
||||
if report.estimatedMemory > 100 * 1024 * 1024 then
|
||||
table.insert(report.issues, {
|
||||
severity = "warning",
|
||||
message = string.format("ImageCache using ~%.2f MB", report.estimatedMemory / 1024 / 1024),
|
||||
suggestion = "Consider implementing cache eviction or clearing unused images",
|
||||
})
|
||||
end
|
||||
|
||||
-- Check for excessive image count
|
||||
if report.imageCount > 50 then
|
||||
table.insert(report.issues, {
|
||||
severity = "info",
|
||||
message = string.format("ImageCache has %d images", report.imageCount),
|
||||
suggestion = "Review if all cached images are necessary",
|
||||
})
|
||||
end
|
||||
|
||||
return report
|
||||
end
|
||||
|
||||
---Check if a circular reference is intentional (parent-child, module, or metatable)
|
||||
---@param path string The current path where circular ref was detected
|
||||
---@param originalPath string The original path where the table was first seen
|
||||
---@return boolean True if this is an intentional circular reference
|
||||
local function isIntentionalCircularReference(path, originalPath)
|
||||
-- Pattern 1: child.parent points back to parent
|
||||
-- Example: "topElements.1.children.1.parent" -> "topElements.1"
|
||||
if path:match("%.parent$") then
|
||||
local parentPath = path:match("^(.+)%.children%.[^.]+%.parent$")
|
||||
if parentPath == originalPath then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- Pattern 2: parent.children[n] points to child, child points back somewhere in parent tree
|
||||
-- Example: "topElements.1" -> "topElements.1.children.1.parent"
|
||||
if originalPath:match("%.parent$") then
|
||||
local childParentPath = originalPath:match("^(.+)%.children%.[^.]+%.parent$")
|
||||
if childParentPath == path then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- Pattern 3: Check for nested parent-child cycles
|
||||
-- child.children[n].parent -> child
|
||||
local segments = {}
|
||||
for segment in path:gmatch("[^.]+") do
|
||||
table.insert(segments, segment)
|
||||
end
|
||||
|
||||
-- Look for .children.N.parent pattern
|
||||
for i = 1, #segments - 2 do
|
||||
if segments[i] == "children" and segments[i + 2] == "parent" then
|
||||
-- Reconstruct path without the .children.N.parent suffix
|
||||
local reconstructedPath = table.concat(segments, ".", 1, i - 1)
|
||||
if reconstructedPath == originalPath then
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Pattern 4: Metatable __index self-references (modules)
|
||||
-- Example: "element._renderer._Theme.__index" -> "element._renderer._Theme"
|
||||
if path:match("%.__index$") then
|
||||
local basePath = path:match("^(.+)%.__index$")
|
||||
if basePath == originalPath then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- Pattern 5: Shared module references (elements sharing same module instances)
|
||||
-- Example: Multiple elements referencing _utils, _Theme, _Blur, etc.
|
||||
-- These start with _ and are typically modules
|
||||
local pathModuleName = path:match("%.(_[%w]+)%.")
|
||||
local originalModuleName = originalPath:match("%.(_[%w]+)%.")
|
||||
|
||||
if pathModuleName and originalModuleName then
|
||||
-- If both paths reference the same internal module (starting with _), it's intentional
|
||||
if pathModuleName == originalModuleName then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- Pattern 6: Shared Color/Transform objects between elements
|
||||
-- These are value objects that can be safely shared
|
||||
if path:match("Color") and originalPath:match("Color") then
|
||||
return true
|
||||
end
|
||||
if path:match("Transform") and originalPath:match("Transform") then
|
||||
return true
|
||||
end
|
||||
|
||||
-- Pattern 7: LayoutEngine holding reference to its element
|
||||
-- Example: "element._layoutEngine.element" -> "element"
|
||||
if path:match("%._layoutEngine%.element$") then
|
||||
local elementPath = path:match("^(.+)%._layoutEngine%.element$")
|
||||
if elementPath == originalPath then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- Pattern 8: Renderer holding references to element properties
|
||||
-- Example: "element._renderer.cornerRadius" -> "element.cornerRadius"
|
||||
if path:match("%._renderer%.") then
|
||||
local rendererBasePath = path:match("^(.+)%._renderer%.")
|
||||
local originalBasePath = originalPath:match("^(.+)%.")
|
||||
if rendererBasePath == originalBasePath then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- Pattern 9: Context reference from layout engine (shared singleton)
|
||||
-- Example: "element._layoutEngine._Context.topElements" -> "topElements"
|
||||
if path:match("%._layoutEngine%._Context%.") and originalPath == "topElements" then
|
||||
return true
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
---Detect circular references in a table
|
||||
---@param tbl table Table to check
|
||||
---@param path string? Current path (for reporting)
|
||||
---@param visited table? Tracking table
|
||||
---@return table[] circularRefs Array of circular reference paths
|
||||
---@return table[] intentionalRefs Array of intentional parent-child refs
|
||||
local function detectCircularReferences(tbl, path, visited)
|
||||
if type(tbl) ~= "table" then
|
||||
return {}, {}
|
||||
end
|
||||
|
||||
path = path or "root"
|
||||
visited = visited or {}
|
||||
local circularRefs = {}
|
||||
local intentionalRefs = {}
|
||||
|
||||
-- Check if we've seen this table before
|
||||
if visited[tbl] then
|
||||
local ref = {
|
||||
path = path,
|
||||
originalPath = visited[tbl],
|
||||
}
|
||||
|
||||
-- Determine if this is an intentional circular reference
|
||||
if isIntentionalCircularReference(path, visited[tbl]) then
|
||||
table.insert(intentionalRefs, ref)
|
||||
else
|
||||
table.insert(circularRefs, ref)
|
||||
end
|
||||
|
||||
return circularRefs, intentionalRefs
|
||||
end
|
||||
|
||||
-- Mark as visited
|
||||
visited[tbl] = path
|
||||
|
||||
-- Recursively check children
|
||||
for k, v in pairs(tbl) do
|
||||
if type(v) == "table" then
|
||||
local childPath = path .. "." .. tostring(k)
|
||||
local childRefs, childIntentionalRefs = detectCircularReferences(v, childPath, visited)
|
||||
for _, ref in ipairs(childRefs) do
|
||||
table.insert(circularRefs, ref)
|
||||
end
|
||||
for _, ref in ipairs(childIntentionalRefs) do
|
||||
table.insert(intentionalRefs, ref)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return circularRefs, intentionalRefs
|
||||
end
|
||||
|
||||
---Scan for circular references in immediate mode
|
||||
---@return table report Detailed report of circular references
|
||||
function MemoryScanner.scanCircularReferences()
|
||||
local report = {
|
||||
stateStoreCircularRefs = {},
|
||||
stateStoreIntentionalRefs = {},
|
||||
contextCircularRefs = {},
|
||||
contextIntentionalRefs = {},
|
||||
issues = {},
|
||||
}
|
||||
|
||||
if MemoryScanner._StateManager then
|
||||
local internal = MemoryScanner._StateManager._getInternalState()
|
||||
report.stateStoreCircularRefs, report.stateStoreIntentionalRefs =
|
||||
detectCircularReferences(internal.stateStore, "stateStore")
|
||||
end
|
||||
|
||||
if MemoryScanner._Context then
|
||||
report.contextCircularRefs, report.contextIntentionalRefs =
|
||||
detectCircularReferences(MemoryScanner._Context.topElements, "topElements")
|
||||
end
|
||||
|
||||
-- Report issues only for cross-module circular references
|
||||
if #report.stateStoreCircularRefs > 0 then
|
||||
table.insert(report.issues, {
|
||||
severity = "info",
|
||||
message = string.format(
|
||||
"Found %d cross-module circular references in StateManager",
|
||||
#report.stateStoreCircularRefs
|
||||
),
|
||||
suggestion = "These are typically architectural dependencies between modules, not memory leaks",
|
||||
})
|
||||
end
|
||||
|
||||
if #report.contextCircularRefs > 0 then
|
||||
table.insert(report.issues, {
|
||||
severity = "info",
|
||||
message = string.format("Found %d cross-module circular references in Context", #report.contextCircularRefs),
|
||||
suggestion = "These are typically architectural dependencies (e.g., layout engine ↔ renderer), not memory leaks",
|
||||
})
|
||||
end
|
||||
|
||||
return report
|
||||
end
|
||||
|
||||
---Run comprehensive memory scan
|
||||
---@return table report Complete memory analysis report
|
||||
function MemoryScanner.scan()
|
||||
local startMemory = collectgarbage("count")
|
||||
|
||||
local report = {
|
||||
timestamp = os.time(),
|
||||
startMemory = startMemory / 1024, -- MB
|
||||
stateManager = MemoryScanner.scanStateManager(),
|
||||
context = MemoryScanner.scanContext(),
|
||||
imageCache = MemoryScanner.scanImageCache(),
|
||||
circularRefs = MemoryScanner.scanCircularReferences(),
|
||||
summary = {
|
||||
totalIssues = 0,
|
||||
criticalIssues = 0,
|
||||
warnings = 0,
|
||||
info = 0,
|
||||
},
|
||||
}
|
||||
|
||||
-- Count issues by severity
|
||||
local function countIssues(subReport)
|
||||
for _, issue in ipairs(subReport.issues or {}) do
|
||||
report.summary.totalIssues = report.summary.totalIssues + 1
|
||||
if issue.severity == "error" then
|
||||
report.summary.criticalIssues = report.summary.criticalIssues + 1
|
||||
elseif issue.severity == "warning" then
|
||||
report.summary.warnings = report.summary.warnings + 1
|
||||
elseif issue.severity == "info" then
|
||||
report.summary.info = report.summary.info + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
countIssues(report.stateManager)
|
||||
countIssues(report.context)
|
||||
countIssues(report.imageCache)
|
||||
countIssues(report.circularRefs)
|
||||
|
||||
-- Force GC and measure freed memory
|
||||
local beforeGC = collectgarbage("count")
|
||||
collectgarbage("collect")
|
||||
collectgarbage("collect")
|
||||
local afterGC = collectgarbage("count")
|
||||
|
||||
report.gcAnalysis = {
|
||||
beforeGC = beforeGC / 1024, -- MB
|
||||
afterGC = afterGC / 1024, -- MB
|
||||
freed = (beforeGC - afterGC) / 1024, -- MB
|
||||
freedPercent = ((beforeGC - afterGC) / beforeGC) * 100,
|
||||
}
|
||||
|
||||
-- Analyze GC effectiveness
|
||||
if report.gcAnalysis.freedPercent < 5 then
|
||||
table.insert(report.stateManager.issues, {
|
||||
severity = "info",
|
||||
message = string.format("GC freed only %.1f%% of memory", report.gcAnalysis.freedPercent),
|
||||
suggestion = "Most memory is still referenced - this is normal if UI is active",
|
||||
})
|
||||
elseif report.gcAnalysis.freedPercent > 30 then
|
||||
table.insert(report.stateManager.issues, {
|
||||
severity = "warning",
|
||||
message = string.format("GC freed %.1f%% of memory", report.gcAnalysis.freedPercent),
|
||||
suggestion = "Significant memory was unreferenced - may indicate cleanup issues",
|
||||
})
|
||||
end
|
||||
|
||||
return report
|
||||
end
|
||||
|
||||
---Format report as human-readable string
|
||||
---@param report table Memory scan report
|
||||
---@return string formatted Formatted report
|
||||
function MemoryScanner.formatReport(report)
|
||||
local lines = {}
|
||||
|
||||
table.insert(lines, "=== FlexLöve Memory Scanner Report ===")
|
||||
table.insert(lines, string.format("Timestamp: %s", os.date("%Y-%m-%d %H:%M:%S", report.timestamp)))
|
||||
table.insert(lines, string.format("Memory: %.2f MB", report.startMemory))
|
||||
table.insert(lines, "")
|
||||
|
||||
-- Summary
|
||||
table.insert(lines, "--- Summary ---")
|
||||
table.insert(lines, string.format("Total Issues: %d", report.summary.totalIssues))
|
||||
table.insert(lines, string.format(" Critical: %d", report.summary.criticalIssues))
|
||||
table.insert(lines, string.format(" Warnings: %d", report.summary.warnings))
|
||||
table.insert(lines, string.format(" Info: %d", report.summary.info))
|
||||
table.insert(lines, "")
|
||||
|
||||
-- StateManager
|
||||
table.insert(lines, "--- StateManager ---")
|
||||
table.insert(lines, string.format("State Count: %d", report.stateManager.stateCount))
|
||||
table.insert(lines, string.format("State Store Size: %.2f KB", report.stateManager.stateStoreSize / 1024))
|
||||
table.insert(lines, string.format("Metadata Size: %.2f KB", report.stateManager.metadataSize / 1024))
|
||||
table.insert(lines, string.format("CallSite Counters: %.2f KB", report.stateManager.callSiteCounterSize / 1024))
|
||||
table.insert(lines, string.format("Orphaned States: %d", #report.stateManager.orphanedStates))
|
||||
table.insert(lines, string.format("Stale States: %d", #report.stateManager.staleStates))
|
||||
table.insert(lines, string.format("Large States: %d", #report.stateManager.largeStates))
|
||||
|
||||
if #report.stateManager.issues > 0 then
|
||||
table.insert(lines, "Issues:")
|
||||
for _, issue in ipairs(report.stateManager.issues) do
|
||||
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
|
||||
if issue.suggestion then
|
||||
table.insert(lines, string.format(" → %s", issue.suggestion))
|
||||
end
|
||||
end
|
||||
end
|
||||
table.insert(lines, "")
|
||||
|
||||
-- Context
|
||||
table.insert(lines, "--- Context ---")
|
||||
table.insert(lines, string.format("Top Elements: %d", report.context.topElementCount))
|
||||
table.insert(lines, string.format("Z-Index Elements: %d", report.context.zIndexElementCount))
|
||||
table.insert(lines, string.format("Frame Elements: %d", report.context.frameElementCount))
|
||||
|
||||
if #report.context.issues > 0 then
|
||||
table.insert(lines, "Issues:")
|
||||
for _, issue in ipairs(report.context.issues) do
|
||||
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
|
||||
if issue.suggestion then
|
||||
table.insert(lines, string.format(" → %s", issue.suggestion))
|
||||
end
|
||||
end
|
||||
end
|
||||
table.insert(lines, "")
|
||||
|
||||
-- ImageCache
|
||||
table.insert(lines, "--- ImageCache ---")
|
||||
table.insert(lines, string.format("Image Count: %d", report.imageCache.imageCount))
|
||||
table.insert(lines, string.format("Estimated Memory: %.2f MB", report.imageCache.estimatedMemory / 1024 / 1024))
|
||||
|
||||
if #report.imageCache.issues > 0 then
|
||||
table.insert(lines, "Issues:")
|
||||
for _, issue in ipairs(report.imageCache.issues) do
|
||||
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
|
||||
if issue.suggestion then
|
||||
table.insert(lines, string.format(" → %s", issue.suggestion))
|
||||
end
|
||||
end
|
||||
end
|
||||
table.insert(lines, "")
|
||||
|
||||
-- Circular References
|
||||
table.insert(lines, "--- Circular References ---")
|
||||
table.insert(lines, string.format("StateStore (Cross-module refs): %d", #report.circularRefs.stateStoreCircularRefs))
|
||||
table.insert(
|
||||
lines,
|
||||
string.format(
|
||||
"StateStore (Intentional - parent-child, modules, metatables): %d",
|
||||
#report.circularRefs.stateStoreIntentionalRefs
|
||||
)
|
||||
)
|
||||
table.insert(lines, string.format("Context (Cross-module refs): %d", #report.circularRefs.contextCircularRefs))
|
||||
table.insert(
|
||||
lines,
|
||||
string.format(
|
||||
"Context (Intentional - parent-child, modules, metatables): %d",
|
||||
#report.circularRefs.contextIntentionalRefs
|
||||
)
|
||||
)
|
||||
|
||||
if #report.circularRefs.issues > 0 then
|
||||
table.insert(lines, "Issues:")
|
||||
for _, issue in ipairs(report.circularRefs.issues) do
|
||||
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
|
||||
if issue.suggestion then
|
||||
table.insert(lines, string.format(" → %s", issue.suggestion))
|
||||
end
|
||||
end
|
||||
else
|
||||
table.insert(lines, " ✓ No unexpected circular references detected")
|
||||
end
|
||||
table.insert(lines, " Note: Cross-module refs are typically architectural dependencies, not memory leaks")
|
||||
table.insert(lines, "")
|
||||
|
||||
-- GC Analysis
|
||||
table.insert(lines, "--- Garbage Collection Analysis ---")
|
||||
table.insert(lines, string.format("Before GC: %.2f MB", report.gcAnalysis.beforeGC))
|
||||
table.insert(lines, string.format("After GC: %.2f MB", report.gcAnalysis.afterGC))
|
||||
table.insert(lines, string.format("Freed: %.2f MB (%.1f%%)", report.gcAnalysis.freed, report.gcAnalysis.freedPercent))
|
||||
table.insert(lines, "")
|
||||
|
||||
table.insert(lines, "=== End Report ===")
|
||||
|
||||
return table.concat(lines, "\n")
|
||||
end
|
||||
|
||||
---Save report to file
|
||||
---@param report table Memory scan report
|
||||
---@param filename string? Output filename (default: memory_report.txt)
|
||||
function MemoryScanner.saveReport(report, filename)
|
||||
filename = filename or "memory_report.txt"
|
||||
local formatted = MemoryScanner.formatReport(report)
|
||||
|
||||
local file = io.open(filename, "w")
|
||||
if file then
|
||||
file:write(formatted)
|
||||
file:close()
|
||||
if MemoryScanner._ErrorHandler then
|
||||
MemoryScanner._ErrorHandler:warn("MemoryScanner", "RES_004", {
|
||||
resourceType = "report",
|
||||
path = filename,
|
||||
status = "saved",
|
||||
})
|
||||
end
|
||||
else
|
||||
if MemoryScanner._ErrorHandler then
|
||||
MemoryScanner._ErrorHandler:warn("MemoryScanner", "RES_004", {
|
||||
resourceType = "report",
|
||||
path = filename,
|
||||
status = "failed to save",
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return MemoryScanner
|
||||
@@ -0,0 +1,202 @@
|
||||
---@class ModuleLoader
|
||||
local ModuleLoader = {}
|
||||
|
||||
-- Module registry to track loaded vs. stub modules
|
||||
ModuleLoader._registry = {}
|
||||
ModuleLoader._ErrorHandler = nil
|
||||
|
||||
--- Initialize ModuleLoader with dependencies
|
||||
---@param deps table
|
||||
function ModuleLoader.init(deps)
|
||||
ModuleLoader._ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
|
||||
--- Create a null-object stub for a missing optional module
|
||||
--- Provides safe defaults that won't cause runtime errors
|
||||
---@param moduleName string
|
||||
---@return table
|
||||
local function createNullObject(moduleName)
|
||||
local stub = {
|
||||
_isStub = true,
|
||||
_moduleName = moduleName,
|
||||
}
|
||||
|
||||
-- Common method stubs that return safe defaults
|
||||
local metatable = {
|
||||
__index = function(_, key)
|
||||
-- Common initialization method
|
||||
if key == "init" then
|
||||
return function()
|
||||
return stub
|
||||
end
|
||||
end
|
||||
|
||||
-- Common constructor method
|
||||
if key == "new" then
|
||||
return function()
|
||||
return stub
|
||||
end
|
||||
end
|
||||
|
||||
-- Common draw method
|
||||
if key == "draw" then
|
||||
return function() end
|
||||
end
|
||||
|
||||
-- Common update method
|
||||
if key == "update" then
|
||||
return function() end
|
||||
end
|
||||
|
||||
-- Common render method
|
||||
if key == "render" then
|
||||
return function() end
|
||||
end
|
||||
|
||||
-- Common cleanup method
|
||||
if key == "destroy" then
|
||||
return function() end
|
||||
end
|
||||
|
||||
-- Common cleanup method
|
||||
if key == "cleanup" then
|
||||
return function() end
|
||||
end
|
||||
|
||||
-- Common clear method
|
||||
if key == "clear" then
|
||||
return function() end
|
||||
end
|
||||
|
||||
-- Common reset method
|
||||
if key == "reset" then
|
||||
return function() end
|
||||
end
|
||||
|
||||
-- Common get method
|
||||
if key == "get" then
|
||||
return function()
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Common set method
|
||||
if key == "set" then
|
||||
return function() end
|
||||
end
|
||||
|
||||
-- Common load method
|
||||
if key == "load" then
|
||||
return function()
|
||||
return stub
|
||||
end
|
||||
end
|
||||
|
||||
-- Common cache-related methods
|
||||
if key == "cache" or key == "getCache" or key == "clearCache" then
|
||||
return function()
|
||||
return {}
|
||||
end
|
||||
end
|
||||
|
||||
-- For any unknown method, return a no-op function that accepts any arguments
|
||||
-- This allows safe method calls on stub objects (e.g., Performance:startFrame())
|
||||
return function()
|
||||
return stub
|
||||
end
|
||||
end,
|
||||
|
||||
-- Make function calls safe (in case the stub itself is called)
|
||||
__call = function()
|
||||
return stub
|
||||
end,
|
||||
}
|
||||
|
||||
setmetatable(stub, metatable)
|
||||
return stub
|
||||
end
|
||||
|
||||
--- Safely require a module with graceful fallback for optional modules
|
||||
--- Returns the module if it exists, or a null-object stub if it's optional and missing
|
||||
--- Throws an error if a required module is missing
|
||||
---@param modulePath string Full path to the module (e.g., "modules.Performance")
|
||||
---@param isOptional boolean If true, returns null-object on failure; if false, throws error
|
||||
---@return table module The loaded module or a null-object stub
|
||||
function ModuleLoader.safeRequire(modulePath, isOptional)
|
||||
-- Check if already loaded
|
||||
if ModuleLoader._registry[modulePath] then
|
||||
return ModuleLoader._registry[modulePath]
|
||||
end
|
||||
|
||||
-- Attempt to load the module
|
||||
local success, result = pcall(require, modulePath)
|
||||
|
||||
if success then
|
||||
-- Module loaded successfully
|
||||
ModuleLoader._registry[modulePath] = result
|
||||
return result
|
||||
else
|
||||
-- Module failed to load
|
||||
if isOptional then
|
||||
-- Create null-object stub for optional module
|
||||
local stub = createNullObject(modulePath)
|
||||
ModuleLoader._registry[modulePath] = stub
|
||||
|
||||
-- Log warning about missing optional module
|
||||
if ModuleLoader._ErrorHandler then
|
||||
ModuleLoader._ErrorHandler:warn("ModuleLoader", "MOD_001", {
|
||||
modulePath = modulePath,
|
||||
})
|
||||
end
|
||||
|
||||
return stub
|
||||
else
|
||||
-- Required module is missing - throw error
|
||||
error(string.format("Required module '%s' not found: %s", modulePath, tostring(result)))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Check if a module is actually loaded (not a stub)
|
||||
---@param modulePath string Full path to the module
|
||||
---@return boolean isLoaded True if module is loaded, false if it's a stub or not loaded
|
||||
function ModuleLoader.isModuleLoaded(modulePath)
|
||||
local module = ModuleLoader._registry[modulePath]
|
||||
if not module then
|
||||
return false
|
||||
end
|
||||
|
||||
-- Check if it's a stub
|
||||
return not module._isStub
|
||||
end
|
||||
|
||||
--- Get list of all loaded modules
|
||||
---@return table modules List of module paths that are actually loaded (not stubs)
|
||||
function ModuleLoader.getLoadedModules()
|
||||
local loaded = {}
|
||||
for path, module in pairs(ModuleLoader._registry) do
|
||||
if not module._isStub then
|
||||
table.insert(loaded, path)
|
||||
end
|
||||
end
|
||||
return loaded
|
||||
end
|
||||
|
||||
--- Get list of all stub modules
|
||||
---@return table stubs List of module paths that are stubs
|
||||
function ModuleLoader.getStubModules()
|
||||
local stubs = {}
|
||||
for path, module in pairs(ModuleLoader._registry) do
|
||||
if module._isStub then
|
||||
table.insert(stubs, path)
|
||||
end
|
||||
end
|
||||
return stubs
|
||||
end
|
||||
|
||||
--- Clear the module registry (useful for testing)
|
||||
function ModuleLoader._clearRegistry()
|
||||
ModuleLoader._registry = {}
|
||||
end
|
||||
|
||||
return ModuleLoader
|
||||
@@ -0,0 +1,217 @@
|
||||
local modulePath = (...):match("(.-)[^%.]+$")
|
||||
local ImageScaler = require(modulePath .. "ImageScaler")
|
||||
|
||||
local NinePatch = {}
|
||||
|
||||
-- ErrorHandler will be injected via init
|
||||
local ErrorHandler = nil
|
||||
|
||||
--- Initialize NinePatch with dependencies
|
||||
---@param deps table Dependencies table with ErrorHandler
|
||||
function NinePatch.init(deps)
|
||||
if deps and deps.ErrorHandler then
|
||||
ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
-- Also initialize ImageScaler since it's a dependency
|
||||
if ImageScaler.init then
|
||||
ImageScaler.init(deps)
|
||||
end
|
||||
end
|
||||
|
||||
--- Draw a 9-patch component using Android-style rendering
|
||||
--- Corners are scaled by scaleCorners multiplier, edges stretch in one dimension only
|
||||
---@param component ThemeComponent
|
||||
---@param atlas love.Image
|
||||
---@param x number -- X position (top-left corner)
|
||||
---@param y number -- Y position (top-left corner)
|
||||
---@param width number -- Total width (border-box)
|
||||
---@param height number -- Total height (border-box)
|
||||
---@param opacity number?
|
||||
---@param elementScaleCorners number? -- Element-level override for scaleCorners (scale multiplier)
|
||||
---@param elementScalingAlgorithm "nearest"|"bilinear"? -- Element-level override for scalingAlgorithm
|
||||
function NinePatch.draw(component, atlas, x, y, width, height, opacity, elementScaleCorners, elementScalingAlgorithm)
|
||||
if not component or not atlas then
|
||||
return
|
||||
end
|
||||
|
||||
opacity = opacity or 1
|
||||
love.graphics.setColor(1, 1, 1, opacity)
|
||||
|
||||
local regions = component.regions
|
||||
|
||||
-- Extract border dimensions from regions (in pixels)
|
||||
local left = regions.topLeft.w
|
||||
local right = regions.topRight.w
|
||||
local top = regions.topLeft.h
|
||||
local bottom = regions.bottomLeft.h
|
||||
local centerW = regions.middleCenter.w
|
||||
local centerH = regions.middleCenter.h
|
||||
|
||||
-- Calculate content area (space remaining after borders)
|
||||
local contentWidth = width - left - right
|
||||
local contentHeight = height - top - bottom
|
||||
|
||||
-- Clamp to prevent negative dimensions
|
||||
contentWidth = math.max(0, contentWidth)
|
||||
contentHeight = math.max(0, contentHeight)
|
||||
|
||||
-- Calculate stretch scales for edges and center
|
||||
local scaleX = contentWidth / centerW
|
||||
local scaleY = contentHeight / centerH
|
||||
|
||||
-- Create quads for each region
|
||||
local atlasWidth, atlasHeight = atlas:getDimensions()
|
||||
|
||||
local function makeQuad(region)
|
||||
return love.graphics.newQuad(region.x, region.y, region.w, region.h, atlasWidth, atlasHeight)
|
||||
end
|
||||
|
||||
-- Get corner scale multiplier
|
||||
-- Priority: element-level override > component setting > default (nil = no scaling)
|
||||
local scaleCorners = elementScaleCorners
|
||||
if scaleCorners == nil then
|
||||
scaleCorners = component.scaleCorners
|
||||
end
|
||||
|
||||
-- Priority: element-level override > component setting > default ("bilinear")
|
||||
local scalingAlgorithm = elementScalingAlgorithm
|
||||
if scalingAlgorithm == nil then
|
||||
scalingAlgorithm = component.scalingAlgorithm or "bilinear"
|
||||
end
|
||||
|
||||
if scaleCorners and type(scaleCorners) == "number" and scaleCorners > 0 then
|
||||
-- Initialize cache if needed
|
||||
if not component._scaledRegionCache then
|
||||
component._scaledRegionCache = {}
|
||||
end
|
||||
|
||||
-- Use the numeric scale multiplier directly
|
||||
local scaleFactor = scaleCorners
|
||||
|
||||
-- Helper to get or create scaled region
|
||||
local function getScaledRegion(regionName, region, targetWidth, targetHeight)
|
||||
local cacheKey = string.format("%s_%.2f_%s", regionName, scaleFactor, scalingAlgorithm)
|
||||
|
||||
if component._scaledRegionCache[cacheKey] then
|
||||
return component._scaledRegionCache[cacheKey]
|
||||
end
|
||||
|
||||
-- Get ImageData from component (stored during theme loading)
|
||||
local atlasData = component._loadedAtlasData
|
||||
if not atlasData then
|
||||
ErrorHandler.error(
|
||||
"NinePatch",
|
||||
"REN_007",
|
||||
"No ImageData available for atlas. Image must be loaded with safeLoadImage.",
|
||||
{
|
||||
componentType = component.type,
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
local scaledData
|
||||
|
||||
if scalingAlgorithm == "nearest" then
|
||||
scaledData =
|
||||
ImageScaler.scaleNearest(atlasData, region.x, region.y, region.w, region.h, targetWidth, targetHeight)
|
||||
else
|
||||
scaledData =
|
||||
ImageScaler.scaleBilinear(atlasData, region.x, region.y, region.w, region.h, targetWidth, targetHeight)
|
||||
end
|
||||
|
||||
-- Convert to image and cache
|
||||
local scaledImage = love.graphics.newImage(scaledData)
|
||||
component._scaledRegionCache[cacheKey] = scaledImage
|
||||
|
||||
return scaledImage
|
||||
end
|
||||
|
||||
-- Calculate scaled dimensions for corners
|
||||
local scaledLeft = math.floor(left * scaleFactor + 0.5)
|
||||
local scaledRight = math.floor(right * scaleFactor + 0.5)
|
||||
local scaledTop = math.floor(top * scaleFactor + 0.5)
|
||||
local scaledBottom = math.floor(bottom * scaleFactor + 0.5)
|
||||
|
||||
-- CORNERS (scaled using algorithm)
|
||||
local topLeftScaled = getScaledRegion("topLeft", regions.topLeft, scaledLeft, scaledTop)
|
||||
local topRightScaled = getScaledRegion("topRight", regions.topRight, scaledRight, scaledTop)
|
||||
local bottomLeftScaled = getScaledRegion("bottomLeft", regions.bottomLeft, scaledLeft, scaledBottom)
|
||||
local bottomRightScaled = getScaledRegion("bottomRight", regions.bottomRight, scaledRight, scaledBottom)
|
||||
|
||||
love.graphics.draw(topLeftScaled, x, y)
|
||||
love.graphics.draw(topRightScaled, x + width - scaledRight, y)
|
||||
love.graphics.draw(bottomLeftScaled, x, y + height - scaledBottom)
|
||||
love.graphics.draw(bottomRightScaled, x + width - scaledRight, y + height - scaledBottom)
|
||||
|
||||
-- Update content dimensions to account for scaled borders
|
||||
local adjustedContentWidth = width - scaledLeft - scaledRight
|
||||
local adjustedContentHeight = height - scaledTop - scaledBottom
|
||||
adjustedContentWidth = math.max(0, adjustedContentWidth)
|
||||
adjustedContentHeight = math.max(0, adjustedContentHeight)
|
||||
|
||||
-- Recalculate stretch scales
|
||||
local adjustedScaleX = adjustedContentWidth / centerW
|
||||
local adjustedScaleY = adjustedContentHeight / centerH
|
||||
|
||||
-- TOP/BOTTOM EDGES (stretch horizontally, scale vertically)
|
||||
if adjustedContentWidth > 0 then
|
||||
local topCenterScaled = getScaledRegion("topCenter", regions.topCenter, regions.topCenter.w, scaledTop)
|
||||
local bottomCenterScaled =
|
||||
getScaledRegion("bottomCenter", regions.bottomCenter, regions.bottomCenter.w, scaledBottom)
|
||||
|
||||
love.graphics.draw(topCenterScaled, x + scaledLeft, y, 0, adjustedScaleX, 1)
|
||||
love.graphics.draw(bottomCenterScaled, x + scaledLeft, y + height - scaledBottom, 0, adjustedScaleX, 1)
|
||||
end
|
||||
|
||||
-- LEFT/RIGHT EDGES (stretch vertically, scale horizontally)
|
||||
if adjustedContentHeight > 0 then
|
||||
local middleLeftScaled = getScaledRegion("middleLeft", regions.middleLeft, scaledLeft, regions.middleLeft.h)
|
||||
local middleRightScaled = getScaledRegion("middleRight", regions.middleRight, scaledRight, regions.middleRight.h)
|
||||
|
||||
love.graphics.draw(middleLeftScaled, x, y + scaledTop, 0, 1, adjustedScaleY)
|
||||
love.graphics.draw(middleRightScaled, x + width - scaledRight, y + scaledTop, 0, 1, adjustedScaleY)
|
||||
end
|
||||
|
||||
-- CENTER (stretch both dimensions, no scaling)
|
||||
if adjustedContentWidth > 0 and adjustedContentHeight > 0 then
|
||||
love.graphics.draw(
|
||||
atlas,
|
||||
makeQuad(regions.middleCenter),
|
||||
x + scaledLeft,
|
||||
y + scaledTop,
|
||||
0,
|
||||
adjustedScaleX,
|
||||
adjustedScaleY
|
||||
)
|
||||
end
|
||||
else
|
||||
-- Original rendering logic (no scaling)
|
||||
-- CORNERS (no scaling - 1:1 pixel perfect)
|
||||
love.graphics.draw(atlas, makeQuad(regions.topLeft), x, y)
|
||||
love.graphics.draw(atlas, makeQuad(regions.topRight), x + left + contentWidth, y)
|
||||
love.graphics.draw(atlas, makeQuad(regions.bottomLeft), x, y + top + contentHeight)
|
||||
love.graphics.draw(atlas, makeQuad(regions.bottomRight), x + left + contentWidth, y + top + contentHeight)
|
||||
|
||||
-- TOP/BOTTOM EDGES (stretch horizontally only)
|
||||
if contentWidth > 0 then
|
||||
love.graphics.draw(atlas, makeQuad(regions.topCenter), x + left, y, 0, scaleX, 1)
|
||||
love.graphics.draw(atlas, makeQuad(regions.bottomCenter), x + left, y + top + contentHeight, 0, scaleX, 1)
|
||||
end
|
||||
|
||||
-- LEFT/RIGHT EDGES (stretch vertically only)
|
||||
if contentHeight > 0 then
|
||||
love.graphics.draw(atlas, makeQuad(regions.middleLeft), x, y + top, 0, 1, scaleY)
|
||||
love.graphics.draw(atlas, makeQuad(regions.middleRight), x + left + contentWidth, y + top, 0, 1, scaleY)
|
||||
end
|
||||
|
||||
-- CENTER (stretch both dimensions)
|
||||
if contentWidth > 0 and contentHeight > 0 then
|
||||
love.graphics.draw(atlas, makeQuad(regions.middleCenter), x + left, y + top, 0, scaleX, scaleY)
|
||||
end
|
||||
end
|
||||
|
||||
-- Reset color
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return NinePatch
|
||||
@@ -0,0 +1,351 @@
|
||||
local modulePath = (...):match("(.-)[^%.]+$")
|
||||
local function req(name)
|
||||
return require(modulePath .. name)
|
||||
end
|
||||
|
||||
-- All numeric, range, type, and enum validation lives here.
|
||||
-- `clamp` is injected via init() to avoid a cross-import into utils.
|
||||
-- `ErrorHandler` is injected via init() so error reporting routes through
|
||||
-- the shared handler (matching the pre-split behavior of utils.validate*).
|
||||
|
||||
local ErrorHandler = nil
|
||||
local clamp = nil
|
||||
|
||||
--- Initialize dependencies
|
||||
---@param deps table Dependencies: { ErrorHandler = table, clamp = function }
|
||||
local function init(deps)
|
||||
if type(deps) == "table" then
|
||||
ErrorHandler = deps.ErrorHandler or ErrorHandler
|
||||
clamp = deps.clamp or clamp
|
||||
end
|
||||
end
|
||||
|
||||
-- Numeric validation utilities
|
||||
|
||||
--- Check if a value is NaN (not-a-number)
|
||||
--- @param value any Value to check
|
||||
--- @return boolean
|
||||
local function isNaN(value)
|
||||
return type(value) == "number" and value ~= value
|
||||
end
|
||||
|
||||
--- Check if a value is Infinity
|
||||
--- @param value any Value to check
|
||||
--- @return boolean
|
||||
local function isInfinity(value)
|
||||
return type(value) == "number" and (value == math.huge or value == -math.huge)
|
||||
end
|
||||
|
||||
--- Validate a numeric value with comprehensive checks
|
||||
--- @param value any Value to validate
|
||||
--- @param options table? Validation options
|
||||
--- @return boolean, string?, number? Returns valid, errorMessage, sanitizedValue
|
||||
local function validateNumber(value, options)
|
||||
options = options or {}
|
||||
|
||||
-- Check if value is a number type
|
||||
if type(value) ~= "number" then
|
||||
if options.default ~= nil then
|
||||
return true, nil, options.default
|
||||
end
|
||||
return false, string.format("Value must be a number, got %s", type(value)), nil
|
||||
end
|
||||
|
||||
-- Check for NaN
|
||||
if isNaN(value) then
|
||||
if not options.allowNaN then
|
||||
if options.default ~= nil then
|
||||
return true, nil, options.default
|
||||
end
|
||||
return false, "Value is NaN (not-a-number)", nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Check for Infinity
|
||||
if isInfinity(value) then
|
||||
if not options.allowInfinity then
|
||||
if options.default ~= nil then
|
||||
return true, nil, options.default
|
||||
end
|
||||
return false, "Value is Infinity", nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Check for integer requirement
|
||||
if options.integer and math.floor(value) ~= value then
|
||||
return false, string.format("Value must be an integer, got %s", value), nil
|
||||
end
|
||||
|
||||
-- Check for positive requirement
|
||||
if options.positive and value <= 0 then
|
||||
return false, string.format("Value must be positive, got %s", value), nil
|
||||
end
|
||||
|
||||
-- Check bounds
|
||||
if options.min and value < options.min then
|
||||
return false, string.format("Value %s is below minimum %s", value, options.min), nil
|
||||
end
|
||||
|
||||
if options.max and value > options.max then
|
||||
return false, string.format("Value %s is above maximum %s", value, options.max), nil
|
||||
end
|
||||
|
||||
return true, nil, value
|
||||
end
|
||||
|
||||
--- Sanitize a numeric value (never errors, always returns valid number)
|
||||
--- @param value any Value to sanitize
|
||||
--- @param min number? Minimum value
|
||||
--- @param max number? Maximum value
|
||||
--- @param default number? Default value for invalid inputs
|
||||
--- @return number Sanitized value
|
||||
local function sanitizeNumber(value, min, max, default)
|
||||
default = default or 0
|
||||
min = min or -math.huge
|
||||
max = max or math.huge
|
||||
|
||||
-- Convert to number if possible
|
||||
if type(value) == "string" then
|
||||
value = tonumber(value)
|
||||
end
|
||||
|
||||
-- Handle non-numeric
|
||||
if type(value) ~= "number" then
|
||||
return default
|
||||
end
|
||||
|
||||
-- Handle NaN
|
||||
if isNaN(value) then
|
||||
return default
|
||||
end
|
||||
|
||||
-- Handle Infinity
|
||||
if value == math.huge then
|
||||
return max
|
||||
end
|
||||
if value == -math.huge then
|
||||
return min
|
||||
end
|
||||
|
||||
-- Clamp to range
|
||||
return clamp(value, min, max)
|
||||
end
|
||||
|
||||
--- Validate and convert to integer
|
||||
--- @param value any Value to validate
|
||||
--- @param min number? Minimum value
|
||||
--- @param max number? Maximum value
|
||||
--- @return boolean, string?, number? Returns valid, errorMessage, integerValue
|
||||
local function validateInteger(value, min, max)
|
||||
local valid, err, sanitized = validateNumber(value, {
|
||||
min = min,
|
||||
max = max,
|
||||
integer = true,
|
||||
})
|
||||
|
||||
if not valid then
|
||||
return false, err, nil
|
||||
end
|
||||
|
||||
return true, nil, math.floor(sanitized or value)
|
||||
end
|
||||
|
||||
--- Validate and normalize percentage value
|
||||
--- @param value any Value to validate (can be "50%", 0.5, or 50)
|
||||
--- @return boolean, string?, number? Returns valid, errorMessage, normalizedValue (0-1)
|
||||
local function validatePercentage(value)
|
||||
-- Handle string percentage
|
||||
if type(value) == "string" then
|
||||
local num = value:match("^(%d+%.?%d*)%%$")
|
||||
if num then
|
||||
value = tonumber(num)
|
||||
if value then
|
||||
value = value / 100
|
||||
end
|
||||
else
|
||||
value = tonumber(value)
|
||||
end
|
||||
end
|
||||
|
||||
if type(value) ~= "number" then
|
||||
return false, "Percentage must be a number", nil
|
||||
end
|
||||
|
||||
if isNaN(value) or isInfinity(value) then
|
||||
return false, "Percentage cannot be NaN or Infinity", nil
|
||||
end
|
||||
|
||||
-- If value is > 1, assume it's 0-100 range
|
||||
if value > 1 then
|
||||
value = value / 100
|
||||
end
|
||||
|
||||
-- Clamp to 0-1
|
||||
value = clamp(value, 0, 1)
|
||||
|
||||
return true, nil, value
|
||||
end
|
||||
|
||||
--- Validate opacity value (0-1)
|
||||
--- @param value any Value to validate
|
||||
--- @return boolean, string?, number? Returns valid, errorMessage, opacityValue
|
||||
local function validateOpacity(value)
|
||||
return validateNumber(value, { min = 0, max = 1, default = 1 })
|
||||
end
|
||||
|
||||
--- Validate degree value (0-360)
|
||||
--- @param value any Value to validate
|
||||
--- @return boolean, string?, number? Returns valid, errorMessage, degreeValue
|
||||
local function validateDegrees(value)
|
||||
local valid, err, sanitized = validateNumber(value)
|
||||
if not valid then
|
||||
return false, err, nil
|
||||
end
|
||||
|
||||
-- Normalize to 0-360 range
|
||||
local degrees = sanitized or value
|
||||
degrees = degrees % 360
|
||||
if degrees < 0 then
|
||||
degrees = degrees + 360
|
||||
end
|
||||
|
||||
return true, nil, degrees
|
||||
end
|
||||
|
||||
--- Validate coordinate value (pixel position)
|
||||
--- @param value any Value to validate
|
||||
--- @return boolean, string?, number? Returns valid, errorMessage, coordinateValue
|
||||
local function validateCoordinate(value)
|
||||
return validateNumber(value, {
|
||||
allowNaN = false,
|
||||
allowInfinity = false,
|
||||
})
|
||||
end
|
||||
|
||||
--- Validate dimension value (width/height, must be non-negative)
|
||||
--- @param value any Value to validate
|
||||
--- @return boolean, string?, number? Returns valid, errorMessage, dimensionValue
|
||||
local function validateDimension(value)
|
||||
return validateNumber(value, {
|
||||
min = 0,
|
||||
allowNaN = false,
|
||||
allowInfinity = false,
|
||||
})
|
||||
end
|
||||
|
||||
--- Validate that a value is in an enum table
|
||||
---@param value any Value to validate
|
||||
---@param enumTable table Enum table with valid values
|
||||
---@param propName string Property name for error messages
|
||||
---@param moduleName string? Module name for error messages (default: "Element")
|
||||
---@return boolean True if valid
|
||||
local function validateEnum(value, enumTable, propName, moduleName)
|
||||
if value == nil then
|
||||
return true
|
||||
end
|
||||
|
||||
for _, validValue in pairs(enumTable) do
|
||||
if value == validValue then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- Build list of valid options
|
||||
local validOptions = {}
|
||||
for _, v in pairs(enumTable) do
|
||||
table.insert(validOptions, "'" .. v .. "'")
|
||||
end
|
||||
table.sort(validOptions)
|
||||
|
||||
if ErrorHandler then
|
||||
ErrorHandler:error(moduleName or "Element", "VAL_007", {
|
||||
property = propName,
|
||||
expected = table.concat(validOptions, ", "),
|
||||
got = tostring(value),
|
||||
})
|
||||
else
|
||||
error(
|
||||
string.format("%s must be one of: %s. Got: '%s'", propName, table.concat(validOptions, ", "), tostring(value))
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
--- Validate that a numeric value is within a range
|
||||
---@param value any Value to validate
|
||||
---@param min number Minimum allowed value
|
||||
---@param max number Maximum allowed value
|
||||
---@param propName string Property name for error messages
|
||||
---@param moduleName string? Module name for error messages (default: "Element")
|
||||
---@return boolean True if valid
|
||||
local function validateRange(value, min, max, propName, moduleName)
|
||||
if value == nil then
|
||||
return true
|
||||
end
|
||||
if type(value) ~= "number" then
|
||||
if ErrorHandler then
|
||||
ErrorHandler:error(moduleName or "Element", "VAL_001", {
|
||||
property = propName,
|
||||
expected = "number",
|
||||
got = type(value),
|
||||
})
|
||||
else
|
||||
error(string.format("%s must be a number, got %s", propName, type(value)))
|
||||
end
|
||||
elseif value < min or value > max then
|
||||
if ErrorHandler then
|
||||
ErrorHandler:error(moduleName or "Element", "VAL_002", {
|
||||
property = propName,
|
||||
min = tostring(min),
|
||||
max = tostring(max),
|
||||
value = tostring(value),
|
||||
})
|
||||
else
|
||||
error(
|
||||
string.format("%s must be between %s and %s, got %s", propName, tostring(min), tostring(max), tostring(value))
|
||||
)
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
--- Validate that a value is of the expected type
|
||||
---@param value any Value to validate
|
||||
---@param expectedType string Expected type name
|
||||
---@param propName string Property name for error messages
|
||||
---@param moduleName string? Module name for error messages (default: "Element")
|
||||
---@return boolean True if valid
|
||||
local function validateType(value, expectedType, propName, moduleName)
|
||||
if value == nil then
|
||||
return true
|
||||
end
|
||||
local actualType = type(value)
|
||||
if actualType ~= expectedType then
|
||||
if ErrorHandler then
|
||||
ErrorHandler:error(moduleName or "Element", "VAL_001", {
|
||||
property = propName,
|
||||
expected = expectedType,
|
||||
got = actualType,
|
||||
})
|
||||
else
|
||||
error(string.format("%s must be %s, got %s", propName, expectedType, actualType))
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return {
|
||||
init = init,
|
||||
isNaN = isNaN,
|
||||
isInfinity = isInfinity,
|
||||
validateNumber = validateNumber,
|
||||
sanitizeNumber = sanitizeNumber,
|
||||
validateInteger = validateInteger,
|
||||
validatePercentage = validatePercentage,
|
||||
validateOpacity = validateOpacity,
|
||||
validateDegrees = validateDegrees,
|
||||
validateCoordinate = validateCoordinate,
|
||||
validateDimension = validateDimension,
|
||||
validateEnum = validateEnum,
|
||||
validateRange = validateRange,
|
||||
validateType = validateType,
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
local modulePath = (...):match("(.-)[^%.]+$")
|
||||
local function req(name)
|
||||
return require(modulePath .. name)
|
||||
end
|
||||
|
||||
-- Path sanitization, validation, and file-extension helpers.
|
||||
-- Uses love.filesystem when available (optional) for existence checks.
|
||||
|
||||
--- Normalize a file path for consistent cache keys
|
||||
---@param path string File path to normalize
|
||||
---@return string Normalized path
|
||||
local function normalizePath(path)
|
||||
path = path:match("^%s*(.-)%s*$")
|
||||
path = path:gsub("\\", "/")
|
||||
path = path:gsub("/+", "/")
|
||||
return path
|
||||
end
|
||||
|
||||
--- Sanitize a file path
|
||||
--- @param path string Path to sanitize
|
||||
--- @return string Sanitized path
|
||||
local function sanitizePath(path)
|
||||
if path == nil then
|
||||
return ""
|
||||
end
|
||||
path = tostring(path)
|
||||
|
||||
-- Trim whitespace
|
||||
path = path:match("^%s*(.-)%s*$") or ""
|
||||
|
||||
-- Normalize separators to forward slash
|
||||
path = path:gsub("\\", "/")
|
||||
|
||||
-- Remove duplicate slashes
|
||||
path = path:gsub("/+", "/")
|
||||
|
||||
-- Remove trailing slash (except for root)
|
||||
if #path > 1 and path:sub(-1) == "/" then
|
||||
path = path:sub(1, -2)
|
||||
end
|
||||
|
||||
return path
|
||||
end
|
||||
|
||||
--- Check if a path is safe (no traversal attacks)
|
||||
--- @param path string Path to check
|
||||
--- @param baseDir string? Base directory to check against (optional)
|
||||
--- @return boolean, string? Returns true if safe, or false with reason
|
||||
local function isPathSafe(path, baseDir)
|
||||
if path == nil or path == "" then
|
||||
return false, "Path is empty"
|
||||
end
|
||||
|
||||
-- Sanitize the path
|
||||
path = sanitizePath(path)
|
||||
|
||||
-- Check for suspicious patterns
|
||||
if path:match("%.%.") then
|
||||
return false, "Path contains '..' (parent directory reference)"
|
||||
end
|
||||
|
||||
-- Check for null bytes
|
||||
if path:match("%z") then
|
||||
return false, "Path contains null bytes"
|
||||
end
|
||||
|
||||
-- Check for encoded traversal attempts (including double-encoding)
|
||||
local lowerPath = path:lower()
|
||||
if
|
||||
lowerPath:match("%%2e")
|
||||
or lowerPath:match("%%2f")
|
||||
or lowerPath:match("%%5c")
|
||||
or lowerPath:match("%%252e")
|
||||
or lowerPath:match("%%252f")
|
||||
or lowerPath:match("%%255c")
|
||||
then
|
||||
return false, "Path contains URL-encoded directory separators"
|
||||
end
|
||||
|
||||
-- If baseDir is provided, ensure path is within it
|
||||
if baseDir then
|
||||
baseDir = sanitizePath(baseDir)
|
||||
|
||||
-- For relative paths, prepend baseDir
|
||||
local fullPath = path
|
||||
if not path:match("^/") and not path:match("^%a:") then
|
||||
fullPath = baseDir .. "/" .. path
|
||||
end
|
||||
fullPath = sanitizePath(fullPath)
|
||||
|
||||
-- Check if fullPath starts with baseDir
|
||||
if not fullPath:match("^" .. baseDir:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%1")) then
|
||||
return false, "Path is outside allowed directory"
|
||||
end
|
||||
end
|
||||
|
||||
return true, nil
|
||||
end
|
||||
|
||||
--- Validate a file path with comprehensive checks
|
||||
--- @param path string Path to validate
|
||||
--- @param options table? Validation options
|
||||
--- @return boolean, string? Returns true if valid, or false with error message
|
||||
local function validatePath(path, options)
|
||||
options = options or {}
|
||||
|
||||
-- Check path is not nil/empty
|
||||
if path == nil or path == "" then
|
||||
return false, "Path is empty"
|
||||
end
|
||||
|
||||
path = tostring(path)
|
||||
|
||||
-- Check maximum length
|
||||
local maxLength = options.maxLength or 4096
|
||||
if #path > maxLength then
|
||||
return false, string.format("Path exceeds maximum length of %d characters", maxLength)
|
||||
end
|
||||
|
||||
-- Sanitize path
|
||||
path = sanitizePath(path)
|
||||
|
||||
-- Check for safety (traversal attacks)
|
||||
local safe, reason = isPathSafe(path, options.baseDir)
|
||||
if not safe then
|
||||
return false, reason
|
||||
end
|
||||
|
||||
-- Check allowed extensions
|
||||
if options.allowedExtensions then
|
||||
local ext = path:match("%.([^%.]+)$")
|
||||
if not ext then
|
||||
return false, "Path has no file extension"
|
||||
end
|
||||
|
||||
ext = ext:lower()
|
||||
local allowed = false
|
||||
for _, allowedExt in ipairs(options.allowedExtensions) do
|
||||
if ext == allowedExt:lower() then
|
||||
allowed = true
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if not allowed then
|
||||
return false, string.format("File extension '%s' is not allowed", ext)
|
||||
end
|
||||
end
|
||||
|
||||
-- Check if file must exist
|
||||
if options.mustExist and love and love.filesystem then
|
||||
local info = love.filesystem.getInfo(path)
|
||||
if not info then
|
||||
return false, "File does not exist"
|
||||
end
|
||||
end
|
||||
|
||||
return true, nil
|
||||
end
|
||||
|
||||
--- Get file extension from path
|
||||
--- @param path string File path
|
||||
--- @return string? extension File extension (lowercase) or nil
|
||||
local function getFileExtension(path)
|
||||
if not path then
|
||||
return nil
|
||||
end
|
||||
local ext = path:match("%.([^%.]+)$")
|
||||
return ext and ext:lower() or nil
|
||||
end
|
||||
|
||||
--- Check if path has allowed extension
|
||||
--- @param path string File path
|
||||
--- @param allowedExtensions table Array of allowed extensions
|
||||
--- @return boolean
|
||||
local function hasAllowedExtension(path, allowedExtensions)
|
||||
local ext = getFileExtension(path)
|
||||
if not ext then
|
||||
return false
|
||||
end
|
||||
|
||||
for _, allowedExt in ipairs(allowedExtensions) do
|
||||
if ext == allowedExt:lower() then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
return {
|
||||
normalizePath = normalizePath,
|
||||
sanitizePath = sanitizePath,
|
||||
isPathSafe = isPathSafe,
|
||||
validatePath = validatePath,
|
||||
getFileExtension = getFileExtension,
|
||||
hasAllowedExtension = hasAllowedExtension,
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
---@class Performance
|
||||
---@field enabled boolean
|
||||
---@field hudEnabled boolean
|
||||
---@field hudToggleKey string
|
||||
---@field hudPosition {x: number, y: number}
|
||||
---@field warningThresholdMs number
|
||||
---@field criticalThresholdMs number
|
||||
---@field logToConsole boolean
|
||||
---@field logWarnings boolean
|
||||
---@field warningsEnabled boolean
|
||||
---@field _ErrorHandler table?
|
||||
---@field _timers table
|
||||
---@field _metrics table
|
||||
---@field _lastMetricsCleanup number
|
||||
---@field _frameMetrics table
|
||||
---@field _memoryMetrics table
|
||||
---@field _warnings table
|
||||
---@field _lastFrameStart number?
|
||||
---@field _shownWarnings table
|
||||
---@field _memoryProfiler table
|
||||
local Performance = {}
|
||||
Performance.__index = Performance
|
||||
|
||||
---@type Performance|nil
|
||||
local instance = nil
|
||||
|
||||
local METRICS_CLEANUP_INTERVAL = 30
|
||||
local METRICS_RETENTION_TIME = 10
|
||||
local MAX_METRICS_COUNT = 500
|
||||
local CORE_METRICS = { frame = true, layout = true, render = true }
|
||||
|
||||
---@param config {enabled?: boolean, hudEnabled?: boolean, hudToggleKey?: string, hudPosition?: {x: number, y: number}, warningThresholdMs?: number, criticalThresholdMs?: number, logToConsole?: boolean, logWarnings?: boolean, warningsEnabled?: boolean, memoryProfiling?: boolean}?
|
||||
---@param deps {ErrorHandler: ErrorHandler}
|
||||
---@return Performance
|
||||
function Performance.init(config, deps)
|
||||
if instance == nil then
|
||||
local self = setmetatable({}, Performance)
|
||||
|
||||
-- Configuration
|
||||
self.enabled = config and config.enabled or false
|
||||
self.hudEnabled = config and config.hudEnabled or false
|
||||
self.hudToggleKey = config and config.hudToggleKey or "f3"
|
||||
self.hudPosition = config and config.hudPosition or { x = 10, y = 10 }
|
||||
self.warningThresholdMs = config and config.warningThresholdMs or 13.0
|
||||
self.criticalThresholdMs = config and config.criticalThresholdMs or 16.67
|
||||
self.logToConsole = config and config.logToConsole or false
|
||||
self.logWarnings = config and config.logWarnings or true
|
||||
self.warningsEnabled = config and config.warningsEnabled or true
|
||||
|
||||
self._timers = {}
|
||||
self._metrics = {}
|
||||
self._lastMetricsCleanup = 0
|
||||
self._frameMetrics = {
|
||||
frameCount = 0,
|
||||
totalTime = 0,
|
||||
lastFrameTime = 0,
|
||||
minFrameTime = math.huge,
|
||||
maxFrameTime = 0,
|
||||
fps = 0,
|
||||
lastFpsUpdate = 0,
|
||||
fpsUpdateInterval = 0.5,
|
||||
}
|
||||
self._memoryMetrics = {
|
||||
current = 0,
|
||||
peak = 0,
|
||||
gcCount = 0,
|
||||
lastGcCheck = 0,
|
||||
}
|
||||
self._warnings = {}
|
||||
self._lastFrameStart = nil
|
||||
self._shownWarnings = {}
|
||||
self._memoryProfiler = {
|
||||
enabled = config and config.memoryProfiling or false,
|
||||
sampleInterval = 60,
|
||||
framesSinceLastSample = 0,
|
||||
samples = {},
|
||||
maxSamples = 20,
|
||||
monitoredTables = {},
|
||||
}
|
||||
self._ErrorHandler = deps and deps.ErrorHandler
|
||||
instance = self
|
||||
end
|
||||
return instance
|
||||
end
|
||||
|
||||
--- Toggle HUD visibility
|
||||
function Performance:toggleHUD()
|
||||
self.hudEnabled = not self.hudEnabled
|
||||
end
|
||||
|
||||
function Performance:startTimer(name)
|
||||
if not self.enabled then
|
||||
return
|
||||
end
|
||||
self._timers[name] = love.timer.getTime()
|
||||
end
|
||||
|
||||
function Performance:stopTimer(name)
|
||||
if not self.enabled then
|
||||
return nil
|
||||
end
|
||||
|
||||
local startTime = self._timers[name]
|
||||
if not startTime then
|
||||
-- Silently return nil if timer wasn't started
|
||||
-- This can happen legitimately when Performance is toggled mid-frame
|
||||
-- or when layout functions have early returns
|
||||
return nil
|
||||
end
|
||||
|
||||
local elapsed = (love.timer.getTime() - startTime) * 1000
|
||||
self._timers[name] = nil
|
||||
|
||||
-- Update metrics
|
||||
if not self._metrics[name] then
|
||||
self._metrics[name] = {
|
||||
total = 0,
|
||||
count = 0,
|
||||
min = math.huge,
|
||||
max = 0,
|
||||
average = 0,
|
||||
lastUsed = love.timer.getTime(),
|
||||
}
|
||||
end
|
||||
|
||||
local m = self._metrics[name]
|
||||
m.total = m.total + elapsed
|
||||
m.count = m.count + 1
|
||||
m.min = math.min(m.min, elapsed)
|
||||
m.max = math.max(m.max, elapsed)
|
||||
m.average = m.total / m.count
|
||||
m.lastUsed = love.timer.getTime()
|
||||
|
||||
-- Check for warnings
|
||||
if elapsed > self.criticalThresholdMs then
|
||||
self:_addWarning(name, elapsed, "critical")
|
||||
elseif elapsed > self.warningThresholdMs then
|
||||
self:_addWarning(name, elapsed, "warning")
|
||||
end
|
||||
|
||||
if self.logToConsole then
|
||||
-- Use ErrorHandler if available, otherwise fall back to print
|
||||
if self._ErrorHandler and self._ErrorHandler.warn then
|
||||
self._ErrorHandler:warn("Performance", "PERF_001", {
|
||||
metric = name,
|
||||
elapsed = string.format("%.3fms", elapsed),
|
||||
})
|
||||
else
|
||||
print(string.format("[Performance] %s: %.3fms", name, elapsed))
|
||||
end
|
||||
end
|
||||
|
||||
return elapsed
|
||||
end
|
||||
|
||||
--- Update with actual delta time from LÖVE (call from love.update)
|
||||
---@param dt number Delta time in seconds
|
||||
function Performance:updateDeltaTime(dt)
|
||||
if not self.enabled then
|
||||
return
|
||||
end
|
||||
local now = love.timer.getTime()
|
||||
if now - self._frameMetrics.lastFpsUpdate >= self._frameMetrics.fpsUpdateInterval then
|
||||
if dt > 0 then
|
||||
self._frameMetrics.fps = math.floor(1 / dt + 0.5)
|
||||
end
|
||||
self._frameMetrics.lastFpsUpdate = now
|
||||
end
|
||||
end
|
||||
|
||||
--- Start frame timing (call at beginning of frame)
|
||||
function Performance:startFrame()
|
||||
if not self.enabled then
|
||||
return
|
||||
end
|
||||
self._lastFrameStart = love.timer.getTime()
|
||||
self:_updateMemory()
|
||||
end
|
||||
|
||||
function Performance:endFrame()
|
||||
if not self.enabled or not self._lastFrameStart then
|
||||
return
|
||||
end
|
||||
|
||||
local now = love.timer.getTime()
|
||||
local frameTime = (now - self._lastFrameStart) * 1000
|
||||
|
||||
self._frameMetrics.lastFrameTime = frameTime
|
||||
self._frameMetrics.totalTime = self._frameMetrics.totalTime + frameTime
|
||||
self._frameMetrics.frameCount = self._frameMetrics.frameCount + 1
|
||||
self._frameMetrics.minFrameTime = math.min(self._frameMetrics.minFrameTime, frameTime)
|
||||
self._frameMetrics.maxFrameTime = math.max(self._frameMetrics.maxFrameTime, frameTime)
|
||||
|
||||
if frameTime > self.criticalThresholdMs then
|
||||
self:_addWarning("frame", frameTime, "critical")
|
||||
end
|
||||
|
||||
self:updateMemoryProfiling()
|
||||
|
||||
-- Periodic metrics cleanup
|
||||
if now - self._lastMetricsCleanup >= METRICS_CLEANUP_INTERVAL then
|
||||
local cleanupTime = now - METRICS_RETENTION_TIME
|
||||
for name, data in pairs(self._metrics) do
|
||||
if not CORE_METRICS[name] and data.lastUsed and data.lastUsed < cleanupTime then
|
||||
self._metrics[name] = nil
|
||||
end
|
||||
end
|
||||
self._lastMetricsCleanup = now
|
||||
end
|
||||
|
||||
-- Enforce max metrics limit
|
||||
local metricsCount = 0
|
||||
for _ in pairs(self._metrics) do
|
||||
metricsCount = metricsCount + 1
|
||||
end
|
||||
|
||||
if metricsCount > MAX_METRICS_COUNT then
|
||||
local sortedMetrics = {}
|
||||
for name, data in pairs(self._metrics) do
|
||||
if not CORE_METRICS[name] then
|
||||
table.insert(sortedMetrics, { name = name, lastUsed = data.lastUsed or 0 })
|
||||
end
|
||||
end
|
||||
|
||||
table.sort(sortedMetrics, function(a, b)
|
||||
return a.lastUsed < b.lastUsed
|
||||
end)
|
||||
|
||||
local toRemove = metricsCount - MAX_METRICS_COUNT
|
||||
for i = 1, math.min(toRemove, #sortedMetrics) do
|
||||
self._metrics[sortedMetrics[i].name] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Update memory metrics
|
||||
function Performance:_updateMemory()
|
||||
if not self.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
local memKb = collectgarbage("count")
|
||||
self._memoryMetrics.current = memKb
|
||||
self._memoryMetrics.peak = math.max(self._memoryMetrics.peak, memKb)
|
||||
|
||||
local now = love.timer.getTime()
|
||||
if now - self._memoryMetrics.lastGcCheck >= 1.0 then
|
||||
self._memoryMetrics.gcCount = self._memoryMetrics.gcCount + 1
|
||||
self._memoryMetrics.lastGcCheck = now
|
||||
end
|
||||
end
|
||||
|
||||
--- Add a performance warning (private)
|
||||
--- @param name string Metric name
|
||||
--- @param value number Metric value
|
||||
--- @param level "warning"|"critical" Warning level
|
||||
function Performance:_addWarning(name, value, level)
|
||||
if not self.logWarnings then
|
||||
return
|
||||
end
|
||||
|
||||
local warning = {
|
||||
name = name,
|
||||
value = value,
|
||||
level = level,
|
||||
time = love.timer.getTime(),
|
||||
}
|
||||
|
||||
table.insert(self._warnings, warning)
|
||||
|
||||
if #self._warnings > 100 then
|
||||
table.remove(self._warnings, 1)
|
||||
end
|
||||
|
||||
if self.logToConsole or self.warningsEnabled then
|
||||
local warningKey = name .. "_" .. level
|
||||
local lastWarningTime = self._shownWarnings[warningKey] or 0
|
||||
local now = love.timer.getTime()
|
||||
|
||||
if now - lastWarningTime >= 60 then
|
||||
if self._ErrorHandler and self._ErrorHandler.warn then
|
||||
local code = level == "critical" and "PERF_002" or "PERF_001"
|
||||
|
||||
self._ErrorHandler:warn("Performance", code, {
|
||||
metric = name,
|
||||
value = string.format("%.2fms", value),
|
||||
threshold = level == "critical" and self.criticalThresholdMs or self.warningThresholdMs,
|
||||
})
|
||||
end
|
||||
|
||||
self._shownWarnings[warningKey] = now
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Render performance HUD
|
||||
--- @param x number? X position (default: 10)
|
||||
--- @param y number? Y position (default: 10)
|
||||
function Performance:renderHUD(x, y)
|
||||
if not self.hudEnabled then
|
||||
return
|
||||
end
|
||||
|
||||
x = x or self.hudPosition.x
|
||||
y = y or self.hudPosition.y
|
||||
|
||||
self:_updateMemory()
|
||||
|
||||
local fm = self._frameMetrics
|
||||
local mm = self._memoryMetrics
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 0.8)
|
||||
love.graphics.rectangle("fill", x, y, 300, 220)
|
||||
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local lineHeight = 18
|
||||
local currentY = y + 10
|
||||
|
||||
-- FPS
|
||||
local fpsColor = { 1, 1, 1 }
|
||||
if fm.lastFrameTime > self.criticalThresholdMs then
|
||||
fpsColor = { 1, 0, 0 }
|
||||
elseif fm.lastFrameTime > self.warningThresholdMs then
|
||||
fpsColor = { 1, 1, 0 }
|
||||
end
|
||||
love.graphics.setColor(fpsColor)
|
||||
love.graphics.print(string.format("FPS: %d (%.2fms)", fm.fps, fm.lastFrameTime), x + 10, currentY)
|
||||
currentY = currentY + lineHeight
|
||||
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local avgFrame = fm.frameCount > 0 and fm.totalTime / fm.frameCount or 0
|
||||
love.graphics.print(string.format("Avg Frame: %.2fms", avgFrame), x + 10, currentY)
|
||||
currentY = currentY + lineHeight
|
||||
love.graphics.print(string.format("Min/Max: %.2f/%.2fms", fm.minFrameTime, fm.maxFrameTime), x + 10, currentY)
|
||||
currentY = currentY + lineHeight
|
||||
|
||||
local currentMb = mm.current / 1024
|
||||
local peakMb = mm.peak / 1024
|
||||
love.graphics.print(string.format("Memory: %.2f MB (peak: %.2f MB)", currentMb, peakMb), x + 10, currentY)
|
||||
currentY = currentY + lineHeight
|
||||
|
||||
local metricsCount = 0
|
||||
for _ in pairs(self._metrics) do
|
||||
metricsCount = metricsCount + 1
|
||||
end
|
||||
local metricsColor = metricsCount > MAX_METRICS_COUNT * 0.8 and { 1, 0.5, 0 } or { 1, 1, 1 }
|
||||
love.graphics.setColor(metricsColor)
|
||||
love.graphics.print(string.format("Metrics: %d/%d", metricsCount, MAX_METRICS_COUNT), x + 10, currentY)
|
||||
currentY = currentY + lineHeight + 5
|
||||
|
||||
-- Top timings
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local sortedMetrics = {}
|
||||
for name, data in pairs(self._metrics) do
|
||||
table.insert(sortedMetrics, { name = name, average = data.average })
|
||||
end
|
||||
table.sort(sortedMetrics, function(a, b)
|
||||
return a.average > b.average
|
||||
end)
|
||||
|
||||
love.graphics.print("Top Timings:", x + 10, currentY)
|
||||
currentY = currentY + lineHeight
|
||||
|
||||
for i = 1, math.min(5, #sortedMetrics) do
|
||||
local m = sortedMetrics[i]
|
||||
love.graphics.print(string.format(" %s: %.3fms", m.name, m.average), x + 10, currentY)
|
||||
currentY = currentY + lineHeight
|
||||
end
|
||||
|
||||
if #self._warnings > 0 then
|
||||
love.graphics.setColor(1, 0.5, 0, 1)
|
||||
love.graphics.print(string.format("Warnings: %d", #self._warnings), x + 10, currentY)
|
||||
end
|
||||
end
|
||||
|
||||
--- Handle keyboard input for HUD toggle
|
||||
--- @param key string Key pressed
|
||||
function Performance:keypressed(key)
|
||||
if key == self.hudToggleKey then
|
||||
self:toggleHUD()
|
||||
end
|
||||
end
|
||||
|
||||
--- Log a performance warning (only once per warning key)
|
||||
--- @param warningKey string Unique key for this warning type
|
||||
--- @param module string Module name (e.g., "LayoutEngine", "Element")
|
||||
--- @param message string Warning message
|
||||
--- @param details table? Additional details
|
||||
--- @param suggestion string? Optimization suggestion
|
||||
function Performance:logWarning(warningKey, module, message, details, suggestion)
|
||||
if not self.warningsEnabled then
|
||||
return
|
||||
end
|
||||
|
||||
if self._shownWarnings[warningKey] then
|
||||
return
|
||||
end
|
||||
|
||||
self._shownWarnings[warningKey] = true
|
||||
|
||||
local count = 0
|
||||
for _ in pairs(self._shownWarnings) do
|
||||
count = count + 1
|
||||
end
|
||||
if count > 1000 then
|
||||
self._shownWarnings = { [warningKey] = true }
|
||||
end
|
||||
|
||||
if self._ErrorHandler and self._ErrorHandler.warn then
|
||||
self._ErrorHandler:warn(module, "PERF_001", details or {})
|
||||
end
|
||||
end
|
||||
|
||||
--- Track a counter metric (increments per frame)
|
||||
--- @param name string Counter name
|
||||
--- @param value number? Value to add (default: 1)
|
||||
function Performance:incrementCounter(name, value)
|
||||
if not self.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
value = value or 1
|
||||
|
||||
if not self._metrics[name] then
|
||||
self._metrics[name] = {
|
||||
total = 0,
|
||||
count = 0,
|
||||
min = math.huge,
|
||||
max = 0,
|
||||
average = 0,
|
||||
frameValue = 0,
|
||||
lastUsed = love.timer.getTime(),
|
||||
}
|
||||
end
|
||||
|
||||
local m = self._metrics[name]
|
||||
m.frameValue = (m.frameValue or 0) + value
|
||||
m.lastUsed = love.timer.getTime()
|
||||
end
|
||||
|
||||
--- Reset frame counters (call at end of frame)
|
||||
function Performance:resetFrameCounters()
|
||||
if not self.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
local now = love.timer.getTime()
|
||||
local toRemove = {}
|
||||
|
||||
for name, data in pairs(self._metrics) do
|
||||
if data.frameValue then
|
||||
if data.frameValue > 0 then
|
||||
data.total = data.total + data.frameValue
|
||||
data.count = data.count + 1
|
||||
data.min = math.min(data.min, data.frameValue)
|
||||
data.max = math.max(data.max, data.frameValue)
|
||||
data.average = data.total / data.count
|
||||
data.lastUsed = now
|
||||
end
|
||||
|
||||
data.frameValue = 0
|
||||
|
||||
if data.count == 0 and not CORE_METRICS[name] then
|
||||
table.insert(toRemove, name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
for _, name in ipairs(toRemove) do
|
||||
self._metrics[name] = nil
|
||||
end
|
||||
end
|
||||
|
||||
--- Register a table for memory leak monitoring
|
||||
--- @param name string Friendly name for the table
|
||||
--- @param tableRef table Reference to the table to monitor
|
||||
function Performance:registerTableForMonitoring(name, tableRef)
|
||||
self._memoryProfiler.monitoredTables[name] = tableRef
|
||||
end
|
||||
|
||||
function Performance:_sampleMemory()
|
||||
local sample = {
|
||||
time = love.timer.getTime(),
|
||||
memory = collectgarbage("count") / 1024, -- MB
|
||||
tableSizes = {},
|
||||
}
|
||||
local function getTableSize(tbl)
|
||||
local count = 0
|
||||
for _ in pairs(tbl) do
|
||||
count = count + 1
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
for name, tableRef in pairs(self._memoryProfiler.monitoredTables) do
|
||||
sample.tableSizes[name] = getTableSize(tableRef)
|
||||
end
|
||||
|
||||
table.insert(self._memoryProfiler.samples, sample)
|
||||
|
||||
-- Keep only maxSamples
|
||||
if #self._memoryProfiler.samples > self._memoryProfiler.maxSamples then
|
||||
table.remove(self._memoryProfiler.samples, 1)
|
||||
end
|
||||
|
||||
-- Check for memory leaks (consistent growth)
|
||||
if #self._memoryProfiler.samples >= 5 then
|
||||
for name, _ in pairs(self._memoryProfiler.monitoredTables) do
|
||||
local sizes = {}
|
||||
for i = math.max(1, #self._memoryProfiler.samples - 4), #self._memoryProfiler.samples do
|
||||
table.insert(sizes, self._memoryProfiler.samples[i].tableSizes[name])
|
||||
end
|
||||
|
||||
-- Check if table is consistently growing
|
||||
local growing = true
|
||||
for i = 2, #sizes do
|
||||
if sizes[i] <= sizes[i - 1] then
|
||||
growing = false
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if growing and sizes[#sizes] > sizes[1] * 1.5 then
|
||||
self:_addWarning("memory_leak", sizes[#sizes], "warning")
|
||||
|
||||
if not self._shownWarnings[name] then
|
||||
local message = string.format("Table '%s' growing consistently", name)
|
||||
if self._ErrorHandler and self._ErrorHandler.warn then
|
||||
self._ErrorHandler:warn("Performance", "MEM_001", {
|
||||
table = name,
|
||||
initialSize = sizes[1],
|
||||
currentSize = sizes[#sizes],
|
||||
growthPercent = math.floor(((sizes[#sizes] / sizes[1]) - 1) * 100),
|
||||
})
|
||||
end
|
||||
|
||||
self._shownWarnings[name] = true
|
||||
end
|
||||
elseif not growing then
|
||||
self._shownWarnings[name] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Update memory profiling (call from endFrame)
|
||||
function Performance:updateMemoryProfiling()
|
||||
if not self._memoryProfiler.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
self._memoryProfiler.framesSinceLastSample = self._memoryProfiler.framesSinceLastSample + 1
|
||||
|
||||
if self._memoryProfiler.framesSinceLastSample >= self._memoryProfiler.sampleInterval then
|
||||
self:_sampleMemory()
|
||||
self._memoryProfiler.framesSinceLastSample = 0
|
||||
end
|
||||
end
|
||||
|
||||
return Performance
|
||||
@@ -0,0 +1,505 @@
|
||||
-- modules/PropertySchema.lua
|
||||
--
|
||||
-- Declarative source of truth for every Element prop.
|
||||
--
|
||||
-- Each entry describes one prop that Element.new / Element:setProperty currently
|
||||
-- handles inline. Downstream tasks (03 data-driven prop binding, 05 registry-driven
|
||||
-- setProperty dispatch) read this metadata instead of hardcoding property names.
|
||||
--
|
||||
-- Design constraints (locked — tasks 03/05 depend on this API):
|
||||
-- * Pure Lua — NO `love` import, NO dependency on utils/Color/Units/ErrorHandler.
|
||||
-- Normalizers/validators are small, dependency-free closures so the module is
|
||||
-- unit-testable standalone. Color/^/unit/enum *defaults* that require those
|
||||
-- modules are left as `nil` here and applied by construction-time special
|
||||
-- handlers in Task 03; only defaults expressible as literals are stored.
|
||||
-- * O(1) lookup — `get(name)` is a single table index into a pre-built registry;
|
||||
-- no per-call construction.
|
||||
-- * Additive — `define(specs)` merges entries by name so build profiles can
|
||||
-- extend/override without rebuilding the whole table.
|
||||
--
|
||||
-- Metadata shape per prop (all fields present, false/nil when not applicable):
|
||||
-- type string — type tag for tooling ("number"|"string"|"boolean"|
|
||||
-- "table"|"function"|"color"|"any")
|
||||
-- default any|nil — literal default value applied when prop is absent
|
||||
-- normalizer fn|nil — pure fn(value) -> value; transforms input before
|
||||
-- storage (e.g. single-value padding -> 4-side table)
|
||||
-- validator fn|nil — pure fn(value) -> bool; returns false for invalid
|
||||
-- input (Task 03 warns + falls back on false)
|
||||
-- isDimension boolean — true for width/height: setProperty routes these
|
||||
-- through _resolveDimensionProperty (unit-string
|
||||
-- resolution + border-box sync). Other unit-accepting
|
||||
-- props (x/y/gap/padding/etc.) are resolved at
|
||||
-- construction via special handlers, NOT via this flag.
|
||||
-- affectsLayout boolean — true for props in the legacy setProperty
|
||||
-- `layoutProperties` table; setting one invalidates
|
||||
-- layout (matches baseline behavior exactly).
|
||||
-- syncsTheme boolean — true for props whose setProperty path must reach
|
||||
-- ThemeManager/Renderer (disabled/active/themeComponent)
|
||||
-- hasDeferred boolean — true for callbacks that have an `on<Name>Deferred`
|
||||
-- boolean companion prop (auto-wired by Task 03)
|
||||
-- storageKey string|nil— when set, the prop is stored on the element under
|
||||
-- this key instead of its own name (prop aliases, e.g.
|
||||
-- isDisabled -> stored as `disabled`)
|
||||
|
||||
local PropertySchema = {}
|
||||
|
||||
---@type table<string, table>
|
||||
local registry = {}
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Pure normalizers (small + dependency-free; hot-pathed during construction)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
--- Expand a single value to a 4-side table. Leaves tables unchanged. nil passthrough.
|
||||
--- Used by padding/margin: `padding = 5` -> `{top=5,right=5,bottom=5,left=5}`.
|
||||
local function expandSides(value)
|
||||
if value == nil then
|
||||
return nil
|
||||
end
|
||||
if type(value) == "table" then
|
||||
return value
|
||||
end
|
||||
return { top = value, right = value, bottom = value, left = value }
|
||||
end
|
||||
|
||||
--- Normalize flex direction aliases to internal enum names.
|
||||
--- "row" -> "horizontal", "column" -> "vertical",
|
||||
--- "row-reverse" -> "horizontal-reverse", "column-reverse" -> "vertical-reverse";
|
||||
--- everything else passes through.
|
||||
local function normalizeFlexDirection(value)
|
||||
if value == "row" then
|
||||
return "horizontal"
|
||||
elseif value == "column" then
|
||||
return "vertical"
|
||||
elseif value == "row-reverse" then
|
||||
return "horizontal-reverse"
|
||||
elseif value == "column-reverse" then
|
||||
return "vertical-reverse"
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
--- Replicate Element.new's border-shape normalization (pure).
|
||||
--- * table with sides: true -> 1, number -> value, false/nil -> false; nil if no
|
||||
--- truthy side remains.
|
||||
--- * number / other truthy scalar: kept as-is.
|
||||
--- * nil / false: nil.
|
||||
local function normalizeBorder(value)
|
||||
if value == nil or value == false then
|
||||
return nil
|
||||
end
|
||||
if type(value) == "table" then
|
||||
local function side(v)
|
||||
if v == true then
|
||||
return 1
|
||||
elseif type(v) == "number" then
|
||||
return v
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
local t = side(value.top)
|
||||
local r = side(value.right)
|
||||
local b = side(value.bottom)
|
||||
local l = side(value.left)
|
||||
if not (t or r or b or l) then
|
||||
return nil
|
||||
end
|
||||
return { top = t, right = r, bottom = b, left = l }
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
--- Replicate Element.new's cornerRadius-shape normalization (pure).
|
||||
--- * number: 0 -> nil, else the number.
|
||||
--- * table: nil if all four sides are zero/absent, else fill zeros for absent sides.
|
||||
--- * nil -> nil.
|
||||
local function normalizeCornerRadius(value)
|
||||
if value == nil then
|
||||
return nil
|
||||
end
|
||||
if type(value) == "number" then
|
||||
if value == 0 then
|
||||
return nil
|
||||
end
|
||||
return value
|
||||
end
|
||||
if type(value) == "table" then
|
||||
-- Mirrors Element.new: `or` truthiness (0 is truthy in Lua). Only an all-
|
||||
-- nil/false table collapses to nil; any present side — including 0 — yields
|
||||
-- the 4-side table with zero-filled absent sides.
|
||||
local hasAny = value.topLeft or value.topRight or value.bottomLeft or value.bottomRight
|
||||
if not hasAny then
|
||||
return nil
|
||||
end
|
||||
return {
|
||||
topLeft = value.topLeft or 0,
|
||||
topRight = value.topRight or 0,
|
||||
bottomLeft = value.bottomLeft or 0,
|
||||
bottomRight = value.bottomRight or 0,
|
||||
}
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Pure validators (dependency-free; return boolean)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
--- Range validator factory: returns fn(v) -> bool. nil is treated as valid
|
||||
--- (absence handling is the default mechanism's job).
|
||||
local function rangeValidator(min, max)
|
||||
return function(v)
|
||||
if v == nil then
|
||||
return true
|
||||
end
|
||||
return type(v) == "number" and v >= min and v <= max
|
||||
end
|
||||
end
|
||||
|
||||
--- Enum validator factory: returns fn(v) -> bool for membership in `set` (set may
|
||||
--- be an array or a map of value->truthy).
|
||||
local function enumValidator(set)
|
||||
local lookup = {}
|
||||
if type(set) == "table" then
|
||||
for k, v in pairs(set) do
|
||||
if type(k) == "number" then
|
||||
lookup[v] = true
|
||||
else
|
||||
lookup[k] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
return function(v)
|
||||
if v == nil then
|
||||
return true
|
||||
end
|
||||
return lookup[v] == true
|
||||
end
|
||||
end
|
||||
|
||||
--- Boolean validator: nil is valid (absence); otherwise must be a boolean.
|
||||
local function booleanValidator(v)
|
||||
return v == nil or type(v) == "boolean"
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Registry construction
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
--- Build a fully-populated metadata entry, filling omitted fields with defaults.
|
||||
local function entry(spec)
|
||||
return {
|
||||
type = spec.type or "any",
|
||||
default = spec.default,
|
||||
normalizer = spec.normalizer,
|
||||
validator = spec.validator,
|
||||
isDimension = spec.isDimension == true,
|
||||
affectsLayout = spec.affectsLayout == true,
|
||||
syncsTheme = spec.syncsTheme == true,
|
||||
hasDeferred = spec.hasDeferred == true,
|
||||
storageKey = spec.storageKey,
|
||||
}
|
||||
end
|
||||
|
||||
--- Merge prop specs into the registry (additive; later entries override earlier).
|
||||
---@param specs table<string, table> map of prop-name -> spec
|
||||
---@return table registry the live registry table (for chaining/inspection)
|
||||
function PropertySchema.define(specs)
|
||||
for name, spec in pairs(specs) do
|
||||
registry[name] = entry(spec)
|
||||
end
|
||||
return registry
|
||||
end
|
||||
|
||||
--- O(1) metadata lookup.
|
||||
---@param name string prop name
|
||||
---@return table|nil metadata nil for unknown props (no error)
|
||||
function PropertySchema.get(name)
|
||||
return registry[name]
|
||||
end
|
||||
|
||||
--- Return the live registry (for inspection / coverage assertions only — not for
|
||||
--- per-call construction).
|
||||
---@return table
|
||||
function PropertySchema.all()
|
||||
return registry
|
||||
end
|
||||
|
||||
--- True if a prop is registered.
|
||||
---@param name string
|
||||
---@return boolean
|
||||
function PropertySchema.has(name)
|
||||
return registry[name] ~= nil
|
||||
end
|
||||
|
||||
--- True if setting this prop invalidates layout (legacy `layoutProperties` set).
|
||||
--- O(1) registry lookup — no per-call table construction. Unknown props return false,
|
||||
--- matching the legacy `layoutProperties[name]` nil-lookup behavior exactly.
|
||||
---@param name string prop name
|
||||
---@return boolean
|
||||
function PropertySchema.affectsLayout(name)
|
||||
local meta = registry[name]
|
||||
return meta ~= nil and meta.affectsLayout == true
|
||||
end
|
||||
|
||||
--- True for dimension props (width/height) that `setProperty` routes through
|
||||
--- `_resolveDimensionProperty` (unit-string resolution + border-box sync).
|
||||
--- O(1) registry lookup — no per-call table construction. Unknown props return false,
|
||||
--- matching the legacy `dimensionProperties[name]` nil-lookup behavior exactly.
|
||||
---@param name string prop name
|
||||
---@return boolean
|
||||
function PropertySchema.isDimension(name)
|
||||
local meta = registry[name]
|
||||
return meta ~= nil and meta.isDimension == true
|
||||
end
|
||||
|
||||
--- True for props whose setProperty path must reach ThemeManager/Renderer
|
||||
--- (disabled/active/themeComponent). O(1) registry lookup — no per-call table
|
||||
--- construction. Unknown props return false, matching a legacy nil-lookup exactly.
|
||||
---@param name string prop name
|
||||
---@return boolean
|
||||
function PropertySchema.syncsTheme(name)
|
||||
local meta = registry[name]
|
||||
return meta ~= nil and meta.syncsTheme == true
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Default schema (covers every prop handled in Element.new lines 259-1909 and
|
||||
-- Element:setProperty lines 4291-4417 of the Task-01 baseline).
|
||||
-- ---------------------------------------------------------------------------
|
||||
local function defineDefaults()
|
||||
PropertySchema.define({
|
||||
-- ------------------------------------------------------------------ identity
|
||||
id = { type = "string" },
|
||||
userdata = { type = "any" },
|
||||
parent = { type = "table", affectsLayout = true },
|
||||
children = { type = "table" },
|
||||
|
||||
-- ------------------------------------------------------------------ callbacks
|
||||
onEvent = { type = "function", hasDeferred = true },
|
||||
onFocus = { type = "function", hasDeferred = true },
|
||||
onBlur = { type = "function", hasDeferred = true },
|
||||
onTextInput = { type = "function", hasDeferred = true },
|
||||
onTextChange = { type = "function", hasDeferred = true },
|
||||
onEnter = { type = "function", hasDeferred = true },
|
||||
onCreate = { type = "function", hasDeferred = true },
|
||||
onTouchEvent = { type = "function", hasDeferred = true },
|
||||
onGesture = { type = "function", hasDeferred = true },
|
||||
onImageLoad = { type = "function", hasDeferred = true },
|
||||
onImageError = { type = "function", hasDeferred = true },
|
||||
|
||||
-- Deferred companion flags (stored directly; no further Deferred companion)
|
||||
onEventDeferred = { type = "boolean", default = false },
|
||||
onFocusDeferred = { type = "boolean", default = false },
|
||||
onBlurDeferred = { type = "boolean", default = false },
|
||||
onTextInputDeferred = { type = "boolean", default = false },
|
||||
onTextChangeDeferred = { type = "boolean", default = false },
|
||||
onEnterDeferred = { type = "boolean", default = false },
|
||||
onCreateDeferred = { type = "boolean", default = false },
|
||||
onTouchEventDeferred = { type = "boolean", default = false },
|
||||
onGestureDeferred = { type = "boolean", default = false },
|
||||
onImageLoadDeferred = { type = "boolean", default = false },
|
||||
onImageErrorDeferred = { type = "boolean", default = false },
|
||||
|
||||
-- focus / touch behavior
|
||||
dropFocusOnSelection = { type = "boolean" },
|
||||
customDraw = { type = "function" },
|
||||
touchEnabled = { type = "boolean", default = true },
|
||||
multiTouchEnabled = { type = "boolean", default = false },
|
||||
|
||||
-- ------------------------------------------------------------------ theme
|
||||
theme = { type = "table" },
|
||||
themeComponent = { type = "string", syncsTheme = true },
|
||||
disabled = { type = "boolean", default = false, syncsTheme = true },
|
||||
isDisabled = {
|
||||
type = "boolean",
|
||||
default = false,
|
||||
syncsTheme = true,
|
||||
storageKey = "disabled",
|
||||
},
|
||||
active = { type = "boolean", default = false, syncsTheme = true },
|
||||
disableHighlight = { type = "boolean" },
|
||||
themeStateLock = { type = "boolean" },
|
||||
themeComponentDisabledStates = { type = "table" },
|
||||
scaleCorners = { type = "boolean" },
|
||||
scalingAlgorithm = { type = "string" },
|
||||
contentAutoSizingMultiplier = { type = "table" },
|
||||
contentBlur = { type = "table" },
|
||||
backdropBlur = { type = "table" },
|
||||
|
||||
-- ------------------------------------------------------------------ text editing
|
||||
editable = { type = "boolean", default = false },
|
||||
multiline = { type = "boolean", default = false },
|
||||
passwordMode = { type = "boolean", default = false },
|
||||
textWrap = { type = "string" }, -- default computed from multiline
|
||||
maxLines = { type = "number" },
|
||||
maxLength = { type = "number" },
|
||||
placeholder = { type = "string" },
|
||||
inputType = { type = "string", default = "text" },
|
||||
textOverflow = { type = "string", default = "clip" },
|
||||
scrollable = { type = "boolean" }, -- default = multiline
|
||||
autoGrow = { type = "boolean" }, -- default = multiline
|
||||
selectOnFocus = { type = "boolean", default = false },
|
||||
cursorColor = { type = "color" },
|
||||
selectionColor = { type = "color" },
|
||||
cursorBlinkRate = { type = "number", default = 0.5 },
|
||||
text = { type = "string" },
|
||||
textAlign = {
|
||||
type = "string",
|
||||
default = "start",
|
||||
validator = enumValidator({ "start", "center", "end", "justify" }),
|
||||
},
|
||||
-- textAlignVertical is a derived storage field split out from textAlign
|
||||
-- (bindVisualState resolves table/compound-string input into H + V). Its
|
||||
-- validator is exposed for bindVisualState to validate the V component; the
|
||||
-- prop itself stays in SPECIAL_PROPS because compound parsing needs
|
||||
-- ErrorHandler warnings (schema is pure-Lua, cannot warn).
|
||||
textAlignVertical = {
|
||||
type = "string",
|
||||
default = "start",
|
||||
validator = enumValidator({ "start", "center", "end" }),
|
||||
},
|
||||
textColor = { type = "color" },
|
||||
fontFamily = { type = "string" },
|
||||
textSize = { type = "any" }, -- number | preset string; resolved by special handler
|
||||
minTextSize = { type = "number" },
|
||||
maxTextSize = { type = "number" },
|
||||
autoScaleText = { type = "boolean", default = true },
|
||||
|
||||
-- ------------------------------------------------------------------ dimensions / box model
|
||||
width = { type = "any", isDimension = true, affectsLayout = true },
|
||||
height = { type = "any", isDimension = true, affectsLayout = true },
|
||||
x = { type = "any", affectsLayout = false },
|
||||
y = { type = "any", affectsLayout = false },
|
||||
minWidth = { type = "any" },
|
||||
maxWidth = { type = "any" },
|
||||
minHeight = { type = "any" },
|
||||
maxHeight = { type = "any" },
|
||||
gap = { type = "any", affectsLayout = true },
|
||||
padding = {
|
||||
type = "any",
|
||||
affectsLayout = true,
|
||||
normalizer = expandSides,
|
||||
},
|
||||
margin = {
|
||||
type = "any",
|
||||
affectsLayout = true,
|
||||
normalizer = expandSides,
|
||||
},
|
||||
flexDirection = {
|
||||
type = "string",
|
||||
default = "horizontal",
|
||||
affectsLayout = true,
|
||||
normalizer = normalizeFlexDirection,
|
||||
},
|
||||
flexWrap = { type = "string", default = "nowrap", affectsLayout = true },
|
||||
justifyContent = { type = "string", default = "flex-start", affectsLayout = true },
|
||||
alignItems = { type = "string", default = "stretch", affectsLayout = true },
|
||||
alignContent = { type = "string", default = "stretch", affectsLayout = true },
|
||||
positioning = { type = "string", default = "relative", affectsLayout = true },
|
||||
gridRows = { type = "number", affectsLayout = true },
|
||||
gridColumns = { type = "number", affectsLayout = true },
|
||||
top = { type = "any", affectsLayout = true },
|
||||
right = { type = "any", affectsLayout = true },
|
||||
bottom = { type = "any", affectsLayout = true },
|
||||
left = { type = "any", affectsLayout = true },
|
||||
columnGap = { type = "any" },
|
||||
rowGap = { type = "any" },
|
||||
flex = { type = "any" }, -- shorthand: expands to flexGrow/flexShrink/flexBasis
|
||||
flexGrow = { type = "number", default = 0, validator = rangeValidator(0, math.huge) },
|
||||
flexShrink = { type = "number", default = 1, validator = rangeValidator(0, math.huge) },
|
||||
flexBasis = { type = "any", default = "auto" },
|
||||
alignSelf = { type = "string", default = "auto" },
|
||||
justifySelf = { type = "string" },
|
||||
z = { type = "number", default = 0 },
|
||||
tabIndex = { type = "number" },
|
||||
|
||||
-- ------------------------------------------------------------------ border / background / visual
|
||||
border = { type = "any", normalizer = normalizeBorder },
|
||||
borderColor = { type = "color" }, -- default Color.new(0,0,0,1) via special handler
|
||||
backgroundColor = { type = "color" }, -- default transparent via special handler
|
||||
opacity = {
|
||||
type = "number",
|
||||
default = 1,
|
||||
validator = rangeValidator(0, 1),
|
||||
},
|
||||
visibility = { type = "string", default = "visible" },
|
||||
display = {
|
||||
type = "boolean",
|
||||
default = true,
|
||||
validator = booleanValidator,
|
||||
},
|
||||
transform = { type = "table" },
|
||||
cornerRadius = { type = "any", normalizer = normalizeCornerRadius },
|
||||
|
||||
-- ------------------------------------------------------------------ image
|
||||
imagePath = { type = "string" },
|
||||
image = { type = "table" },
|
||||
objectFit = {
|
||||
type = "string",
|
||||
default = "fill",
|
||||
validator = enumValidator({ "fill", "contain", "cover", "scale-down", "none" }),
|
||||
},
|
||||
objectPosition = { type = "string", default = "center center" },
|
||||
imageOpacity = {
|
||||
type = "number",
|
||||
default = 1,
|
||||
validator = rangeValidator(0, 1),
|
||||
},
|
||||
imageRepeat = {
|
||||
type = "string",
|
||||
default = "no-repeat",
|
||||
validator = enumValidator({
|
||||
"no-repeat",
|
||||
"repeat",
|
||||
"repeat-x",
|
||||
"repeat-y",
|
||||
"space",
|
||||
"round",
|
||||
}),
|
||||
},
|
||||
imageTint = { type = "color" },
|
||||
|
||||
-- ------------------------------------------------------------------ scroll / scrollbar
|
||||
overflow = { type = "string" },
|
||||
overflowX = { type = "string" },
|
||||
overflowY = { type = "string" },
|
||||
scrollbarWidth = { type = "number" },
|
||||
scrollbarColor = { type = "color" },
|
||||
scrollbarTrackColor = { type = "color" },
|
||||
scrollbarRadius = { type = "number" },
|
||||
scrollbarPadding = { type = "number" },
|
||||
scrollSpeed = { type = "number" },
|
||||
invertScroll = { type = "boolean" },
|
||||
smoothScrollEnabled = { type = "boolean" },
|
||||
scrollBarStyle = { type = "string" },
|
||||
scrollbarKnobOffset = { type = "number" },
|
||||
hideScrollbars = { type = "boolean" },
|
||||
scrollbarPlacement = { type = "string" },
|
||||
scrollbarBalance = { type = "number" },
|
||||
_scrollX = { type = "number", storageKey = "_scrollX" },
|
||||
_scrollY = { type = "number", storageKey = "_scrollY" },
|
||||
|
||||
-- ------------------------------------------------------------------ select
|
||||
selectParent = { type = "table" },
|
||||
selectOption = { type = "table" },
|
||||
|
||||
-- ------------------------------------------------------------------ transition
|
||||
transition = { type = "table", default = {} },
|
||||
})
|
||||
end
|
||||
|
||||
--- (Re)populate the default schema. Idempotent: safe to call from Element.init
|
||||
--- for build profiles that re-require the module. Returns the live registry.
|
||||
---@return table registry
|
||||
function PropertySchema.populate()
|
||||
defineDefaults()
|
||||
return registry
|
||||
end
|
||||
|
||||
-- Auto-populate on require so the registry is ready without an explicit init call
|
||||
-- (pure module, no external deps — safe at load time).
|
||||
PropertySchema.populate()
|
||||
|
||||
return PropertySchema
|
||||
@@ -0,0 +1,124 @@
|
||||
local RoundedRect = {}
|
||||
|
||||
--- Generate points for a rounded rectangle
|
||||
---@param x number
|
||||
---@param y number
|
||||
---@param width number
|
||||
---@param height number
|
||||
---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number
|
||||
---@param segments number? -- Number of segments per corner arc (default: 10)
|
||||
---@return table -- Array of vertices for love.graphics.polygon
|
||||
function RoundedRect.getPoints(x, y, width, height, cornerRadius, segments)
|
||||
segments = segments or 10
|
||||
local points = {}
|
||||
|
||||
-- Helper to add arc points
|
||||
local function addArc(cx, cy, radius, startAngle, endAngle)
|
||||
if radius <= 0 then
|
||||
table.insert(points, cx)
|
||||
table.insert(points, cy)
|
||||
return
|
||||
end
|
||||
|
||||
for i = 0, segments do
|
||||
local angle = startAngle + (endAngle - startAngle) * (i / segments)
|
||||
table.insert(points, cx + math.cos(angle) * radius)
|
||||
table.insert(points, cy + math.sin(angle) * radius)
|
||||
end
|
||||
end
|
||||
|
||||
-- Handle uniform corner radius (number)
|
||||
if type(cornerRadius) == "number" then
|
||||
cornerRadius = {
|
||||
topLeft = cornerRadius,
|
||||
topRight = cornerRadius,
|
||||
bottomLeft = cornerRadius,
|
||||
bottomRight = cornerRadius,
|
||||
}
|
||||
end
|
||||
|
||||
local r1 = math.min(cornerRadius.topLeft, width / 2, height / 2)
|
||||
local r2 = math.min(cornerRadius.topRight, width / 2, height / 2)
|
||||
local r3 = math.min(cornerRadius.bottomRight, width / 2, height / 2)
|
||||
local r4 = math.min(cornerRadius.bottomLeft, width / 2, height / 2)
|
||||
|
||||
-- Top-right corner
|
||||
addArc(x + width - r2, y + r2, r2, -math.pi / 2, 0)
|
||||
|
||||
-- Bottom-right corner
|
||||
addArc(x + width - r3, y + height - r3, r3, 0, math.pi / 2)
|
||||
|
||||
-- Bottom-left corner
|
||||
addArc(x + r4, y + height - r4, r4, math.pi / 2, math.pi)
|
||||
|
||||
-- Top-left corner
|
||||
addArc(x + r1, y + r1, r1, math.pi, math.pi * 1.5)
|
||||
|
||||
return points
|
||||
end
|
||||
|
||||
--- Draw a filled rounded rectangle
|
||||
---@param mode string -- "fill" or "line"
|
||||
---@param x number
|
||||
---@param y number
|
||||
---@param width number
|
||||
---@param height number
|
||||
---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number|nil
|
||||
function RoundedRect.draw(mode, x, y, width, height, cornerRadius)
|
||||
-- OPTIMIZATION: Handle nil cornerRadius (no rounding)
|
||||
if not cornerRadius then
|
||||
love.graphics.rectangle(mode, x, y, width, height)
|
||||
return
|
||||
end
|
||||
|
||||
-- Handle uniform corner radius (number)
|
||||
if type(cornerRadius) == "number" then
|
||||
if cornerRadius <= 0 then
|
||||
love.graphics.rectangle(mode, x, y, width, height)
|
||||
return
|
||||
end
|
||||
-- Convert to table format for processing
|
||||
cornerRadius = {
|
||||
topLeft = cornerRadius,
|
||||
topRight = cornerRadius,
|
||||
bottomLeft = cornerRadius,
|
||||
bottomRight = cornerRadius,
|
||||
}
|
||||
end
|
||||
|
||||
-- Check if any corners are rounded
|
||||
local hasRoundedCorners = cornerRadius.topLeft > 0
|
||||
or cornerRadius.topRight > 0
|
||||
or cornerRadius.bottomLeft > 0
|
||||
or cornerRadius.bottomRight > 0
|
||||
|
||||
if not hasRoundedCorners then
|
||||
-- No rounded corners, use regular rectangle
|
||||
love.graphics.rectangle(mode, x, y, width, height)
|
||||
return
|
||||
end
|
||||
|
||||
local points = RoundedRect.getPoints(x, y, width, height, cornerRadius)
|
||||
|
||||
if mode == "fill" then
|
||||
love.graphics.polygon("fill", points)
|
||||
else
|
||||
-- For line mode, draw the outline
|
||||
love.graphics.polygon("line", points)
|
||||
end
|
||||
end
|
||||
|
||||
--- Create a stencil function for rounded rectangle clipping
|
||||
---@param x number
|
||||
---@param y number
|
||||
---@param width number
|
||||
---@param height number
|
||||
---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number|nil
|
||||
---@return function
|
||||
function RoundedRect.stencilFunction(x, y, width, height, cornerRadius)
|
||||
return function()
|
||||
RoundedRect.draw("fill", x, y, width, height, cornerRadius)
|
||||
end
|
||||
end
|
||||
|
||||
return RoundedRect
|
||||
@@ -0,0 +1,719 @@
|
||||
---@class Select
|
||||
local Select = {}
|
||||
|
||||
---Initialize Select module with required dependencies
|
||||
---@param deps table
|
||||
function Select.init(deps)
|
||||
Select._ErrorHandler = deps.ErrorHandler
|
||||
Select._Context = deps.Context
|
||||
Select._StateManager = deps.StateManager
|
||||
Select._utils = deps.utils
|
||||
Select._Element = deps.Element
|
||||
end
|
||||
|
||||
---Initialize selectParent state on an element
|
||||
---@param element Element
|
||||
---@param selectParentConfig table
|
||||
function Select.initSelectParent(element, selectParentConfig)
|
||||
element._selectState = {
|
||||
value = selectParentConfig.value,
|
||||
open = selectParentConfig.open or false,
|
||||
placeholder = selectParentConfig.placeholder,
|
||||
selectFrame = nil,
|
||||
selectAnchor = nil,
|
||||
onChange = selectParentConfig.onChange,
|
||||
options = {},
|
||||
optionLookup = {},
|
||||
expectedFrameParent = nil,
|
||||
frameAdopted = false,
|
||||
}
|
||||
|
||||
-- Restore select state from StateManager. Mode-aware via
|
||||
-- Context.isImmediateMode (behavior-mode-unification task 11).
|
||||
if Select._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then
|
||||
local state = Select._StateManager.getState(element._stateId)
|
||||
if state and state._selectOpen ~= nil then
|
||||
element._selectState.open = state._selectOpen
|
||||
end
|
||||
if state and state._selectValue ~= nil then
|
||||
element._selectState.value = state._selectValue
|
||||
if element.selectParent then
|
||||
element.selectParent.value = state._selectValue
|
||||
end
|
||||
end
|
||||
if state and state._selectSelectedLabel ~= nil then
|
||||
element._selectState.selectedLabel = state._selectSelectedLabel
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---Initialize selectOption on an element
|
||||
---@param element Element
|
||||
---@param selectOptionConfig table
|
||||
function Select.initSelectOption(element, selectOptionConfig)
|
||||
element.selectOption = {
|
||||
value = selectOptionConfig.value,
|
||||
label = selectOptionConfig.label or element.text,
|
||||
disabled = selectOptionConfig.disabled or false,
|
||||
}
|
||||
end
|
||||
|
||||
---@param selectParent Element
|
||||
function Select.rebuildOptionLookup(selectParent)
|
||||
if not selectParent or not selectParent._selectState then
|
||||
return
|
||||
end
|
||||
|
||||
selectParent._selectState.optionLookup = {}
|
||||
for _, optionElement in ipairs(selectParent._selectState.options) do
|
||||
if optionElement and optionElement.selectOption then
|
||||
selectParent._selectState.optionLookup[optionElement.selectOption.value] = optionElement
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param selectParent Element
|
||||
function Select.syncOptionStates(selectParent)
|
||||
if not selectParent or not selectParent._selectState then
|
||||
return
|
||||
end
|
||||
|
||||
local selectedOption = nil
|
||||
local selectedLabel = selectParent._selectState.selectedLabel
|
||||
|
||||
for _, optionElement in ipairs(selectParent._selectState.options) do
|
||||
local isSelected = optionElement.selectOption
|
||||
and optionElement.selectOption.value == selectParent._selectState.value
|
||||
optionElement._selectSelected = isSelected
|
||||
optionElement.ariaChecked = isSelected
|
||||
|
||||
if isSelected then
|
||||
selectedOption = optionElement
|
||||
selectedLabel = optionElement.selectOption.label or optionElement.text
|
||||
end
|
||||
end
|
||||
|
||||
selectParent._selectState.selectedOption = selectedOption
|
||||
selectParent._selectState.selectedLabel = selectedLabel
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
function Select.resetOptions(element)
|
||||
if not element._selectState then
|
||||
return
|
||||
end
|
||||
|
||||
element._selectState.options = {}
|
||||
element._selectState.optionLookup = {}
|
||||
element._selectState.selectedOption = nil
|
||||
end
|
||||
|
||||
---@param frame any
|
||||
---@return boolean
|
||||
function Select.isValidSelectFrame(frame)
|
||||
local Element = Select._Element
|
||||
return type(frame) == "table" and getmetatable(frame) == Element
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@param code string
|
||||
---@param details table?
|
||||
function Select.warnSelectFrame(element, code, details)
|
||||
Select._ErrorHandler:warn("Element", code, details or { element = element.id })
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@param frame Element
|
||||
function Select.trackManagedFrame(element, frame)
|
||||
element._selectState.selectFrame = frame
|
||||
local expectedParent = element._selectState.selectAnchor or element
|
||||
element._selectState.expectedFrameParent = expectedParent
|
||||
element._selectState.frameAdopted = frame.parent == expectedParent
|
||||
if frame._managedSelectBaseOpacity == nil then
|
||||
frame._managedSelectBaseOpacity = frame.opacity
|
||||
end
|
||||
if frame._managedSelectBaseVisibility == nil then
|
||||
frame._managedSelectBaseVisibility = frame.visibility or "visible"
|
||||
end
|
||||
if frame._managedSelectBaseDisabled == nil then
|
||||
frame._managedSelectBaseDisabled = frame.disabled or false
|
||||
end
|
||||
frame._managedSelectOwner = element
|
||||
frame._managedSelectFrame = true
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@return Element
|
||||
function Select.getOrCreateManagedAnchor(element)
|
||||
if element._selectState.selectAnchor then
|
||||
return element._selectState.selectAnchor
|
||||
end
|
||||
|
||||
local Element = Select._Element
|
||||
local anchor = Element.new({
|
||||
id = string.format("%s__select_anchor", element.id or "select"),
|
||||
parent = element,
|
||||
positioning = Select._utils.enums.Positioning.ABSOLUTE,
|
||||
left = 0,
|
||||
top = element:getBorderBoxHeight(),
|
||||
width = element:getBorderBoxWidth(),
|
||||
opacity = 1,
|
||||
visibility = "hidden",
|
||||
disabled = true,
|
||||
})
|
||||
|
||||
anchor._managedSelectAnchor = true
|
||||
anchor._managedSelectOwner = element
|
||||
element._selectState.selectAnchor = anchor
|
||||
return anchor
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@param frame Element
|
||||
function Select.applyManagedFrameLayout(element, frame)
|
||||
local anchor = Select.getOrCreateManagedAnchor(element)
|
||||
local triggerBorderBoxWidth = element:getBorderBoxWidth()
|
||||
anchor.left = 0
|
||||
anchor.top = element:getBorderBoxHeight()
|
||||
anchor.width = triggerBorderBoxWidth
|
||||
anchor.units.left = { value = 0, unit = "px" }
|
||||
anchor.units.top = { value = element:getBorderBoxHeight(), unit = "px" }
|
||||
anchor.units.width = { value = triggerBorderBoxWidth, unit = "px" }
|
||||
frame._managedSelectMinimumBorderBoxWidth = triggerBorderBoxWidth
|
||||
|
||||
frame.positioning = frame.positioning or Select._utils.enums.Positioning.RELATIVE
|
||||
frame._explicitlyAbsolute = false
|
||||
frame.left = nil
|
||||
frame.top = nil
|
||||
frame.right = nil
|
||||
frame.bottom = nil
|
||||
|
||||
if frame.parent ~= anchor then
|
||||
frame:setParent(anchor)
|
||||
end
|
||||
|
||||
if frame.autosizing and frame.autosizing.width then
|
||||
local contentWidth = frame:calculateAutoWidth()
|
||||
frame._borderBoxWidth = contentWidth + frame.padding.left + frame.padding.right
|
||||
frame.width = contentWidth
|
||||
end
|
||||
|
||||
if frame.parent == anchor then
|
||||
anchor.width = math.max(triggerBorderBoxWidth, frame:getBorderBoxWidth())
|
||||
anchor.units.width = { value = anchor.width, unit = "px" }
|
||||
end
|
||||
|
||||
element._selectState.expectedFrameParent = anchor
|
||||
element._selectState.frameAdopted = frame.parent == anchor
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@param frame Element
|
||||
function Select.adoptSelectFrame(element, frame)
|
||||
if not element._selectState then
|
||||
return
|
||||
end
|
||||
|
||||
if not Select.isValidSelectFrame(frame) then
|
||||
Select.warnSelectFrame(element, "ELEM_007", {
|
||||
element = element.id,
|
||||
property = "selectParent.selectFrame",
|
||||
got = type(frame),
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
if frame == element then
|
||||
Select.warnSelectFrame(element, "ELEM_007", {
|
||||
element = element.id,
|
||||
property = "selectParent.selectFrame",
|
||||
reason = "select cannot use itself as its managed frame",
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
local anchor = Select.getOrCreateManagedAnchor(element)
|
||||
|
||||
if frame.parent and frame.parent ~= element and frame.parent ~= anchor then
|
||||
Select.warnSelectFrame(element, "ELEM_008", {
|
||||
element = element.id,
|
||||
frame = frame.id,
|
||||
parent = frame.parent.id,
|
||||
})
|
||||
end
|
||||
|
||||
Select.trackManagedFrame(element, frame)
|
||||
Select.applyManagedFrameLayout(element, frame)
|
||||
Select.syncManagedFrameVisibility(element)
|
||||
|
||||
-- Layout is deferred to endFrame in immediate mode. shouldLayout()
|
||||
-- encapsulates the mode check (behavior-mode-unification task 11).
|
||||
if Select._StateManager.shouldLayout() then
|
||||
anchor:layoutChildren()
|
||||
element:layoutChildren()
|
||||
end
|
||||
|
||||
local pendingOptions = {}
|
||||
for _, child in ipairs(element.children) do
|
||||
if child ~= frame and child.selectOption then
|
||||
table.insert(pendingOptions, child)
|
||||
end
|
||||
end
|
||||
|
||||
for _, option in ipairs(pendingOptions) do
|
||||
Select.attachOptionToManagedFrame(option)
|
||||
end
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
function Select.ensureFrameState(element)
|
||||
if not element._selectState or not element._selectState.selectFrame then
|
||||
return
|
||||
end
|
||||
|
||||
local frame = element._selectState.selectFrame
|
||||
local anchor = element._selectState.selectAnchor
|
||||
if anchor then
|
||||
local triggerBorderBoxWidth = element:getBorderBoxWidth()
|
||||
anchor.left = 0
|
||||
anchor.top = element:getBorderBoxHeight()
|
||||
anchor.width = triggerBorderBoxWidth
|
||||
anchor.units.left = { value = 0, unit = "px" }
|
||||
anchor.units.top = { value = element:getBorderBoxHeight(), unit = "px" }
|
||||
anchor.units.width = { value = triggerBorderBoxWidth, unit = "px" }
|
||||
frame._managedSelectMinimumBorderBoxWidth = triggerBorderBoxWidth
|
||||
if frame.autosizing and frame.autosizing.width then
|
||||
local contentWidth = frame:calculateAutoWidth()
|
||||
frame._borderBoxWidth = contentWidth + frame.padding.left + frame.padding.right
|
||||
frame.width = contentWidth
|
||||
end
|
||||
if frame.parent == anchor then
|
||||
anchor.width = math.max(triggerBorderBoxWidth, frame:getBorderBoxWidth())
|
||||
anchor.units.width = { value = anchor.width, unit = "px" }
|
||||
end
|
||||
if frame.parent == anchor then
|
||||
anchor:layoutChildren()
|
||||
end
|
||||
elseif frame.parent == element then
|
||||
Select.applyManagedFrameLayout(element, frame)
|
||||
end
|
||||
|
||||
local expectedParent = anchor or element._selectState.expectedFrameParent
|
||||
if frame.parent ~= expectedParent then
|
||||
Select.warnSelectFrame(element, "ELEM_009", {
|
||||
element = element.id,
|
||||
frame = frame.id,
|
||||
expectedParent = expectedParent and expectedParent.id or nil,
|
||||
actualParent = frame.parent and frame.parent.id or nil,
|
||||
})
|
||||
element._selectState.expectedFrameParent = frame.parent
|
||||
element._selectState.frameAdopted = frame.parent == expectedParent
|
||||
end
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
function Select.syncManagedFrameVisibility(element)
|
||||
if not element._selectState or not element._selectState.selectFrame then
|
||||
return
|
||||
end
|
||||
|
||||
local frame = element._selectState.selectFrame
|
||||
local anchor = element._selectState.selectAnchor
|
||||
local isOpen = element._selectState.open == true
|
||||
frame.visibility = isOpen and (frame._managedSelectBaseVisibility or "visible") or "hidden"
|
||||
frame.opacity = frame._managedSelectBaseOpacity or 1
|
||||
if isOpen then
|
||||
frame.disabled = frame._managedSelectBaseDisabled == true
|
||||
else
|
||||
frame.disabled = true
|
||||
end
|
||||
if anchor then
|
||||
anchor.visibility = isOpen and "visible" or "hidden"
|
||||
anchor.opacity = 1
|
||||
anchor.disabled = not isOpen
|
||||
end
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@return Element?
|
||||
function Select.findOwningSelectParent(element)
|
||||
if element._selectParentHint and element._selectParentHint._selectState then
|
||||
return element._selectParentHint
|
||||
end
|
||||
|
||||
local current = element.parent
|
||||
while current do
|
||||
if current._selectState then
|
||||
return current
|
||||
end
|
||||
current = current.parent
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
function Select.registerWithSelectParent(element)
|
||||
if not element.selectOption then
|
||||
return
|
||||
end
|
||||
|
||||
local selectParent = Select.findOwningSelectParent(element)
|
||||
if not selectParent then
|
||||
return
|
||||
end
|
||||
|
||||
element._selectParentElement = selectParent
|
||||
|
||||
for _, optionElement in ipairs(selectParent._selectState.options) do
|
||||
if optionElement == element then
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(selectParent._selectState.options, element)
|
||||
Select.rebuildOptionLookup(selectParent)
|
||||
Select.syncOptionStates(selectParent)
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
function Select.attachOptionToManagedFrame(element)
|
||||
if not element.selectOption then
|
||||
return
|
||||
end
|
||||
|
||||
local selectParent = Select.findOwningSelectParent(element)
|
||||
if not selectParent or not selectParent._selectState or not selectParent._selectState.selectFrame then
|
||||
return
|
||||
end
|
||||
|
||||
local selectFrame = selectParent._selectState.selectFrame
|
||||
if element.parent ~= selectFrame then
|
||||
element._selectParentHint = selectParent
|
||||
|
||||
if
|
||||
element._originalPositioning == Select._utils.enums.Positioning.ABSOLUTE
|
||||
and element._managedSelectOptionUsesFrameLayout == nil
|
||||
then
|
||||
element._managedSelectOptionUsesFrameLayout = true
|
||||
element.positioning = Select._utils.enums.Positioning.RELATIVE
|
||||
element._originalPositioning = nil
|
||||
element._explicitlyAbsolute = false
|
||||
element.left = nil
|
||||
element.top = nil
|
||||
element.right = nil
|
||||
element.bottom = nil
|
||||
end
|
||||
|
||||
element:setParent(selectFrame)
|
||||
-- Ensure frame geometry eagerly only in retained mode; deferred to the
|
||||
-- per-frame update in immediate mode (behavior-mode-unification task 11).
|
||||
if Select._StateManager.shouldLayout() then
|
||||
Select.ensureFrameState(selectParent)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
function Select.unregisterFromSelectParent(element)
|
||||
if not element.selectOption or not element._selectParentElement or not element._selectParentElement._selectState then
|
||||
element._selectParentElement = nil
|
||||
return
|
||||
end
|
||||
|
||||
local selectParent = element._selectParentElement
|
||||
for index, optionElement in ipairs(selectParent._selectState.options) do
|
||||
if optionElement == element then
|
||||
table.remove(selectParent._selectState.options, index)
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
Select.rebuildOptionLookup(selectParent)
|
||||
Select.syncOptionStates(selectParent)
|
||||
element._selectParentElement = nil
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
function Select.saveStateToStateManager(element)
|
||||
if not element._selectState then
|
||||
return
|
||||
end
|
||||
if element._stateId and Select._Context.isImmediateMode() and element._stateId ~= "" then
|
||||
Select._StateManager.updateState(element._stateId, {
|
||||
_selectOpen = element._selectState.open,
|
||||
_selectValue = element._selectState.value,
|
||||
_selectSelectedLabel = element._selectState.selectedLabel,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
function Select.openSelect(element)
|
||||
if not element._selectState then
|
||||
return
|
||||
end
|
||||
|
||||
Select.ensureFrameState(element)
|
||||
element._selectState.open = true
|
||||
element.ariaExpanded = true
|
||||
if element.selectParent then
|
||||
element.selectParent.open = true
|
||||
end
|
||||
Select.syncManagedFrameVisibility(element)
|
||||
Select.saveStateToStateManager(element)
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
function Select.closeSelect(element)
|
||||
if not element._selectState then
|
||||
return
|
||||
end
|
||||
|
||||
Select.ensureFrameState(element)
|
||||
element._selectState.open = false
|
||||
element.ariaExpanded = false
|
||||
if element.selectParent then
|
||||
element.selectParent.open = false
|
||||
end
|
||||
Select.syncManagedFrameVisibility(element)
|
||||
Select.saveStateToStateManager(element)
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
function Select.toggleSelect(element)
|
||||
if not element._selectState then
|
||||
return
|
||||
end
|
||||
|
||||
if element.disabled then
|
||||
return
|
||||
end
|
||||
|
||||
if element._selectState.open then
|
||||
Select.closeSelect(element)
|
||||
else
|
||||
Select.openSelect(element)
|
||||
end
|
||||
|
||||
if element.onEvent then
|
||||
element.onEvent(element, { type = "selecttoggle", open = element._selectState.open })
|
||||
end
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@return boolean
|
||||
function Select.isSelectOpen(element)
|
||||
return element._selectState ~= nil and element._selectState.open == true
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@return any
|
||||
function Select.getSelectValue(element)
|
||||
if not element._selectState then
|
||||
return nil
|
||||
end
|
||||
return element._selectState.value
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@return string?
|
||||
function Select.getSelectLabel(element)
|
||||
if not element._selectState then
|
||||
return nil
|
||||
end
|
||||
|
||||
local selectedOption = element._selectState.selectedOption
|
||||
or element._selectState.optionLookup[element._selectState.value]
|
||||
if selectedOption and selectedOption.selectOption then
|
||||
return selectedOption.selectOption.label or selectedOption.text
|
||||
end
|
||||
|
||||
return element._selectState.selectedLabel or element._selectState.placeholder
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@return boolean
|
||||
function Select.isSelectedOption(element)
|
||||
if not element.selectOption or not element._selectParentElement or not element._selectParentElement._selectState then
|
||||
return false
|
||||
end
|
||||
return element._selectParentElement._selectState.value == element.selectOption.value
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@param value any
|
||||
---@param optionElement Element?
|
||||
function Select.setSelectValue(element, value, optionElement)
|
||||
if not element._selectState then
|
||||
return
|
||||
end
|
||||
|
||||
if element.disabled then
|
||||
return
|
||||
end
|
||||
|
||||
local didChange = element._selectState.value ~= value
|
||||
element._selectState.value = value
|
||||
if element.selectParent then
|
||||
element.selectParent.value = value
|
||||
end
|
||||
|
||||
if optionElement and optionElement.selectOption then
|
||||
element._selectState.selectedLabel = optionElement.selectOption.label or optionElement.text
|
||||
end
|
||||
|
||||
Select.syncOptionStates(element)
|
||||
Select.closeSelect(element)
|
||||
Select.saveStateToStateManager(element)
|
||||
|
||||
if element.onEvent then
|
||||
element.onEvent(element, { type = "selectchange", value = value, option = optionElement })
|
||||
end
|
||||
|
||||
if didChange and element._selectState.onChange then
|
||||
element._selectState.onChange(element, value, optionElement and optionElement.selectOption or nil)
|
||||
end
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
function Select.handleRelease(element)
|
||||
if element.disabled then
|
||||
return
|
||||
end
|
||||
|
||||
if element.selectOption then
|
||||
local selectParent = element._selectParentElement or Select.findOwningSelectParent(element)
|
||||
if not selectParent then
|
||||
return
|
||||
end
|
||||
|
||||
if element.selectOption.disabled then
|
||||
Select.closeSelect(selectParent)
|
||||
return
|
||||
end
|
||||
|
||||
Select.setSelectValue(selectParent, element.selectOption.value, element)
|
||||
return
|
||||
end
|
||||
|
||||
if element._selectState then
|
||||
Select.toggleSelect(element)
|
||||
end
|
||||
end
|
||||
|
||||
---Save select state for state persistence (called from Element:saveState)
|
||||
---@param element Element
|
||||
---@return table?
|
||||
function Select.saveState(element)
|
||||
if not element._selectState then
|
||||
return nil
|
||||
end
|
||||
return {
|
||||
value = element._selectState.value,
|
||||
open = element._selectState.open,
|
||||
selectedLabel = element._selectState.selectedLabel,
|
||||
}
|
||||
end
|
||||
|
||||
---Restore select state (called from Element:restoreState)
|
||||
---@param element Element
|
||||
---@param state table
|
||||
function Select.restoreState(element, state)
|
||||
if not element._selectState or not state then
|
||||
return
|
||||
end
|
||||
element._selectState.value = state.value
|
||||
element._selectState.open = state.open or false
|
||||
element._selectState.selectedLabel = state.selectedLabel
|
||||
if element.selectParent then
|
||||
element.selectParent.value = state.value
|
||||
element.selectParent.open = state.open or false
|
||||
end
|
||||
element.ariaExpanded = element._selectState.open
|
||||
Select.syncOptionStates(element)
|
||||
end
|
||||
|
||||
---Clean up select-related resources (called from Element:destroy)
|
||||
---@param element Element
|
||||
function Select.cleanupDestroy(element)
|
||||
if element._selectState then
|
||||
local frame = element._selectState.selectFrame
|
||||
local anchor = element._selectState.selectAnchor
|
||||
if frame then
|
||||
frame._managedSelectOwner = nil
|
||||
frame._managedSelectFrame = nil
|
||||
frame._managedSelectBaseOpacity = nil
|
||||
frame._managedSelectBaseVisibility = nil
|
||||
frame._managedSelectBaseDisabled = nil
|
||||
end
|
||||
if anchor then
|
||||
anchor._managedSelectOwner = nil
|
||||
anchor._managedSelectAnchor = nil
|
||||
end
|
||||
element._selectState = nil
|
||||
end
|
||||
if element._managedSelectFrame and element._managedSelectOwner then
|
||||
if element._managedSelectOwner._selectState then
|
||||
element._managedSelectOwner._selectState.selectFrame = nil
|
||||
element._managedSelectOwner._selectState.expectedFrameParent = nil
|
||||
element._managedSelectOwner._selectState.frameAdopted = false
|
||||
end
|
||||
element._managedSelectOwner = nil
|
||||
element._managedSelectFrame = nil
|
||||
element._managedSelectBaseOpacity = nil
|
||||
element._managedSelectBaseVisibility = nil
|
||||
element._managedSelectBaseDisabled = nil
|
||||
end
|
||||
if element._managedSelectAnchor and element._managedSelectOwner then
|
||||
if element._managedSelectOwner._selectState then
|
||||
element._managedSelectOwner._selectState.selectAnchor = nil
|
||||
end
|
||||
element._managedSelectOwner = nil
|
||||
element._managedSelectAnchor = nil
|
||||
end
|
||||
if element.selectParent then
|
||||
element.selectParent.onChange = nil
|
||||
end
|
||||
end
|
||||
|
||||
--- Called when a select parent removes a child: clears frame/anchor refs if the removed child was the
|
||||
--- select-managed frame or anchor. Keeps select state-mutation logic owned by the Select module.
|
||||
---@param element Element The select parent whose child was removed.
|
||||
---@param child Element The removed child.
|
||||
function Select.handleChildRemoved(element, child)
|
||||
if not element._selectState then
|
||||
return
|
||||
end
|
||||
if element._selectState.selectFrame == child then
|
||||
element._selectState.selectFrame = nil
|
||||
element._selectState.expectedFrameParent = nil
|
||||
element._selectState.frameAdopted = false
|
||||
end
|
||||
if element._selectState.selectAnchor == child then
|
||||
element._selectState.selectAnchor = nil
|
||||
end
|
||||
end
|
||||
|
||||
--- Layout-path hook: adjust an auto-width child's border-box width for a managed-select frame.
|
||||
--- Invoked from LayoutEngine (via the Element delegate) during vertical-flex auto-width calculation.
|
||||
---@param element Element The managed-select frame (the dropdown container).
|
||||
---@param child Element The flex child being measured.
|
||||
---@param childBorderBoxWidth number Current computed border-box width of `child`.
|
||||
---@return number Possibly-adjusted border-box width.
|
||||
function Select.adjustAutoWidthChild(element, child, childBorderBoxWidth)
|
||||
if
|
||||
element._managedSelectFrame
|
||||
and element.autosizing
|
||||
and element.autosizing.width
|
||||
and child.units
|
||||
and child.units.width
|
||||
and child.units.width.unit == "%"
|
||||
then
|
||||
local intrinsicBorderBoxWidth = child:calculateAutoWidth() + child.padding.left + child.padding.right
|
||||
return math.max(childBorderBoxWidth, intrinsicBorderBoxWidth)
|
||||
end
|
||||
return childBorderBoxWidth
|
||||
end
|
||||
|
||||
return Select
|
||||
@@ -0,0 +1,790 @@
|
||||
---@class StateManager
|
||||
local StateManager = {}
|
||||
|
||||
-- ErrorHandler will be injected via init
|
||||
local ErrorHandler
|
||||
|
||||
-- State storage: ID -> state table
|
||||
local stateStore = {}
|
||||
|
||||
-- Frame tracking metadata: ID -> {lastFrame, createdFrame, accessCount}
|
||||
local stateMetadata = {}
|
||||
|
||||
-- Frame counter
|
||||
local frameNumber = 0
|
||||
|
||||
-- Counter to track multiple elements created at the same source location (e.g., in loops)
|
||||
local callSiteCounters = {}
|
||||
|
||||
-- Stateful element mapping: stateId -> element instance
|
||||
-- Used in retained mode for cache-through: StateManager resolves id -> element -> field
|
||||
local statefulElements = {}
|
||||
|
||||
-- Dirty state tracking for flushFrame: set of {id, key} pairs modified this frame
|
||||
local dirtyState = {}
|
||||
|
||||
-- Immediate mode flag
|
||||
local _immediateMode = false
|
||||
|
||||
-- Configuration
|
||||
local config = {
|
||||
stateRetentionFrames = 2, -- Keep unused state for 2 frames
|
||||
maxStateEntries = 1000, -- Maximum state entries before forced GC
|
||||
}
|
||||
|
||||
-- Default state values (sparse storage - don't store these)
|
||||
local stateDefaults = {
|
||||
-- Interaction states
|
||||
hover = false,
|
||||
pressed = false,
|
||||
focused = false,
|
||||
disabled = false,
|
||||
active = false,
|
||||
|
||||
-- Scrollbar states
|
||||
scrollbarHoveredVertical = false,
|
||||
scrollbarHoveredHorizontal = false,
|
||||
scrollbarDragging = false,
|
||||
hoveredScrollbar = nil,
|
||||
scrollbarDragOffset = 0,
|
||||
dragStartMouseX = 0,
|
||||
dragStartMouseY = 0,
|
||||
dragStartScrollX = 0,
|
||||
dragStartScrollY = 0,
|
||||
|
||||
-- Scroll position
|
||||
scrollX = 0,
|
||||
scrollY = 0,
|
||||
_scrollX = 0,
|
||||
_scrollY = 0,
|
||||
|
||||
-- Click tracking
|
||||
_clickCount = 0,
|
||||
_lastClickTime = nil,
|
||||
_lastClickButton = nil,
|
||||
|
||||
-- Internal states
|
||||
_hovered = nil,
|
||||
_focused = nil,
|
||||
_cursorPosition = nil,
|
||||
_selectionStart = nil,
|
||||
_selectionEnd = nil,
|
||||
_textBuffer = "",
|
||||
_cursorBlinkTimer = 0,
|
||||
_cursorVisible = true,
|
||||
_cursorBlinkPaused = false,
|
||||
_cursorBlinkPauseTimer = 0,
|
||||
}
|
||||
|
||||
--- Check if a value equals the default for a key
|
||||
---@param key string State key
|
||||
---@param value any Value to check
|
||||
---@return boolean isDefault True if value equals default
|
||||
local function isDefaultValue(key, value)
|
||||
local defaultVal = stateDefaults[key]
|
||||
|
||||
-- If no default defined, check for common defaults
|
||||
if defaultVal == nil then
|
||||
-- Empty tables are default
|
||||
if type(value) == "table" and next(value) == nil then
|
||||
return true
|
||||
end
|
||||
-- nil values are default
|
||||
if value == nil then
|
||||
return true
|
||||
end
|
||||
-- Otherwise, not a default value
|
||||
return false
|
||||
end
|
||||
|
||||
-- Compare values
|
||||
if type(value) == "table" then
|
||||
-- Empty tables are considered default
|
||||
if next(value) == nil then
|
||||
return true
|
||||
end
|
||||
-- For other tables, compare contents (shallow)
|
||||
if type(defaultVal) ~= "table" then
|
||||
return false
|
||||
end
|
||||
for k, v in pairs(value) do
|
||||
if defaultVal[k] ~= v then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
else
|
||||
return value == defaultVal
|
||||
end
|
||||
end
|
||||
|
||||
-- ====================
|
||||
-- ID Generation
|
||||
-- ====================
|
||||
|
||||
--- Generate a hash from a table of properties
|
||||
---@param props table
|
||||
---@param visited table|nil Tracking table to prevent circular references
|
||||
---@param depth number|nil Current recursion depth
|
||||
---@return string
|
||||
local function hashProps(props, visited, depth)
|
||||
if not props then
|
||||
return ""
|
||||
end
|
||||
|
||||
-- Initialize visited table on first call
|
||||
visited = visited or {}
|
||||
depth = depth or 0
|
||||
|
||||
-- Limit recursion depth to prevent deep nesting issues
|
||||
if depth > 3 then
|
||||
return "[deep]"
|
||||
end
|
||||
|
||||
-- Check if we've already visited this table (circular reference)
|
||||
if visited[props] then
|
||||
return "[circular]"
|
||||
end
|
||||
|
||||
-- Mark this table as visited
|
||||
visited[props] = true
|
||||
|
||||
local parts = {}
|
||||
local keys = {}
|
||||
|
||||
-- Properties to skip (they cause issues or aren't relevant for ID generation)
|
||||
local skipKeys = {
|
||||
onEvent = true,
|
||||
parent = true,
|
||||
children = true,
|
||||
onFocus = true,
|
||||
onBlur = true,
|
||||
onTextInput = true,
|
||||
onTextChange = true,
|
||||
onEnter = true,
|
||||
userdata = true,
|
||||
-- Dynamic input/state properties that should not affect ID stability
|
||||
text = true, -- Text content changes as user types
|
||||
placeholder = true, -- Placeholder text is presentational
|
||||
editable = true, -- Editable state can be toggled dynamically
|
||||
selectOnFocus = true, -- Input behavior flag
|
||||
autoGrow = true, -- Auto-grow behavior flag
|
||||
passwordMode = true, -- Password mode can be toggled
|
||||
}
|
||||
|
||||
-- Collect and sort keys for consistent ordering
|
||||
for k in pairs(props) do
|
||||
if not skipKeys[k] then
|
||||
table.insert(keys, k)
|
||||
end
|
||||
end
|
||||
table.sort(keys)
|
||||
|
||||
-- Build hash string from sorted key-value pairs
|
||||
for _, k in ipairs(keys) do
|
||||
local v = props[k]
|
||||
local vtype = type(v)
|
||||
|
||||
if vtype == "string" or vtype == "number" or vtype == "boolean" then
|
||||
table.insert(parts, k .. "=" .. tostring(v))
|
||||
elseif vtype == "table" then
|
||||
table.insert(parts, k .. "={" .. hashProps(v, visited, depth + 1) .. "}")
|
||||
end
|
||||
end
|
||||
|
||||
return table.concat(parts, ";")
|
||||
end
|
||||
|
||||
--- Generate a unique ID from call site and properties
|
||||
---@param props table|nil Optional properties to include in ID generation
|
||||
---@param parent table|nil Optional parent element for tree-based ID generation
|
||||
---@return string
|
||||
function StateManager.generateID(props, parent)
|
||||
-- Get call stack information
|
||||
local info = debug.getinfo(3, "Sl") -- Level 3: caller of Element.new -> caller of generateID
|
||||
|
||||
if not info then
|
||||
-- Fallback to random ID if debug info unavailable
|
||||
return "auto_" .. tostring(math.random(1000000, 9999999))
|
||||
end
|
||||
|
||||
local source = info.source or "unknown"
|
||||
local line = info.currentline or 0
|
||||
|
||||
-- Create base location key from source file and line number
|
||||
local filename = source:match("([^/\\]+)$") or source -- Get filename
|
||||
filename = filename:gsub("%.lua$", "") -- Remove .lua extension
|
||||
local locationKey = filename .. "_L" .. line
|
||||
|
||||
-- If we have a parent, use tree-based ID generation for stability
|
||||
if parent and parent.id and parent.id ~= "" then
|
||||
-- For child elements, use call-site (file + line) like top-level elements
|
||||
-- This ensures the same call site always generates the same ID, even when
|
||||
-- retained children persist in parent.children array
|
||||
local baseID = parent.id .. "_" .. locationKey
|
||||
|
||||
-- Count how many children have been created at THIS call site
|
||||
local callSiteKey = parent.id .. "_" .. locationKey
|
||||
callSiteCounters[callSiteKey] = (callSiteCounters[callSiteKey] or 0) + 1
|
||||
local instanceNum = callSiteCounters[callSiteKey]
|
||||
|
||||
if instanceNum > 1 then
|
||||
baseID = baseID .. "_" .. instanceNum
|
||||
end
|
||||
|
||||
-- Add property hash if provided (for additional differentiation)
|
||||
if props then
|
||||
local propHash = hashProps(props)
|
||||
if propHash ~= "" then
|
||||
-- Use first 8 chars of a simple hash
|
||||
local hash = 0
|
||||
for i = 1, #propHash do
|
||||
hash = (hash * 31 + string.byte(propHash, i)) % 1000000
|
||||
end
|
||||
baseID = baseID .. "_" .. hash
|
||||
end
|
||||
end
|
||||
|
||||
return baseID
|
||||
end
|
||||
|
||||
-- No parent (top-level element): use call-site counter approach
|
||||
-- Track how many elements have been created at this location
|
||||
callSiteCounters[locationKey] = (callSiteCounters[locationKey] or 0) + 1
|
||||
local instanceNum = callSiteCounters[locationKey]
|
||||
|
||||
local baseID = locationKey
|
||||
|
||||
-- Add instance number if multiple elements created at same location (e.g., in loops)
|
||||
if instanceNum > 1 then
|
||||
baseID = baseID .. "_" .. instanceNum
|
||||
end
|
||||
|
||||
-- Add property hash if provided (for additional differentiation)
|
||||
if props then
|
||||
local propHash = hashProps(props)
|
||||
if propHash ~= "" then
|
||||
-- Use first 8 chars of a simple hash
|
||||
local hash = 0
|
||||
for i = 1, #propHash do
|
||||
hash = (hash * 31 + string.byte(propHash, i)) % 1000000
|
||||
end
|
||||
baseID = baseID .. "_" .. hash
|
||||
end
|
||||
end
|
||||
|
||||
return baseID
|
||||
end
|
||||
|
||||
-- ====================
|
||||
-- State Management
|
||||
-- ====================
|
||||
|
||||
--- Initialize StateManager with dependencies
|
||||
---@param deps table Dependencies: { ErrorHandler = ErrorHandler }
|
||||
function StateManager.init(deps)
|
||||
if type(deps) == "table" then
|
||||
ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
end
|
||||
|
||||
--- Get state for an element ID, creating if it doesn't exist
|
||||
---@param id string Element ID
|
||||
---@param defaultState table|nil Default state if creating new
|
||||
---@return table state State table for the element
|
||||
function StateManager.getState(id, defaultState)
|
||||
if not id then
|
||||
ErrorHandler:error("StateManager", "SYS_001", {
|
||||
parameter = "id",
|
||||
value = "nil",
|
||||
})
|
||||
end
|
||||
|
||||
-- Create state if it doesn't exist
|
||||
if not stateStore[id] then
|
||||
-- Start with empty state (sparse storage)
|
||||
stateStore[id] = defaultState or {}
|
||||
|
||||
-- Create metadata
|
||||
stateMetadata[id] = {
|
||||
lastFrame = frameNumber,
|
||||
createdFrame = frameNumber,
|
||||
accessCount = 0,
|
||||
}
|
||||
else
|
||||
-- Update metadata
|
||||
local meta = stateMetadata[id]
|
||||
meta.lastFrame = frameNumber
|
||||
meta.accessCount = meta.accessCount + 1
|
||||
end
|
||||
|
||||
return stateStore[id]
|
||||
end
|
||||
|
||||
--- Set state for an element ID (replaces entire state)
|
||||
---@param id string Element ID
|
||||
---@param state table State to store
|
||||
function StateManager.setState(id, state)
|
||||
if not id then
|
||||
ErrorHandler:error("StateManager", "SYS_001", {
|
||||
parameter = "id",
|
||||
value = "nil",
|
||||
})
|
||||
end
|
||||
|
||||
-- Create sparse state (remove default values)
|
||||
local sparseState = {}
|
||||
for key, value in pairs(state) do
|
||||
if not isDefaultValue(key, value) then
|
||||
sparseState[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
stateStore[id] = sparseState
|
||||
|
||||
-- Update or create metadata
|
||||
if not stateMetadata[id] then
|
||||
stateMetadata[id] = {
|
||||
lastFrame = frameNumber,
|
||||
createdFrame = frameNumber,
|
||||
accessCount = 1,
|
||||
}
|
||||
else
|
||||
stateMetadata[id].lastFrame = frameNumber
|
||||
end
|
||||
end
|
||||
|
||||
--- Update state for an element ID (merges with existing state)
|
||||
---@param id string Element ID
|
||||
---@param newState table New state values to merge
|
||||
function StateManager.updateState(id, newState)
|
||||
local state = StateManager.getState(id)
|
||||
|
||||
-- Merge new state into existing state (with diffing optimization)
|
||||
local changed = false
|
||||
for key, value in pairs(newState) do
|
||||
if state[key] ~= value then
|
||||
state[key] = value
|
||||
changed = true
|
||||
end
|
||||
end
|
||||
|
||||
-- Only update metadata if something actually changed
|
||||
if changed then
|
||||
stateMetadata[id].lastFrame = frameNumber
|
||||
end
|
||||
end
|
||||
|
||||
--- Update state only if values have changed (optimized for immediate mode)
|
||||
---@param id string Element ID
|
||||
---@param newState table New state values to merge
|
||||
---@return boolean changed True if any values changed
|
||||
function StateManager.updateStateIfChanged(id, newState)
|
||||
local state = StateManager.getState(id)
|
||||
local changed = false
|
||||
|
||||
for key, value in pairs(newState) do
|
||||
-- Skip if value hasn't changed (optimization)
|
||||
if state[key] ~= value then
|
||||
state[key] = value
|
||||
changed = true
|
||||
end
|
||||
end
|
||||
|
||||
if changed then
|
||||
stateMetadata[id].lastFrame = frameNumber
|
||||
end
|
||||
|
||||
return changed
|
||||
end
|
||||
|
||||
--- Clear state for a specific element ID
|
||||
---@param id string Element ID
|
||||
function StateManager.clearState(id)
|
||||
stateStore[id] = nil
|
||||
stateMetadata[id] = nil
|
||||
end
|
||||
|
||||
--- Mark state as used this frame (updates last accessed frame)
|
||||
---@param id string Element ID
|
||||
function StateManager.markStateUsed(id)
|
||||
if stateMetadata[id] then
|
||||
stateMetadata[id].lastFrame = frameNumber
|
||||
end
|
||||
end
|
||||
|
||||
-- ====================
|
||||
-- Frame Management
|
||||
-- ====================
|
||||
|
||||
--- Increment frame counter (called at frame start)
|
||||
function StateManager.incrementFrame()
|
||||
frameNumber = frameNumber + 1
|
||||
-- Reset call site counters for new frame
|
||||
callSiteCounters = {}
|
||||
end
|
||||
|
||||
--- Get current frame number
|
||||
---@return number
|
||||
function StateManager.getFrameNumber()
|
||||
return frameNumber
|
||||
end
|
||||
|
||||
-- ====================
|
||||
-- Granular State Access (Unified API for both modes)
|
||||
-- ====================
|
||||
|
||||
--- Get a single state value by key for a given element ID.
|
||||
--- Works identically in both modes — the caller does not need to know the mode.
|
||||
---
|
||||
--- Immediate mode: reads from persistent state store.
|
||||
--- Retained mode: resolves through registered element field (cache-through).
|
||||
---
|
||||
---@param id string Element state ID
|
||||
---@param key string State key
|
||||
---@return any value The stored value, or nil if not found
|
||||
function StateManager.getStateValue(id, key)
|
||||
if not id or not key then
|
||||
ErrorHandler:error("StateManager", "SYS_001", {
|
||||
parameter = "id and key",
|
||||
value = "missing",
|
||||
})
|
||||
end
|
||||
|
||||
-- Update metadata for access tracking
|
||||
if stateMetadata[id] then
|
||||
stateMetadata[id].lastFrame = frameNumber
|
||||
stateMetadata[id].accessCount = stateMetadata[id].accessCount + 1
|
||||
end
|
||||
|
||||
if _immediateMode then
|
||||
-- Immediate mode: read from persistent state store
|
||||
local state = stateStore[id]
|
||||
if state then
|
||||
return state[key]
|
||||
end
|
||||
return nil
|
||||
else
|
||||
-- Retained mode: resolve through element field
|
||||
local element = statefulElements[id]
|
||||
if element then
|
||||
return element[key]
|
||||
end
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
--- Set a single state value by key for a given element ID.
|
||||
--- Works identically in both modes — the caller does not need to know the mode.
|
||||
---
|
||||
--- Immediate mode: marks dirty for flushFrame() persistence.
|
||||
--- Retained mode: writes directly to element field (cache-through).
|
||||
---
|
||||
---@param id string Element state ID
|
||||
---@param key string State key
|
||||
---@param value any Value to store
|
||||
function StateManager.setStateValue(id, key, value)
|
||||
if not id or not key then
|
||||
ErrorHandler:error("StateManager", "SYS_001", {
|
||||
parameter = "id and key",
|
||||
value = "missing",
|
||||
})
|
||||
end
|
||||
|
||||
-- Update metadata
|
||||
if not stateMetadata[id] then
|
||||
stateMetadata[id] = {
|
||||
lastFrame = frameNumber,
|
||||
createdFrame = frameNumber,
|
||||
accessCount = 1,
|
||||
}
|
||||
else
|
||||
stateMetadata[id].lastFrame = frameNumber
|
||||
end
|
||||
|
||||
if _immediateMode then
|
||||
-- Immediate mode: mark dirty for flushFrame persistence
|
||||
local state = StateManager.getState(id)
|
||||
state[key] = value
|
||||
dirtyState[id] = dirtyState[id] or {}
|
||||
dirtyState[id][key] = true
|
||||
else
|
||||
-- Retained mode: write directly to element field
|
||||
local element = statefulElements[id]
|
||||
if element then
|
||||
element[key] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ====================
|
||||
-- Stateful Element Registration (Retained Mode Cache-Through)
|
||||
-- ====================
|
||||
|
||||
--- Register an element instance for retained-mode cache-through.
|
||||
--- After registration, getStateValue/setStateValue will resolve through the element's fields.
|
||||
---
|
||||
--- Called by Element in _construct phase.
|
||||
---
|
||||
---@param id string State ID (typically element.id)
|
||||
---@param element table Element instance to link
|
||||
function StateManager.registerStateful(id, element)
|
||||
if not id or not element then
|
||||
return
|
||||
end
|
||||
statefulElements[id] = element
|
||||
end
|
||||
|
||||
--- Unregister an element instance.
|
||||
--- After unregistration, retained-mode access will fall back to nil.
|
||||
---
|
||||
--- Called by Element in _cleanup phase.
|
||||
---
|
||||
---@param id string State ID to unregister
|
||||
function StateManager.unregisterStateful(id)
|
||||
if id then
|
||||
statefulElements[id] = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- ====================
|
||||
-- Frame Flush (Immediate Mode Dirty State Persistence)
|
||||
-- ====================
|
||||
|
||||
--- Flush dirty state to persistent store at end of frame.
|
||||
--- Called automatically at frame end in immediate mode.
|
||||
--- Behaviors call setStateValue during update without knowing the mode.
|
||||
---
|
||||
--- In retained mode, this is a no-op (state is written directly to elements).
|
||||
function StateManager.flushFrame()
|
||||
if not _immediateMode then
|
||||
return
|
||||
end
|
||||
|
||||
-- All dirty writes were already applied to stateStore during setStateValue
|
||||
-- This method exists for future extensions (e.g., batching, analytics)
|
||||
-- Reset dirty tracking for next frame
|
||||
dirtyState = {}
|
||||
end
|
||||
|
||||
-- ====================
|
||||
-- Mode Configuration
|
||||
-- ====================
|
||||
|
||||
--- Configure immediate mode state.
|
||||
--- Called by Context when immediate mode is enabled/disabled.
|
||||
---
|
||||
---@param enabled boolean Whether immediate mode is active
|
||||
function StateManager.setImmediateMode(enabled)
|
||||
_immediateMode = enabled
|
||||
end
|
||||
|
||||
--- Check if immediate mode is active.
|
||||
---@return boolean
|
||||
function StateManager.isImmediateMode()
|
||||
return _immediateMode
|
||||
end
|
||||
|
||||
--- Whether at-construction layout / eager initialization should run now.
|
||||
--- Returns true in retained mode (layout eagerly), false in immediate mode
|
||||
--- (layout is deferred to `FlexLove.endFrame` / FlexLove so it runs once all
|
||||
--- elements for the frame have been created). This replaces the scattered
|
||||
--- `if not _immediateMode then layoutChildren()` mode checks with a single
|
||||
--- mode-aware query (behavior-mode-unification task 11).
|
||||
---@return boolean
|
||||
function StateManager.shouldLayout()
|
||||
return not _immediateMode
|
||||
end
|
||||
|
||||
-- ====================
|
||||
-- Cleanup & Maintenance
|
||||
-- ====================
|
||||
|
||||
--- Clean up stale states (not accessed recently)
|
||||
---@return number count Number of states cleaned up
|
||||
function StateManager.cleanup()
|
||||
local cleanedCount = 0
|
||||
local retentionFrames = config.stateRetentionFrames
|
||||
|
||||
for id, meta in pairs(stateMetadata) do
|
||||
local framesSinceAccess = frameNumber - meta.lastFrame
|
||||
|
||||
if framesSinceAccess > retentionFrames then
|
||||
stateStore[id] = nil
|
||||
stateMetadata[id] = nil
|
||||
cleanedCount = cleanedCount + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- Clean up empty states (sparse storage optimization)
|
||||
for id, state in pairs(stateStore) do
|
||||
if next(state) == nil then
|
||||
stateStore[id] = nil
|
||||
stateMetadata[id] = nil
|
||||
cleanedCount = cleanedCount + 1
|
||||
end
|
||||
end
|
||||
|
||||
return cleanedCount
|
||||
end
|
||||
|
||||
--- Force cleanup if state count exceeds maximum
|
||||
---@return number count Number of states cleaned up
|
||||
function StateManager.forceCleanupIfNeeded()
|
||||
local stateCount = StateManager.getStateCount()
|
||||
|
||||
if stateCount > config.maxStateEntries then
|
||||
-- Clean up states not accessed in last 10 frames (aggressive)
|
||||
local cleanedCount = 0
|
||||
|
||||
for id, meta in pairs(stateMetadata) do
|
||||
local framesSinceAccess = frameNumber - meta.lastFrame
|
||||
|
||||
if framesSinceAccess > 10 then
|
||||
stateStore[id] = nil
|
||||
stateMetadata[id] = nil
|
||||
cleanedCount = cleanedCount + 1
|
||||
end
|
||||
end
|
||||
|
||||
return cleanedCount
|
||||
end
|
||||
|
||||
return 0
|
||||
end
|
||||
|
||||
--- Get total number of stored states
|
||||
---@return number
|
||||
function StateManager.getStateCount()
|
||||
local count = 0
|
||||
for _ in pairs(stateStore) do
|
||||
count = count + 1
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
--- Clear all states
|
||||
function StateManager.clearAllStates()
|
||||
stateStore = {}
|
||||
stateMetadata = {}
|
||||
end
|
||||
|
||||
--- Configure state management
|
||||
---@param newConfig {stateRetentionFrames?: number, maxStateEntries?: number}
|
||||
function StateManager.configure(newConfig)
|
||||
if newConfig.stateRetentionFrames then
|
||||
config.stateRetentionFrames = newConfig.stateRetentionFrames
|
||||
end
|
||||
if newConfig.maxStateEntries then
|
||||
config.maxStateEntries = newConfig.maxStateEntries
|
||||
end
|
||||
end
|
||||
|
||||
--- Get state statistics for debugging
|
||||
---@return table stats State usage statistics
|
||||
function StateManager.getStats()
|
||||
local stateCount = StateManager.getStateCount()
|
||||
local oldest = nil
|
||||
local newest = nil
|
||||
|
||||
for _, meta in pairs(stateMetadata) do
|
||||
if not oldest or meta.createdFrame < oldest then
|
||||
oldest = meta.createdFrame
|
||||
end
|
||||
if not newest or meta.createdFrame > newest then
|
||||
newest = meta.createdFrame
|
||||
end
|
||||
end
|
||||
|
||||
-- Count callSiteCounters
|
||||
local callSiteCount = 0
|
||||
for _ in pairs(callSiteCounters) do
|
||||
callSiteCount = callSiteCount + 1
|
||||
end
|
||||
|
||||
-- Warn if callSiteCounters is unexpectedly large
|
||||
if callSiteCount > 1000 then
|
||||
if ErrorHandler then
|
||||
ErrorHandler.warn("StateManager", "STATE_001", {
|
||||
count = callSiteCount,
|
||||
expected = "near 0",
|
||||
frameNumber = frameNumber,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
stateCount = stateCount,
|
||||
frameNumber = frameNumber,
|
||||
oldestState = oldest,
|
||||
newestState = newest,
|
||||
callSiteCounterCount = callSiteCount,
|
||||
}
|
||||
end
|
||||
|
||||
--- Get internal state (for debugging/profiling only)
|
||||
---@return table internal {stateStore, stateMetadata, callSiteCounters}
|
||||
function StateManager._getInternalState()
|
||||
return {
|
||||
stateStore = stateStore,
|
||||
stateMetadata = stateMetadata,
|
||||
callSiteCounters = callSiteCounters,
|
||||
}
|
||||
end
|
||||
|
||||
--- Reset the entire state system (for testing)
|
||||
function StateManager.reset()
|
||||
stateStore = {}
|
||||
stateMetadata = {}
|
||||
frameNumber = 0
|
||||
callSiteCounters = {}
|
||||
statefulElements = {}
|
||||
dirtyState = {}
|
||||
_immediateMode = false
|
||||
end
|
||||
|
||||
-- ====================
|
||||
-- Convenience Functions (for backward compatibility)
|
||||
-- ====================
|
||||
|
||||
--- Check if an element is currently hovered
|
||||
---@param id string Element ID
|
||||
---@return boolean
|
||||
function StateManager.isHovered(id)
|
||||
local state = StateManager.getState(id)
|
||||
return state.hover or false
|
||||
end
|
||||
|
||||
--- Check if an element is currently pressed
|
||||
---@param id string Element ID
|
||||
---@return boolean
|
||||
function StateManager.isPressed(id)
|
||||
local state = StateManager.getState(id)
|
||||
return state.pressed or false
|
||||
end
|
||||
|
||||
--- Check if an element is currently focused
|
||||
---@param id string Element ID
|
||||
---@return boolean
|
||||
function StateManager.isFocused(id)
|
||||
local state = StateManager.getState(id)
|
||||
return state.focused or false
|
||||
end
|
||||
|
||||
--- Check if an element is disabled
|
||||
---@param id string Element ID
|
||||
---@return boolean
|
||||
function StateManager.isDisabled(id)
|
||||
local state = StateManager.getState(id)
|
||||
return state.disabled or false
|
||||
end
|
||||
|
||||
--- Check if an element is active (e.g., input focused)
|
||||
---@param id string Element ID
|
||||
---@return boolean
|
||||
function StateManager.isActive(id)
|
||||
local state = StateManager.getState(id)
|
||||
return state.active or false
|
||||
end
|
||||
|
||||
return StateManager
|
||||
@@ -0,0 +1,183 @@
|
||||
local modulePath = (...):match("(.-)[^%.]+$")
|
||||
local function req(name)
|
||||
return require(modulePath .. name)
|
||||
end
|
||||
|
||||
-- Text sanitization, escaping, and input validation utilities.
|
||||
|
||||
-- ErrorHandler is injected via init() for truncation warnings.
|
||||
local ErrorHandler = nil
|
||||
|
||||
--- Initialize dependencies
|
||||
---@param deps table Dependencies: { ErrorHandler = ErrorHandler }
|
||||
local function init(deps)
|
||||
if type(deps) == "table" then
|
||||
ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
end
|
||||
|
||||
--- Sanitize text to prevent security vulnerabilities
|
||||
--- @param text string? Text to sanitize
|
||||
--- @param options table? Sanitization options
|
||||
--- @return string Sanitized text
|
||||
local function sanitizeText(text, options)
|
||||
local utf8 = require("utf8")
|
||||
-- Handle nil or non-string inputs
|
||||
if text == nil then
|
||||
return ""
|
||||
end
|
||||
if type(text) ~= "string" then
|
||||
text = tostring(text)
|
||||
end
|
||||
|
||||
-- Default options
|
||||
options = options or {}
|
||||
local maxLength = options.maxLength or 10000
|
||||
local allowNewlines = options.allowNewlines ~= false -- default true
|
||||
local allowTabs = options.allowTabs ~= false -- default true
|
||||
local stripControls = options.stripControls ~= false -- default true
|
||||
local trimWhitespace = options.trimWhitespace ~= false -- default true
|
||||
|
||||
-- Remove null bytes (critical security risk)
|
||||
text = text:gsub("%z", "")
|
||||
|
||||
-- Strip control characters except allowed ones
|
||||
if stripControls then
|
||||
local pattern = "[\1-\31\127]" -- All control characters
|
||||
if allowNewlines and allowTabs then
|
||||
pattern = "[\1-\8\11\12\14-\31\127]" -- Exclude \t (9), \n (10), \r (13)
|
||||
elseif allowNewlines then
|
||||
pattern = "[\1-\9\11\12\14-\31\127]" -- Exclude \n (10), \r (13)
|
||||
elseif allowTabs then
|
||||
pattern = "[\1-\8\10\12-\31\127]" -- Exclude \t (9)
|
||||
end
|
||||
text = text:gsub(pattern, "")
|
||||
end
|
||||
|
||||
-- Trim leading/trailing whitespace
|
||||
if trimWhitespace then
|
||||
text = text:match("^%s*(.-)%s*$") or ""
|
||||
end
|
||||
|
||||
-- Limit string length (use UTF-8 character count, not byte count)
|
||||
local charCount = utf8.len(text)
|
||||
if charCount and charCount > maxLength then
|
||||
if ErrorHandler then
|
||||
ErrorHandler:warn("utils", "UTIL_001", {
|
||||
original = charCount,
|
||||
truncated = maxLength,
|
||||
})
|
||||
end
|
||||
-- Truncate to maxLength UTF-8 characters
|
||||
local bytePos = utf8.offset(text, maxLength + 1)
|
||||
if bytePos then
|
||||
text = text:sub(1, bytePos - 1)
|
||||
end
|
||||
if ErrorHandler then
|
||||
ErrorHandler:warn("utils", string.format("Text truncated from %d to %d characters", charCount, maxLength))
|
||||
end
|
||||
end
|
||||
|
||||
return text
|
||||
end
|
||||
|
||||
--- Validate text input against rules
|
||||
--- @param text string Text to validate
|
||||
--- @param rules table Validation rules
|
||||
--- @return boolean, string? Returns true if valid, or false with error message
|
||||
local function validateTextInput(text, rules)
|
||||
rules = rules or {}
|
||||
|
||||
-- Check minimum length
|
||||
if rules.minLength and #text < rules.minLength then
|
||||
return false, string.format("Text must be at least %d characters", rules.minLength)
|
||||
end
|
||||
|
||||
-- Check maximum length
|
||||
if rules.maxLength and #text > rules.maxLength then
|
||||
return false, string.format("Text must be at most %d characters", rules.maxLength)
|
||||
end
|
||||
|
||||
-- Check pattern match
|
||||
if rules.pattern and not text:match(rules.pattern) then
|
||||
return false, rules.patternError or "Text does not match required pattern"
|
||||
end
|
||||
|
||||
-- Check character whitelist
|
||||
if rules.allowedChars then
|
||||
local pattern = "[^" .. rules.allowedChars .. "]"
|
||||
if text:match(pattern) then
|
||||
return false, "Text contains invalid characters"
|
||||
end
|
||||
end
|
||||
|
||||
-- Check character blacklist
|
||||
if rules.forbiddenChars then
|
||||
local pattern = "[" .. rules.forbiddenChars .. "]"
|
||||
if text:match(pattern) then
|
||||
return false, "Text contains forbidden characters"
|
||||
end
|
||||
end
|
||||
|
||||
return true, nil
|
||||
end
|
||||
|
||||
--- Validate text against range/length rules (alias of validateTextInput)
|
||||
--- @param text string Text to validate
|
||||
--- @param rules table Validation rules (minLength, maxLength, pattern, etc.)
|
||||
--- @return boolean, string? Returns true if valid, or false with error message
|
||||
local function validateTextRange(text, rules)
|
||||
return validateTextInput(text, rules)
|
||||
end
|
||||
|
||||
--- Escape HTML special characters
|
||||
--- @param text string Text to escape
|
||||
--- @return string Escaped text
|
||||
local function escapeHtml(text)
|
||||
if text == nil then
|
||||
return ""
|
||||
end
|
||||
text = tostring(text)
|
||||
text = text:gsub("&", "&")
|
||||
text = text:gsub("<", "<")
|
||||
text = text:gsub(">", ">")
|
||||
text = text:gsub('"', """)
|
||||
text = text:gsub("'", "'")
|
||||
return text
|
||||
end
|
||||
|
||||
--- Escape Lua pattern special characters
|
||||
--- @param text string Text to escape
|
||||
--- @return string Escaped text
|
||||
local function escapeLuaPattern(text)
|
||||
if text == nil then
|
||||
return ""
|
||||
end
|
||||
text = tostring(text)
|
||||
-- Escape all Lua pattern special characters
|
||||
text = text:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1")
|
||||
return text
|
||||
end
|
||||
|
||||
--- Strip all non-printable characters from text
|
||||
--- @param text string Text to clean
|
||||
--- @return string Cleaned text
|
||||
local function stripNonPrintable(text)
|
||||
if text == nil then
|
||||
return ""
|
||||
end
|
||||
text = tostring(text)
|
||||
-- Keep printable ASCII (32-126), newline (10), tab (9), and carriage return (13)
|
||||
text = text:gsub("[^\9\10\13\32-\126]", "")
|
||||
return text
|
||||
end
|
||||
|
||||
return {
|
||||
init = init,
|
||||
sanitizeText = sanitizeText,
|
||||
validateTextInput = validateTextInput,
|
||||
validateTextRange = validateTextRange,
|
||||
escapeHtml = escapeHtml,
|
||||
escapeLuaPattern = escapeLuaPattern,
|
||||
stripNonPrintable = stripNonPrintable,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
---@class UTF8
|
||||
---Compatibility layer for UTF-8 support across Lua versions
|
||||
---Handles utf8 (Lua 5.3+), lua-utf8 (LuaRocks), and basic fallbacks
|
||||
|
||||
local UTF8 = {}
|
||||
|
||||
-- Try to load UTF-8 library in order of preference:
|
||||
-- 1. Built-in utf8 (Lua 5.3+, LÖVE2D)
|
||||
-- 2. lua-utf8 from LuaRocks (Lua 5.1, 5.2)
|
||||
-- 3. Error if neither available
|
||||
local function loadUTF8()
|
||||
-- Try built-in utf8 first (Lua 5.3+ and LÖVE2D)
|
||||
if utf8 and type(utf8) == "table" and utf8.len then
|
||||
return utf8
|
||||
end
|
||||
|
||||
-- Try lua-utf8 from LuaRocks
|
||||
local ok, luautf8 = pcall(require, "lua-utf8")
|
||||
if ok then
|
||||
return luautf8
|
||||
end
|
||||
|
||||
-- Try standard utf8 module name as fallback
|
||||
ok, luautf8 = pcall(require, "utf8")
|
||||
if ok then
|
||||
return luautf8
|
||||
end
|
||||
|
||||
-- No UTF-8 library available
|
||||
error("No UTF-8 library available. Please install 'luautf8' via LuaRocks: luarocks install luautf8")
|
||||
end
|
||||
|
||||
-- Load the UTF-8 implementation
|
||||
local utf8lib = loadUTF8()
|
||||
|
||||
-- Export all utf8 functions
|
||||
UTF8.char = utf8lib.char
|
||||
UTF8.charpattern = utf8lib.charpattern
|
||||
UTF8.codes = utf8lib.codes
|
||||
UTF8.codepoint = utf8lib.codepoint
|
||||
UTF8.len = utf8lib.len
|
||||
UTF8.offset = utf8lib.offset
|
||||
|
||||
return UTF8
|
||||
@@ -0,0 +1,335 @@
|
||||
--- Utility module for parsing and resolving CSS-like units (px, %, vw, vh)
|
||||
--- Provides unit parsing, validation, and conversion to pixel values
|
||||
---@class Units
|
||||
---@field _Context table? Context module dependency
|
||||
---@field _ErrorHandler table? ErrorHandler module dependency
|
||||
---@field _Calc table? Calc module dependency
|
||||
local Units = {}
|
||||
|
||||
--- Initialize Units module with dependencies
|
||||
---@param deps table Dependencies: { Context = table?, ErrorHandler = table?, Calc = table? }
|
||||
function Units.init(deps)
|
||||
Units._Context = deps.Context
|
||||
Units._ErrorHandler = deps.ErrorHandler
|
||||
Units._Calc = deps.Calc
|
||||
end
|
||||
|
||||
--- Parse a unit value into numeric value and unit type
|
||||
--- Supports: px (pixels), % (percentage), vw/vh (viewport), and calc() expressions
|
||||
---@param value string|number|table The value to parse (e.g., "50px", "10%", "2vw", 100, or calc object)
|
||||
---@return number|table numericValue The numeric portion of the value or calc object
|
||||
---@return string unitType The unit type ("px", "%", "vw", "vh", "calc")
|
||||
function Units.parse(value)
|
||||
-- Check if value is a calc expression
|
||||
if Units._Calc and Units._Calc.isCalc(value) then
|
||||
return value, "calc"
|
||||
end
|
||||
|
||||
if type(value) == "number" then
|
||||
return value, "px"
|
||||
end
|
||||
|
||||
if type(value) ~= "string" and type(value) ~= "table" then
|
||||
Units._ErrorHandler:warn("Units", "VAL_001", {
|
||||
property = "unit value",
|
||||
expected = "string, number, or calc object",
|
||||
got = type(value),
|
||||
})
|
||||
return 0, "px"
|
||||
end
|
||||
|
||||
-- Check for unit-only input (e.g., "px", "%", "vw" without a number)
|
||||
local validUnits = { px = true, ["%"] = true, vw = true, vh = true }
|
||||
if validUnits[value] then
|
||||
Units._ErrorHandler:warn("Units", "VAL_005", {
|
||||
input = value,
|
||||
expected = "number + unit (e.g., '50" .. value .. "')",
|
||||
})
|
||||
return 0, "px"
|
||||
end
|
||||
|
||||
-- Check for invalid format (space between number and unit)
|
||||
if value:match("%d%s+%a") then
|
||||
Units._ErrorHandler:warn("Units", "VAL_005", {
|
||||
input = value,
|
||||
issue = "contains space between number and unit",
|
||||
})
|
||||
return 0, "px"
|
||||
end
|
||||
|
||||
-- Match number followed by optional unit
|
||||
local numStr, unit = value:match("^([%-]?[%d%.]+)(.*)$")
|
||||
if not numStr then
|
||||
Units._ErrorHandler:warn("Units", "VAL_005", {
|
||||
input = value,
|
||||
})
|
||||
return 0, "px"
|
||||
end
|
||||
|
||||
local num = tonumber(numStr)
|
||||
if not num then
|
||||
Units._ErrorHandler:warn("Units", "VAL_005", {
|
||||
input = value,
|
||||
issue = "numeric value cannot be parsed",
|
||||
})
|
||||
return 0, "px"
|
||||
end
|
||||
|
||||
-- Default to pixels if no unit specified
|
||||
if unit == "" then
|
||||
unit = "px"
|
||||
end
|
||||
|
||||
-- validUnits is already defined at the top of the function
|
||||
if not validUnits[unit] then
|
||||
Units._ErrorHandler:warn("Units", "VAL_005", {
|
||||
input = value,
|
||||
unit = unit,
|
||||
validUnits = "px, %, vw, vh",
|
||||
})
|
||||
return num, "px"
|
||||
end
|
||||
|
||||
return num, unit
|
||||
end
|
||||
|
||||
--- Convert relative units to absolute pixel values
|
||||
--- Resolves %, vw, vh units based on viewport and parent dimensions, and evaluates calc() expressions
|
||||
---@param value number|table Numeric value to convert or calc object
|
||||
---@param unit string Unit type ("px", "%", "vw", "vh", "calc")
|
||||
---@param viewportWidth number Current viewport width in pixels
|
||||
---@param viewportHeight number Current viewport height in pixels
|
||||
---@param parentSize number? Required for percentage units (parent dimension in pixels)
|
||||
---@return number resolvedValue Resolved pixel value
|
||||
function Units.resolve(value, unit, viewportWidth, viewportHeight, parentSize)
|
||||
if unit == "calc" then
|
||||
-- Resolve calc expression
|
||||
if Units._Calc then
|
||||
return Units._Calc.resolve(value, viewportWidth, viewportHeight, parentSize)
|
||||
else
|
||||
Units._ErrorHandler:warn("Units", "VAL_006", {
|
||||
unit = "calc",
|
||||
issue = "Calc module not available",
|
||||
})
|
||||
return 0
|
||||
end
|
||||
elseif unit == "px" then
|
||||
return value
|
||||
elseif unit == "%" then
|
||||
if not parentSize then
|
||||
Units._ErrorHandler:warn("Units", "LAY_003", {
|
||||
unit = "%",
|
||||
issue = "parent dimension not available",
|
||||
})
|
||||
return 0
|
||||
end
|
||||
return (value / 100) * parentSize
|
||||
elseif unit == "vw" then
|
||||
return (value / 100) * viewportWidth
|
||||
elseif unit == "vh" then
|
||||
return (value / 100) * viewportHeight
|
||||
else
|
||||
Units._ErrorHandler:warn("Units", "VAL_005", {
|
||||
unit = unit,
|
||||
validUnits = "px, %, vw, vh, calc",
|
||||
})
|
||||
return 0
|
||||
end
|
||||
end
|
||||
|
||||
--- Get current viewport dimensions
|
||||
--- Uses cached viewport during resize operations, otherwise queries LÖVE graphics
|
||||
---@return number width Viewport width in pixels
|
||||
---@return number height Viewport height in pixels
|
||||
function Units.getViewport()
|
||||
-- Return cached viewport if available (only during resize operations)
|
||||
if Units._Context._cachedViewport and Units._Context._cachedViewport.width > 0 then
|
||||
return Units._Context._cachedViewport.width, Units._Context._cachedViewport.height
|
||||
end
|
||||
|
||||
if love.graphics and love.graphics.getDimensions then
|
||||
return love.graphics.getDimensions()
|
||||
else
|
||||
local w, h = love.window.getMode()
|
||||
return w, h
|
||||
end
|
||||
end
|
||||
|
||||
--- Apply base scale factor to a value based on axis
|
||||
--- Used for responsive scaling of UI elements
|
||||
---@param value number The value to scale
|
||||
---@param axis "x"|"y" The axis to scale on
|
||||
---@param scaleFactors {x:number, y:number} Scale factors for each axis
|
||||
---@return number scaledValue The scaled value
|
||||
function Units.applyBaseScale(value, axis, scaleFactors)
|
||||
if axis == "x" then
|
||||
return value * scaleFactors.x
|
||||
else
|
||||
return value * scaleFactors.y
|
||||
end
|
||||
end
|
||||
|
||||
--- Resolve spacing properties (margin, padding) to pixel values
|
||||
--- Supports individual sides (top, right, bottom, left) and shortcuts (vertical, horizontal)
|
||||
---@param spacingProps table? Spacing properties with top/right/bottom/left/vertical/horizontal
|
||||
---@param parentWidth number Parent element width in pixels
|
||||
---@param parentHeight number Parent element height in pixels
|
||||
---@return table resolvedSpacing Table with top, right, bottom, left in pixels
|
||||
function Units.resolveSpacing(spacingProps, parentWidth, parentHeight)
|
||||
if not spacingProps then
|
||||
return { top = 0, right = 0, bottom = 0, left = 0 }
|
||||
end
|
||||
|
||||
local viewportWidth, viewportHeight = Units.getViewport()
|
||||
local result = {}
|
||||
|
||||
local vertical = spacingProps.vertical
|
||||
local horizontal = spacingProps.horizontal
|
||||
|
||||
if vertical then
|
||||
if type(vertical) == "string" or (Units._Calc and Units._Calc.isCalc(vertical)) then
|
||||
local value, unit = Units.parse(vertical)
|
||||
vertical = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight)
|
||||
end
|
||||
end
|
||||
|
||||
if horizontal then
|
||||
if type(horizontal) == "string" or (Units._Calc and Units._Calc.isCalc(horizontal)) then
|
||||
local value, unit = Units.parse(horizontal)
|
||||
horizontal = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth)
|
||||
end
|
||||
end
|
||||
|
||||
for _, side in ipairs({ "top", "right", "bottom", "left" }) do
|
||||
local value = spacingProps[side]
|
||||
if value then
|
||||
if type(value) == "string" or (Units._Calc and Units._Calc.isCalc(value)) then
|
||||
local numValue, unit = Units.parse(value)
|
||||
local parentSize = (side == "top" or side == "bottom") and parentHeight or parentWidth
|
||||
result[side] = Units.resolve(numValue, unit, viewportWidth, viewportHeight, parentSize)
|
||||
else
|
||||
result[side] = value
|
||||
end
|
||||
else
|
||||
if side == "top" or side == "bottom" then
|
||||
result[side] = vertical or 0
|
||||
else
|
||||
result[side] = horizontal or 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
--- Validate a unit string format
|
||||
--- Checks if the string can be successfully parsed as a valid unit or calc expression
|
||||
---@param unitStr string|table The unit string to validate (e.g., "50px", "10%") or calc object
|
||||
---@return boolean isValid True if the unit string is valid, false otherwise
|
||||
function Units.isValid(unitStr)
|
||||
-- Check if it's a calc expression
|
||||
if Units._Calc and Units._Calc.isCalc(unitStr) then
|
||||
return true
|
||||
end
|
||||
|
||||
if type(unitStr) ~= "string" then
|
||||
return false
|
||||
end
|
||||
|
||||
-- Check for invalid format (space between number and unit)
|
||||
if unitStr:match("%d%s+%a") then
|
||||
return false
|
||||
end
|
||||
|
||||
-- Match number followed by optional unit
|
||||
local numStr, unit = unitStr:match("^([%-]?[%d%.]+)(.*)$")
|
||||
if not numStr then
|
||||
return false
|
||||
end
|
||||
|
||||
-- Check if numeric part is valid
|
||||
local num = tonumber(numStr)
|
||||
if not num then
|
||||
return false
|
||||
end
|
||||
|
||||
-- Default to pixels if no unit specified
|
||||
if unit == "" then
|
||||
unit = "px"
|
||||
end
|
||||
|
||||
-- Check if unit is valid
|
||||
local validUnits = { px = true, ["%"] = true, vw = true, vh = true }
|
||||
return validUnits[unit] == true
|
||||
end
|
||||
|
||||
--- Parse CSS flex shorthand into flexGrow, flexShrink, flexBasis
|
||||
--- Supports: number, "auto", "none", "grow shrink basis"
|
||||
---@param flexValue number|string The flex shorthand value
|
||||
---@return number flexGrow
|
||||
---@return number flexShrink
|
||||
---@return string|number flexBasis
|
||||
function Units.parseFlexShorthand(flexValue)
|
||||
-- Single number: flex-grow
|
||||
if type(flexValue) == "number" then
|
||||
return flexValue, 1, 0
|
||||
end
|
||||
|
||||
-- String values
|
||||
if type(flexValue) == "string" then
|
||||
-- "auto" = 1 1 auto
|
||||
if flexValue == "auto" then
|
||||
return 1, 1, "auto"
|
||||
end
|
||||
|
||||
-- "none" = 0 0 auto
|
||||
if flexValue == "none" then
|
||||
return 0, 0, "auto"
|
||||
end
|
||||
|
||||
-- Parse "grow shrink basis" format
|
||||
local parts = {}
|
||||
for part in flexValue:gmatch("%S+") do
|
||||
table.insert(parts, part)
|
||||
end
|
||||
|
||||
local grow = 0
|
||||
local shrink = 1
|
||||
local basis = "auto"
|
||||
|
||||
if #parts == 1 then
|
||||
-- Single value: could be grow (number) or basis (with unit)
|
||||
local num = tonumber(parts[1])
|
||||
if num then
|
||||
grow = num
|
||||
basis = 0
|
||||
else
|
||||
basis = parts[1]
|
||||
end
|
||||
elseif #parts == 2 then
|
||||
-- Two values: grow shrink (both numbers) or grow basis
|
||||
local num1 = tonumber(parts[1])
|
||||
local num2 = tonumber(parts[2])
|
||||
if num1 and num2 then
|
||||
grow = num1
|
||||
shrink = num2
|
||||
basis = 0
|
||||
elseif num1 then
|
||||
grow = num1
|
||||
basis = parts[2]
|
||||
end
|
||||
elseif #parts >= 3 then
|
||||
-- Three values: grow shrink basis
|
||||
grow = tonumber(parts[1]) or 0
|
||||
shrink = tonumber(parts[2]) or 1
|
||||
basis = parts[3]
|
||||
end
|
||||
|
||||
return grow, shrink, basis
|
||||
end
|
||||
|
||||
-- Default fallback
|
||||
return 0, 1, "auto"
|
||||
end
|
||||
|
||||
return Units
|
||||
@@ -0,0 +1,35 @@
|
||||
---@class ZIndex
|
||||
local ZIndex = {}
|
||||
|
||||
-- The effective z-index formula used for sorting is:
|
||||
-- rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ
|
||||
-- where rootZ is the z-index of the top-level ancestor, depth is the
|
||||
-- nesting level, and ownZ is the element's own z property.
|
||||
--
|
||||
-- Constraints enforced by these weights:
|
||||
-- |ownZ| <= MAX_Z (must fit within DEPTH_WEIGHT digits)
|
||||
-- DEPTH_WEIGHT has enough room for depths well beyond any practical tree
|
||||
-- ROOT_WEIGHT has enough room for the rootZ without exceeding double-precision
|
||||
---
|
||||
---@type integer
|
||||
ZIndex.MIN_Z = -999
|
||||
---@type integer
|
||||
ZIndex.MAX_Z = 999
|
||||
---@type integer
|
||||
ZIndex.ROOT_WEIGHT = 10000000000
|
||||
---@type integer
|
||||
ZIndex.DEPTH_WEIGHT = 1000
|
||||
|
||||
--- Clamp a z-index value to the valid range
|
||||
---@param value number
|
||||
---@return integer
|
||||
function ZIndex.clamp(value)
|
||||
if value < ZIndex.MIN_Z then
|
||||
return ZIndex.MIN_Z
|
||||
elseif value > ZIndex.MAX_Z then
|
||||
return ZIndex.MAX_Z
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
return ZIndex
|
||||
@@ -0,0 +1,245 @@
|
||||
-- modules/behaviors/Animated.lua
|
||||
--
|
||||
-- Concrete behavior: animation update, interpolation application, chaining
|
||||
-- resolution, and transition wiring.
|
||||
--
|
||||
-- Task 06 of the behavior-mode-unification refactor. Moves the entire
|
||||
-- animation-update block out of Element:update (lines ~2761-2800) into
|
||||
-- `Animated.onUpdate(element, dt)`, and the `_ColorModule`/`_TransformModule`
|
||||
-- init-time wiring into `Animated.onAttach(element)`.
|
||||
--
|
||||
-- This behavior is UNIQUE among the behavior set because it can attach
|
||||
-- AFTER element creation. Animation is opt-in: a plain Element created without
|
||||
-- `transitions` and without an `animation` field never attaches Animated.
|
||||
-- The moment something creates an animation on the element — either directly
|
||||
-- (`element.animation = Animation.new(...)`, `element:fadeIn(...)`) or via a
|
||||
-- transition firing in `setProperty` — `Animated.ensureAttached(element)`
|
||||
-- attaches this behavior on demand so subsequent `Element:update` frames
|
||||
-- dispatch to `Animated.onUpdate`.
|
||||
--
|
||||
-- Attachment rule (shouldAttach): true when `props.transitions` is set OR an
|
||||
-- `element.animation` already exists at runtime. The runtime arm covers the
|
||||
-- late-attach case (animateTo / fadeIn / direct animation assignment).
|
||||
--
|
||||
-- State ownership (per the locked Behavior contract):
|
||||
-- * Per-element runtime state lives ON THE ELEMENT (`element.animation`).
|
||||
-- * The behavior instance itself is stateless and shared across elements.
|
||||
-- * Element-class-level dependencies (Element._Animation, Element._Color,
|
||||
-- Element._Transform) are resolved from the owning element's metatable,
|
||||
-- exactly like Clickable does — keeping the behavior stateless without
|
||||
-- expanding the 6-hook signature.
|
||||
--
|
||||
-- saveState/restoreState are no-ops: animations are ephemeral (an in-flight
|
||||
-- animation is not part of immediate-mode persisted state — the next frame
|
||||
-- re-evaluates transitions / re-applies animations fresh). Persisted scalar
|
||||
-- props (`opacity`, `x`, ...) survive via Element.saveState's `_props` block,
|
||||
-- not via the animation.
|
||||
|
||||
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||
local Behavior = require(_pkg .. "Behavior")
|
||||
|
||||
-- Resolve the Element class from an element instance.
|
||||
-- Element instances are created via `setmetatable({}, Element)` in _construct,
|
||||
-- so their metatable IS the Element class — giving us Element._Animation,
|
||||
-- Element._Color, Element._Transform, etc. without threading deps through the
|
||||
-- behavior hook signature.
|
||||
local function ElementClass(element)
|
||||
return getmetatable(element)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- ensureAnimationModuleWiring — set Element._Animation._ColorModule /
|
||||
-- _TransformModule. Idempotent; called from both onAttach and onUpdate so it
|
||||
-- works even when an animation was assigned by a caller that bypassed
|
||||
-- onAttach (direct `element.animation = Animation.new(...)`).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function ensureAnimationModuleWiring(element)
|
||||
local Element = ElementClass(element)
|
||||
local Animation = Element._Animation
|
||||
if not Animation then
|
||||
return
|
||||
end
|
||||
-- Ensure animation has Color module reference for color interpolation
|
||||
if not Animation._ColorModule and Element._Color then
|
||||
Animation._ColorModule = Element._Color
|
||||
end
|
||||
-- Ensure animation has Transform module reference for transform interpolation
|
||||
if not Animation._TransformModule and Element._Transform then
|
||||
Animation._TransformModule = Element._Transform
|
||||
end
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- shouldAttach (class-level predicate, no element required)
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- True when the element declares transitions up front OR already has an
|
||||
-- animation attached. The `animation` arm is consulted by ensureAttached at
|
||||
-- runtime (after creation); the `transitions` arm lets Animated auto-attach
|
||||
-- during Element.new for elements that pre-declare transitions.
|
||||
local function shouldAttach(props)
|
||||
if not props then
|
||||
return false
|
||||
end
|
||||
if props.transitions ~= nil then
|
||||
return true
|
||||
end
|
||||
-- Late-attach case: an animation was assigned after creation. When ensure
|
||||
-- Attached passes the element instance as `props`, this arm catches it.
|
||||
if type(props) == "table" and props.animation ~= nil then
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- ensureAttached — dynamic late-attach entry point
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- Idempotently attach the Animated behavior to an element that just gained an
|
||||
-- animation (via animateTo / fadeIn / direct assignment / a firing transition
|
||||
-- in setProperty). Called from Element.setProperty when a transition fires and
|
||||
-- from the transition helper methods on Element. Safe to call when already
|
||||
-- attached (no-op / returns false).
|
||||
--
|
||||
-- `animatedBehavior` is the shared behavior instance resolved lazily by
|
||||
-- Element (see Element._resolveAnimatedBehavior). The behavior is looked up
|
||||
-- from the registry once and cached on the class.
|
||||
--
|
||||
-- Returns true if the behavior was attached this call, false otherwise.
|
||||
local function ensureAttached(element, animatedBehavior)
|
||||
if not element or not animatedBehavior then
|
||||
return false
|
||||
end
|
||||
-- Already attached? Avoid duplicate entries within one element lifetime
|
||||
-- (a behavior may legitimately be re-added across immediate-mode frames
|
||||
-- since Element is recreated each frame, but within one lifetime at most
|
||||
-- once).
|
||||
local behaviors = element.behaviors
|
||||
if behaviors then
|
||||
for i = 1, #behaviors do
|
||||
if behaviors[i] == animatedBehavior then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
table.insert(element.behaviors, animatedBehavior)
|
||||
animatedBehavior.onAttach(element)
|
||||
return true
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onAttach — initialize Animation module references (formerly the
|
||||
-- Element._Animation._ColorModule / _TransformModule wiring in Element:update
|
||||
-- lines ~2772-2778).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function onAttach(element)
|
||||
ensureAnimationModuleWiring(element)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onUpdate — the animation update + interpolation + chain-resolution block
|
||||
-- (formerly Element:update lines ~2761-2800).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function onUpdate(element, dt)
|
||||
local animation = element.animation
|
||||
if not animation then
|
||||
return
|
||||
end
|
||||
|
||||
-- (Re)ensure module wiring is present in case the Animation instance was
|
||||
-- created by a caller that bypassed onAttach (e.g. direct
|
||||
-- `element.animation = Animation.new(...)`). Cheap idempotent writes.
|
||||
ensureAnimationModuleWiring(element)
|
||||
|
||||
local finished = animation:update(dt, element)
|
||||
if finished then
|
||||
-- Animation:update() already called onComplete callback.
|
||||
-- Check for chained animation.
|
||||
if animation._next then
|
||||
element.animation = animation._next
|
||||
elseif animation._nextFactory and type(animation._nextFactory) == "function" then
|
||||
local success, nextAnim = pcall(animation._nextFactory, element)
|
||||
if success and nextAnim then
|
||||
element.animation = nextAnim
|
||||
else
|
||||
element.animation = nil
|
||||
end
|
||||
else
|
||||
element.animation = nil
|
||||
end
|
||||
else
|
||||
-- Apply animation interpolation during update.
|
||||
animation:applyInterpolation(element)
|
||||
end
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- saveState / restoreState — no-ops (animations are ephemeral).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- Animations are not persisted across immediate-mode frames — they are
|
||||
-- re-derived each frame from transitions / direct calls. The element's scalar
|
||||
-- props (opacity, x, ...) are persisted by Element.saveState's _props block,
|
||||
-- so a completed animation's final visual state still survives recreation.
|
||||
-- While an animation is mid-flight in immediate mode, the element is recreated
|
||||
-- and the animation is NOT carried over (intentional — animating in immediate
|
||||
-- mode requires setting up the animation each frame).
|
||||
local function saveState()
|
||||
return nil
|
||||
end
|
||||
|
||||
local function restoreState()
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Build the (stateless, shared) behavior instance.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- onDetach/onDraw omitted: they default to no-ops (the behavior allocates no
|
||||
-- behavior-local state and animations have no draw pass). Animation state lives
|
||||
-- on the element (`element.animation`); nothing to tear down on detach.
|
||||
--
|
||||
-- We build the immutable behavior via Behavior.new (for validation + freeze +
|
||||
-- isBehavior parity with Clickable), then expose the late-attach helper on a
|
||||
-- thin module table since the frozen instance cannot accept new keys. The
|
||||
-- module table passes the behavior to the registry while making
|
||||
-- `Animated.ensureAttached` callable from Element.setProperty / the transition
|
||||
-- helpers — exactly as the task spec requires.
|
||||
local behavior = Behavior.new({
|
||||
onAttach = onAttach,
|
||||
onUpdate = onUpdate,
|
||||
saveState = saveState,
|
||||
restoreState = restoreState,
|
||||
shouldAttach = shouldAttach,
|
||||
})
|
||||
|
||||
-- Thin module table: exposes the behavior instance (for the registry) plus the
|
||||
-- late-attach helper (for Element.setProperty). All hooks delegate to the
|
||||
-- frozen behavior instance so dispatch sites get the validated, frozen
|
||||
-- implementation. shouldAttach is also exposed at module level (mirrors
|
||||
-- Clickable.shouldAttach) for tests/callers without an element.
|
||||
local Animated = {
|
||||
behavior = behavior,
|
||||
ensureAttached = ensureAttached,
|
||||
shouldAttach = shouldAttach,
|
||||
onAttach = onAttach,
|
||||
onUpdate = onUpdate,
|
||||
}
|
||||
|
||||
-- Metatable so the module table itself satisfies the duck-typed registry
|
||||
-- contract (iterating `Element._behaviorRegistry` calls `behavior.shouldAttach`
|
||||
-- and `behavior.onAttach` / `behavior.onUpdate` directly). Falls through to the
|
||||
-- frozen behavior instance for every hook.
|
||||
setmetatable(Animated, {
|
||||
__index = behavior,
|
||||
__tostring = function()
|
||||
return "Animated"
|
||||
end,
|
||||
})
|
||||
|
||||
return Animated
|
||||
@@ -0,0 +1,344 @@
|
||||
-- modules/behaviors/Clickable.lua
|
||||
--
|
||||
-- Concrete behavior: mouse/touch event handling, pressed-state tracking,
|
||||
-- hit-testing, and theme-state sync.
|
||||
--
|
||||
-- This is the largest behavior in the behavior-mode-unification refactor
|
||||
-- (~200 LOC moved out of Element:update / _initSubSystems / saveState).
|
||||
-- Task 02 extracts the entire `if self.onEvent or self.themeComponent or
|
||||
-- self.editable or self._selectState or self.selectOption then ... end` block
|
||||
-- from Element:update (hit-testing, mouse/touch event processing, immediate-
|
||||
-- mode state save, theme-state update) plus EventHandler creation (formerly the
|
||||
-- first half of Element:_initSubSystems) plus pressed-state drawing (formerly a
|
||||
-- render layer in Renderer) plus EventHandler save/restore.
|
||||
--
|
||||
-- Attachment rule (shouldAttach): the same predicate that previously guarded
|
||||
-- mouse-event processing in Element:update. An element owns the EventHandler /
|
||||
-- gets press feedback exactly when it is interactive: when it declares an
|
||||
-- `onEvent` callback, a `themeComponent`, is `editable`, or participates in a
|
||||
-- Select group (selectParent / selectOption). A plain passive element never
|
||||
-- attaches Clickable and therefore never allocates an EventHandler.
|
||||
--
|
||||
-- Element retains only the `self._eventHandler` field; Clickable owns it on
|
||||
-- attach. All other Element paths that touched the EventHandler (handleTouchEvent,
|
||||
-- handleGesture, getTouches) already nil-guard `self._eventHandler`, so they keep
|
||||
-- working unchanged for non-clickable elements.
|
||||
--
|
||||
-- State ownership (per the locked Behavior contract):
|
||||
-- * Per-element runtime state lives ON THE ELEMENT (self._eventHandler etc.).
|
||||
-- * The behavior instance itself is stateless and shared across elements.
|
||||
-- * Element-class-level dependencies (EventHandler factory, StateManager,
|
||||
-- Context) are resolved from the owning element's metatable (the Element
|
||||
-- class set by Element:_construct). This keeps the behavior stateless while
|
||||
-- avoiding a dependency-injection parameter that would violate the locked
|
||||
-- 6-hook signature `(element, ...)`.
|
||||
|
||||
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||
local Behavior = require(_pkg .. "Behavior")
|
||||
|
||||
-- Resolve the Element class from an element instance.
|
||||
-- Element instances are created via `setmetatable({}, Element)` in _construct,
|
||||
-- so their metatable IS the Element class — giving us Element._EventHandler,
|
||||
-- Element._eventHandlerDeps, Element._StateManager, Element._Context, etc.
|
||||
-- without threading deps through the behavior hook signature.
|
||||
local function ElementClass(element)
|
||||
return getmetatable(element)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- shouldAttach (class-level predicate, no element required)
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- Mirrors the cases that previously caused Element to allocate + use an
|
||||
-- EventHandler. MUST cover every element that touches the EventHandler at
|
||||
-- runtime: click (onEvent), theme press-feedback (themeComponent), text mouse
|
||||
-- interaction (editable), Select groups (selectParent / selectOption), touch
|
||||
-- callbacks (onTouchEvent), and gesture callbacks (onGesture). selectParent /
|
||||
-- selectOption are the props that produce _selectState during _initSubSystems;
|
||||
-- checking the props (rather than the runtime _selectState) lets shouldAttach
|
||||
-- run before the Select subsystem is initialized.
|
||||
local function shouldAttach(props)
|
||||
props = props or {}
|
||||
return props.onEvent ~= nil
|
||||
or props.themeComponent ~= nil
|
||||
or props.editable == true
|
||||
or props.onTouchEvent ~= nil
|
||||
or props.onGesture ~= nil
|
||||
or props.selectOption ~= nil
|
||||
or props.selectParent ~= nil
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onAttach — create the EventHandler (formerly Element:_initSubSystems
|
||||
-- lines ~640-690) and restore immediate-mode EventHandler state.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function onAttach(element)
|
||||
local Element = ElementClass(element)
|
||||
|
||||
local eventHandlerConfig = {
|
||||
-- element.onEvent is source of truth; not cached on handler
|
||||
onEventDeferred = element.onEventDeferred,
|
||||
-- element.onTouchEvent is source of truth; not cached on handler
|
||||
onTouchEventDeferred = element.onTouchEventDeferred,
|
||||
-- element.onGesture is source of truth; not cached on handler
|
||||
onGestureDeferred = element.onGestureDeferred,
|
||||
touchEnabled = element.touchEnabled,
|
||||
multiTouchEnabled = element.multiTouchEnabled,
|
||||
}
|
||||
|
||||
-- In immediate mode, restore EventHandler state from StateManager so pressed
|
||||
-- / hovered / click-count survive the per-frame element recreation cycle.
|
||||
-- Mode-aware via Context.isImmediateMode (behavior-mode-unification task 11):
|
||||
-- in retained mode the eventHandler persists, so nothing to restore.
|
||||
if Element._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then
|
||||
local state = Element._StateManager.getState(element._stateId)
|
||||
if state then
|
||||
-- Restore EventHandler state from StateManager (sparse storage — provide defaults)
|
||||
eventHandlerConfig._pressed = state._pressed or {}
|
||||
eventHandlerConfig._lastClickTime = state._lastClickTime
|
||||
eventHandlerConfig._lastClickButton = state._lastClickButton
|
||||
eventHandlerConfig._clickCount = state._clickCount or 0
|
||||
eventHandlerConfig._dragStartX = state._dragStartX or {}
|
||||
eventHandlerConfig._dragStartY = state._dragStartY or {}
|
||||
eventHandlerConfig._lastMouseX = state._lastMouseX or {}
|
||||
eventHandlerConfig._lastMouseY = state._lastMouseY or {}
|
||||
eventHandlerConfig._hovered = state._hovered
|
||||
end
|
||||
end
|
||||
|
||||
element._eventHandler = Element._EventHandler.new(eventHandlerConfig, Element._eventHandlerDeps)
|
||||
end
|
||||
|
||||
local function onDetach(element)
|
||||
-- Clear focus callbacks read by KeyboardNavigation / TextEditor:focus so the
|
||||
-- element's closure references can be collected in immediate mode (formerly
|
||||
-- part of Element:_cleanup). The EventHandler instance itself is INTENTIONALLY
|
||||
-- kept: Element:_cleanup preserves element structure for inspection (the
|
||||
-- stale-element refs are released when the element is GC'd). onEvent,
|
||||
-- onTouchEvent, onGesture are also left intact — the Renderer/EventHandler
|
||||
-- read those directly from the element (not the cache), so clearing them
|
||||
-- would break retained mode.
|
||||
element.onFocus = nil
|
||||
element.onBlur = nil
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onUpdate — the mouse hit-testing + event-processing + theme-state +
|
||||
-- immediate-mode save block (formerly Element:update lines ~2813-2960).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function onUpdate(element, dt)
|
||||
local Element = ElementClass(element)
|
||||
local eventHandler = element._eventHandler
|
||||
if not eventHandler then
|
||||
return
|
||||
end
|
||||
|
||||
local mx, my = love.mouse.getPosition()
|
||||
|
||||
-- Clickable area is the border box (x, y already includes padding)
|
||||
-- BORDER-BOX MODEL: Use stored border-box dimensions for hit detection
|
||||
local bx = element.x
|
||||
local by = element.y
|
||||
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
|
||||
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
|
||||
|
||||
-- Account for scroll offsets from parent containers
|
||||
-- Walk up the parent chain and accumulate scroll offsets. This stays in
|
||||
-- Clickable because it's an interaction concern (hit-testing), not layout.
|
||||
local scrollOffsetX = 0
|
||||
local scrollOffsetY = 0
|
||||
local current = element.parent
|
||||
while current do
|
||||
local overflowX = current.overflowX or current.overflow
|
||||
local overflowY = current.overflowY or current.overflow
|
||||
local hasScrollableOverflow = (
|
||||
overflowX == "scroll"
|
||||
or overflowX == "auto"
|
||||
or overflowY == "scroll"
|
||||
or overflowY == "auto"
|
||||
or overflowX == "hidden"
|
||||
or overflowY == "hidden"
|
||||
)
|
||||
if hasScrollableOverflow then
|
||||
scrollOffsetX = scrollOffsetX + (current._scrollX or 0)
|
||||
scrollOffsetY = scrollOffsetY + (current._scrollY or 0)
|
||||
end
|
||||
current = current.parent
|
||||
end
|
||||
|
||||
-- Adjust mouse position by accumulated scroll offset for hit testing
|
||||
local adjustedMx = mx + scrollOffsetX
|
||||
local adjustedMy = my + scrollOffsetY
|
||||
local isHovering = adjustedMx >= bx and adjustedMx <= bx + bw and adjustedMy >= by and adjustedMy <= by + bh
|
||||
|
||||
-- Check if this is the topmost interactive element at the mouse position
|
||||
-- (z-index ordering). This prevents blocked/occluded elements from
|
||||
-- receiving interactions or visual feedback. A single mode-agnostic lookup
|
||||
-- via `Context.findInteractiveAtPosition` (unified-event-routing task 05)
|
||||
-- replaces the previous immediate/retained-mode split that used
|
||||
-- `getTopElementAt` in immediate mode and `_activeEventElement` in retained
|
||||
-- mode. `findInteractiveAtPosition` routes every hit test through
|
||||
-- `pointHitsElement` (the single canonical `display == false` guard) and
|
||||
-- resolves occlusion by z-index in both modes, so the active element is the
|
||||
-- same one that would receive a hit under the cursor.
|
||||
local topElement = Element._Context.findInteractiveAtPosition(mx, my)
|
||||
local isActiveElement = (topElement == element or topElement == nil)
|
||||
|
||||
-- Reset scrollbar press flag at start of each frame
|
||||
eventHandler:resetScrollbarPressFlag()
|
||||
|
||||
-- Process mouse events through EventHandler FIRST
|
||||
-- This ensures pressed states are updated before theme state is calculated
|
||||
eventHandler:processMouseEvents(element, mx, my, isHovering, isActiveElement)
|
||||
|
||||
-- In immediate mode, save EventHandler state to StateManager after
|
||||
-- processing events so it survives the per-frame recreation.
|
||||
if element._stateId and Element._Context.isImmediateMode() and element._stateId ~= "" then
|
||||
local eventHandlerState = eventHandler:getState()
|
||||
Element._StateManager.updateState(element._stateId, {
|
||||
_pressed = eventHandlerState._pressed,
|
||||
_lastClickTime = eventHandlerState._lastClickTime,
|
||||
_lastClickButton = eventHandlerState._lastClickButton,
|
||||
_clickCount = eventHandlerState._clickCount,
|
||||
_dragStartX = eventHandlerState._dragStartX,
|
||||
_dragStartY = eventHandlerState._dragStartY,
|
||||
_lastMouseX = eventHandlerState._lastMouseX,
|
||||
_lastMouseY = eventHandlerState._lastMouseY,
|
||||
_hovered = eventHandlerState._hovered,
|
||||
})
|
||||
end
|
||||
|
||||
-- Update theme state based on interaction. themeComponent state update
|
||||
-- lives in Clickable because it is driven by hover/press state; the actual
|
||||
-- theme RENDERING is the Themed behavior (task 07).
|
||||
if element.themeComponent then
|
||||
-- Check if any button is pressed via EventHandler
|
||||
local anyPressed = eventHandler:isAnyButtonPressed()
|
||||
|
||||
-- Update theme state via ThemeManager
|
||||
local isFocused = Element._Context.getFocused() == element
|
||||
local newThemeState =
|
||||
element._themeManager:updateState(isHovering and isActiveElement, anyPressed, isFocused, element.disabled)
|
||||
|
||||
if element._stateId and Element._Context.isImmediateMode() then
|
||||
local hover = newThemeState == "hover"
|
||||
local pressed = newThemeState == "pressed"
|
||||
local focused = isFocused
|
||||
|
||||
Element._StateManager.updateState(element._stateId, {
|
||||
hover = hover,
|
||||
pressed = pressed,
|
||||
focused = focused,
|
||||
disabled = element.disabled,
|
||||
active = element.active,
|
||||
})
|
||||
end
|
||||
|
||||
if element._renderer then
|
||||
element._renderer:setThemeState(newThemeState)
|
||||
end
|
||||
end
|
||||
|
||||
-- Process touch events through EventHandler
|
||||
eventHandler:processTouchEvents(element)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onDraw — pressed-state visual feedback (formerly Renderer Layer 5).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- Draws the grey pressed overlay when any mouse button is currently pressed on
|
||||
-- the element. Delegates the actual pixels to Renderer:drawPressedState (which
|
||||
-- owns the RoundedRect + opacity math) but drives the DECISION + transform
|
||||
-- context here, so the renderer no longer needs the `if element.onEvent ...`
|
||||
-- behavioral branch. Honors disableHighlight (themes handle their own visual
|
||||
-- feedback) exactly as the old render layer did.
|
||||
local function onDraw(element)
|
||||
if element.disableHighlight then
|
||||
return
|
||||
end
|
||||
local eventHandler = element._eventHandler
|
||||
if not eventHandler then
|
||||
return
|
||||
end
|
||||
|
||||
local anyPressed = false
|
||||
local pressedState = eventHandler:getState()._pressed or {}
|
||||
for _, pressed in pairs(pressedState) do
|
||||
if pressed then
|
||||
anyPressed = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if not anyPressed then
|
||||
return
|
||||
end
|
||||
|
||||
local renderer = element._renderer
|
||||
if not renderer then
|
||||
return
|
||||
end
|
||||
|
||||
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
|
||||
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
|
||||
|
||||
-- Apply the element transform around the overlay, mirroring how the
|
||||
-- Renderer wrapped its whole command buffer (pressed state was a render
|
||||
-- layer subject to the same transform).
|
||||
local Element = ElementClass(element)
|
||||
local Transform = Element._Transform
|
||||
local hasTransform = element.transform ~= nil and Transform ~= nil and not Transform.isIdentity(element.transform)
|
||||
if hasTransform then
|
||||
Transform.apply(element.transform, element.x, element.y, element.width, element.height)
|
||||
end
|
||||
|
||||
renderer:drawPressedState(element.x, element.y, bw, bh, element.opacity, element.cornerRadius)
|
||||
|
||||
if hasTransform then
|
||||
Transform.unapply()
|
||||
end
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- saveState / restoreState — EventHandler state (formerly the eventHandler
|
||||
-- branches of Element:saveState / Element:restoreState).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function saveState(element)
|
||||
if element._eventHandler then
|
||||
return { eventHandler = element._eventHandler:getState() }
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function restoreState(element, state)
|
||||
if not state then
|
||||
return nil
|
||||
end
|
||||
if element._eventHandler and state.eventHandler then
|
||||
element._eventHandler:setState(state.eventHandler)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Build the (stateless, shared, immutable) behavior instance.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local Clickable = Behavior.new({
|
||||
onAttach = onAttach,
|
||||
onDetach = onDetach,
|
||||
onUpdate = onUpdate,
|
||||
onDraw = onDraw,
|
||||
saveState = saveState,
|
||||
restoreState = restoreState,
|
||||
shouldAttach = shouldAttach,
|
||||
})
|
||||
|
||||
-- Expose the predicate at module level so callers/tests can reference it
|
||||
-- directly without an element instance (mirrors Behavior.shouldAttach).
|
||||
Clickable.shouldAttach = shouldAttach
|
||||
|
||||
return Clickable
|
||||
@@ -0,0 +1,282 @@
|
||||
-- modules/behaviors/Imageable.lua
|
||||
--
|
||||
-- Concrete behavior: image loading + image rendering config.
|
||||
--
|
||||
-- Imageable owns the image side of the Renderer: it runs the deferred image-
|
||||
-- load pipeline (cache check → defer → load → fire onImageLoad/onImageError
|
||||
-- callbacks), populates the resolved `_loadedImage` cache on both the element
|
||||
-- and the shared renderer, and persists that cache across immediate-mode
|
||||
-- recreation. It is the behavior-mode-unification replacement for the image-
|
||||
-- loading half of Element:_initImageAndRenderer and the deferred
|
||||
-- Element:_loadImage method (behavior-mode-unification task 07).
|
||||
--
|
||||
-- Image value props (imagePath/image/objectFit/objectPosition/imageOpacity/
|
||||
-- imageRepeat/imageTint) are bound on the ELEMENT by Element:_applyProps and read
|
||||
-- from the element at draw time (Renderer._executeDrawCommand image branch) —
|
||||
-- Imageable does NOT mirror them onto the renderer, so bare writes and
|
||||
-- setProperty(...) are immediately consistent. Only the resolved _loadedImage
|
||||
-- cache (the love.Image produced by the load pipeline) is renderer-mirrored,
|
||||
-- because Renderer:draw reads `self._loadedImage`.
|
||||
--
|
||||
-- Runtime reload: setProperty("imagePath", ...) / setProperty("image", ...) and
|
||||
-- the bare-write-equivalent setImage* flows route through element._reloadImage
|
||||
-- (installed below) which re-runs the load pipeline. See
|
||||
-- TestRetainedPropertyConsistency (image props) and TestImageableIntegration.
|
||||
--
|
||||
-- Attachment rule (shouldAttach): an element owns image concern exactly when it
|
||||
-- declares an `imagePath` (load-from-path) or a direct `image` (already-loaded
|
||||
-- love.Image). Mirrors the old `if self.imagePath / if self.image` init branches.
|
||||
--
|
||||
-- Pairing with Themed: Themed.onAttach creates the Renderer with theme/blur
|
||||
-- config; Imageable.onAttach enriches the SAME renderer instance with image
|
||||
-- config + kicks off loading. They share `element._renderer`. In the registry
|
||||
-- Imageable runs after Themed, so the renderer already exists; the create-or-
|
||||
-- reuse guard below covers the defensive case where Imageable attaches first.
|
||||
--
|
||||
-- onDraw: the image LAYER is rendered by the integrated `Renderer:draw` call
|
||||
-- (owned by the Themed behavior) which executes the renderer's `image` draw
|
||||
-- command using the config Imageable.onAttach wired. Imageable.onDraw is
|
||||
-- therefore a no-op for the draw call itself — there is no separate
|
||||
-- `_renderer:_drawImage` entry point; pixel emission lives in the integrated
|
||||
-- Renderer:draw command buffer. Splitting it out would require Renderer surgery
|
||||
-- with no behavioral gain (Renderer:draw already conditionally skips the image
|
||||
-- layer when no image is loaded).
|
||||
--
|
||||
-- State ownership (per the locked Behavior contract):
|
||||
-- * Per-element runtime state lives ON THE ELEMENT (`element._loadedImage`,
|
||||
-- `element._renderer._loadedImage`). The behavior instance is stateless.
|
||||
-- * saveState/restoreState persist `_loadedImage` across immediate-mode frames
|
||||
-- so the image renders even if the ImageCache is cleared between frames and
|
||||
-- so the renderer's loaded-image cache survives element recreation.
|
||||
|
||||
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||
local Behavior = require(_pkg .. "Behavior")
|
||||
|
||||
-- Lua 5.4 removed the global `unpack`; mirror Element's alias.
|
||||
local unpack = table.unpack or unpack
|
||||
|
||||
-- Resolve the Element class from an element instance (mirrors Clickable/Themed).
|
||||
local function ElementClass(element)
|
||||
return getmetatable(element)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- shouldAttach (class-level predicate, no element required)
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function shouldAttach(props)
|
||||
props = props or {}
|
||||
return props.imagePath ~= nil or props.image ~= nil
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Image callback helper (moved from Element._fireImageCallback).
|
||||
-- Fires a user-supplied image callback (onImageLoad/onImageError) under pcall,
|
||||
-- honoring the onXDeferred flag when `honorDeferred` is true, and emits a single
|
||||
-- EVT_002 warn on failure. The direct-`image` sync init path passes
|
||||
-- honorDeferred=false to preserve immediate firing (image is already loaded).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function fireImageCallback(element, callbackField, honorDeferred, ...)
|
||||
local cb = element[callbackField]
|
||||
if type(cb) ~= "function" then
|
||||
return
|
||||
end
|
||||
local Element = ElementClass(element)
|
||||
local argc = select("#", ...)
|
||||
local args = { ... }
|
||||
local function invoke()
|
||||
local ok, err = pcall(cb, element, unpack(args, 1, argc))
|
||||
if not ok then
|
||||
Element._ErrorHandler:warn("Element", "EVT_002", {
|
||||
callback = callbackField,
|
||||
error = tostring(err),
|
||||
})
|
||||
end
|
||||
end
|
||||
if honorDeferred and element[callbackField .. "Deferred"] then
|
||||
Element._Context.deferCallback(invoke)
|
||||
else
|
||||
invoke()
|
||||
end
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Deferred image loader (replaces Element:_loadImage).
|
||||
--
|
||||
-- Invoked by Element's deferred-method dispatcher via the instance closure that
|
||||
-- onAttach installs on `element._loadImage`. Loads the image from cache or disk
|
||||
-- (I/O), updates BOTH the element and renderer `_loadedImage` caches so the
|
||||
-- image draws after an async load, and fires the load/error callback (deferred,
|
||||
-- honoring onImageLoadDeferred / onImageErrorDeferred).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function loadImage(element)
|
||||
if not element.imagePath or element.image then
|
||||
return
|
||||
end
|
||||
local Element = ElementClass(element)
|
||||
local loadedImage, err = Element._ImageCache.load(element.imagePath)
|
||||
if loadedImage then
|
||||
element._loadedImage = loadedImage
|
||||
if element._renderer then
|
||||
element._renderer._loadedImage = loadedImage
|
||||
end
|
||||
fireImageCallback(element, "onImageLoad", true, loadedImage)
|
||||
else
|
||||
fireImageCallback(element, "onImageError", true, err or "Unknown error")
|
||||
end
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- reloadImage — recompute the loaded-image cache from the current image/imagePath.
|
||||
--
|
||||
-- This is the single entry point for (re)loading after either initial attach or
|
||||
-- a runtime property change (see Element._specialSetHandlers.imagePath/image,
|
||||
-- which call element:_reloadImage()). Precedence matches onAttach: a direct
|
||||
-- `image` wins over `imagePath`; `nil` for both clears the cache.
|
||||
--
|
||||
-- * direct image → set _loadedImage immediately, fire onImageLoad SYNC (the
|
||||
-- image is already loaded; honorDeferred=false preserves the
|
||||
-- original synchronous init contract).
|
||||
-- * imagePath → cache CHECK only (no I/O) so a cached image can draw this
|
||||
-- frame, then defer the loader (_loadImage) for the actual
|
||||
-- I/O + deferred callbacks. load bails if `image` is later set.
|
||||
-- * neither → clear _loadedImage on both element + renderer.
|
||||
--
|
||||
-- Image value props (objectFit/imageOpacity/imageRepeat/imageTint/objectPosition)
|
||||
-- and imagePath/image themselves live on the ELEMENT as source of truth; the
|
||||
-- renderer reads them at draw time, so reloadImage does NOT mirror them onto the
|
||||
-- renderer — only the resolved _loadedImage cache is pushed.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function reloadImage(element)
|
||||
local Element = ElementClass(element)
|
||||
local renderer = element._renderer
|
||||
if element.image then
|
||||
element._loadedImage = element.image
|
||||
if renderer then
|
||||
renderer._loadedImage = element.image
|
||||
end
|
||||
fireImageCallback(element, "onImageLoad", false, element.image)
|
||||
elseif element.imagePath then
|
||||
-- Cache check (no I/O). Populate both caches immediately if cached so the
|
||||
-- image can draw this frame without waiting for the deferred load.
|
||||
local cached = Element._ImageCache.get(element.imagePath)
|
||||
element._loadedImage = cached
|
||||
if renderer then
|
||||
renderer._loadedImage = cached
|
||||
end
|
||||
-- Kick off the deferred I/O load + callbacks (idempotent: loadImage bails
|
||||
-- if image is set or imagePath is nil by the time it runs).
|
||||
if element._loadImage then
|
||||
element:_deferMethod("_loadImage")
|
||||
end
|
||||
else
|
||||
element._loadedImage = nil
|
||||
if renderer then
|
||||
renderer._loadedImage = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onAttach — enrich the shared renderer with image config + kick off loading
|
||||
-- (formerly the image block of Element:_initImageAndRenderer).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function onAttach(element)
|
||||
local Element = ElementClass(element)
|
||||
|
||||
-- Ensure the renderer exists (Thamed normally creates it; this create-or-reuse
|
||||
-- guard is defensive for the Imageable-attaches-first ordering).
|
||||
if not element._renderer then
|
||||
element._renderer = Element._Renderer.new({
|
||||
theme = element.theme,
|
||||
scaleCorners = element.scaleCorners,
|
||||
scalingAlgorithm = element.scalingAlgorithm,
|
||||
contentBlur = element.contentBlur,
|
||||
backdropBlur = element.backdropBlur,
|
||||
}, Element._rendererDeps)
|
||||
end
|
||||
|
||||
-- Install the (re)load hooks as instance methods so Element's
|
||||
-- deferred-method dispatcher / setProperty special handlers can trigger a
|
||||
-- reload without Element needing a behavior reference. This keeps Element
|
||||
-- decoupled from the Imageable behavior (mirrors the stateless-behavior +
|
||||
-- element-owned-state contract). Image value props and imagePath/image live
|
||||
-- on the element as source of truth (read at draw time); only the resolved
|
||||
-- _loadedImage cache is mirrored onto the renderer by reloadImage.
|
||||
element._loadImage = function(el)
|
||||
loadImage(el)
|
||||
end
|
||||
element._reloadImage = function(el)
|
||||
reloadImage(el)
|
||||
end
|
||||
|
||||
-- Initial load: compute _loadedImage + defer the I/O load.
|
||||
reloadImage(element)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onDraw — no-op (see file header: the image layer is rendered by the integrated
|
||||
-- Renderer:draw call owned by the Themed behavior, using the config wired here).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- saveState / restoreState — `_loadedImage` cache (for immediate-mode).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function saveState(element)
|
||||
if element._loadedImage ~= nil then
|
||||
return { _loadedImage = element._loadedImage }
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function restoreState(element, state)
|
||||
if not state or state._loadedImage == nil then
|
||||
return nil
|
||||
end
|
||||
local loadedImage = state._loadedImage
|
||||
element._loadedImage = loadedImage
|
||||
if element._renderer then
|
||||
element._renderer._loadedImage = loadedImage
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onDetach — release image-load callback closures so the element can be GC'd
|
||||
-- cleanly in immediate mode (formerly part of Element:_cleanup). The cached
|
||||
-- `_loadedImage` is reproduced on the next attach via the Imageable saveState
|
||||
-- -> restoreState cycle, so dropping the live references is always safe.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function onDetach(element)
|
||||
element.onImageLoad = nil
|
||||
element.onImageError = nil
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Build the (stateless, shared, immutable) behavior instance.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local Imageable = Behavior.new({
|
||||
onAttach = onAttach,
|
||||
onDetach = onDetach,
|
||||
onUpdate = function() end,
|
||||
onDraw = function() end,
|
||||
saveState = saveState,
|
||||
restoreState = restoreState,
|
||||
shouldAttach = shouldAttach,
|
||||
})
|
||||
|
||||
-- Expose the predicate at module level so callers/tests can reference it
|
||||
-- directly without an element instance (mirrors Behavior.shouldAttach /
|
||||
-- Clickable.shouldAttach). `loadImage` is NOT exposed on the (frozen) behavior
|
||||
-- instance; it is captured as a module-local upvalue by the onAttach closure that
|
||||
-- installs `element._loadImage`.
|
||||
Imageable.shouldAttach = shouldAttach
|
||||
|
||||
return Imageable
|
||||
@@ -0,0 +1,132 @@
|
||||
-- modules/behaviors/Persistable.lua
|
||||
--
|
||||
-- Concrete behavior: generic public-property persistence across the immediate-
|
||||
-- mode recreation cycle (behavior-mode-unification task 12).
|
||||
--
|
||||
-- Owns the ONE piece of Element save/restore state that is NOT subsystem state:
|
||||
-- the snapshot of an element's own public scalar fields (`text`, `display`,
|
||||
-- `opacity`, `x`, `width`, ...). Event-driven mutations to these fields (a
|
||||
-- release callback changing `text`, a toggle hiding a panel via `display =
|
||||
-- false`) must survive the per-frame Element recreation that defines immediate
|
||||
-- mode. Persistable captures them in `saveState` and reapplies them in
|
||||
-- `restoreState`, so the caller never branches on mode.
|
||||
--
|
||||
-- This behavior is the final home for the former `Element:saveState` `_props`
|
||||
-- block and the former `Element:restoreState` `_props` block (~20 LOC moved out
|
||||
-- of Element.lua). With it in place, `Element:saveState` / `Element:restoreState`
|
||||
-- collapse to a pure behavior-dispatch loop and Element owns zero property-
|
||||
-- extraction logic — every persisted slice is owned by exactly one behavior.
|
||||
--
|
||||
-- Attachment rule (shouldAttach): every element. Persistable attaches
|
||||
-- unconditionally (mirrors the pre-refactor invariant that every element's
|
||||
-- public scalar props were scanned). The actual snapshot is mode-gated inside
|
||||
-- `saveState` (immediate-mode-only, matching the legacy contract); in retained
|
||||
-- mode `saveState` returns nil and `restoreState` is a no-op unless a snapshot
|
||||
-- is explicitly passed.
|
||||
--
|
||||
-- Registry ordering: Persistable is intentionally placed LAST in the behavior
|
||||
-- registry. `restoreState` applies `_props` AFTER every other behavior has
|
||||
-- hydrated its subsystem state, so a persisted public-prop mutation (e.g.
|
||||
-- `text = "mutated"`) overrides the freshly-restored TextEditor/Select state —
|
||||
-- preserving the legacy restore ordering (behaviors first, `_props` tail).
|
||||
--
|
||||
-- State ownership (per the locked Behavior contract):
|
||||
-- * The persisted props live ON the element (they ARE the element's public
|
||||
-- fields). The behavior instance is stateless + immutable and shared.
|
||||
-- * The snapshot is returned under the `_props` key (prefixed with `_` so
|
||||
-- the public-prop scan itself skips it — avoiding self-recursion).
|
||||
|
||||
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||
local Behavior = require(_pkg .. "Behavior")
|
||||
|
||||
-- Resolve the Element class from an element instance (mirrors Clickable /
|
||||
-- Themed). Element instances are created via `setmetatable({}, Element)`, so
|
||||
-- their metatable IS the Element class — giving access to Element._StateManager
|
||||
-- without threading deps through the hook signature.
|
||||
local function ElementClass(element)
|
||||
return getmetatable(element)
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- shouldAttach (class-level predicate, no element required)
|
||||
-- ============================================================================
|
||||
|
||||
-- Every element's public scalar props are persistable, so this behavior
|
||||
-- attaches unconditionally. The mode gate lives inside saveState (it needs the
|
||||
-- runtime mode, which is only available with an element via StateManager).
|
||||
local function shouldAttach()
|
||||
return true
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- saveState — snapshot public scalar fields (immediate-mode-only).
|
||||
-- ============================================================================
|
||||
|
||||
-- Mirrors the former `Element:saveState` `_props` block exactly:
|
||||
-- * Only string keys NOT prefixed with `_` (so internal fields like
|
||||
-- `_renderer`, `_themeState`, `_initProps` are excluded).
|
||||
-- * Only scalar values (numbers, strings, booleans); tables and functions
|
||||
-- are excluded (children, padding, onEvent, ...).
|
||||
-- Returns `{ _props = {...} }` when there is at least one persistable prop and
|
||||
-- the element is in immediate mode; nil otherwise (retained mode no-op —
|
||||
-- state lives on the element directly there, so nothing to snapshot).
|
||||
local function saveState(element)
|
||||
local Element = ElementClass(element)
|
||||
if not Element._StateManager.isImmediateMode() then
|
||||
return nil
|
||||
end
|
||||
local props = {}
|
||||
for k, v in pairs(element) do
|
||||
if type(k) == "string" and k:sub(1, 1) ~= "_" and type(v) ~= "table" and type(v) ~= "function" then
|
||||
props[k] = v
|
||||
end
|
||||
end
|
||||
if next(props) then
|
||||
return { _props = props }
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- restoreState — reapply the persisted public-prop snapshot onto a fresh
|
||||
-- element (mode-agnostic; only fires when a `_props` slice is present).
|
||||
-- ============================================================================
|
||||
|
||||
-- Applies persisted mutations on top of whatever the constructor + other
|
||||
-- behaviors already set, so event-driven changes from the previous frame
|
||||
-- override the declarative props of the recreated element. Runs last in the
|
||||
-- behavior dispatch (Persistable is the registry tail) to preserve the legacy
|
||||
-- restore ordering (subsystem restore first, `_props` override last).
|
||||
local function restoreState(element, state)
|
||||
if not state or not state._props then
|
||||
return
|
||||
end
|
||||
for k, v in pairs(state._props) do
|
||||
element[k] = v
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- onAttach / onUpdate / onDraw / onDetach — no-ops.
|
||||
-- ============================================================================
|
||||
|
||||
-- Persistable owns no subsystem and allocates no per-element state (the
|
||||
-- "state" it persists IS the element's own fields). The lifecycle is purely
|
||||
-- save/restore.
|
||||
|
||||
-- ============================================================================
|
||||
-- Build the (stateless, shared, immutable) behavior instance.
|
||||
-- ============================================================================
|
||||
|
||||
local Persistable = Behavior.new({
|
||||
saveState = saveState,
|
||||
restoreState = restoreState,
|
||||
shouldAttach = shouldAttach,
|
||||
})
|
||||
|
||||
-- Expose the predicate at module level so callers/tests can reference it
|
||||
-- directly without an element instance (mirrors Behavior.shouldAttach /
|
||||
-- Clickable.shouldAttach).
|
||||
Persistable.shouldAttach = shouldAttach
|
||||
|
||||
return Persistable
|
||||
@@ -0,0 +1,264 @@
|
||||
-- modules/behaviors/Scrollable.lua
|
||||
--
|
||||
-- Concrete behavior: ScrollManager lifecycle (creation + immediate-mode
|
||||
-- scrollbar interaction-state restore).
|
||||
--
|
||||
-- Scrollable owns the per-element ScrollManager instance — the subsystem that
|
||||
-- manages overflow detection, scrollbar geometry, scroll position, and scrollbar
|
||||
-- drag/hover interaction. It is the behavior-mode-unification replacement for
|
||||
-- the former `Element:_initScrollManager` phase (~84 LOC) of Element.new
|
||||
-- (behavior-mode-unification task 03 / landed as part of the task 08 capstone).
|
||||
--
|
||||
-- Attachment rule (shouldAttach): an element owns a ScrollManager exactly when
|
||||
-- it declares an `overflow`, `overflowX`, or `overflowY` prop — mirroring the
|
||||
-- legacy `if props.overflow or props.overflowX or props.overflowY then` guard
|
||||
-- in `Element:_initScrollManager`. The ScrollManager is created and its
|
||||
-- normalized fields are exposed back onto the element (so the Renderer /
|
||||
-- ScrollManager delegates read `element.overflow` / `element.scrollbarWidth`
|
||||
-- etc.) exactly as the legacy inline phase did.
|
||||
--
|
||||
-- Why onAttach reads `element._initProps` (not element fields): the scrollbar
|
||||
-- configuration props (scrollbarWidth / scrollbarColor / scrollSpeed /
|
||||
-- scrollbarPlacement / scrollbarBalance / invertScroll / smoothScrollEnabled /
|
||||
-- scrollBarStyle / scrollbarKnobOffset / hideScrollbars / scrollbarRadius /
|
||||
-- scrollbarPadding / scrollbarTrackColor / _scrollX / _scrollY) are listed in
|
||||
-- SPECIAL_PROPS and therefore NOT bound onto the element by the schema-driven
|
||||
-- `_applyProps` loop — they are consumed only by the ScrollManager constructor.
|
||||
-- The locked behavior hook signature is `(element, ...)` with no props arg, so
|
||||
-- the original construction props are stashed on the element as `_initProps` by
|
||||
-- `Element:_construct` and read back here. (`overflow` / `overflowX` /
|
||||
-- `overflowY` ARE bound onto the element by `_applyProps` so that
|
||||
-- `Element:addChild`'s scroll-container auto-size guard sees them during
|
||||
-- declarative-children processing in `_finalizeConstruction`, which runs BEFORE
|
||||
-- this onAttach; onAttach then overwrites them with the ScrollManager's
|
||||
-- normalized values, matching the legacy field-exposure order.)
|
||||
--
|
||||
-- onUpdate / onDraw / saveState / restoreState are deferred to the
|
||||
-- behavior-driven update/draw tasks (09 / 12): the ScrollManager update,
|
||||
-- interaction, scrollbar drawing, and state save/restore currently stay inline
|
||||
-- in `Element:update` / `Element:draw` / `Element:saveState` /
|
||||
-- `Element:restoreState` (delegated through the ScrollManager API bound in
|
||||
-- `Element.init`). Those inline call sites are NOT behavioral `if` branches —
|
||||
-- they are unconditional 1-line delegates — so leaving them in Element does not
|
||||
-- regress the behavior-dispatch goals of tasks 09/12; task 09 will fold them
|
||||
-- into Scrollable hooks.
|
||||
--
|
||||
-- State ownership (per the locked Behavior contract):
|
||||
-- * Per-element runtime state lives ON THE ELEMENT (`element._scrollManager`,
|
||||
-- `element.overflow`, `element._scrollX`, `element._scrollbarDragging`, ...).
|
||||
-- * The behavior instance is stateless + immutable and shared across elements.
|
||||
-- * Element-class-level dependencies (`Element._ScrollManager`,
|
||||
-- `Element._scrollManagerDeps`, `Element._Context`, `Element._StateManager`)
|
||||
-- are resolved from the owning element's metatable (the Element class set by
|
||||
-- `Element:_construct`).
|
||||
|
||||
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||
local Behavior = require(_pkg .. "Behavior")
|
||||
|
||||
-- Resolve the Element class from an element instance.
|
||||
-- `setmetatable({}, Element)` in `_construct` makes the instance metatable BE
|
||||
-- the Element class, so this yields Element._ScrollManager,
|
||||
-- Element._scrollManagerDeps, Element._Context, Element._StateManager without
|
||||
-- threading deps through the hook signature.
|
||||
local function ElementClass(element)
|
||||
return getmetatable(element)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- shouldAttach (class-level predicate, no element required)
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- Mirrors the legacy `if props.overflow or props.overflowX or props.overflowY`
|
||||
-- guard. Uses `~= nil` (rather than truthiness) so that an explicit
|
||||
-- `overflow = false` / `overflow = ""` does not spuriously attach — though in
|
||||
-- practice overflow values are always strings or unset, matching the predicate
|
||||
-- semantics of the other behaviors (Clickable / TextEditable / Selectable).
|
||||
local function shouldAttach(props)
|
||||
props = props or {}
|
||||
return props.overflow ~= nil or props.overflowX ~= nil or props.overflowY ~= nil
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onAttach — create the ScrollManager + expose its fields + restore immediate-
|
||||
-- mode scrollbar interaction state (formerly Element:_initScrollManager).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function onAttach(element)
|
||||
local Element = ElementClass(element)
|
||||
-- Construction props are stashed on the element by _construct (the scrollbar
|
||||
-- config props are SPECIAL_PROPS and not bound as element fields).
|
||||
local props = element._initProps or {}
|
||||
|
||||
element._scrollManager = Element._ScrollManager.new({
|
||||
overflow = props.overflow,
|
||||
overflowX = props.overflowX,
|
||||
overflowY = props.overflowY,
|
||||
scrollbarWidth = props.scrollbarWidth,
|
||||
scrollbarColor = props.scrollbarColor,
|
||||
scrollbarTrackColor = props.scrollbarTrackColor,
|
||||
scrollbarRadius = props.scrollbarRadius,
|
||||
scrollbarPadding = props.scrollbarPadding,
|
||||
scrollSpeed = props.scrollSpeed,
|
||||
invertScroll = props.invertScroll,
|
||||
smoothScrollEnabled = props.smoothScrollEnabled,
|
||||
scrollBarStyle = props.scrollBarStyle,
|
||||
scrollbarKnobOffset = props.scrollbarKnobOffset,
|
||||
hideScrollbars = props.hideScrollbars,
|
||||
scrollbarPlacement = props.scrollbarPlacement,
|
||||
scrollbarBalance = props.scrollbarBalance,
|
||||
_scrollX = props._scrollX,
|
||||
_scrollY = props._scrollY,
|
||||
}, Element._scrollManagerDeps)
|
||||
|
||||
-- Expose ScrollManager properties for backward compatibility (Renderer access).
|
||||
local sm = element._scrollManager
|
||||
element.overflow = sm.overflow
|
||||
element.overflowX = sm.overflowX
|
||||
element.overflowY = sm.overflowY
|
||||
element.scrollbarWidth = sm.scrollbarWidth
|
||||
element.scrollbarColor = sm.scrollbarColor
|
||||
element.scrollbarTrackColor = sm.scrollbarTrackColor
|
||||
element.scrollbarRadius = sm.scrollbarRadius
|
||||
element.scrollbarPadding = sm.scrollbarPadding
|
||||
element.scrollSpeed = sm.scrollSpeed
|
||||
element.invertScroll = sm.invertScroll
|
||||
element.scrollBarStyle = sm.scrollBarStyle
|
||||
element.scrollbarKnobOffset = sm.scrollbarKnobOffset
|
||||
element.hideScrollbars = sm.hideScrollbars
|
||||
element.scrollbarPlacement = sm.scrollbarPlacement
|
||||
element.scrollbarBalance = sm.scrollbarBalance
|
||||
|
||||
-- Initialize state properties (will be synced from ScrollManager).
|
||||
element._overflowX = false
|
||||
element._overflowY = false
|
||||
element._contentWidth = 0
|
||||
element._contentHeight = 0
|
||||
element._scrollX = 0
|
||||
element._scrollY = 0
|
||||
element._maxScrollX = 0
|
||||
element._maxScrollY = 0
|
||||
element._scrollbarHoveredVertical = false
|
||||
element._scrollbarHoveredHorizontal = false
|
||||
element._scrollbarDragging = false
|
||||
element._hoveredScrollbar = nil
|
||||
element._scrollbarDragOffset = 0
|
||||
|
||||
-- Restore scrollbar state from StateManager in immediate mode (must happen
|
||||
-- before layout). Mirrors the legacy _initScrollManager restore block.
|
||||
-- Mode-aware via Context.isImmediateMode (behavior-mode-unification task 11).
|
||||
if Element._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then
|
||||
local state = Element._StateManager.getState(element._stateId)
|
||||
if state and state.scrollManager then
|
||||
element._scrollbarHoveredVertical = state.scrollManager._scrollbarHoveredVertical or false
|
||||
element._scrollbarHoveredHorizontal = state.scrollManager._scrollbarHoveredHorizontal or false
|
||||
element._scrollbarDragging = state.scrollManager._scrollbarDragging or false
|
||||
element._hoveredScrollbar = state.scrollManager._hoveredScrollbar
|
||||
element._scrollbarDragOffset = state.scrollManager._scrollbarDragOffset or 0
|
||||
|
||||
-- Apply to ScrollManager immediately.
|
||||
sm._scrollbarHoveredVertical = element._scrollbarHoveredVertical
|
||||
sm._scrollbarHoveredHorizontal = element._scrollbarHoveredHorizontal
|
||||
sm._scrollbarDragging = element._scrollbarDragging
|
||||
sm._hoveredScrollbar = element._hoveredScrollbar
|
||||
sm._scrollbarDragOffset = element._scrollbarDragOffset
|
||||
|
||||
-- Restore drag start positions for relative movement tracking.
|
||||
sm._dragStartMouseX = state.scrollManager._dragStartMouseX or 0
|
||||
sm._dragStartMouseY = state.scrollManager._dragStartMouseY or 0
|
||||
sm._dragStartScrollX = state.scrollManager._dragStartScrollX or 0
|
||||
sm._dragStartScrollY = state.scrollManager._dragStartScrollY or 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- onUpdate — scroll-position momentum + scrollbar hover/drag/press interaction
|
||||
-- (formerly the inline ScrollManager blocks in Element:update).
|
||||
-- Runs BEFORE Clickable.onUpdate in the registry so the scrollbar press flag
|
||||
-- is set before Clickable's EventHandler processes mouse events.
|
||||
-- --------------------------------------------------------------------------
|
||||
|
||||
local function onUpdate(element, dt)
|
||||
local Element = ElementClass(element)
|
||||
local sm = element._scrollManager
|
||||
if not sm then
|
||||
return
|
||||
end
|
||||
-- Restore scrollbar interaction state from StateManager in immediate mode
|
||||
-- (no-op outside immediate mode / when no state is stored).
|
||||
Element._ScrollManager.restoreImmediateState(element)
|
||||
|
||||
-- Smooth-scroll / momentum interpolation.
|
||||
sm:update(dt)
|
||||
element:_syncScrollManagerState()
|
||||
|
||||
-- Scrollbar hover / drag / press interaction. Captures the mouse here so the
|
||||
-- interaction state is consistent across the rest of the frame's behaviors.
|
||||
local mx, my = love.mouse.getPosition()
|
||||
Element._ScrollManager.updateInteraction(element, mx, my)
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- onDraw — scrollbar rendering (post-children overlay). Marked
|
||||
-- `drawLayer = "overlay"` so Element:draw dispatches it AFTER children, so
|
||||
-- scrollbars paint on top of clipped child content and without parent clipping.
|
||||
-- --------------------------------------------------------------------------
|
||||
|
||||
local function onDraw(element, _ctx)
|
||||
local overflowX = element.overflowX or element.overflow
|
||||
local overflowY = element.overflowY or element.overflow
|
||||
if overflowX ~= "scroll" and overflowX ~= "auto" and overflowY ~= "scroll" and overflowY ~= "auto" then
|
||||
return
|
||||
end
|
||||
local scrollbarDims = element:_calculateScrollbarDimensions()
|
||||
if not (scrollbarDims.vertical.visible or scrollbarDims.horizontal.visible) then
|
||||
return
|
||||
end
|
||||
-- Clear any parent scissor clipping before drawing scrollbars so they render
|
||||
-- fully visible (scrollbars must not be clipped by ancestor overflow).
|
||||
love.graphics.setScissor()
|
||||
element._renderer:drawScrollbars(element, element.x, element.y, element.width, element.height, scrollbarDims)
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- saveState / restoreState — ScrollManager state snapshot for immediate-mode
|
||||
-- recreation (formerly the inline blocks in Element:saveState/
|
||||
-- Element:restoreState). Returns a table merged under the `scrollManager` key
|
||||
-- by Element:saveState's behavior loop, mirroring the legacy contract.
|
||||
-- --------------------------------------------------------------------------
|
||||
|
||||
local function saveState(element)
|
||||
local sm = element._scrollManager
|
||||
if not sm then
|
||||
return nil
|
||||
end
|
||||
return { scrollManager = sm:getState() }
|
||||
end
|
||||
|
||||
local function restoreState(element, state)
|
||||
if not state then
|
||||
return
|
||||
end
|
||||
local sm = element._scrollManager
|
||||
local smState = state.scrollManager
|
||||
if sm and smState then
|
||||
sm:setState(smState)
|
||||
end
|
||||
end
|
||||
|
||||
local Scrollable = Behavior.new({
|
||||
onAttach = onAttach,
|
||||
onDetach = function() end,
|
||||
onUpdate = onUpdate,
|
||||
onDraw = onDraw,
|
||||
saveState = saveState,
|
||||
restoreState = restoreState,
|
||||
drawLayer = "overlay",
|
||||
})
|
||||
|
||||
-- Expose the predicate at module level so callers/tests can reference it
|
||||
-- directly without an element instance (mirrors Clickable.shouldAttach /
|
||||
-- Selectable.shouldAttach).
|
||||
Scrollable.shouldAttach = shouldAttach
|
||||
|
||||
return Scrollable
|
||||
@@ -0,0 +1,206 @@
|
||||
-- modules/behaviors/Selectable.lua
|
||||
--
|
||||
-- Concrete behavior: Select state-machine lifecycle for dropdown-style
|
||||
-- select groups. Owns the per-element Select subsystem initialization, the
|
||||
-- managed-frame layout sync each frame, and select save/restore across the
|
||||
-- immediate-mode recreation cycle.
|
||||
--
|
||||
-- This behavior consolidates the legacy `if self._selectState` / `if
|
||||
-- self.selectOption` branches that previously lived inside Element.lua:
|
||||
--
|
||||
-- * Select subsystem init (formerly Element:_initSubSystems lines ~810-825 —
|
||||
-- `Select.initSelectParent` / `Select.initSelectOption`).
|
||||
-- * Managed-frame adoption (formerly Element:_initPositioning lines ~1700-
|
||||
-- 1702 — `Select.adoptSelectFrame`).
|
||||
-- * Per-frame frame-state sync (formerly Element:update line ~2747 —
|
||||
-- `Select.ensureFrameState`).
|
||||
-- * Save/restore of select open/value/label (formerly the `select` branch of
|
||||
-- Element:saveState / Element:restoreState).
|
||||
--
|
||||
-- Element retains `self._selectState` and `self.selectOption` for backward-
|
||||
-- compat field access; runtime state lives ON THE ELEMENT. The behavior itself
|
||||
-- is stateless + immutable (a single shared instance attaches to every
|
||||
-- selectable element).
|
||||
--
|
||||
-- The 20 Element select-API delegate methods (openSelect, closeSelect,
|
||||
-- toggleSelect, isSelectOpen, getSelectValue, setSelectValue, ...) stay as
|
||||
-- 1-line forwarders into the Select module — the behavior owns the
|
||||
-- *lifecycle* (attach / update / save / restore / detach), not the API
|
||||
-- surface (per task 05 spec notes).
|
||||
--
|
||||
-- State ownership (per the locked Behavior contract):
|
||||
-- * Per-element runtime state lives ON THE ELEMENT (self._selectState,
|
||||
-- self.selectOption, self._selectParentElement, ...).
|
||||
-- * The behavior instance is stateless + immutable and shared across elements.
|
||||
-- * Element-class-level dependencies are resolved via `getmetatable(element)`
|
||||
-- (which IS the Element class set by Element._construct), so the hook
|
||||
-- signature stays exactly `(element, ...)` with no DI parameters.
|
||||
|
||||
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||
local Behavior = require(_pkg .. "Behavior")
|
||||
|
||||
-- Resolve the Element class from an element instance.
|
||||
-- `setmetatable({}, Element)` in `_construct` makes the instance metatable BE
|
||||
-- the Element class, so this yields Element._Select, Element._Context,
|
||||
-- Element._StateManager, etc. without threading deps through the hook signature.
|
||||
local function ElementClass(element)
|
||||
return getmetatable(element)
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- shouldAttach (class-level predicate, no element required)
|
||||
-- ============================================================================
|
||||
|
||||
-- Mirrors the cases that previously caused Element to initialize a Select
|
||||
-- subsystem. An element owns select state exactly when it declares a
|
||||
-- `selectParent` config (the dropdown trigger) or a `selectOption` config (an
|
||||
-- option inside a dropdown). Checking the props (rather than the runtime
|
||||
-- `_selectState`) lets shouldAttach run before onAttach initializes the
|
||||
-- subsystem, matching the auto-attach contract established by Clickable /
|
||||
-- TextEditable.
|
||||
local function shouldAttach(props)
|
||||
props = props or {}
|
||||
return type(props.selectParent) == "table" or type(props.selectOption) == "table"
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- onAttach — initialize the Select subsystem (formerly Element:_initSubSystems
|
||||
-- lines ~810-825) and adopt the managed frame (formerly Element:_initPositioning
|
||||
-- lines ~1700-1702).
|
||||
-- ============================================================================
|
||||
|
||||
local function onAttach(element)
|
||||
local Element = ElementClass(element)
|
||||
|
||||
-- Initialize the appropriate select role. Mirrors the legacy _initSubSystems
|
||||
-- block exactly: selectParent → initSelectParent (sets _selectState +
|
||||
-- immediate-mode restore from StateManager); selectOption → initSelectOption
|
||||
-- (sets the option value/label/disabled).
|
||||
if type(element.selectParent) == "table" then
|
||||
Element._Select.initSelectParent(element, element.selectParent)
|
||||
end
|
||||
|
||||
if type(element.selectOption) == "table" then
|
||||
Element._Select.initSelectOption(element, element.selectOption)
|
||||
end
|
||||
|
||||
-- Adopt the managed dropdown frame. This was formerly the tail of
|
||||
-- _initPositioning (after the select parent's own addChild). It creates the
|
||||
-- select anchor, reparents the frame under it, and syncs visibility. Moving
|
||||
-- it here is safe because onAttach runs after _initPositioning: the parent's
|
||||
-- own positioning is finalized, so the anchor's geometry can be computed.
|
||||
if element._selectState and type(element.selectParent) == "table" and element.selectParent.selectFrame ~= nil then
|
||||
Element._Select.adoptSelectFrame(element, element.selectParent.selectFrame)
|
||||
end
|
||||
|
||||
-- Backfill option registration for children added BEFORE this behavior
|
||||
-- attached. The auto-attach pass runs at the very end of Element.new
|
||||
-- (after _finalizeConstruction, which processes declarative `children`).
|
||||
-- Declarative select-option children are addChild'd to this element during
|
||||
-- _finalizeConstruction — at that point _selectState did not yet exist (this
|
||||
-- onAttach had not run), so their registerWithSelectParent call walked the
|
||||
-- parent chain, found no _selectState, and returned early. Re-scan now that
|
||||
-- _selectState is initialized so these options are registered + reparented
|
||||
-- into the managed frame exactly like runtime-added options.
|
||||
-- (registerWithSelectParent is idempotent — it skips options already
|
||||
-- registered — so this is a no-op for children added after _selectState was
|
||||
-- set, e.g. the common `FlexLove.new({ parent = sp, selectOption = {...} })`
|
||||
-- pattern.)
|
||||
if element._selectState then
|
||||
for _, child in ipairs(element.children) do
|
||||
if child.selectOption then
|
||||
Element._Select.registerWithSelectParent(child)
|
||||
Element._Select.attachOptionToManagedFrame(child)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function onDetach(element)
|
||||
-- Clear select-managed fields so the element can be GC'd cleanly in immediate
|
||||
-- mode (formerly part of Element:_cleanup). This mirrors the select-clearing
|
||||
-- block that lived in Element:_cleanup; Element:destroy separately routes
|
||||
-- through Select.cleanupDestroy for full teardown (idempotent with this).
|
||||
if element.selectParent then
|
||||
element.selectParent.onChange = nil
|
||||
end
|
||||
element._selectState = nil
|
||||
element._managedSelectOwner = nil
|
||||
element._managedSelectFrame = nil
|
||||
element._managedSelectAnchor = nil
|
||||
element._managedSelectBaseOpacity = nil
|
||||
element._managedSelectBaseVisibility = nil
|
||||
element._managedSelectBaseDisabled = nil
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- onUpdate — per-frame managed-frame layout sync (formerly Element:update
|
||||
-- line ~2747 — `Select.ensureFrameState`).
|
||||
-- ============================================================================
|
||||
|
||||
local function onUpdate(element, dt)
|
||||
local Element = ElementClass(element)
|
||||
Element._Select.ensureFrameState(element)
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- onDraw — no-op.
|
||||
-- ============================================================================
|
||||
|
||||
-- Select rendering is driven by the managed frame / anchor elements themselves
|
||||
-- (visibility synced by Select.syncManagedFrameVisibility), not by the select
|
||||
-- parent's draw path. The parent's own pixels are the theme/renderer's job.
|
||||
local function onDraw() end
|
||||
|
||||
-- ============================================================================
|
||||
-- saveState / restoreState — select open/value/label (formerly the `select`
|
||||
-- branch of Element:saveState / Element:restoreState).
|
||||
-- ============================================================================
|
||||
|
||||
-- Returns a snapshot under the `select` key to match the legacy immediate-mode
|
||||
-- restoreState contract (Element:restoreState looked up state.select). The
|
||||
-- behavior-dispatch loop merges behavior snapshots into the top-level state
|
||||
-- table, so returning { select = ... } slots in identically to the old inline
|
||||
-- `state.select = selectState` assignment.
|
||||
local function saveState(element)
|
||||
local Element = ElementClass(element)
|
||||
local selectState = Element._Select.saveState(element)
|
||||
if selectState then
|
||||
return { select = selectState }
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Consumes the previously-saved snapshot keyed under `select`. The behavior-
|
||||
-- dispatch loop passes the FULL top-level state table; this hook reads only
|
||||
-- its own `state.select` slice, mirroring the legacy `if state.select then`
|
||||
-- guard in Element:restoreState.
|
||||
local function restoreState(element, state)
|
||||
if not state then
|
||||
return
|
||||
end
|
||||
local Element = ElementClass(element)
|
||||
if state.select then
|
||||
Element._Select.restoreState(element, state.select)
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Build the (stateless, shared, immutable) behavior instance.
|
||||
-- ============================================================================
|
||||
|
||||
local Selectable = Behavior.new({
|
||||
onAttach = onAttach,
|
||||
onDetach = onDetach,
|
||||
onUpdate = onUpdate,
|
||||
onDraw = onDraw,
|
||||
saveState = saveState,
|
||||
restoreState = restoreState,
|
||||
shouldAttach = shouldAttach,
|
||||
})
|
||||
|
||||
-- Expose the predicate at module level so callers/tests can reference it
|
||||
-- directly without an element instance (mirrors Behavior.shouldAttach).
|
||||
Selectable.shouldAttach = shouldAttach
|
||||
|
||||
return Selectable
|
||||
@@ -0,0 +1,576 @@
|
||||
-- modules/behaviors/TextEditable.lua
|
||||
--
|
||||
-- Concrete behavior: TextEditor subsystem ownership — text editing, cursor
|
||||
-- management, text selection, text-related input handling, and text-editor
|
||||
-- state save/restore.
|
||||
--
|
||||
-- This behavior consolidates the legacy `if self._textEditor` nil-guard
|
||||
-- patterns that previously lived inside Element.lua:
|
||||
--
|
||||
-- * TextEditor creation + immediate-mode state restore (formerly
|
||||
-- Element:_initSubSystems lines ~813-830 — the `if self.editable then
|
||||
-- self._textEditor = Element._TextEditor.new {...}` block).
|
||||
-- * Cursor-blink update (formerly Element:update line ~2810 —
|
||||
-- `if self._textEditor then self._textEditor:update(self, dt) end`).
|
||||
-- * The 27 text-editor delegate methods (formerly Element:setText /
|
||||
-- getText / setCursorPosition / setSelection / focus / textinput /
|
||||
-- keypressed / _handleTextClick / _handleTextDrag / ...). Each was a 3-line
|
||||
-- nil-guard stub (check `_textEditor`, forward call, end). They are now
|
||||
-- module-level functions on this behavior; Element retains only 1-line
|
||||
-- forwarders that route through `Element._TextEditable.<fn>(self, ...)`.
|
||||
-- * Text-editor state save/restore (formerly the textEditor branch of
|
||||
-- Element:saveState / Element:restoreState), including the cursor/selection
|
||||
-- field sync and the text-selection drag-tracking fields
|
||||
-- (`_mouseDownPosition` / `_textDragOccurred`).
|
||||
--
|
||||
-- Element retains the `self._textEditor` field for backward-compat field
|
||||
-- access (Renderer:drawText reads it directly for cursor/selection rendering);
|
||||
-- runtime state lives ON THE ELEMENT. The behavior itself is stateless +
|
||||
-- immutable + shared across elements.
|
||||
--
|
||||
-- State ownership (per the locked Behavior contract):
|
||||
-- * Per-element runtime state lives ON THE ELEMENT (`self._textEditor`,
|
||||
-- `self._mouseDownPosition`, `self._textDragOccurred`). The behavior
|
||||
-- instance is stateless + immutable and shared across all editable
|
||||
-- elements.
|
||||
-- * Element-class-level dependencies are resolved via `getmetatable(element)`
|
||||
-- (which IS the Element class set by Element._construct), so the hook
|
||||
-- signature stays exactly `(element, ...)` with no DI parameters.
|
||||
--
|
||||
-- onDraw is a no-op: text/cursor/selection rendering stays in the Renderer's
|
||||
-- command buffer (Layer 4 "text"), driven by the Thamed behavior's single
|
||||
-- `Renderer:draw` call. The Renderer's `drawText` already reads
|
||||
-- `element._textEditor` for cursor/selection, so TextEditable OWNS the
|
||||
-- subsystem that drawText consumes, but the draw dispatch stays in the
|
||||
-- renderer to preserve the unified transform/scissor command-buffer ordering
|
||||
-- (mirrors Selectable.onDraw's no-op precedent, where rendering is owned by a
|
||||
-- different layer). Hoisting drawText into this behavior's onDraw would
|
||||
-- double-render text, since the Renderer command buffer already emits a "text"
|
||||
-- layer for every element.
|
||||
|
||||
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||
local Behavior = require(_pkg .. "Behavior")
|
||||
|
||||
-- Resolve the Element class from an element instance (mirrors Clickable /
|
||||
-- Selectable). `setmetatable({}, Element)` in `_construct` makes the instance
|
||||
-- metatable BE the Element class, so this yields Element._TextEditor,
|
||||
-- Element._textEditorDeps, Element._Context, Element._StateManager, etc.
|
||||
-- without threading deps through the hook signature.
|
||||
local function ElementClass(element)
|
||||
return getmetatable(element)
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- shouldAttach (class-level predicate, no element required)
|
||||
-- ============================================================================
|
||||
|
||||
-- Mirrors the spec predicate: attach when the element is text-editable OR
|
||||
-- carries text content. onAttach only ALLOCATES a TextEditor when
|
||||
-- `element.editable` is true (preserving the pre-refactor creation invariant
|
||||
-- "TextEditor created iff editable"), so non-editable text labels attach the
|
||||
-- behavior but allocate no TextEditor — their onUpdate/onDraw/saveState are
|
||||
-- nil-guarded no-ops, and the Element forwarders route them through the
|
||||
-- non-editable branch of each delegate function (reads/writes `element.text`
|
||||
-- directly). This keeps shouldAttach faithful to the spec while preserving
|
||||
-- exact pre-refactor allocation behavior.
|
||||
local function shouldAttach(props)
|
||||
props = props or {}
|
||||
return props.editable == true or props.text ~= nil
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- onAttach — create the TextEditor (formerly Element:_initSubSystems lines
|
||||
-- ~813-830) and restore immediate-mode TextEditor state.
|
||||
-- ============================================================================
|
||||
|
||||
local function onAttach(element)
|
||||
local Element = ElementClass(element)
|
||||
|
||||
-- Only editable elements own a TextEditor. Preserves the exact pre-refactor
|
||||
-- creation guard (`if self.editable then ... end`) — non-editable text
|
||||
-- elements attach the behavior (so their forwarders route through a single
|
||||
-- code path) but allocate no TextEditor.
|
||||
if not element.editable then
|
||||
return
|
||||
end
|
||||
|
||||
-- Config is sourced from element fields (bound by _applyProps / _initVisualState
|
||||
-- before _attachBehaviors runs at the tail of Element.new) — NOT from raw
|
||||
-- props. The callbacks (onFocus/onBlur/onTextInput/onTextChange/onEnter) are
|
||||
-- schema-bound element fields by this point, and `element.text` is set by
|
||||
-- _initVisualState, so no `props` reference is needed here (the hook
|
||||
-- signature is `(element)`).
|
||||
element._textEditor = Element._TextEditor.new({
|
||||
editable = element.editable,
|
||||
multiline = element.multiline,
|
||||
passwordMode = element.passwordMode,
|
||||
textWrap = element.textWrap,
|
||||
maxLines = element.maxLines,
|
||||
maxLength = element.maxLength,
|
||||
placeholder = element.placeholder,
|
||||
inputType = element.inputType,
|
||||
textOverflow = element.textOverflow,
|
||||
scrollable = element.scrollable,
|
||||
autoGrow = element.autoGrow,
|
||||
selectOnFocus = element.selectOnFocus,
|
||||
cursorColor = element.cursorColor,
|
||||
selectionColor = element.selectionColor,
|
||||
cursorBlinkRate = element.cursorBlinkRate,
|
||||
text = element.text or "",
|
||||
onFocus = element.onFocus,
|
||||
onBlur = element.onBlur,
|
||||
onTextInput = element.onTextInput,
|
||||
onTextChange = element.onTextChange,
|
||||
onEnter = element.onEnter,
|
||||
}, Element._textEditorDeps)
|
||||
|
||||
-- Restore TextEditor state from StateManager in immediate mode. Mirrors the
|
||||
-- legacy _initSubSystems immediate-mode restore. Safe to run here (after
|
||||
-- _construct registered the element with StateManager) — the StateManager
|
||||
-- lookup is sparse and returns nil for a fresh element. Mode-aware via
|
||||
-- Context.isImmediateMode (behavior-mode-unification task 11).
|
||||
if Element._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then
|
||||
local state = Element._StateManager.getState(element._stateId)
|
||||
if state and state.textEditor then
|
||||
element._textEditor:setState(state.textEditor, element)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function onDetach(element)
|
||||
-- Clear text-input callback closures read by TextEditor / KeyboardNavigation
|
||||
-- so the element's closure references can be collected in immediate mode
|
||||
-- (formerly part of Element:_cleanup). The TextEditor instance itself is
|
||||
-- INTENTIONALLY kept: Element:_cleanup preserves element structure for
|
||||
-- inspection (released when the element is GC'd).
|
||||
element.onTextInput = nil
|
||||
element.onTextChange = nil
|
||||
element.onEnter = nil
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- onUpdate — cursor-blink animation (formerly Element:update line ~2810).
|
||||
-- ============================================================================
|
||||
|
||||
-- Drives TextEditor:update (cursor blink + blink-pause timer). Guarded on
|
||||
-- `element._textEditor` because non-editable text elements attach this
|
||||
-- behavior (per shouldAttach) but own no TextEditor. Element:update contains
|
||||
-- zero text-editor references — the dispatch loop calls this hook.
|
||||
local function onUpdate(element, dt)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:update(element, dt)
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- onDraw — no-op (see file header: text rendering stays in the Renderer
|
||||
-- command buffer driven by the Thamed behavior's Renderer:draw call).
|
||||
-- ============================================================================
|
||||
|
||||
local function onDraw() end
|
||||
|
||||
-- ============================================================================
|
||||
-- saveState / restoreState — TextEditor state + text-selection drag
|
||||
-- tracking (formerly the textEditor branch of Element:saveState /
|
||||
-- Element:restoreState, including the _mouseDownPosition / _textDragOccurred
|
||||
-- fields).
|
||||
-- ============================================================================
|
||||
|
||||
-- Returns a snapshot under the `textEditor` key to match the legacy immediate-
|
||||
-- mode restoreState contract (Element:restoreState looked up state.textEditor).
|
||||
-- The behavior-dispatch loop in Element:saveState merges behavior snapshots
|
||||
-- into the top-level state table, so returning { textEditor = ... } slots in
|
||||
-- identically to the old inline `state.textEditor = self._textEditor:getState()`
|
||||
-- assignment. The drag-tracking fields are merged at the top level too
|
||||
-- (matching the legacy `state._mouseDownPosition` / `state._textDragOccurred`
|
||||
-- assignments) since they are text-selection state.
|
||||
local function saveState(element)
|
||||
local textEditor = element._textEditor
|
||||
if not textEditor then
|
||||
-- Non-editable text element: still persist drag-tracking fields if set
|
||||
-- (they are only ever set for editable elements, but persist defensively).
|
||||
local hasDragState = element._mouseDownPosition ~= nil or element._textDragOccurred ~= nil
|
||||
if not hasDragState then
|
||||
return nil
|
||||
end
|
||||
local snapshot = {}
|
||||
if element._mouseDownPosition ~= nil then
|
||||
snapshot._mouseDownPosition = element._mouseDownPosition
|
||||
end
|
||||
if element._textDragOccurred ~= nil then
|
||||
snapshot._textDragOccurred = element._textDragOccurred
|
||||
end
|
||||
return snapshot
|
||||
end
|
||||
|
||||
local snapshot = { textEditor = textEditor:getState() }
|
||||
if element._mouseDownPosition ~= nil then
|
||||
snapshot._mouseDownPosition = element._mouseDownPosition
|
||||
end
|
||||
if element._textDragOccurred ~= nil then
|
||||
snapshot._textDragOccurred = element._textDragOccurred
|
||||
end
|
||||
return snapshot
|
||||
end
|
||||
|
||||
-- Consumes the previously-saved snapshot keyed under `textEditor` plus the
|
||||
-- drag-tracking fields. The behavior-dispatch loop passes the FULL top-level
|
||||
-- state table; this hook reads only its own slices, mirroring the legacy
|
||||
-- `if self._textEditor and state.textEditor then ... end` guard.
|
||||
local function restoreState(element, state)
|
||||
if not state then
|
||||
return
|
||||
end
|
||||
local textEditor = element._textEditor
|
||||
if textEditor and state.textEditor then
|
||||
textEditor:setState(state.textEditor, element)
|
||||
-- Sync TextEditor's focus/cursor/selection state to Element for theme
|
||||
-- management (mirrors the legacy restoreState field sync).
|
||||
element._focused = textEditor._focused
|
||||
element._cursorPosition = textEditor._cursorPosition
|
||||
element._selectionStart = textEditor._selectionStart
|
||||
element._selectionEnd = textEditor._selectionEnd
|
||||
element._textBuffer = textEditor._textBuffer
|
||||
end
|
||||
|
||||
-- Restore drag-tracking state for text selection (top-level keys).
|
||||
if state._mouseDownPosition ~= nil then
|
||||
element._mouseDownPosition = state._mouseDownPosition
|
||||
end
|
||||
if state._textDragOccurred ~= nil then
|
||||
element._textDragOccurred = state._textDragOccurred
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Text-editor delegate functions.
|
||||
--
|
||||
-- These are the module-level implementations of the 27 text-editor delegate
|
||||
-- methods that previously lived on Element. Each mirrors the pre-refactor
|
||||
-- Element method body VERBATIM (with `self` → `element`), including the
|
||||
-- `element._textEditor` nil-guard: the guard is required because (a) non-
|
||||
-- editable text elements attach this behavior (per shouldAttach) but own no
|
||||
-- TextEditor, and (b) Element forwards these methods BEFORE onAttach has run
|
||||
-- (e.g. an `onCreate` callback firing during _finalizeConstruction, which
|
||||
-- runs before _attachBehaviors). The nil-guards live in THIS file (not in
|
||||
-- Element.lua), so the Element.lua `if self._textEditor` count drops to 0.
|
||||
--
|
||||
-- Element retains 1-line forwarders: `Element.setText = function(self, text)
|
||||
-- return Element._TextEditable.setText(self, text) end` (etc.), so external
|
||||
-- callers (EventHandler, KeyboardNavigation, game UI) keep working unchanged.
|
||||
--
|
||||
-- The TextEditor API is mixed: most methods take the element as first arg
|
||||
-- (`te:method(element, ...)` — "passesSelf"); a few getters omit it
|
||||
-- (`te:method()`). The delegation contract is pinned by
|
||||
-- subsystem_delegation_test.lua, so this mapping must match TextEditor's
|
||||
-- method signatures exactly.
|
||||
-- ============================================================================
|
||||
|
||||
-- --- Cursor management (passesSelf = element forwarded) ------------------
|
||||
|
||||
local function setCursorPosition(element, position)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:setCursorPosition(element, position)
|
||||
end
|
||||
end
|
||||
|
||||
local function getCursorPosition(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
return textEditor:getCursorPosition()
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
local function moveCursorBy(element, delta)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:moveCursorBy(element, delta)
|
||||
end
|
||||
end
|
||||
|
||||
local function moveCursorToStart(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:moveCursorToStart(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function moveCursorToEnd(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:moveCursorToEnd(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function moveCursorToLineStart(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:moveCursorToLineStart(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function moveCursorToLineEnd(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:moveCursorToLineEnd(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function moveCursorToPreviousWord(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:moveCursorToPreviousWord(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function moveCursorToNextWord(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:moveCursorToNextWord(element)
|
||||
end
|
||||
end
|
||||
|
||||
-- --- Selection management ------------------------------------------------
|
||||
|
||||
local function setSelection(element, startPos, endPos)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:setSelection(element, startPos, endPos)
|
||||
end
|
||||
end
|
||||
|
||||
local function getSelection(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
return textEditor:getSelection()
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function hasSelection(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor ~= nil then
|
||||
return textEditor:hasSelection()
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function clearSelection(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:clearSelection(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function selectAll(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:selectAll(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function getSelectedText(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
return textEditor:getSelectedText()
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function deleteSelection(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
return textEditor:deleteSelection(element)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- --- Focus management ----------------------------------------------------
|
||||
|
||||
local function focus(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:focus(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function blur(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:blur(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function isFocused(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
return textEditor:isFocused()
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- --- Text buffer management (with post-delegation sync) ------------------
|
||||
-- These methods sync `element.text` from the TextEditor result + drive
|
||||
-- auto-grow, exactly as the legacy Element methods did.
|
||||
|
||||
local function getText(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
return textEditor:getText()
|
||||
end
|
||||
return element.text or ""
|
||||
end
|
||||
|
||||
local function setText(element, text)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:setText(element, text)
|
||||
element.text = textEditor:getText() -- Sync display text
|
||||
textEditor:updateAutoGrowHeight(element)
|
||||
return
|
||||
end
|
||||
element.text = text
|
||||
end
|
||||
|
||||
local function insertText(element, text, position)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:insertText(element, text, position)
|
||||
element.text = textEditor:getText() -- Sync display text
|
||||
textEditor:updateAutoGrowHeight(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function deleteText(element, startPos, endPos)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:deleteText(element, startPos, endPos)
|
||||
element.text = textEditor:getText() -- Sync display text
|
||||
textEditor:updateAutoGrowHeight(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function replaceText(element, startPos, endPos, newText)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:replaceText(element, startPos, endPos, newText)
|
||||
element.text = textEditor:getText() -- Sync display text
|
||||
textEditor:updateAutoGrowHeight(element)
|
||||
end
|
||||
end
|
||||
|
||||
-- --- Mouse text selection ------------------------------------------------
|
||||
|
||||
local function handleTextClick(element, mouseX, mouseY, clickCount)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:handleTextClick(element, mouseX, mouseY, clickCount)
|
||||
-- Store mouse down position on element for drag tracking
|
||||
if clickCount == 1 then
|
||||
element._mouseDownPosition = textEditor:mouseToTextPosition(element, mouseX, mouseY)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function handleTextDrag(element, mouseX, mouseY)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:handleTextDrag(element, mouseX, mouseY)
|
||||
element._textDragOccurred = textEditor._textDragOccurred
|
||||
end
|
||||
end
|
||||
|
||||
-- --- Keyboard input ------------------------------------------------------
|
||||
|
||||
local function textinput(element, text)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:handleTextInput(element, text)
|
||||
element.text = textEditor:getText() -- Sync display text
|
||||
textEditor:updateAutoGrowHeight(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function keypressed(element, key, scancode, isrepeat)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:handleKeyPress(element, key, scancode, isrepeat)
|
||||
element.text = textEditor:getText() -- Sync display text
|
||||
textEditor:updateAutoGrowHeight(element)
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Build the (stateless, shared, immutable) behavior instance + thin module
|
||||
-- table exposing the delegate functions (mirrors the Animated pattern).
|
||||
-- ============================================================================
|
||||
|
||||
local behavior = Behavior.new({
|
||||
onAttach = onAttach,
|
||||
onDetach = onDetach,
|
||||
onUpdate = onUpdate,
|
||||
onDraw = onDraw,
|
||||
saveState = saveState,
|
||||
restoreState = restoreState,
|
||||
shouldAttach = shouldAttach,
|
||||
})
|
||||
|
||||
-- Thin module table: exposes the frozen behavior instance (for the registry)
|
||||
-- plus the text-editor delegate functions (for Element's 1-line forwarders).
|
||||
-- All hooks delegate to the frozen behavior instance so dispatch sites get
|
||||
-- the validated, frozen implementation. shouldAttach is also exposed at module
|
||||
-- level (mirrors Clickable.shouldAttach) for tests/callers without an element.
|
||||
local TextEditable = {
|
||||
behavior = behavior,
|
||||
shouldAttach = shouldAttach,
|
||||
onAttach = onAttach,
|
||||
onUpdate = onUpdate,
|
||||
onDraw = onDraw,
|
||||
saveState = saveState,
|
||||
restoreState = restoreState,
|
||||
-- Text-editor delegate functions (Element forwarders route through these):
|
||||
setCursorPosition = setCursorPosition,
|
||||
getCursorPosition = getCursorPosition,
|
||||
moveCursorBy = moveCursorBy,
|
||||
moveCursorToStart = moveCursorToStart,
|
||||
moveCursorToEnd = moveCursorToEnd,
|
||||
moveCursorToLineStart = moveCursorToLineStart,
|
||||
moveCursorToLineEnd = moveCursorToLineEnd,
|
||||
moveCursorToPreviousWord = moveCursorToPreviousWord,
|
||||
moveCursorToNextWord = moveCursorToNextWord,
|
||||
setSelection = setSelection,
|
||||
getSelection = getSelection,
|
||||
hasSelection = hasSelection,
|
||||
clearSelection = clearSelection,
|
||||
selectAll = selectAll,
|
||||
getSelectedText = getSelectedText,
|
||||
deleteSelection = deleteSelection,
|
||||
focus = focus,
|
||||
blur = blur,
|
||||
isFocused = isFocused,
|
||||
getText = getText,
|
||||
setText = setText,
|
||||
insertText = insertText,
|
||||
deleteText = deleteText,
|
||||
replaceText = replaceText,
|
||||
_handleTextClick = handleTextClick,
|
||||
_handleTextDrag = handleTextDrag,
|
||||
textinput = textinput,
|
||||
keypressed = keypressed,
|
||||
}
|
||||
|
||||
-- Metatable so the module table itself satisfies the duck-typed registry
|
||||
-- contract (iterating `Element._behaviorRegistry` calls `behavior.shouldAttach`
|
||||
-- and `behavior.onAttach` / `behavior.onUpdate` directly). Falls through to the
|
||||
-- frozen behavior instance for every hook / isBehavior parity.
|
||||
setmetatable(TextEditable, {
|
||||
__index = behavior,
|
||||
__tostring = function()
|
||||
return "TextEditable"
|
||||
end,
|
||||
})
|
||||
|
||||
return TextEditable
|
||||
@@ -0,0 +1,178 @@
|
||||
-- modules/behaviors/Themed.lua
|
||||
--
|
||||
-- Concrete behavior: Renderer ownership + theme-state rendering.
|
||||
--
|
||||
-- Themed owns the per-element Renderer instance and the single
|
||||
-- `Renderer:draw` call that paints the core visual layers (background, image,
|
||||
-- theme 9-patch, borders, text, customDraw). It is the behavior-mode-unification
|
||||
-- replacement for the former `_initImageAndRenderer` Renderer creation block and
|
||||
-- the former first `self._renderer:draw(self, backdropCanvas)` call in
|
||||
-- Element:draw (behavior-mode-unification task 07).
|
||||
--
|
||||
-- Attachment rule (shouldAttach): every renderable Element. The pre-refactor
|
||||
-- code unconditionally created a Renderer for every Element and unconditionally
|
||||
-- called `Renderer:draw` in Element:draw; Themed mirrors that invariant so the
|
||||
-- Renderer is always available to subsystems that depend on it (TextEditor font
|
||||
-- / wrap delegation, ScrollManager scrollbar drawing) AND so visual rendering of
|
||||
-- background / border / theme / image layers is preserved for every element.
|
||||
-- Restricting attachment to `themeComponent`-only elements would break editable
|
||||
-- text fields and scrollable containers (which need a Renderer for subsystem
|
||||
-- delegation even when they have no theme component). The 9-patch theme-state
|
||||
-- rendering within `Renderer:draw` is a no-op for elements without a
|
||||
-- `themeComponent`, so always-attaching carries no rendering cost.
|
||||
--
|
||||
-- Themed and Imageable are paired (both configure the same `element._renderer`):
|
||||
-- Themed.onAttach creates the Renderer with the theme/blur config; Imageable
|
||||
-- (attached for imagePath/image elements) enriches the SAME renderer instance with
|
||||
-- image config + deferred image loading. They share `element._renderer`.
|
||||
--
|
||||
-- onUpdate is a no-op: theme-state transitions are DRIVEN by the Clickable
|
||||
-- behavior (whose onUpdate recomputes hover/press/focus and calls
|
||||
-- `renderer:setThemeState`). Themed only READS that state for rendering, so it has
|
||||
-- no per-frame update work.
|
||||
--
|
||||
-- saveState owns the blur-region snapshot (`state.blur`): the per-frame blur
|
||||
-- geometry + radius/quality used by the Blur cache for invalidation (formerly
|
||||
-- the inline `if self.backdropBlur or self.contentBlur` block of
|
||||
-- Element:saveState — behavior-mode-unification task 12). restoreState is a
|
||||
-- no-op: blur cache data is used for invalidation, not restoration (the Blur
|
||||
-- cache is keyed by element id and cleared via `Blur.clearElementCache` from
|
||||
-- FlexLove.endFrame, not replayed through restoreState).
|
||||
--
|
||||
-- State ownership (per the locked Behavior contract):
|
||||
-- * Per-element runtime state lives ON THE ELEMENT (`element._renderer`,
|
||||
-- `element._themeState`, `element.backdropBlur`, `element.contentBlur`).
|
||||
-- The behavior instance is stateless and shared.
|
||||
-- * `element._renderer` is recreated on attach; onDetach is a no-op — the
|
||||
-- reference is released when the element is GC'd (Element:_cleanup keeps
|
||||
-- element structure for inspection).
|
||||
|
||||
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||
local Behavior = require(_pkg .. "Behavior")
|
||||
|
||||
-- Resolve the Element class from an element instance (mirrors Clickable).
|
||||
-- Element instances are created via `setmetatable({}, Element)`, so their
|
||||
-- metatable IS the Element class — giving access to Element._Renderer,
|
||||
-- Element._rendererDeps, etc. without threading deps through the hook signature.
|
||||
local function ElementClass(element)
|
||||
return getmetatable(element)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- shouldAttach (class-level predicate, no element required)
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- Returns true for every renderable Element. See file header for the rationale:
|
||||
-- the pre-refactor invariant was "every Element has a Renderer; Element:draw
|
||||
-- always calls Renderer:draw", and Thamed is the behavior-system embodiment of
|
||||
-- that invariant. Returns true for `themeComponent`-bearing props (the spec's
|
||||
-- headline case) and for every other element so subsystems/rendering stay intact.
|
||||
local function shouldAttach(props)
|
||||
return true
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onAttach — create the Renderer with theme/blur config (formerly the
|
||||
-- Renderer.new block of Element:_initImageAndRenderer).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function onAttach(element)
|
||||
local Element = ElementClass(element)
|
||||
|
||||
-- Create-or-reuse the Renderer. Thamed is the first render behavior in the
|
||||
-- registry, so it normally creates the instance; Imageable (if attached) will
|
||||
-- reuse this same instance for image config. Guarded so Imageable-onAttach-
|
||||
-- first (defensive) does not clobber an existing renderer.
|
||||
if element._renderer then
|
||||
return
|
||||
end
|
||||
|
||||
-- NOTE: backgroundColor/borderColor/opacity/cornerRadius/themeComponent are
|
||||
-- intentionally NOT passed here. Renderer:draw() reads them from the element
|
||||
-- as the single source of truth (see Renderer.lua draw()). Only renderer-owned
|
||||
-- state (theme, blur) is cached on the renderer; image config is added by the
|
||||
-- Imageable behavior. border is element-sourced too.
|
||||
element._renderer = Element._Renderer.new({
|
||||
theme = element.theme,
|
||||
scaleCorners = element.scaleCorners,
|
||||
scalingAlgorithm = element.scalingAlgorithm,
|
||||
contentBlur = element.contentBlur,
|
||||
backdropBlur = element.backdropBlur,
|
||||
}, Element._rendererDeps)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onDraw — the single Renderer:draw call (formerly the first call in
|
||||
-- Element:draw). Paints all core visual layers for this element.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function onDraw(element, ctx)
|
||||
local renderer = element._renderer
|
||||
if not renderer then
|
||||
return
|
||||
end
|
||||
renderer:draw(element, ctx and ctx.backdropCanvas)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onDetach — no-op. Element:_cleanup preserves element structure for
|
||||
-- inspection (the original invariant), so the Renderer reference is released
|
||||
-- when the element is GC'd rather than torn down here. Present as an explicit
|
||||
-- hook so the behavior conforms to the full lifecycle contract.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function onDetach() end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- saveState — blur-region snapshot (formerly the `blur` branch of
|
||||
-- Element:saveState). Returns `{ blur = {...} }` when the element configures a
|
||||
-- backdrop or content blur, so the Blur cache can invalidate by element id;
|
||||
-- nil otherwise. Mode-agnostic to match the legacy contract (the snapshot is
|
||||
-- only read back by the cache-invalidation path, which itself is
|
||||
-- immediate-mode-only via FlexLove.endFrame).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function saveState(element)
|
||||
if not (element.backdropBlur or element.contentBlur) then
|
||||
return nil
|
||||
end
|
||||
local blur = {
|
||||
_blurX = element.x,
|
||||
_blurY = element.y,
|
||||
_blurWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right),
|
||||
_blurHeight = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom),
|
||||
}
|
||||
if element.backdropBlur then
|
||||
blur._backdropBlurRadius = element.backdropBlur.radius
|
||||
blur._backdropBlurQuality = element.backdropBlur.quality or 5
|
||||
end
|
||||
if element.contentBlur then
|
||||
blur._contentBlurRadius = element.contentBlur.radius
|
||||
blur._contentBlurQuality = element.contentBlur.quality or 5
|
||||
end
|
||||
return { blur = blur }
|
||||
end
|
||||
|
||||
-- restoreState — no-op: blur cache data is used for invalidation, not
|
||||
-- restoration (see file header). Present so the behavior conforms to the
|
||||
-- lifecycle contract without replaying geometry that the cache recomputes.
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Build the (stateless, shared, immutable) behavior instance.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local Themed = Behavior.new({
|
||||
onAttach = onAttach,
|
||||
onDetach = onDetach,
|
||||
onUpdate = function() end,
|
||||
onDraw = onDraw,
|
||||
saveState = saveState,
|
||||
restoreState = function() end,
|
||||
})
|
||||
|
||||
-- Expose the predicate at module level so callers/tests can reference it
|
||||
-- directly without an element instance (mirrors Behavior.shouldAttach /
|
||||
-- Clickable.shouldAttach).
|
||||
Themed.shouldAttach = shouldAttach
|
||||
|
||||
return Themed
|
||||
@@ -0,0 +1,662 @@
|
||||
---@class SelectOptionProps
|
||||
---@field value any -- Stable option value owned by the parent select
|
||||
---@field label string? -- Optional label override, falls back to the element text
|
||||
---@field disabled boolean? -- Whether the option can be selected
|
||||
local SelectOptionProps = {}
|
||||
|
||||
---@class SelectParentProps
|
||||
---@field value any -- Currently selected option value
|
||||
---@field open boolean? -- Initial open state for the select container
|
||||
---@field placeholder string? -- Fallback text when no option is selected
|
||||
---@field selectFrame Element? -- Optional pre-instantiated dropdown container; intended to be unattached before being adopted by the select
|
||||
---@field onChange fun(element:Element, value:any, option:SelectOptionProps)? -- Called when selection changes
|
||||
local SelectParentProps = {}
|
||||
|
||||
---@class Animation
|
||||
local Animation = {}
|
||||
|
||||
---@class Color
|
||||
local Color = {}
|
||||
|
||||
---@class Theme
|
||||
local Theme = {}
|
||||
|
||||
---@class ThemeManager
|
||||
local ThemeManager = {}
|
||||
|
||||
--=====================================--
|
||||
-- For Animation.lua
|
||||
--=====================================--
|
||||
---@alias EasingFunction fun(t:number): number
|
||||
|
||||
---@class AnimationProps
|
||||
---@field duration number -- Duration in seconds
|
||||
---@field start table -- Starting values (can contain: width, height, opacity, x, y, gap, imageOpacity, backgroundColor, borderColor, textColor, padding, margin, cornerRadius, transform, etc.)
|
||||
---@field final table -- Final values (same properties as start)
|
||||
---@field easing string? -- Easing function name: "linear", "easeInQuad", "easeOutQuad", "easeInOutQuad", "easeInCubic", "easeOutCubic", "easeInOutCubic", "easeInQuart", "easeOutQuart", "easeInExpo", "easeOutExpo" (default: "linear")
|
||||
---@field keyframes AnimationKeyframe[]? -- Array of keyframes for complex animations
|
||||
---@field onStart fun(animation:Animation, element:Element?)? -- Called when animation starts
|
||||
---@field onUpdate fun(animation:Animation, element:Element?, progress:number)? -- Called each frame with progress (0-1)
|
||||
---@field onComplete fun(animation:Animation, element:Element?)? -- Called when animation completes
|
||||
---@field onCancel fun(animation:Animation, element:Element?)? -- Called when animation is cancelled
|
||||
---@field transform TransformProps? -- Additional transform properties (legacy support)
|
||||
---@field transition table? -- Transition properties (legacy support)
|
||||
local AnimationProps = {}
|
||||
|
||||
---@class Transform
|
||||
---@field rotate number? Rotation in radians (default: 0)
|
||||
---@field scaleX number? X-axis scale (default: 1)
|
||||
---@field scaleY number? Y-axis scale (default: 1)
|
||||
---@field translateX number? X translation in pixels (default: 0)
|
||||
---@field translateY number? Y translation in pixels (default: 0)
|
||||
---@field skewX number? X-axis skew in radians (default: 0)
|
||||
---@field skewY number? Y-axis skew in radians (default: 0)
|
||||
---@field originX number? Transform origin X (0-1, default: 0.5)
|
||||
---@field originY number? Transform origin Y (0-1, default: 0.5)
|
||||
local Transform = {}
|
||||
|
||||
---@alias TransformProps Transform
|
||||
|
||||
---@class TransitionProps
|
||||
---@field duration number?
|
||||
---@field easing string?
|
||||
---@field delay number?
|
||||
---@field onComplete fun(element:Element)?
|
||||
|
||||
--=====================================--
|
||||
-- For Element.lua
|
||||
--=====================================--
|
||||
---@class ElementProps
|
||||
---@field id string? -- Unique identifier for the element (auto-generated in immediate mode if not provided)
|
||||
---@field mode "immediate"|"retained"|nil -- Lifecycle mode override: "immediate" (auto-managed state), "retained" (manual state), nil (use global mode from FlexLove.getMode(), default)
|
||||
---@field parent Element? -- Parent element for hierarchical structure
|
||||
---@field x number|string|CalcObject? -- X coordinate: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: 0)
|
||||
---@field y number|string|CalcObject? -- Y coordinate: number (px), string ("50%", "10vh"), or CalcObject from FlexLove.calc() (default: 0)
|
||||
---@field z number? -- Z-index for layering (default: 0, clamped to -999..999)
|
||||
---@field tabIndex number? -- Tab navigation order: >0 (explicit order, visited first), 0 or nil (natural document order), -1 (excluded from keyboard navigation)
|
||||
---@field width number|string|CalcObject? -- Width of the element: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: calculated automatically)
|
||||
---@field height number|string|CalcObject? -- Height of the element: number (px), string ("50%", "10vh"), or CalcObject from FlexLove.calc() (default: calculated automatically)
|
||||
---@field minWidth number|string|CalcObject? -- Minimum width constraint: number (px), string ("50%", "10vw"), or CalcObject. Clamps both fixed `width` and the flex-distributed main size when horizontal.
|
||||
---@field maxWidth number|string|CalcObject? -- Maximum width constraint: number (px), string ("50%", "10vw"), or CalcObject. Clamps both fixed `width` and the flex-distributed main size when horizontal.
|
||||
---@field minHeight number|string|CalcObject? -- Minimum height constraint: number (px), string ("50%", "10vh"), or CalcObject. Clamps both fixed `height` and the flex-distributed main size when vertical.
|
||||
---@field maxHeight number|string|CalcObject? -- Maximum height constraint: number (px), string ("50%", "10vh"), or CalcObject. Clamps both fixed `height` and the flex-distributed main size when vertical.
|
||||
---@field top number|string|CalcObject? -- Offset from top edge: number (px), string ("50%", "10vh"), or CalcObject (CSS-style positioning)
|
||||
---@field right number|string|CalcObject? -- Offset from right edge: number (px), string ("50%", "10vw"), or CalcObject (CSS-style positioning)
|
||||
---@field bottom number|string|CalcObject? -- Offset from bottom edge: number (px), string ("50%", "10vh"), or CalcObject (CSS-style positioning)
|
||||
---@field left number|string|CalcObject? -- Offset from left edge: number (px), string ("50%", "10vw"), or CalcObject (CSS-style positioning)
|
||||
---@field border Border? -- Border configuration for the element
|
||||
---@field borderColor Color? -- Color of the border (default: black)
|
||||
---@field opacity number? -- Element opacity 0-1 (default: 1)
|
||||
---@field visibility "visible"|"hidden"? -- Element visibility (default: "visible")
|
||||
---@field display boolean? -- Whether element participates in layout, rendering, and hit testing (default: true). Set false for CSS display:none behavior (zero layout space, no rendering, no hit testing). NOTE: In retained mode, toggling at runtime requires setting the parent's `_dirty = true` or calling `layoutChildren()` on the parent to trigger re-layout.
|
||||
---@field backgroundColor Color? -- Background color (default: transparent)
|
||||
---@field cornerRadius number|{topLeft:number?, topRight:number?, bottomLeft:number?, bottomRight:number?}? -- Corner radius: number (all corners) or table for individual corners (default: 0)
|
||||
---@field gap number|string|CalcObject? -- Space between children elements: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: 0)
|
||||
---@field padding number|string|CalcObject|{top:number|string|CalcObject?, right:number|string|CalcObject?, bottom:number|string|CalcObject?, left:number|string|CalcObject?, horizontal:number|string|CalcObject?, vertical:number|string|CalcObject?}? -- Padding around children: single value, string, CalcObject for all sides, or table for individual sides (default: {top=0, right=0, bottom=0, left=0})
|
||||
---@field margin number|string|CalcObject|{top:number|string|CalcObject?, right:number|string|CalcObject?, bottom:number|string|CalcObject?, left:number|string|CalcObject?, horizontal:number|string|CalcObject?, vertical:number|string|CalcObject?}? -- Margin around element: single value, string, CalcObject for all sides, or table for individual sides (default: {top=0, right=0, bottom=0, left=0})
|
||||
---@field text string? -- Text content to display (default: nil)
|
||||
---@field textAlign TextAlignSpec? -- Alignment of the text content: simple string, compound string ("top-left"), or {horizontal, vertical} table (default: START)
|
||||
---@field textColor Color? -- Color of the text content (default: black or theme text color)
|
||||
---@field textSize number|string? -- Font size: number (px), string with units ("2vh", "10%"), or preset ("xxs"|"xs"|"sm"|"md"|"lg"|"xl"|"xxl"|"3xl"|"4xl") (default: "md" or 12px)
|
||||
---@field minTextSize number? -- Minimum text size in pixels for auto-scaling
|
||||
---@field maxTextSize number? -- Maximum text size in pixels for auto-scaling
|
||||
---@field fontFamily string? -- Font family name from theme or path to font file (default: theme default or system default, inherits from parent)
|
||||
---@field autoScaleText boolean? -- Whether text should auto-scale with window size (default: true)
|
||||
---@field positioning Positioning? -- Layout positioning mode: "absolute"|"relative"|"flex"|"grid" (default: RELATIVE)
|
||||
---@field flexDirection FlexDirection? -- Direction of flex layout: "horizontal"|"vertical"|"row"|"column"|"row-reverse"|"column-reverse"|"horizontal-reverse"|"vertical-reverse" (row→horizontal, column→vertical, row-reverse→horizontal-reverse, column-reverse→vertical-reverse, default: HORIZONTAL)
|
||||
---@field justifyContent JustifyContent? -- Alignment of items along main axis (default: FLEX_START)
|
||||
---@field alignItems AlignItems? -- Alignment of items along cross axis (default: STRETCH)
|
||||
---@field alignContent AlignContent? -- Alignment of lines in multi-line flex containers (default: STRETCH)
|
||||
---@field flexWrap FlexWrap? -- Whether children wrap to multiple lines: "nowrap"|"wrap"|"wrap-reverse" (default: NOWRAP)
|
||||
---@field flex number|string? -- Shorthand for flexGrow, flexShrink, flexBasis: number (flex-grow only), string ("1 0 auto"), or nil (default: nil)
|
||||
---@field flexGrow number? -- How much the element should grow relative to siblings (default: 0)
|
||||
---@field flexShrink number? -- How much the element should shrink relative to siblings (default: 1)
|
||||
---@field flexBasis number|string|CalcObject? -- Initial size before growing/shrinking: number (px), string ("50%", "10vw", "auto"), or CalcObject (default: "auto")
|
||||
---@field justifySelf JustifySelf? -- Alignment of the item itself along main axis (default: AUTO)
|
||||
---@field alignSelf AlignSelf? -- Alignment of the item itself along cross axis (default: AUTO)
|
||||
---@field onEvent fun(element:Element, event:InputEvent)? -- Callback function for interaction events
|
||||
---@field onEventDeferred boolean? -- Whether onEvent callback should be deferred until after canvases are released (default: false)
|
||||
---@field onFocus fun(element:Element)? -- Callback when element receives focus
|
||||
---@field onFocusDeferred boolean? -- Whether onFocus callback should be deferred (default: false)
|
||||
---@field dropFocusOnSelection boolean? -- Override keyboard-navigation focus drop after Enter/Space activation (default: nil, uses KeyboardNavigation.config.dropFocusOnSelection)
|
||||
---@field onBlur fun(element:Element)? -- Callback when element loses focus
|
||||
---@field onBlurDeferred boolean? -- Whether onBlur callback should be deferred (default: false)
|
||||
---@field onTextInput fun(element:Element, text:string)? -- Callback when text is input
|
||||
---@field onTextInputDeferred boolean? -- Whether onTextInput callback should be deferred (default: false)
|
||||
---@field onTextChange fun(element:Element, text:string)? -- Callback when text content changes
|
||||
---@field onTextChangeDeferred boolean? -- Whether onTextChange callback should be deferred (default: false)
|
||||
---@field onEnter fun(element:Element)? -- Callback when Enter key is pressed
|
||||
---@field onEnterDeferred boolean? -- Whether onEnter callback should be deferred (default: false)
|
||||
---@field onCreate fun(element:Element, props:table)? -- Callback when element is created, receives the element and original creation props
|
||||
---@field onCreateDeferred boolean? -- Whether onCreate callback should be deferred (default: false)
|
||||
---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Callback for touch-specific events (touchpress, touchmove, touchrelease)
|
||||
---@field onTouchEventDeferred boolean? -- Whether onTouchEvent callback should be deferred (default: false)
|
||||
---@field onGesture fun(element:Element, gesture:table)? -- Callback for recognized gestures (tap, swipe, pinch, etc.)
|
||||
---@field onGestureDeferred boolean? -- Whether onGesture callback should be deferred (default: false)
|
||||
---@field touchEnabled boolean? -- Whether the element responds to touch events (default: true)
|
||||
---@field multiTouchEnabled boolean? -- Whether the element supports multiple simultaneous touches (default: false)
|
||||
---@field transform TransformProps? -- Transform properties for animations and styling
|
||||
---@field transition TransitionProps? -- Transition settings for animations
|
||||
---@field customDraw fun(element:Element)? -- Custom rendering callback called after standard rendering but before visual feedback (default: nil)
|
||||
---@field gridRows number|table? -- Number of equal 1fr rows, or array of track specs (e.g. {"1fr","100px","auto"})
|
||||
---@field gridColumns number|table? -- Number of equal 1fr columns, or array of track specs (e.g. {"1fr","100px","auto"})
|
||||
---@field columnGap number|string|CalcObject? -- Gap between grid columns: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: 0)
|
||||
---@field rowGap number|string|CalcObject? -- Gap between grid rows: number (px), string ("50%", "10vh"), or CalcObject from FlexLove.calc() (default: 0)
|
||||
---@field theme string? -- Theme name to use (e.g., "space", "metal"). Defaults to theme from flexlove.init()
|
||||
---@field themeComponent string? -- Theme component to use (e.g., "panel", "button", "input"). If nil, no theme is applied
|
||||
---@field disabled boolean? -- Whether the element is disabled (default: false)
|
||||
---@field active boolean? -- Whether the element is active/focused (for inputs, default: false)
|
||||
---@field disableHighlight boolean? -- Whether to disable the pressed state highlight overlay (default: false, or true when using themeComponent)
|
||||
---@field themeStateLock boolean|string? -- Lock theme state: true/"default" = lock to base state, false = normal behavior, string = specific state ("hover", "pressed", "active", "disabled") (default: false)
|
||||
---@field themeComponentDisabledStates string[]? -- List of theme states to suppress visually (e.g. {"hover", "pressed"}). Interaction logic still fires.
|
||||
---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Multiplier for auto-sized content dimensions (default: sourced from theme or {1, 1})
|
||||
---@field scaleCorners number? -- Scale multiplier for 9-patch corners/edges. E.g., 2 = 2x size (overrides theme setting)
|
||||
---@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for 9-patch corners: "nearest" (sharp/pixelated) or "bilinear" (smooth) (overrides theme setting)
|
||||
---@field contentBlur {radius:number, quality:number?}? -- Blur the element's content including children (radius: pixels, quality: 1-10, default(quality): 5)
|
||||
---@field backdropBlur {radius:number, quality:number?}? -- Blur content behind the element (radius: pixels, quality: 1-10, default(quality): 5)
|
||||
---@field editable boolean? -- Whether the element is editable (default: false)
|
||||
---@field multiline boolean? -- Whether the element supports multiple lines (default: false)
|
||||
---@field textWrap boolean|"word"|"char"? -- Text wrapping mode (default: false for single-line, "word" for multi-line)
|
||||
---@field maxLines number? -- Maximum number of lines (default: nil)
|
||||
---@field maxLength number? -- Maximum text length in characters (default: nil)
|
||||
---@field placeholder string? -- Placeholder text when empty (default: nil)
|
||||
---@field passwordMode boolean? -- Whether to display text as password (default: false, disables multiline)
|
||||
---@field inputType "text"|"number"|"email"|"url"? -- Input type for validation (default: "text")
|
||||
---@field textOverflow "clip"|"ellipsis"|"scroll"? -- Text overflow behavior (default: "clip")
|
||||
---@field scrollable boolean? -- Whether text is scrollable (default: false for single-line, true for multi-line)
|
||||
---@field autoGrow boolean? -- Whether element auto-grows with text (default: false for single-line, true for multi-line)
|
||||
---@field selectOnFocus boolean? -- Whether to select all text on focus (default: false)
|
||||
---@field cursorColor Color? -- Cursor color (default: nil, uses textColor)
|
||||
---@field selectionColor Color? -- Selection background color (default: nil, uses theme or default)
|
||||
---@field cursorBlinkRate number? -- Cursor blink rate in seconds (default: 0.5)
|
||||
---@field selectParent SelectParentProps? -- Parent-owned select/dropdown state and callbacks
|
||||
---@field selectOption SelectOptionProps? -- Option metadata attached to a child of a select parent
|
||||
---@field overflow "visible"|"hidden"|"scroll"|"auto"? -- Overflow behavior (default: "hidden")
|
||||
---@field overflowX "visible"|"hidden"|"scroll"|"auto"? -- X-axis overflow (overrides overflow)
|
||||
---@field overflowY "visible"|"hidden"|"scroll"|"auto"? -- Y-axis overflow (overrides overflow)
|
||||
---@field scrollbarWidth number? -- Width of scrollbar track in pixels (default: 12)
|
||||
---@field scrollbarColor Color? -- Scrollbar thumb color (default: Color.new(0.5, 0.5, 0.5, 0.8))
|
||||
---@field scrollbarTrackColor Color? -- Scrollbar track color (default: Color.new(0.2, 0.2, 0.2, 0.5))
|
||||
---@field scrollbarRadius number? -- Corner radius for scrollbar (default: 6)
|
||||
---@field scrollbarPadding number? -- Padding between scrollbar and edge (default: 2)
|
||||
---@field scrollSpeed number? -- Pixels per wheel notch (default: 20)
|
||||
---@field invertScroll boolean? -- Invert mouse wheel scroll direction (default: false)
|
||||
---@field smoothScrollEnabled boolean? -- Enable smooth scrolling animation for wheel events (default: false)
|
||||
---@field scrollBarStyle string? -- Scrollbar style name from theme (selects from theme.scrollbars, default: uses first scrollbar or fallback rendering)
|
||||
---@field scrollbarKnobOffset number|{x:number, y:number}|{horizontal:number, vertical:number}? -- Offset for scrollbar knob/handle position in pixels (number for both axes, or table for per-axis control, default: 0, adds to theme offset)
|
||||
---@field scrollbarPlacement "reserve-space"|"overlay"? -- Scrollbar rendering mode: "reserve-space" (reduces content area, default) or "overlay" (renders over content)
|
||||
---@field scrollbarBalance boolean? -- When true, reserve scrollbar space on both sides of content for visual balance (default: false)
|
||||
---@field hideScrollbars boolean|{vertical:boolean, horizontal:boolean}? -- Hide scrollbars (boolean for both, or table for individual control, default: false)
|
||||
---@field imagePath string? -- Path to image file (auto-loads via ImageCache)
|
||||
---@field image love.Image? -- Image object to display
|
||||
---@field objectFit "fill"|"contain"|"cover"|"scale-down"|"none"? -- Image fit mode (default: "fill")
|
||||
---@field objectPosition string? -- Image position like "center center", "top left", "50% 50%" (default: "center center")
|
||||
---@field imageOpacity number? -- Image opacity 0-1 (default: 1, combines with element opacity)
|
||||
---@field imageRepeat "no-repeat"|"repeat"|"repeat-x"|"repeat-y"|"space"|"round"? -- Image repeat/tiling mode (default: "no-repeat")
|
||||
---@field imageTint Color? -- Color to tint the image (default: nil/white, no tint)
|
||||
---@field onImageLoad fun(element:Element, image:love.Image)? -- Callback when image loads successfully
|
||||
---@field onImageLoadDeferred boolean? -- Whether onImageLoad callback should be deferred (default: false)
|
||||
---@field onImageError fun(element:Element, error:string)? -- Callback when image fails to load
|
||||
---@field onImageErrorDeferred boolean? -- Whether onImageError callback should be deferred (default: false)
|
||||
---@field _scrollX number? -- Internal: scroll X position (restored in immediate mode)
|
||||
---@field _scrollY number? -- Internal: scroll Y position (restored in immediate mode)
|
||||
---@field children? ElementProps[]
|
||||
---@field userdata table? -- User-defined data storage for custom properties
|
||||
---@field ariaRole ARIA? -- ARIA role for screen readers (e.g., "button", "link", "dialog")
|
||||
---@field ariaLabel string? -- Accessible name for screen readers (overrides text content)
|
||||
---@field ariaDescribedBy string? -- ID of element that describes this element
|
||||
---@field ariaExpanded boolean? -- Whether element is expanded/collapsed (for containers)
|
||||
---@field ariaPressed boolean? -- Whether element is pressed (for toggle buttons)
|
||||
---@field ariaChecked boolean? -- Whether element is checked (for checkboxes/radios)
|
||||
---@field ariaDisabled boolean? -- Whether element is disabled (overrides disabled property)
|
||||
---@field ariaBusy boolean? -- Whether element is processing (for live regions)
|
||||
---@field ariaLive "off"|"polite"|"assertive"? -- Live region priority for announcements
|
||||
local ElementProps = {}
|
||||
|
||||
---@class Border
|
||||
---@field top boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0)
|
||||
---@field right boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0)
|
||||
---@field bottom boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0)
|
||||
---@field left boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0)
|
||||
local Border = {}
|
||||
|
||||
--=====================================--
|
||||
-- For KeyboardNavigation.lua
|
||||
--=====================================--
|
||||
---@class KeyboardNavigationKeyConfig
|
||||
---@field next string -- Key used to move to the next focusable element
|
||||
---@field previous string -- Key used to move to the previous focusable element
|
||||
---@field up string -- Key used for directional navigation upward
|
||||
---@field down string -- Key used for directional navigation downward
|
||||
---@field left string -- Key used for directional navigation leftward
|
||||
---@field right string -- Key used for directional navigation rightward
|
||||
---@field activate string[] -- Keys that activate the currently focused element
|
||||
---@field dismiss string -- Key used to dismiss or clear the currently focused element
|
||||
---@field toggleDebug string -- Key used to toggle keyboard-navigation debug tooling
|
||||
---@field inspect string -- Key used to inspect the currently focused element in developer tools
|
||||
local KeyboardNavigationKeyConfig = {}
|
||||
|
||||
---@class KeyboardNavigationDeveloperToolsConfig
|
||||
---@field enabled boolean? -- Enable keyboard-navigation developer tools (default: true)
|
||||
---@field showProperties boolean? -- Show focused element properties in developer tools (default: true)
|
||||
---@field highlightColor number[]? -- RGBA color used for keyboard-navigation debug highlighting (default: {1, 0.8, 0, 0.5})
|
||||
local KeyboardNavigationDeveloperToolsConfig = {}
|
||||
|
||||
---@class KeyboardNavigationFocusIndicatorConfig
|
||||
---@field enabled boolean? -- Enable the keyboard focus indicator (default: true)
|
||||
---@field color number[]? -- RGBA color of the focus indicator (default: {0.2, 0.6, 1.0, 0.8})
|
||||
---@field lineWidth number? -- Focus indicator stroke width in pixels (default: 2)
|
||||
---@field inset number? -- Offset from the element bounds in pixels (default: -3)
|
||||
---@field borderRadius number? -- Focus indicator border radius in pixels (default: 4)
|
||||
---@field animationDuration number? -- Focus indicator entrance animation duration in seconds (default: 0.15)
|
||||
---@field pulseEnabled boolean? -- Enable pulse animation for the focus indicator when supported
|
||||
---@field pulseDuration number? -- Seconds per pulse cycle
|
||||
---@field pulseScaleMin number? -- Minimum scale during pulse animation
|
||||
---@field pulseScaleMax number? -- Maximum scale during pulse animation
|
||||
---@field draw fun(element:Element, bounds:table, style:KeyboardNavigationFocusIndicatorConfig)? -- Custom focus indicator renderer
|
||||
local KeyboardNavigationFocusIndicatorConfig = {}
|
||||
|
||||
---@class KeyboardNavigationConfig
|
||||
---@field enabled boolean? -- Enable or disable keyboard navigation globally (default: true)
|
||||
---@field debugMode boolean? -- Enable keyboard-navigation debug logging (default: false)
|
||||
---@field keys KeyboardNavigationKeyConfig? -- Key bindings used by keyboard navigation
|
||||
---@field wrapAround boolean? -- Allow wrapping from last to first focusable element (default: true)
|
||||
---@field directionalNavigation boolean? -- Enable arrow-key directional navigation (default: true)
|
||||
---@field focusVisible boolean? -- Show the focus indicator for keyboard-driven focus (default: true)
|
||||
---@field autofocusOnCreate boolean? -- Auto-focus the first focusable element on creation (default: false)
|
||||
---@field dropFocusOnSelection boolean? -- Drop focus after Enter/Space activates an element (default: true)
|
||||
---@field developerTools KeyboardNavigationDeveloperToolsConfig? -- Developer tool settings for keyboard navigation
|
||||
---@field focusIndicator KeyboardNavigationFocusIndicatorConfig? -- Focus indicator style configuration
|
||||
local KeyboardNavigationConfig = {}
|
||||
|
||||
--=====================================--
|
||||
-- For FlexLove.init()
|
||||
--=====================================--
|
||||
---@class FlexLoveConfig
|
||||
---@field baseScale {width:number?, height:number?}? -- Base resolution for responsive scaling (default: nil, no scaling)
|
||||
---@field theme string|ThemeDefinition? -- Theme name (string) or ThemeDefinition to use (default: nil, no theme)
|
||||
---@field immediateMode boolean? -- Enable immediate mode (React-like, recreates UI each frame) vs retained mode (default: false)
|
||||
---@field autoFrameManagement boolean? -- Automatically call beginFrame/endFrame (default: false)
|
||||
---@field stateRetentionFrames number? -- Number of frames to retain unused state in immediate mode (default: 60)
|
||||
---@field maxStateEntries number? -- Maximum number of state entries before forcing cleanup (default: 1000)
|
||||
---@field includeStackTrace boolean? -- Include stack traces in error messages (default: true)
|
||||
---@field reportingLogLevel LOG_LEVEL? -- Error log level: 1: critical, 2: error, 3: warn, 4: info, 5: debug/all (default: 3:warn)
|
||||
---@field errorLogTarget string? -- Error log target: "console", "file", "both" (default: "console")
|
||||
---@field errorLogFile string? -- Path to error log file (default: "flexlove_errors.log")
|
||||
---@field errorLogMaxSize number? -- Maximum error log file size in bytes (default: 1048576, 1MB)
|
||||
---@field maxErrorLogFiles number? -- Maximum number of rotated error log files (default: 5)
|
||||
---@field errorLogRotateEnabled boolean? -- Enable error log rotation (default: true)
|
||||
---@field performanceMonitoring boolean? -- Enable performance monitoring (default: true)
|
||||
---@field performanceHudKey string? -- Key to toggle performance HUD (default: "f3")
|
||||
---@field performanceHudPosition {x:number, y:number}? -- Position of performance HUD (default: {x=10, y=10})
|
||||
---@field performanceWarningThreshold number? -- Frame time warning threshold in ms (default: 13.0)
|
||||
---@field performanceCriticalThreshold number? -- Frame time critical threshold in ms (default: 16.67)
|
||||
---@field performanceLogToConsole boolean? -- Log performance metrics to console (default: false)
|
||||
---@field performanceWarnings boolean? -- Enable performance warnings (default: false)
|
||||
---@field memoryProfiling boolean? -- Enable memory profiling (default: false, auto-enabled in immediate mode)
|
||||
---@field gcStrategy string? -- Garbage collection strategy: "auto", "periodic", "manual", "disabled" (default: "auto")
|
||||
---@field gcMemoryThreshold number? -- Memory threshold in MB before forcing GC (default: 100)
|
||||
---@field gcInterval number? -- Frames between GC steps in periodic mode (default: 60)
|
||||
---@field gcStepSize number? -- Work units per GC step, higher = more aggressive (default: 200)
|
||||
---@field immediateModeBlurOptimizations boolean? -- Cache blur canvases in immediate mode to avoid re-rendering each frame (default: true)
|
||||
---@field keyboardNavigation boolean|KeyboardNavigationConfig? -- Enable keyboard navigation with defaults (`true`) or provide configuration overrides
|
||||
---@field debugDraw boolean? -- Enable debug draw overlay showing element boundaries with random colors (default: false)
|
||||
---@field debugDrawKey string? -- Key to toggle debug draw overlay at runtime (default: nil, no toggle key)
|
||||
local FlexLoveConfig = {}
|
||||
|
||||
--=====================================--
|
||||
-- Public FlexLove API
|
||||
--=====================================--
|
||||
---@alias TextAlignCompound "top-left" | "top-center" | "top-right" | "center-left" | "center-center" | "center-right" | "bottom-left" | "bottom-center" | "bottom-right"
|
||||
---@alias TextAlignSpec TextAlign | TextAlignCompound | {horizontal: TextAlign, vertical: TextAlignVertical}
|
||||
|
||||
---@class FlexLoveEnums
|
||||
---@field TextAlign TextAlign
|
||||
---@field TextAlignVertical TextAlignVertical
|
||||
---@field Positioning Positioning
|
||||
---@field FlexDirection FlexDirection
|
||||
---@field JustifyContent JustifyContent
|
||||
---@field JustifySelf JustifySelf
|
||||
---@field AlignItems AlignItems
|
||||
---@field AlignSelf AlignSelf
|
||||
---@field AlignContent AlignContent
|
||||
---@field FlexWrap FlexWrap
|
||||
---@field TextSize TextSize
|
||||
---@field ImageRepeat ImageRepeat
|
||||
---@field ARIA ARIA
|
||||
local FlexLoveEnums = {}
|
||||
|
||||
---@class AnimationKeyframe
|
||||
---@field at number -- Normalized time position (0-1)
|
||||
---@field values table -- Property values at this keyframe
|
||||
---@field easing string|EasingFunction? -- Easing used between this and the next keyframe
|
||||
local AnimationKeyframe = {}
|
||||
|
||||
---@class AnimationGroupProps
|
||||
---@field animations Animation[] -- Animations to coordinate
|
||||
---@field mode "parallel"|"sequence"|"stagger"? -- Group playback mode (default: "parallel")
|
||||
---@field stagger number? -- Delay between staggered animations in seconds (default: 0.1)
|
||||
---@field onComplete fun(group:AnimationGroup)? -- Called when all animations complete
|
||||
---@field onStart fun(group:AnimationGroup)? -- Called when the group starts
|
||||
local AnimationGroupProps = {}
|
||||
|
||||
---@class AnimationGroup
|
||||
---@field animations Animation[]
|
||||
---@field mode "parallel"|"sequence"|"stagger"
|
||||
---@field stagger number
|
||||
---@field onComplete fun(group:AnimationGroup)?
|
||||
---@field onStart fun(group:AnimationGroup)?
|
||||
local AnimationGroup = {}
|
||||
|
||||
---@class Animation
|
||||
---@field duration number
|
||||
---@field start table
|
||||
---@field final table
|
||||
---@field elapsed number
|
||||
---@field easing EasingFunction
|
||||
---@field keyframes AnimationKeyframe[]?
|
||||
---@field transform TransformProps?
|
||||
---@field transition TransitionProps?
|
||||
---@field onStart fun(animation:Animation, element:Element?)?
|
||||
---@field onUpdate fun(animation:Animation, element:Element?, progress:number)?
|
||||
---@field onComplete fun(animation:Animation, element:Element?)?
|
||||
---@field onCancel fun(animation:Animation, element:Element?)?
|
||||
---@field update fun(self:Animation, dt:number, element:table?): boolean
|
||||
---@field findKeyframes fun(self:Animation, progress:number): AnimationKeyframe?, AnimationKeyframe?
|
||||
---@field lerpKeyframes fun(self:Animation, prevFrame:AnimationKeyframe, nextFrame:AnimationKeyframe, easedT:number): table
|
||||
---@field interpolate fun(self:Animation): table
|
||||
---@field apply fun(self:Animation, element:table)
|
||||
---@field pause fun(self:Animation)
|
||||
---@field resume fun(self:Animation)
|
||||
---@field isPaused fun(self:Animation): boolean
|
||||
---@field reverse fun(self:Animation)
|
||||
---@field isReversed fun(self:Animation): boolean
|
||||
---@field setSpeed fun(self:Animation, speed:number)
|
||||
---@field getSpeed fun(self:Animation): number
|
||||
---@field seek fun(self:Animation, time:number)
|
||||
---@field getState fun(self:Animation): string
|
||||
---@field cancel fun(self:Animation, element:table?)
|
||||
---@field reset fun(self:Animation)
|
||||
---@field getProgress fun(self:Animation): number
|
||||
---@field chain fun(self:Animation, nextAnimation:Animation|function): Animation
|
||||
---@field delay fun(self:Animation, seconds:number): Animation
|
||||
---@field repeatCount fun(self:Animation, count:number): Animation
|
||||
---@field yoyo fun(self:Animation, enabled:boolean?): Animation
|
||||
---@class AnimationModule
|
||||
---@field Easing table<string, EasingFunction|fun(...):EasingFunction> -- Built-in easing functions and easing factories
|
||||
---@field Transform table? -- Animation transform helpers exposed by the animation module
|
||||
---@field Group AnimationGroup -- Animation group class table
|
||||
---@field new fun(props:AnimationProps): Animation
|
||||
---@field fade fun(duration:number, fromOpacity:number, toOpacity:number, easing:string?): Animation
|
||||
---@field scale fun(duration:number, fromScale:{width:number, height:number}, toScale:{width:number, height:number}, easing:string?): Animation
|
||||
---@field keyframes fun(props:{duration:number, keyframes:AnimationKeyframe[], onStart:function?, onUpdate:function?, onComplete:function?, onCancel:function?}): Animation
|
||||
---@field chainSequence fun(animations:Animation[]): Animation
|
||||
local AnimationModule = {}
|
||||
|
||||
---@class ColorInputTable
|
||||
---@field [1] number?
|
||||
---@field [2] number?
|
||||
---@field [3] number?
|
||||
---@field [4] number?
|
||||
---@field r number?
|
||||
---@field g number?
|
||||
---@field b number?
|
||||
---@field a number?
|
||||
local ColorInputTable = {}
|
||||
|
||||
---@alias ColorInput string|Color|ColorInputTable
|
||||
|
||||
---@class ColorModule
|
||||
---@field new fun(r:number?, g:number?, b:number?, a:number?): Color
|
||||
---@field fromHex fun(hexWithTag:string): Color
|
||||
---@field validateColorChannel fun(value:any, max:number?): boolean, number?
|
||||
---@field validateHexColor fun(hex:string): boolean, string?
|
||||
---@field validateRGBColor fun(r:number, g:number, b:number, a:number?, max:number?): boolean, string?
|
||||
---@field isValidColorFormat fun(value:any): string?
|
||||
---@field sanitizeColor fun(value:any, default:Color?): Color
|
||||
---@field parse fun(value:any): Color
|
||||
---@field lerp fun(colorA:Color, colorB:Color, t:number): Color
|
||||
local ColorModule = {}
|
||||
|
||||
---@class ThemeManagerConfig
|
||||
---@field theme string? -- Theme name override
|
||||
---@field themeComponent string? -- Component name to resolve from the theme
|
||||
---@field disabled boolean? -- Force disabled theme state
|
||||
---@field active boolean? -- Force active theme state
|
||||
---@field disableHighlight boolean? -- Disable pressed highlight overlay
|
||||
---@field themeStateLock boolean|string? -- Lock the theme state to base/default or a named state
|
||||
---@field themeComponentDisabledStates string[]? -- List of theme states to suppress visually
|
||||
---@field scaleCorners number? -- Scale multiplier for 9-patch corners and edges
|
||||
---@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for non-stretched theme regions
|
||||
local ThemeManagerConfig = {}
|
||||
|
||||
---@class ThemeRegion
|
||||
---@field x number
|
||||
---@field y number
|
||||
---@field w number
|
||||
---@field h number
|
||||
local ThemeRegion = {}
|
||||
|
||||
---@class ThemeComponent
|
||||
---@field atlas string|love.Image?
|
||||
---@field insets {left:number, top:number, right:number, bottom:number}?
|
||||
---@field regions {topLeft:ThemeRegion, topCenter:ThemeRegion, topRight:ThemeRegion, middleLeft:ThemeRegion, middleCenter:ThemeRegion, middleRight:ThemeRegion, bottomLeft:ThemeRegion, bottomCenter:ThemeRegion, bottomRight:ThemeRegion}?
|
||||
---@field stretch {horizontal:table<integer, string>, vertical:table<integer, string>}?
|
||||
---@field states table<string, ThemeComponent>?
|
||||
---@field contentAutoSizingMultiplier {width:number?, height:number?}?
|
||||
---@field scaleCorners number?
|
||||
---@field scalingAlgorithm "nearest"|"bilinear"?
|
||||
---@field knobOffset number|{x:number, y:number}|{horizontal:number, vertical:number}?
|
||||
local ThemeComponent = {}
|
||||
|
||||
---@class ThemeDefinition
|
||||
---@field name string
|
||||
---@field atlas string|love.Image?
|
||||
---@field components table<string, ThemeComponent>
|
||||
---@field scrollbars table<string, ThemeComponent>?
|
||||
---@field colors table<string, Color>?
|
||||
---@field fonts table<string, string>?
|
||||
---@field contentAutoSizingMultiplier {width:number?, height:number?}?
|
||||
local ThemeDefinition = {}
|
||||
|
||||
---@class Theme
|
||||
---@field name string
|
||||
---@field atlas love.Image?
|
||||
---@field atlasData love.ImageData?
|
||||
---@field components table<string, ThemeComponent>
|
||||
---@field scrollbars table<string, ThemeComponent>
|
||||
---@field colors table<string, Color>
|
||||
---@field fonts table<string, string>
|
||||
---@field contentAutoSizingMultiplier {width:number?, height:number?}?
|
||||
---@class ThemeManager
|
||||
---@field theme string?
|
||||
---@field themeComponent string?
|
||||
---@field disabled boolean
|
||||
---@field active boolean
|
||||
---@field disableHighlight boolean?
|
||||
---@field themeStateLock boolean|string?
|
||||
---@field themeComponentDisabledStates table<string, boolean>
|
||||
---@field scaleCorners number?
|
||||
---@field scalingAlgorithm "nearest"|"bilinear"?
|
||||
---@field updateState fun(self:ThemeManager, isHovered:boolean, isPressed:boolean, isFocused:boolean, isDisabled:boolean): string
|
||||
---@field getState fun(self:ThemeManager): string
|
||||
---@field setState fun(self:ThemeManager, state:string)
|
||||
---@field hasThemeComponent fun(self:ThemeManager): boolean
|
||||
---@field getTheme fun(self:ThemeManager): Theme?
|
||||
---@field getComponent fun(self:ThemeManager): ThemeComponent?
|
||||
---@field getStateComponent fun(self:ThemeManager): ThemeComponent?
|
||||
---@field getScrollbarComponent fun(self:ThemeManager, scrollbarName:string?): ThemeComponent?
|
||||
---@field getStyle fun(self:ThemeManager, property:string): any?
|
||||
---@field _getScaledContentPaddingForState fun(self:ThemeManager, state:string, borderBoxWidth:number, borderBoxHeight:number): table?
|
||||
---@field getScaledContentPaddingForState fun(self:ThemeManager, state:string, borderBoxWidth:number, borderBoxHeight:number): table? -- deprecated, use getScaledContentPadding
|
||||
---@field getScaledContentPadding fun(self:ThemeManager, borderBoxWidth:number, borderBoxHeight:number): table?
|
||||
---@field getContentAutoSizingMultiplier fun(self:ThemeManager): table?
|
||||
---@field getDefaultFontFamily fun(self:ThemeManager): string?
|
||||
---@field setTheme fun(self:ThemeManager, themeName:string?, componentName:string?)
|
||||
---@field validateThemeStateLock fun(self:ThemeManager): boolean
|
||||
---@class Color
|
||||
---@field r number
|
||||
---@field g number
|
||||
---@field b number
|
||||
---@field a number
|
||||
---@field toRGBA fun(self:Color): number, number, number, number
|
||||
---@class ThemeModule
|
||||
---@field Manager ThemeManager -- Theme manager class table
|
||||
---@field new fun(definition:ThemeDefinition): Theme
|
||||
---@field load fun(path:string): Theme?
|
||||
---@field setActive fun(themeOrName:string|Theme)
|
||||
---@field getActive fun(): Theme?
|
||||
---@field getComponent fun(componentName:string, state:string?): ThemeComponent?
|
||||
---@field getDefaultScrollbar fun(): ThemeComponent?
|
||||
---@field getScrollbar fun(scrollbarName:string, state:string?): ThemeComponent?
|
||||
---@field getFont fun(fontName:string): string?
|
||||
---@field getColor fun(colorName:string): Color?
|
||||
---@field hasActive fun(): boolean
|
||||
---@field getRegisteredThemes fun(): table<string, Theme>
|
||||
---@field getColorNames fun(): string[]
|
||||
---@field getAllColors fun(): table<string, Color>
|
||||
---@field getColorOrDefault fun(colorName:string, fallback:Color): Color
|
||||
---@field get fun(themeName:string): Theme?
|
||||
---@field validateTheme fun(theme:table?, options:table?): boolean, table
|
||||
---@field sanitizeTheme fun(theme:table?): table
|
||||
local ThemeModule = {}
|
||||
|
||||
---@class FlexLove
|
||||
---@field _VERSION string
|
||||
---@field _DESCRIPTION string
|
||||
---@field _URL string
|
||||
---@field _LICENSE string
|
||||
---@field Animation AnimationModule?
|
||||
---@field Color ColorModule
|
||||
---@field Theme ThemeModule?
|
||||
---@field enums FlexLoveEnums
|
||||
---@field isReady fun(): boolean
|
||||
---@field init fun(config:FlexLoveConfig?)
|
||||
---@field setKeyboardNavigationDebug fun(enabled:boolean)
|
||||
---@field enableKeyboardNavigation fun(config:KeyboardNavigationConfig?)
|
||||
---@field deferCallback fun(callback:function)
|
||||
---@field executeDeferredCallbacks fun()
|
||||
---@field resize fun()
|
||||
---@field setMode fun(mode:"immediate"|"retained")
|
||||
---@field getMode fun(): "immediate"|"retained"
|
||||
---@field beginFrame fun()
|
||||
---@field endFrame fun()
|
||||
---@field draw fun(gameDrawFunc:function|nil, postDrawFunc:function|nil)
|
||||
---@field getElementAtPosition fun(x:number, y:number): Element?
|
||||
---@field update fun(dt:number)
|
||||
---@field collectGarbage fun(mode:string?, stepSize:number?): number?
|
||||
---@field setGCStrategy fun(strategy:"auto"|"periodic"|"manual"|"disabled")
|
||||
---@field getGCStats fun(): GCStats
|
||||
---@field textinput fun(text:string)
|
||||
---@field keypressed fun(key:string, scancode:string, isrepeat:boolean)
|
||||
---@field wheelmoved fun(dx:number, dy:number)
|
||||
---@field touchpressed fun(id:lightuserdata, x:number, y:number, dx:number, dy:number, pressure:number)
|
||||
---@field touchmoved fun(id:lightuserdata, x:number, y:number, dx:number, dy:number, pressure:number)
|
||||
---@field touchreleased fun(id:lightuserdata, x:number, y:number, dx:number, dy:number, pressure:number)
|
||||
---@field getActiveTouchCount fun(): number
|
||||
---@field getTouchOwner fun(touchId:string): Element?
|
||||
---@field getById fun(id:string): Element?
|
||||
---@field destroy fun()
|
||||
---@field new fun(props:ElementProps, callback:function?): Element?
|
||||
---@field getStateCount fun(): number
|
||||
---@field clearState fun(id:string)
|
||||
---@field clearAllStates fun()
|
||||
---@field getStateStats fun(): table
|
||||
---@field calc fun(expr:string): CalcObject
|
||||
---@field getFocusedElement fun(): Element?
|
||||
---@field setFocusedElement fun(element:Element?)
|
||||
---@field clearFocus fun()
|
||||
---@field setDebugDraw fun(enabled:boolean)
|
||||
---@field getDebugDraw fun(): boolean
|
||||
local FlexLove = {}
|
||||
|
||||
--=====================================--
|
||||
-- For State Persistence
|
||||
--=====================================--
|
||||
---@class ElementStateData
|
||||
---@field _focused boolean?
|
||||
---@field eventHandler table? -- EventHandler state
|
||||
---@field textEditor table? -- TextEditor state
|
||||
---@field scrollManager table? -- ScrollManager state
|
||||
---@field blur BlurCacheData? -- Blur cache invalidation data
|
||||
|
||||
---@class BlurCacheData
|
||||
---@field _blurX number
|
||||
---@field _blurY number
|
||||
---@field _blurWidth number
|
||||
---@field _blurHeight number
|
||||
---@field _backdropBlurRadius number?
|
||||
---@field _backdropBlurQuality number?
|
||||
---@field _contentBlurRadius number?
|
||||
---@field _contentBlurQuality number?
|
||||
|
||||
--=====================================--
|
||||
-- For Calc.lua
|
||||
--=====================================--
|
||||
---@class CalcDependencies
|
||||
---@field ErrorHandler ErrorHandler? -- Error handler module
|
||||
|
||||
---@class CalcToken
|
||||
---@field type string -- Token type: "NUMBER", "UNIT", "PLUS", "MINUS", "MULTIPLY", "DIVIDE", "LPAREN", "RPAREN", "EOF"
|
||||
---@field value number? -- Numeric value (for NUMBER tokens)
|
||||
---@field unit string? -- Unit type: "px", "%", "vw", "vh" (for NUMBER tokens)
|
||||
|
||||
---@class CalcASTNode
|
||||
---@field type string -- Node type: "number", "add", "subtract", "multiply", "divide"
|
||||
---@field value number? -- Numeric value (for "number" nodes)
|
||||
---@field unit string? -- Unit type (for "number" nodes)
|
||||
---@field left CalcASTNode? -- Left operand (for operator nodes)
|
||||
---@field right CalcASTNode? -- Right operand (for operator nodes)
|
||||
|
||||
---@class CalcObject
|
||||
---@field _isCalc boolean -- Marker to identify calc objects (always true)
|
||||
---@field _expr string -- Original expression string
|
||||
---@field _ast CalcASTNode? -- Parsed abstract syntax tree (nil if parsing failed)
|
||||
---@field _error string? -- Error message if parsing failed
|
||||
|
||||
--=====================================--
|
||||
-- For FlexLove.lua Internals
|
||||
--=====================================--
|
||||
---@class GCConfig
|
||||
---@field strategy string -- "auto", "periodic", "manual", or "disabled"
|
||||
---@field memoryThreshold number -- MB before forcing GC
|
||||
---@field interval number -- Frames between GC steps (for periodic mode)
|
||||
---@field stepSize number -- Work units per GC step (higher = more aggressive)
|
||||
|
||||
---@class GCState
|
||||
---@field framesSinceLastGC number -- Frames elapsed since last GC
|
||||
---@field lastMemory number -- Last recorded memory usage in MB
|
||||
---@field gcCount number -- Total number of GC operations performed
|
||||
|
||||
---@class GCStats
|
||||
---@field gcCount number -- Total number of GC operations performed
|
||||
---@field framesSinceLastGC number -- Frames elapsed since last GC
|
||||
---@field currentMemoryMB number -- Current memory usage in MB
|
||||
---@field strategy string -- Current GC strategy
|
||||
---@field threshold number -- Memory threshold in MB
|
||||
|
||||
---@class FlexLoveDependencies
|
||||
---@field Context table -- Context module
|
||||
---@field Theme Theme? -- Theme module
|
||||
---@field Color Color -- Color module
|
||||
---@field Calc Calc -- Calc module
|
||||
---@field Units table -- Units module
|
||||
---@field Blur table? -- Blur module
|
||||
---@field ImageRenderer table? -- ImageRenderer module
|
||||
---@field ImageScaler table? -- ImageScaler module
|
||||
---@field NinePatch table? -- NinePatch module
|
||||
---@field RoundedRect table -- RoundedRect module
|
||||
---@field ImageCache table? -- ImageCache module
|
||||
---@field utils table -- Utils module
|
||||
---@field Grid table -- Grid module
|
||||
---@field InputEvent table -- InputEvent module
|
||||
---@field GestureRecognizer table? -- GestureRecognizer module
|
||||
---@field StateManager StateManager -- StateManager module
|
||||
---@field TextEditor table -- TextEditor module
|
||||
---@field LayoutEngine LayoutEngine -- LayoutEngine module
|
||||
---@field Renderer table -- Renderer module
|
||||
---@field EventHandler EventHandler -- EventHandler module
|
||||
---@field ScrollManager table -- ScrollManager module
|
||||
---@field ErrorHandler ErrorHandler -- ErrorHandler module
|
||||
---@field Performance Performance? -- Performance module
|
||||
---@field Transform table? -- Transform module
|
||||
@@ -0,0 +1,319 @@
|
||||
local modulePath = (...):match("(.-)[^%.]+$")
|
||||
local function req(name)
|
||||
return require(modulePath .. name)
|
||||
end
|
||||
|
||||
-- Focused sub-modules (utils now re-exports their surfaces as backward-compatible
|
||||
-- aliases so call sites needn't change). Loaded eagerly so the aliases resolve.
|
||||
local NumberValidation = req("NumberValidation")
|
||||
local TextSanitizer = req("TextSanitizer")
|
||||
local PathValidator = req("PathValidator")
|
||||
local FontCache = req("FontCache")
|
||||
local Enums = req("Enums")
|
||||
|
||||
-- ErrorHandler is injected via init() (safeLoadImage closes over this upvalue).
|
||||
local ErrorHandler = nil
|
||||
|
||||
local enums = Enums.enums
|
||||
|
||||
-- Generic math, table, and path helpers (utils' own concern).
|
||||
-- All validation, font-cache, text-sanitization, and path-validation logic
|
||||
-- lives in the focused sub-modules above and is re-exported below.
|
||||
|
||||
--- Get current keyboard modifiers state
|
||||
---@return {shift:boolean, ctrl:boolean, alt:boolean, super:boolean}
|
||||
local function getModifiers()
|
||||
return {
|
||||
shift = love.keyboard.isDown("lshift", "rshift"),
|
||||
ctrl = love.keyboard.isDown("lctrl", "rctrl"),
|
||||
alt = love.keyboard.isDown("lalt", "ralt"),
|
||||
---@diagnostic disable-next-line
|
||||
super = love.keyboard.isDown("lgui", "rgui"), -- cmd/windows key
|
||||
}
|
||||
end
|
||||
|
||||
local TEXT_SIZE_PRESETS = {
|
||||
["2xs"] = 0.75,
|
||||
xxs = 0.75,
|
||||
xs = 1.25,
|
||||
sm = 1.75,
|
||||
md = 2.25,
|
||||
lg = 2.75,
|
||||
xl = 3.5,
|
||||
xxl = 4.5,
|
||||
["2xl"] = 4.5,
|
||||
["3xl"] = 5.0,
|
||||
["4xl"] = 7.0,
|
||||
}
|
||||
|
||||
--- Resolve text size preset to viewport units
|
||||
---@param sizeValue string|number
|
||||
---@return number?, string?
|
||||
local function resolveTextSizePreset(sizeValue)
|
||||
if type(sizeValue) == "string" then
|
||||
local preset = TEXT_SIZE_PRESETS[sizeValue]
|
||||
if preset then
|
||||
return preset, "vh"
|
||||
end
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
--- Auto-detect the base path where FlexLove is located
|
||||
---@return string filesystemPath
|
||||
local function getFlexLoveBasePath()
|
||||
local info = debug.getinfo(1, "S")
|
||||
if info and info.source then
|
||||
local source = info.source
|
||||
if source:sub(1, 1) == "@" then
|
||||
source = source:sub(2)
|
||||
end
|
||||
|
||||
local filesystemPath = source:match("(.*/)")
|
||||
if filesystemPath then
|
||||
local fsPath = filesystemPath
|
||||
fsPath = fsPath:gsub("^%./", "")
|
||||
fsPath = fsPath:gsub("/$", "")
|
||||
fsPath = fsPath:gsub("/modules$", "")
|
||||
return fsPath
|
||||
end
|
||||
end
|
||||
return "libs"
|
||||
end
|
||||
|
||||
local FLEXLOVE_FILESYSTEM_PATH = getFlexLoveBasePath()
|
||||
|
||||
--- Helper function to resolve paths relative to FlexLove
|
||||
---@param path string
|
||||
---@return string
|
||||
local function resolveImagePath(path)
|
||||
if path:match("^/") or path:match("^[A-Z]:") then
|
||||
return path
|
||||
end
|
||||
return FLEXLOVE_FILESYSTEM_PATH .. "/" .. path
|
||||
end
|
||||
|
||||
-- Math utilities
|
||||
|
||||
--- Clamp a value between optional min/max bounds. Either bound may be nil.
|
||||
--- When both bounds are inverted (min > max), max wins (matches CSS behavior).
|
||||
---@param value number Value to clamp
|
||||
---@param min number|nil Minimum value (nil = no lower bound)
|
||||
---@param max number|nil Maximum value (nil = no upper bound)
|
||||
---@return number Clamped value
|
||||
local function clamp(value, min, max)
|
||||
if min and value < min then
|
||||
value = min
|
||||
end
|
||||
if max and value > max then
|
||||
value = max
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
--- Linear interpolation between two values
|
||||
---@param a number Start value
|
||||
---@param b number End value
|
||||
---@param t number Interpolation factor (0-1)
|
||||
---@return number Interpolated value
|
||||
local function lerp(a, b, t)
|
||||
return a + (b - a) * t
|
||||
end
|
||||
|
||||
--- Round a number to the nearest integer
|
||||
---@param value number Value to round
|
||||
---@return number Rounded value
|
||||
local function round(value)
|
||||
return math.floor(value + 0.5)
|
||||
end
|
||||
|
||||
-- Image utilities
|
||||
|
||||
--- Safely load an image with error handling
|
||||
--- Returns both Image and ImageData to avoid deprecated getData() API
|
||||
---@param imagePath string Path to image file
|
||||
---@return love.Image?, love.ImageData?, string? Returns image, imageData, or nil with error message
|
||||
local function safeLoadImage(imagePath)
|
||||
local success, imageData = pcall(function()
|
||||
return love.image.newImageData(imagePath)
|
||||
end)
|
||||
|
||||
if not success then
|
||||
local errorMsg = string.format("Failed to load image data: %s - %s", imagePath, tostring(imageData))
|
||||
if ErrorHandler then
|
||||
ErrorHandler:warn("utils", "RES_004", {
|
||||
resourceType = "image data",
|
||||
path = imagePath,
|
||||
error = tostring(imageData),
|
||||
})
|
||||
end
|
||||
return nil, nil, errorMsg
|
||||
end
|
||||
|
||||
local imageSuccess, image = pcall(function()
|
||||
return love.graphics.newImage(imageData)
|
||||
end)
|
||||
|
||||
if imageSuccess then
|
||||
return image, imageData, nil
|
||||
else
|
||||
local errorMsg = string.format("Failed to create image: %s - %s", imagePath, tostring(image))
|
||||
if ErrorHandler then
|
||||
ErrorHandler:warn("utils", "RES_004", {
|
||||
resourceType = "image",
|
||||
path = imagePath,
|
||||
error = tostring(image),
|
||||
})
|
||||
end
|
||||
return nil, nil, errorMsg
|
||||
end
|
||||
end
|
||||
|
||||
-- Color manipulation utilities
|
||||
|
||||
--- Brighten a color by a factor
|
||||
---@param r number Red component (0-1)
|
||||
---@param g number Green component (0-1)
|
||||
---@param b number Blue component (0-1)
|
||||
---@param a number Alpha component (0-1)
|
||||
---@param factor number Brightness factor (e.g., 1.2 for 20% brighter)
|
||||
---@return number, number, number, number Brightened color components
|
||||
local function brightenColor(r, g, b, a, factor)
|
||||
return math.min(1, r * factor), math.min(1, g * factor), math.min(1, b * factor), a
|
||||
end
|
||||
|
||||
-- Property normalization utilities
|
||||
|
||||
--- Normalize a boolean or table property with vertical/horizontal fields
|
||||
---@param value boolean|table|nil Input value (boolean applies to both, table for individual control)
|
||||
---@param defaultValue boolean Default value if nil (default: false)
|
||||
---@return table Normalized table with vertical and horizontal fields
|
||||
local function normalizeBooleanTable(value, defaultValue)
|
||||
defaultValue = defaultValue or false
|
||||
|
||||
if value == nil then
|
||||
return { vertical = defaultValue, horizontal = defaultValue }
|
||||
end
|
||||
|
||||
if type(value) == "boolean" then
|
||||
return { vertical = value, horizontal = value }
|
||||
end
|
||||
|
||||
if type(value) == "table" then
|
||||
return {
|
||||
vertical = value.vertical ~= nil and value.vertical or defaultValue,
|
||||
horizontal = value.horizontal ~= nil and value.horizontal or defaultValue,
|
||||
}
|
||||
end
|
||||
|
||||
return { vertical = defaultValue, horizontal = defaultValue }
|
||||
end
|
||||
|
||||
--- Normalize an offset value to {x, y} or {horizontal, vertical} format
|
||||
---@param value number|table|nil Input value (number applies to both, table for individual control)
|
||||
---@param defaultValue number Default value if nil (default: 0)
|
||||
---@return table Normalized table with x/y or horizontal/vertical fields
|
||||
local function normalizeOffsetTable(value, defaultValue)
|
||||
defaultValue = defaultValue or 0
|
||||
|
||||
if value == nil then
|
||||
return { x = defaultValue, y = defaultValue, horizontal = defaultValue, vertical = defaultValue }
|
||||
end
|
||||
|
||||
if type(value) == "number" then
|
||||
return { x = value, y = value, horizontal = value, vertical = value }
|
||||
end
|
||||
|
||||
if type(value) == "table" then
|
||||
-- Support both {x, y} and {horizontal, vertical} formats
|
||||
local x = value.x or value.horizontal or defaultValue
|
||||
local y = value.y or value.vertical or defaultValue
|
||||
return {
|
||||
x = x,
|
||||
y = y,
|
||||
horizontal = x,
|
||||
vertical = y,
|
||||
}
|
||||
end
|
||||
|
||||
return { x = defaultValue, y = defaultValue, horizontal = defaultValue, vertical = defaultValue }
|
||||
end
|
||||
|
||||
--- Apply content auto-sizing multiplier to a dimension
|
||||
---@param value number The dimension value
|
||||
---@param multiplier table? The contentAutoSizingMultiplier table {width:number?, height:number?}
|
||||
---@param axis "width"|"height" Which axis to apply
|
||||
---@return number The multiplied value
|
||||
local function applyContentMultiplier(value, multiplier, axis)
|
||||
if multiplier and multiplier[axis] then
|
||||
return value * multiplier[axis]
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
--- Initialize dependencies
|
||||
---@param deps table Dependencies: { ErrorHandler = ErrorHandler }
|
||||
local function init(deps)
|
||||
if type(deps) == "table" then
|
||||
ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
-- Propagate shared ErrorHandler to focused sub-modules that need it.
|
||||
NumberValidation.init({ ErrorHandler = ErrorHandler, clamp = clamp })
|
||||
TextSanitizer.init({ ErrorHandler = ErrorHandler })
|
||||
FontCache.init({ ErrorHandler = ErrorHandler, resolveImagePath = resolveImagePath })
|
||||
-- PathValidator has no external dependencies.
|
||||
end
|
||||
|
||||
return {
|
||||
enums = enums,
|
||||
FONT_CACHE = FontCache.FONT_CACHE,
|
||||
resolveTextSizePreset = resolveTextSizePreset,
|
||||
getModifiers = getModifiers,
|
||||
TEXT_SIZE_PRESETS = TEXT_SIZE_PRESETS,
|
||||
init = init,
|
||||
clamp = clamp,
|
||||
-- Alias for `clamp`; exposed under the size-clamping name so Element/LayoutEngine
|
||||
-- and tests can reference min/max content-size clamping explicitly.
|
||||
clampSize = clamp,
|
||||
lerp = lerp,
|
||||
round = round,
|
||||
safeLoadImage = safeLoadImage,
|
||||
brightenColor = brightenColor,
|
||||
resolveImagePath = resolveImagePath,
|
||||
normalizeBooleanTable = normalizeBooleanTable,
|
||||
normalizeOffsetTable = normalizeOffsetTable,
|
||||
applyContentMultiplier = applyContentMultiplier,
|
||||
-- Backward-compatible aliases (delegated to focused sub-modules)
|
||||
validateEnum = NumberValidation.validateEnum,
|
||||
validateRange = NumberValidation.validateRange,
|
||||
validateType = NumberValidation.validateType,
|
||||
isNaN = NumberValidation.isNaN,
|
||||
isInfinity = NumberValidation.isInfinity,
|
||||
validateNumber = NumberValidation.validateNumber,
|
||||
sanitizeNumber = NumberValidation.sanitizeNumber,
|
||||
validateInteger = NumberValidation.validateInteger,
|
||||
validatePercentage = NumberValidation.validatePercentage,
|
||||
validateOpacity = NumberValidation.validateOpacity,
|
||||
validateDegrees = NumberValidation.validateDegrees,
|
||||
validateCoordinate = NumberValidation.validateCoordinate,
|
||||
validateDimension = NumberValidation.validateDimension,
|
||||
normalizePath = PathValidator.normalizePath,
|
||||
sanitizePath = PathValidator.sanitizePath,
|
||||
isPathSafe = PathValidator.isPathSafe,
|
||||
validatePath = PathValidator.validatePath,
|
||||
getFileExtension = PathValidator.getFileExtension,
|
||||
hasAllowedExtension = PathValidator.hasAllowedExtension,
|
||||
sanitizeText = TextSanitizer.sanitizeText,
|
||||
validateTextInput = TextSanitizer.validateTextInput,
|
||||
validateTextRange = TextSanitizer.validateTextRange,
|
||||
escapeHtml = TextSanitizer.escapeHtml,
|
||||
escapeLuaPattern = TextSanitizer.escapeLuaPattern,
|
||||
stripNonPrintable = TextSanitizer.stripNonPrintable,
|
||||
resolveFontPath = FontCache.resolveFontPath,
|
||||
getFont = FontCache.getFont,
|
||||
getFontCacheStats = FontCache.getFontCacheStats,
|
||||
setFontCacheSize = FontCache.setFontCacheSize,
|
||||
clearFontCache = FontCache.clearFontCache,
|
||||
preloadFont = FontCache.preloadFont,
|
||||
resetFontCacheStats = FontCache.resetFontCacheStats,
|
||||
}
|
||||
@@ -184,6 +184,11 @@ local function bootGame(version)
|
||||
end
|
||||
|
||||
function love.load(args)
|
||||
-- Before anything can shell out (update check, mod index, ROM picker),
|
||||
-- claim one hidden console on Windows so those children inherit it instead
|
||||
-- of each flashing their own cmd.exe window (#606). No-op elsewhere.
|
||||
require("src.core.HostShell").hideHostConsole()
|
||||
|
||||
-- Self-updater boot shell: a fused build may mount and chainload a newer
|
||||
-- downloaded payload here. True means it took over, so we must stop. A
|
||||
-- dev / source checkout no-ops (see src/update/Boot.lua).
|
||||
@@ -417,16 +422,10 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||
return TouchEditor.touchpressed(id, x, y)
|
||||
end
|
||||
if Importer then
|
||||
-- iOS: LÖVE already synthesizes a mousepressed for the primary touch,
|
||||
-- and love.mousepressed below forwards that to the Importer, so
|
||||
-- forwarding here too fires every launcher button twice per tap. The
|
||||
-- resulting double-present was fatal for the document picker: the
|
||||
-- second sheet stole the first one's weakly-held delegate, so picking
|
||||
-- a file silently did nothing. Android keeps the forward for upstream
|
||||
-- parity (its SAF picker is a separate activity and tolerates the
|
||||
-- re-launch).
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return Importer:mousepressed(x, y, 1)
|
||||
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
|
||||
-- polled inside the view; the istouch filter on mousepressed still drops
|
||||
-- Android's synthesized mouse twin so Import cannot double-fire (#553).
|
||||
return Importer:touchpressed(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
Game:touchpressed(id, x, y)
|
||||
end
|
||||
@@ -437,7 +436,9 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return TouchEditor.touchmoved(id, x, y)
|
||||
end
|
||||
if Importer then return end
|
||||
if Importer then
|
||||
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
Game:touchmoved(id, x, y)
|
||||
end
|
||||
|
||||
@@ -447,7 +448,9 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
return TouchEditor.touchreleased(id, x, y)
|
||||
end
|
||||
if Importer then return end
|
||||
if Importer then
|
||||
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
Game:touchreleased(id, x, y)
|
||||
end
|
||||
|
||||
@@ -469,21 +472,14 @@ function love.mousepressed(x, y, button, istouch)
|
||||
return TouchEditor.mousepressed(x, y, button)
|
||||
end
|
||||
if Importer then
|
||||
-- The same double-fire TouchEditor guards against, which the launcher was
|
||||
-- missing: love.touchpressed above forwards the primary touch to the
|
||||
-- Importer on Android, and LÖVE ALSO synthesizes a mouse press for that
|
||||
-- same touch, so one tap ran every launcher button twice. On Import that
|
||||
-- meant two choose() calls and two stacked SAF picker activities: the
|
||||
-- player picked their ROM, the top picker closed, and the second was still
|
||||
-- underneath asking for it again, which is the "import the file twice"
|
||||
-- in #553. Filtering on istouch keeps a real mouse (DeX, a Chromebook, a
|
||||
-- USB mouse) working, which an Android-wide return would have broken.
|
||||
--
|
||||
-- ANDROID ONLY, and the OS test is load bearing: love.touchpressed above
|
||||
-- returns early on iOS and never forwards, so there the synthesized mouse
|
||||
-- press is the ONLY event the launcher gets. Filtering istouch on both
|
||||
-- killed every tap on iOS outright.
|
||||
if istouch and love.system.getOS() == "Android" then return end
|
||||
-- love.touchpressed already forwards the primary touch into FlexLove for
|
||||
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
|
||||
-- reached a press handler, one tap ran every launcher button twice and
|
||||
-- stacked two SAF pickers (#553). Clicks are polled inside FlexLove from
|
||||
-- love.touch / mouse.isDown, so dropping the synthesized istouch press is
|
||||
-- safe. A real mouse (DeX, Chromebook, USB) still reaches mousepressed.
|
||||
if istouch and (love.system.getOS() == "Android"
|
||||
or love.system.getOS() == "iOS") then return end
|
||||
return Importer:mousepressed(x, y, button)
|
||||
end
|
||||
if editorMode and EditorApp.mousepressed then
|
||||
|
||||
@@ -73,6 +73,21 @@ of the build script's permission trim). The first
|
||||
read runs immediately (`onRequestPermissionsResult`,
|
||||
`STEP_PERMISSION_REQUEST_CODE`).
|
||||
|
||||
### Network transport (mod index / mod updates)
|
||||
|
||||
`love.system.httpDownload(url, absPath [, userAgent [, accept]])` ->
|
||||
`GameActivity.httpDownload` (same JNI route as the picker and the step
|
||||
bridge: `common/android.cpp` -> `modules/system/System.cpp` ->
|
||||
`wrap_System.cpp`). Android ships no `curl`, which is what the desktop
|
||||
builds fetch the mod index, mod release lists and mod zips with, so the
|
||||
"Find mods" tab used to fail with "curl is not available on this
|
||||
platform" (#597). The Java side is a blocking `HttpsURLConnection` GET
|
||||
(https only, redirects followed by hand, body renamed into place only
|
||||
once complete) and runs on LOVE's Lua thread, never the UI thread.
|
||||
`src/core/HostShell.lua` picks the transport: curl when present,
|
||||
otherwise this bridge; an APK older than the bridge simply reports no
|
||||
transport, exactly as a missing curl does.
|
||||
|
||||
### SDK / NDK
|
||||
|
||||
love-android 11.5a expects:
|
||||
@@ -93,7 +108,8 @@ The APK lands under `app/build/outputs/apk/embedNoRecord/debug/`.
|
||||
### Payload path
|
||||
|
||||
`app/src/embed/assets/game.love` - zip of `main.lua`, `conf.lua`, `src/`,
|
||||
`data/`, `assets/`, and the Red, Blue, and Yellow ROM manifests. The Android
|
||||
`libs/` (the vendored FlexLove toolkit the launcher UI needs), `data/`,
|
||||
`assets/`, and the Red, Blue, and Yellow ROM manifests. The Android
|
||||
packer verifies the Yellow manifest before it packages; if a partial source
|
||||
export omitted it, it restores the file from this checkout's Git data and then
|
||||
falls back to the project's GitHub copy. Generated game data,
|
||||
@@ -107,7 +123,7 @@ scripts, tests, and mobile build sources are excluded.
|
||||
| `app.name` | Pokemon Red |
|
||||
| `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. |
|
||||
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted |
|
||||
| Permissions | INTERNET / RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH kept |
|
||||
| Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept |
|
||||
|
||||
## Releases
|
||||
|
||||
|
||||
@@ -253,6 +253,41 @@ bool restartApp()
|
||||
return result;
|
||||
}
|
||||
|
||||
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept)
|
||||
{
|
||||
if (url == nullptr || destPath == nullptr)
|
||||
return false;
|
||||
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
|
||||
// Old APK / new liblove skew: report "no transport" the same way a
|
||||
// missing curl does, instead of aborting on a missing method (#597).
|
||||
jmethodID method = env->GetStaticMethodID(activity, "httpDownload",
|
||||
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Z");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
|
||||
jstring jurl = env->NewStringUTF(url);
|
||||
jstring jdest = env->NewStringUTF(destPath);
|
||||
jstring jua = env->NewStringUTF(userAgent != nullptr ? userAgent : "gen1recomp");
|
||||
jstring jaccept = accept != nullptr ? env->NewStringUTF(accept) : nullptr;
|
||||
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jurl, jdest, jua, jaccept);
|
||||
|
||||
env->DeleteLocalRef(jurl);
|
||||
env->DeleteLocalRef(jdest);
|
||||
env->DeleteLocalRef(jua);
|
||||
if (jaccept != nullptr)
|
||||
env->DeleteLocalRef(jaccept);
|
||||
env->DeleteLocalRef(activity);
|
||||
return result;
|
||||
}
|
||||
|
||||
/*
|
||||
* Helper functions for the filesystem module
|
||||
*/
|
||||
|
||||
@@ -90,6 +90,14 @@ bool syncHealthSteps();
|
||||
**/
|
||||
bool restartApp();
|
||||
|
||||
/**
|
||||
* Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has
|
||||
* no curl binary, so this is the transport src/core/HostShell.lua uses there
|
||||
* for the mod index and mod updates (#597). userAgent / accept may be null.
|
||||
* Returns whether a complete file was written.
|
||||
**/
|
||||
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept);
|
||||
|
||||
/*
|
||||
* Helper functions for the filesystem module
|
||||
*/
|
||||
|
||||
@@ -230,6 +230,20 @@ bool System::restartApp() const
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::httpDownload(const char *url, const char *destPath,
|
||||
const char *userAgent, const char *accept) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::httpDownload(url, destPath, userAgent, accept);
|
||||
#else
|
||||
LOVE_UNUSED(url);
|
||||
LOVE_UNUSED(destPath);
|
||||
LOVE_UNUSED(userAgent);
|
||||
LOVE_UNUSED(accept);
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::hasBackgroundMusic() const
|
||||
{
|
||||
#if defined(LOVE_ANDROID)
|
||||
|
||||