Compare commits
200 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d18824cfae | |||
| 12b4c93279 | |||
| af47e19e1a | |||
| 4e666303d0 | |||
| 0fa8206321 | |||
| 719ba49a85 | |||
| f864837cbe | |||
| 21191b8e80 | |||
| 6426e913b6 | |||
| 3d3e42c6a3 | |||
| 6fe106f55c | |||
| ba8ac3d143 | |||
| 1820f411ae | |||
| 8327942a9c | |||
| aec7f6001a | |||
| ad9b03fe56 | |||
| 3b490bc42f | |||
| dacd9abe73 | |||
| 8fbe819493 | |||
| 626080d228 | |||
| f56e82de81 | |||
| 7d3950658a | |||
| 3c1246ee06 | |||
| d69965e3b1 | |||
| 30edac03a6 | |||
| dd4aface93 | |||
| b1350fdecd | |||
| f2d9b74490 | |||
| 84d09d0894 | |||
| e61f231316 | |||
| 33998aab5d | |||
| 9ce7379946 | |||
| b692474e24 | |||
| 6cfc85ca83 | |||
| 3a6557ffe2 | |||
| fe139ca7f4 | |||
| 5c617d3e32 | |||
| 8d11c04d43 | |||
| 8da51aa7ba | |||
| 78f87111de | |||
| 6ea59f1a79 | |||
| 51fb4db82e | |||
| 7f78de8718 | |||
| 76aab74bf1 | |||
| a2bf08c6ff | |||
| b831076839 | |||
| 45575a1fd8 | |||
| 56d92246cb | |||
| 4a00f2335c | |||
| e9cb830d71 | |||
| c2666d2b43 | |||
| 9ceec3070d | |||
| f89977a8e7 | |||
| 8d512b95cb | |||
| 0914387c16 | |||
| 105672c132 | |||
| 0dd6ecc219 | |||
| 9ef5c8dead | |||
| 12ff4dba2e | |||
| ff9992dff8 | |||
| 98c08e4b22 | |||
| 0bd3b26acc | |||
| 59b55a8f37 | |||
| 67e6b1fb04 | |||
| 17243a7d8b | |||
| 16b6b98ede | |||
| e4ce1063a1 | |||
| f099593136 | |||
| f944507520 | |||
| 6824949247 | |||
| 4f54255518 | |||
| cfe482e1fc | |||
| 0e5ea26e36 | |||
| d5886e69aa | |||
| 32fa215243 | |||
| dd5d8afd82 | |||
| f19f4e9341 | |||
| 3aec2ca89b | |||
| 0f7261dd92 | |||
| 0886573d34 | |||
| 5c041ea857 | |||
| 30cd9afc92 | |||
| cceb74a025 | |||
| 42540076c1 | |||
| a528868656 | |||
| c82af28fd4 | |||
| d72e5db0a5 | |||
| 9907cfb0a0 | |||
| a3cd5a132e | |||
| 0971b47bec | |||
| d86ac70d9e | |||
| be8023a994 | |||
| 50fd0e9c7d | |||
| 17843c2f7a | |||
| 54c8d2706b | |||
| 44bfb1b93f | |||
| 7a78992881 | |||
| d7581dbece | |||
| eeb8c89d3d | |||
| 2591fde764 | |||
| a8f3a3155c | |||
| 2a927651c5 | |||
| 62ef647811 | |||
| fe4491a87b | |||
| 105bf65e02 | |||
| 8120f113af | |||
| 7e0c64cb78 | |||
| 4afb54c54f | |||
| 74f6b68034 | |||
| 5d1e7ff3c1 | |||
| 3c628140f7 | |||
| 8bf99c3318 | |||
| f0829ed54f | |||
| 59dba325b9 | |||
| d13064ee64 | |||
| 669c9f4d8b | |||
| 228306f883 | |||
| 0a2ef854e6 | |||
| 61c471343a | |||
| 95eb1c3a35 | |||
| cf50976d65 | |||
| 3fcee7f951 | |||
| 17a4c83970 | |||
| 9712fb1e9b | |||
| cbf622f4aa | |||
| 0bb0518093 | |||
| 41f549b271 | |||
| ba8aa7b263 | |||
| 243cb6a477 | |||
| c987fefede | |||
| 2a038f7112 | |||
| 548e75a15e | |||
| 339c85b590 | |||
| 0f0420314b | |||
| a08b5e65f7 | |||
| 99b89c4e5e | |||
| 20586e0c48 | |||
| b54d0c605a | |||
| 2f75af902c | |||
| 9147a6413e | |||
| 9a467d4bf1 | |||
| e40f9337b6 | |||
| 1f0712d2d5 | |||
| 345bc9519a | |||
| 0b30bb2eff | |||
| f0e88aa581 | |||
| 317893bd61 | |||
| bd181271d9 | |||
| 4df09010a8 | |||
| 1a5b2b96dc | |||
| 43d89813fc | |||
| 5bc76ce6fe | |||
| 8654444d1a | |||
| 772c39edb8 | |||
| 74e7474349 | |||
| 3aefe41ea1 | |||
| 2223c31e93 | |||
| 568f0fa9eb | |||
| e91cf0f0c2 | |||
| 8a1f583d88 | |||
| 7518319c80 | |||
| 304df44742 | |||
| e92bab6f4f | |||
| 43755c5457 | |||
| d4455c508f | |||
| 7d01242cd1 | |||
| 9d77189cec | |||
| c8adbdcc64 | |||
| ac6dfe7134 | |||
| b1ad7c7254 | |||
| 6fb5602cb0 | |||
| b942d37944 | |||
| b7ee191b6c | |||
| 4ea24a0a6f | |||
| 6ad8c8d73d | |||
| 8982952fa4 | |||
| b135878824 | |||
| e9463d39aa | |||
| dcee7ca8e4 | |||
| 1653aa0a5d | |||
| 2699c9a2f9 | |||
| efd81d8e34 | |||
| 925b224eda | |||
| 7504753ea8 | |||
| da60f40dfc | |||
| 560ebc5997 | |||
| 361d4b81df | |||
| 83cf1fdb00 | |||
| 228a0baf81 | |||
| df7cea4387 | |||
| 13b55a797e | |||
| 1ec33b1374 | |||
| c06991e03d | |||
| 2fcd7abbfd | |||
| 5ca17cfc99 | |||
| 05f1e3852c | |||
| 3b22a45a23 | |||
| f172d3129f | |||
| a7a84b6fab | |||
| d4afa09e03 |
@@ -102,6 +102,164 @@ jobs:
|
||||
if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }}
|
||||
run: security delete-keychain "$RUNNER_TEMP/gen1recomp-ci-signing.keychain-db" 2>/dev/null || true
|
||||
|
||||
switch-changes:
|
||||
name: detect Switch 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 '^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics)_test\.lua$|tests/engine/platform_nx)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
switch-selftest:
|
||||
name: Switch offline selftest
|
||||
needs: switch-changes
|
||||
if: needs.switch-changes.outputs.changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: install luajit
|
||||
run: sudo apt-get update && sudo apt-get install -y luajit
|
||||
- name: Switch offline selftest
|
||||
run: bash scripts/switch/selftest_build_switch.sh
|
||||
- name: verify_payload self-test
|
||||
run: bash scripts/switch/verify_payload.sh --self-test
|
||||
- name: Switch CI workflow content gate
|
||||
run: luajit tests/switch_ci_workflows_test.lua
|
||||
- name: Switch transfer docs content gate
|
||||
run: luajit tests/switch_transfer_docs_test.lua
|
||||
# NX runtime regressions gate this job via switch-changes; run the NX
|
||||
# engine suites here too so a PR touching them gets feedback on the
|
||||
# fork-safe ubuntu runner before the self-hosted Mac build.
|
||||
- name: NX engine suites (headless)
|
||||
run: |
|
||||
luajit tests/engine/assets_version_fallback_test.lua
|
||||
luajit tests/engine/nx_generated_guard_test.lua
|
||||
luajit tests/engine/nx_yellow_boot_test.lua
|
||||
|
||||
switch-build:
|
||||
name: Switch fused build
|
||||
needs: [switch-changes, switch-selftest]
|
||||
if: |
|
||||
always()
|
||||
&& needs.switch-changes.outputs.changed == 'true'
|
||||
&& needs.switch-selftest.result == 'success'
|
||||
&& github.repository == 'bryanthaboi/gen1recomp'
|
||||
&& (github.event_name != 'pull_request'
|
||||
|| github.event.pull_request.head.repo.full_name == github.repository)
|
||||
runs-on: ["self-hosted", "macOS"]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build Switch fused NRO
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER="$(printf '%s' "$GITHUB_SHA" | cut -c1-7)"
|
||||
scripts/build_switch.sh --fetch --fused --version "$VER"
|
||||
echo "SWITCH_VER=$VER" >> "$GITHUB_ENV"
|
||||
- name: upload Switch NRO artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-switch-nro
|
||||
path: |
|
||||
dist/switch/gen1recomp-${{ env.SWITCH_VER }}-switch.nro
|
||||
dist/switch/gen1recomp-${{ env.SWITCH_VER }}-switch.nro.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
xbox-uwp-changes:
|
||||
name: detect Xbox UWP 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 '^(ports/uwp/|scripts/build_xbox_uwp\.sh$|scripts/xbox-uwp/|scripts/pack_love\.sh$|\.github/workflows/(ci|release)\.yml$|src/core/Platform\.lua$|src/import/(CacheFs|RomImporter)\.lua$|src/update/Check\.lua$|tests/engine/(platform_nx|uwp_native_picker)_test\.lua$|tests/rom_importer_double_pick_test\.lua$)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
xbox-uwp-selftest:
|
||||
name: Xbox UWP offline selftest
|
||||
needs: xbox-uwp-changes
|
||||
if: needs.xbox-uwp-changes.outputs.changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Xbox UWP offline selftest
|
||||
run: bash scripts/xbox-uwp/selftest_build_xbox_uwp.sh
|
||||
- name: Build shared payload
|
||||
run: |
|
||||
scripts/pack_love.sh \
|
||||
--output .bazinga/work/ci-game.love \
|
||||
--listing .bazinga/work/ci-love-listing.txt \
|
||||
--version 0.0.0
|
||||
- name: Upload shared payload
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-xbox-uwp-payload
|
||||
path: .bazinga/work/ci-game.love
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
xbox-uwp-build:
|
||||
name: Xbox UWP build
|
||||
needs: [xbox-uwp-changes, xbox-uwp-selftest]
|
||||
if: |
|
||||
always()
|
||||
&& needs.xbox-uwp-changes.outputs.changed == 'true'
|
||||
&& needs.xbox-uwp-selftest.result == 'success'
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Download shared payload
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-xbox-uwp-payload
|
||||
path: .bazinga/work
|
||||
- name: Build Xbox UWP package
|
||||
shell: bash
|
||||
run: |
|
||||
bash scripts/build_xbox_uwp.sh \
|
||||
--release \
|
||||
--version 0.0.0 \
|
||||
--game-love .bazinga/work/ci-game.love
|
||||
- name: Upload Xbox UWP package
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-xbox-uwp
|
||||
path: |
|
||||
dist/xbox-uwp/gen1recomp-0.0.0-xbox-uwp.zip
|
||||
dist/xbox-uwp/gen1recomp-0.0.0-xbox-uwp.zip.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
headless:
|
||||
name: headless suites (no ROM)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -29,13 +29,7 @@ jobs:
|
||||
[ -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 }}
|
||||
# Upsert via comment-tag only — do not delete-all bot comments (clobbers Switch).
|
||||
- name: Get build info
|
||||
id: build-info
|
||||
env:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
name: Release
|
||||
|
||||
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
|
||||
# IPA, and the Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on
|
||||
# the self-hosted Mac runner, and publishes them as a GitHub Release.
|
||||
# IPA, a Nintendo Switch SD-ready zip (experimental), Xbox UWP, and the Anbernic
|
||||
# RG34XXSP (Stock OS 64-bit MOD / PortMaster) port, then publishes them as a
|
||||
# GitHub Release.
|
||||
#
|
||||
# Versioning:
|
||||
# - First ever release is 0.1.0.
|
||||
@@ -42,22 +43,17 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
|
||||
version:
|
||||
name: determine release version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.ver.outputs.version }}
|
||||
tag: ${{ steps.ver.outputs.tag }}
|
||||
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@v7
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Determine version
|
||||
id: ver
|
||||
env:
|
||||
@@ -65,7 +61,6 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
semver_re='^[0-9]+\.[0-9]+\.[0-9]+$'
|
||||
|
||||
# 1) Explicit override from a manual run.
|
||||
@@ -95,7 +90,6 @@ jobs:
|
||||
| grep -E "$semver_re" \
|
||||
| sort -t. -k1,1n -k2,2n -k3,3n \
|
||||
| tail -1 || true)"
|
||||
|
||||
if [ -z "$latest" ]; then
|
||||
version="0.1.0"
|
||||
echo "No existing release tag; starting at $version"
|
||||
@@ -123,10 +117,127 @@ jobs:
|
||||
echo "::error::Release $tag already exists. Pick a different version."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
love-payload:
|
||||
name: build release game.love
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build shared payload
|
||||
run: |
|
||||
scripts/pack_love.sh \
|
||||
--output dist/payload/game.love \
|
||||
--listing dist/payload/love-listing.txt \
|
||||
--version "${{ needs.version.outputs.version }}"
|
||||
- name: Upload shared payload
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-release-love
|
||||
path: dist/payload/game.love
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
xbox-uwp:
|
||||
name: build Xbox UWP release
|
||||
needs: [version, love-payload]
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Download shared payload
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-release-love
|
||||
path: .bazinga/work
|
||||
- name: Prepare signing certificate
|
||||
shell: pwsh
|
||||
env:
|
||||
CERTIFICATE_BASE64: ${{ secrets.XBOX_UWP_SIGNING_CERTIFICATE }}
|
||||
CERTIFICATE_PASSWORD: ${{ secrets.XBOX_UWP_SIGNING_PASSWORD }}
|
||||
CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }}
|
||||
run: |
|
||||
if ($env:CANONICAL_REPOSITORY -eq 'true' -and
|
||||
[string]::IsNullOrWhiteSpace($env:CERTIFICATE_BASE64)) {
|
||||
throw 'XBOX_UWP_SIGNING_CERTIFICATE is not configured.'
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($env:CERTIFICATE_BASE64)) {
|
||||
"UWP_PUBLISHER=CN=Gen1Recomp" | Out-File $env:GITHUB_ENV -Append
|
||||
exit 0
|
||||
}
|
||||
$pfx = Join-Path $env:RUNNER_TEMP 'gen1recomp-uwp.pfx'
|
||||
[IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:CERTIFICATE_BASE64))
|
||||
$flags = [Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet
|
||||
$cert = [Security.Cryptography.X509Certificates.X509Certificate2]::new(
|
||||
$pfx, $env:CERTIFICATE_PASSWORD, $flags)
|
||||
$cer = Join-Path $env:RUNNER_TEMP 'gen1recomp-uwp.cer'
|
||||
[IO.File]::WriteAllBytes(
|
||||
$cer,
|
||||
$cert.Export([Security.Cryptography.X509Certificates.X509ContentType]::Cert))
|
||||
Import-Certificate -FilePath $cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople | Out-Null
|
||||
"UWP_PFX=$pfx" | Out-File $env:GITHUB_ENV -Append
|
||||
"UWP_CERT_THUMBPRINT=$($cert.Thumbprint)" | Out-File $env:GITHUB_ENV -Append
|
||||
"UWP_PUBLISHER=$($cert.Subject)" | Out-File $env:GITHUB_ENV -Append
|
||||
- name: Build Xbox UWP package
|
||||
shell: bash
|
||||
run: |
|
||||
bash scripts/build_xbox_uwp.sh \
|
||||
--release \
|
||||
--version "${{ needs.version.outputs.version }}" \
|
||||
--publisher "$UWP_PUBLISHER" \
|
||||
--game-love .bazinga/work/game.love
|
||||
- name: Sign and stage Xbox UWP release
|
||||
shell: pwsh
|
||||
env:
|
||||
CERTIFICATE_PASSWORD: ${{ secrets.XBOX_UWP_SIGNING_PASSWORD }}
|
||||
run: |
|
||||
if (-not $env:UWP_PFX) {
|
||||
exit 0
|
||||
}
|
||||
scripts/xbox-uwp/stage_release.ps1 `
|
||||
-Version '${{ needs.version.outputs.version }}' `
|
||||
-Configuration Release `
|
||||
-BuildInfo .bazinga/work/xbox-uwp-build-info.json `
|
||||
-CertificatePath $env:UWP_PFX `
|
||||
-CertificatePassword $env:CERTIFICATE_PASSWORD
|
||||
- name: Upload Xbox UWP release
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-xbox-uwp-release
|
||||
path: |
|
||||
dist/xbox-uwp/gen1recomp-${{ needs.version.outputs.version }}-xbox-uwp.zip
|
||||
dist/xbox-uwp/gen1recomp-${{ needs.version.outputs.version }}-xbox-uwp.zip.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
- name: Remove signing certificate
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
if ($env:UWP_CERT_THUMBPRINT) {
|
||||
Remove-Item "Cert:\LocalMachine\TrustedPeople\$env:UWP_CERT_THUMBPRINT" -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($env:UWP_PFX) {
|
||||
Remove-Item $env:UWP_PFX -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
release:
|
||||
needs: [version, xbox-uwp]
|
||||
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@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Import signing certificate into a temporary keychain
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
@@ -171,12 +282,12 @@ jobs:
|
||||
# notarize separately below so it uses secret credentials, not a
|
||||
# login-keychain profile. "all" also builds the Linux AppImage,
|
||||
# which needs no signing/notarization.
|
||||
scripts/build.sh all --version "${{ steps.ver.outputs.version }}" --no-notarize
|
||||
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize
|
||||
|
||||
- name: Build Android
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
||||
scripts/build_android.sh --version "${{ needs.version.outputs.version }}"
|
||||
|
||||
- name: Install xcbeautify
|
||||
run: |
|
||||
@@ -190,18 +301,29 @@ jobs:
|
||||
set -euo pipefail
|
||||
if [ "$CANONICAL_REPOSITORY" = true ]; then
|
||||
scripts/build_ios.sh --fetch --device --release \
|
||||
--version "${{ steps.ver.outputs.version }}"
|
||||
--version "${{ needs.version.outputs.version }}"
|
||||
else
|
||||
scripts/build_ios.sh --fetch --release \
|
||||
--version "${{ steps.ver.outputs.version }}"
|
||||
--version "${{ needs.version.outputs.version }}"
|
||||
fi
|
||||
|
||||
- name: Build Switch
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Hard-fail gate: Switch ships with every release (never soft-fail).
|
||||
# PR CI is path-gated (ubuntu selftest + canonical fused); release
|
||||
# always builds Switch regardless of which files changed.
|
||||
# Needs native switch-tools (nacptool/elf2nro) and/or Docker on the
|
||||
# Mac self-hosted runner; see docs/switch-build.md.
|
||||
scripts/build_switch.sh --fetch --fused \
|
||||
--version "${{ needs.version.outputs.version }}"
|
||||
|
||||
- name: Build Anbernic RG34XXSP port
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5
|
||||
# runtime from PortMaster-GUI, so it needs no signing/notarization.
|
||||
./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}"
|
||||
./build-rg34xxsp.sh --version "${{ needs.version.outputs.version }}"
|
||||
|
||||
- name: Notarize & staple macOS app
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
@@ -238,12 +360,19 @@ jobs:
|
||||
ditto -c -k --sequesterRsrc --keepParent "$app" "$zip"
|
||||
echo "Notarized + stapled ✓"
|
||||
|
||||
- name: Download Xbox UWP release
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-xbox-uwp-release
|
||||
path: dist/xbox-uwp
|
||||
|
||||
- name: Stage release assets
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
id: assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v="${{ steps.ver.outputs.version }}"
|
||||
v="${{ needs.version.outputs.version }}"
|
||||
outdir="dist/release"
|
||||
rm -rf "$outdir"
|
||||
mkdir -p "$outdir"
|
||||
@@ -258,6 +387,16 @@ jobs:
|
||||
[ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; }
|
||||
cp "$ipa" "$outdir/gen1recomp-${v}-ios.ipa"
|
||||
|
||||
swzip="dist/switch/gen1recomp-${v}-switch.zip"
|
||||
[ -f "$swzip" ] || { echo "::error::$swzip not found (expected from scripts/build_switch.sh --fused → pack_sd_zip.sh)"; exit 1; }
|
||||
cp "$swzip" "$outdir/gen1recomp-${v}-switch.zip"
|
||||
# Local fused .nro stays under dist/switch/ for PR CI / debug; release
|
||||
# publishes the SD-ready zip only.
|
||||
|
||||
uwp="dist/xbox-uwp/gen1recomp-${v}-xbox-uwp.zip"
|
||||
[ -f "$uwp" ] || { echo "::error::$uwp not found (expected from the Xbox UWP job)"; exit 1; }
|
||||
cp "$uwp" "$outdir/gen1recomp-${v}-xbox-uwp.zip"
|
||||
|
||||
# Anbernic handheld port (suffix names the CFW it targets, so a
|
||||
# future RG35XX/other-CFW pack can ship alongside it).
|
||||
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
|
||||
@@ -284,8 +423,8 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v="${{ steps.ver.outputs.version }}"
|
||||
tag="${{ steps.ver.outputs.tag }}"
|
||||
v="${{ needs.version.outputs.version }}"
|
||||
tag="${{ needs.version.outputs.tag }}"
|
||||
|
||||
# Issues this release closes. Three sources, deduped by number:
|
||||
# 1. GitHub's own "closing issues" links on every PR whose
|
||||
@@ -369,18 +508,24 @@ jobs:
|
||||
fi
|
||||
printf 'Release notes:\n%s\n' "$notes"
|
||||
|
||||
release_files=(
|
||||
"dist/release/gen1recomp-${v}-macos.zip"
|
||||
"dist/release/gen1recomp-${v}-windows.zip"
|
||||
"dist/release/gen1recomp-${v}-linux.zip"
|
||||
"dist/release/gen1recomp-${v}-android.apk"
|
||||
"dist/release/gen1recomp-${v}-ios.ipa"
|
||||
"dist/release/gen1recomp-${v}-switch.zip"
|
||||
"dist/release/gen1recomp-${v}-xbox-uwp.zip"
|
||||
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
|
||||
"dist/release/gen1recomp-${v}.love"
|
||||
"dist/release/sha256sums.txt"
|
||||
)
|
||||
|
||||
gh release create "$tag" \
|
||||
--target "$GITHUB_SHA" \
|
||||
--title "$v" \
|
||||
--notes "$notes" \
|
||||
"dist/release/gen1recomp-${v}-macos.zip" \
|
||||
"dist/release/gen1recomp-${v}-windows.zip" \
|
||||
"dist/release/gen1recomp-${v}-linux.zip" \
|
||||
"dist/release/gen1recomp-${v}-android.apk" \
|
||||
"dist/release/gen1recomp-${v}-ios.ipa" \
|
||||
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
|
||||
"dist/release/gen1recomp-${v}.love" \
|
||||
"dist/release/sha256sums.txt"
|
||||
"${release_files[@]}"
|
||||
|
||||
echo "Published release $tag"
|
||||
|
||||
@@ -388,7 +533,7 @@ jobs:
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v="${{ steps.ver.outputs.version }}"
|
||||
v="${{ needs.version.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; }
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Switch 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-switch-nro") | .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"
|
||||
# Upsert via comment-tag only — do not delete-all bot comments (clobbers iOS).
|
||||
- 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 Switch artifact
|
||||
if: steps.artifact.outputs.pr_number != ''
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
message: |
|
||||
[gen1recomp-switch.nro](${{ 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: switch-build-result
|
||||
github-token: ${{ github.token }}
|
||||
@@ -31,7 +31,10 @@ mobile/ios/love-src/
|
||||
mobile/ios/cache/
|
||||
mobile/ios/build/
|
||||
|
||||
# Final packaged build artifacts (mac/win/web/android/ios) — see scripts/build.sh
|
||||
# love-nx vendor binaries (fetch per docs/switch-development.md; also covered by .*)
|
||||
.bazinga/love-nx/
|
||||
|
||||
# Final packaged build artifacts (mac/win/web/android/ios/switch) — see scripts/build.sh
|
||||
/dist/
|
||||
|
||||
# Legacy manual convenience-copy location (superseded by /dist/android/)
|
||||
@@ -43,3 +46,8 @@ mobile/dist/
|
||||
|
||||
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
|
||||
mobile/ios/bundle_id.local
|
||||
|
||||
# Xbox UWP build output
|
||||
/ports/uwp/build/
|
||||
/ports/uwp/third_party/*/source/
|
||||
/ports/uwp/third_party/angle/depot_tools/
|
||||
|
||||
@@ -229,6 +229,27 @@ ships with every release as `gen1recomp-*-rg34xxsp-stockos64-mod.zip`.
|
||||
Install steps, controls, and troubleshooting live in
|
||||
[docs/anbernic-rg34xxsp.md](docs/anbernic-rg34xxsp.md).
|
||||
|
||||
## Nintendo Switch
|
||||
|
||||
Releases ship an SD-ready `gen1recomp-*-switch.zip` (issue
|
||||
[#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Runtime target
|
||||
is pinned [love-nx](https://github.com/retronx-team/love-nx) `11.5-nx1`.
|
||||
Requires a console that can run Switch homebrew. Hardware evidence: **OLED**
|
||||
(author) and **V1 / Erista** boot (community).
|
||||
|
||||
- Players: [docs/switch-install.md](docs/switch-install.md) — download the
|
||||
zip, extract at the microSD root (install or update), title-override
|
||||
launch, import your own legal ROM, Joy-Con controls and shortcuts.
|
||||
- Builders: [docs/switch-build.md](docs/switch-build.md) — `--fetch` /
|
||||
`--loose` / `--fused`, toolchain, Docker fallback, and **CI vs release**
|
||||
(path-gated ubuntu selftest, canonical fused PR artifact, release hard-fail).
|
||||
|
||||
Limitations, Dusklight-derived method, and how we tested:
|
||||
[docs/switch-development.md](docs/switch-development.md) and
|
||||
[docs/switch-hardware-evidence.md](docs/switch-hardware-evidence.md). Community
|
||||
help — especially HOS / love-nx packaging and broader hardware coverage — is
|
||||
welcome.
|
||||
|
||||
## Modding
|
||||
|
||||
The game ships a native mod platform: content registries, events and hooks,
|
||||
@@ -276,4 +297,7 @@ This project would not be possible without [pret](https://github.com/pret) >
|
||||
the pret band of decompiling maniacs > and their
|
||||
[pokered](https://github.com/pret/pokered) disassembly.
|
||||
|
||||
Nintendo Switch port: [andrewqsantos](https://github.com/andrewqsantos).
|
||||
Switch hardware testing (V1 boot): [booshankles](https://github.com/booshankles).
|
||||
|
||||
<p align="center"><a href="https://boisclub.games"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/bcg.png"></a></p>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Plain Pixel font
|
||||
|
||||
"Plain Pixel Font" by Douglas Vautour (Burpy Fresh) is licensed under
|
||||
CC-BY 4.0: https://burpyfresh.itch.io
|
||||
|
||||
Version 0.009 (CJK character additions), unmodified. Characters for most
|
||||
languages have a 5x11 base but can extend vertically; double-width
|
||||
characters such as Hiragana and Katakana are 11x11.
|
||||
|
||||
Bundled so a translation mod can opt into TTF text rendering
|
||||
(`mod.content.font:register("ttf", {})`; see the Translation support
|
||||
section of docs/new-features.md) instead of drawing hundreds of glyph-page
|
||||
tiles. The tile font extracted from the player's ROM stays the default.
|
||||
|
After Width: | Height: | Size: 28 KiB |
@@ -58,7 +58,17 @@ function love.conf(t)
|
||||
-- engine before conf runs (LÖVE 11.x / 11.5).
|
||||
local osName = love._os
|
||||
local mobile = osName == "Android" or osName == "iOS"
|
||||
if mobile then
|
||||
local nx = osName == "NX"
|
||||
if nx then
|
||||
-- Switch (love-nx): hint handheld 720p. SDL auto-switches portable↔dock
|
||||
-- (720p↔1080p) only when the window is resizable and not exclusive
|
||||
-- fullscreen; NxDisplay.sync also applies the size on boot and dock change.
|
||||
t.window.width = 1280
|
||||
t.window.height = 720
|
||||
t.window.fullscreen = false
|
||||
t.window.resizable = true
|
||||
t.window.highdpi = false
|
||||
elseif mobile then
|
||||
-- resizable is what unlocks orientation. SDL's Android backend, given no
|
||||
-- SDL_HINT_ORIENTATIONS (LÖVE sets none), calls setRequestedOrientation
|
||||
-- at window creation -- FULL_SENSOR when the window is resizable (rotates
|
||||
@@ -70,6 +80,10 @@ function love.conf(t)
|
||||
-- just work. FULL_SENSOR ignores the device's rotation lock, so
|
||||
-- GameActivity.setOrientationBis remaps it to FULL_USER after SDL has
|
||||
-- run: same orientations allowed, but auto-rotate being off now wins.
|
||||
-- A persisted ORIENTATION lock (#592) overrides all of this after boot:
|
||||
-- src/core/Orientation.lua sets SDL_HINT_ORIENTATIONS over the FFI and
|
||||
-- re-triggers the request, from main.lua for the launcher and from
|
||||
-- Game:applyOptions in game.
|
||||
-- iOS follows the Info.plist orientations
|
||||
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
|
||||
t.window.resizable = true
|
||||
|
||||
@@ -766,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
|
||||
@@ -792,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)
|
||||
@@ -981,7 +1007,11 @@ M.VICTORY_ROAD_3F = {
|
||||
local championsRoomRivalScript = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
|
||||
{ "jump_if_true", 26 }, -- 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
|
||||
-- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after
|
||||
-- the intro text, before the battle itself (#706); pushBattle's wipe-time
|
||||
@@ -989,37 +1019,50 @@ local championsRoomRivalScript = {
|
||||
-- continuous into the fight
|
||||
{ "play_music", "Music_FinalBattle" }, -- 5
|
||||
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 6
|
||||
{ "jump_if_false", 26 }, -- 6 past end
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
|
||||
-- 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),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ local function gift(opts)
|
||||
end
|
||||
end)
|
||||
end
|
||||
if opts.pre then say(opts.pre, "", give) else give() end
|
||||
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -118,11 +118,21 @@ M.CINNABAR_LAB_METRONOME_ROOM = {
|
||||
},
|
||||
}
|
||||
|
||||
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher; no pre text)
|
||||
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher). The fisher's
|
||||
-- YouCanHaveThisText prints before GiveItem, so this gift needs a pre
|
||||
-- text (#775). Like the SilphCo2F worker (#393) that label carries no
|
||||
-- leading underscore, and on Red it sits outside the extractor's symbol
|
||||
-- set, so the literal from text/ViridianCity.asm rides along as the
|
||||
-- fallback; Yellow resolves the ROM string instead.
|
||||
M.VIRIDIAN_CITY = {
|
||||
talk = {
|
||||
TEXT_VIRIDIANCITY_FISHER = gift({
|
||||
flag = "EVENT_GOT_TM42", item = "TM_DREAM_EATER",
|
||||
pre = "ViridianCityFisherYouCanHaveThisText",
|
||||
preFallback = "Yawn!\nI must have dozed\voff in the sun."
|
||||
.. "\fI had this dream\nabout a DROWZEE\veating my dream."
|
||||
.. "\vWhat's this?\vWhere did this TM\vcome from?"
|
||||
.. "\fThis is spooky!\nHere, you can\vhave this TM.",
|
||||
received = "_ViridianCityFisherReceivedTM42Text",
|
||||
explain = "_ViridianCityFisherTM42ExplanationText",
|
||||
noRoom = "_ViridianCityFisherTM42NoRoomText",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
-- 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). After it, the same text pointer
|
||||
-- (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
|
||||
@@ -63,7 +64,12 @@ end
|
||||
local function oldMan2Rows(game, ow, npc)
|
||||
local rows = {
|
||||
{ "show_text", "_ViridianCityOldManHadMyCoffeeNowText" },
|
||||
{ "old_man_demo" },
|
||||
-- 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" },
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ same system picker and install the chosen archive on return.
|
||||
draws one chip per game plus a MODS chip and rebuilds `self.tabRects` every
|
||||
frame so `mousepressed` can dispatch clicks; switching tabs mid-import is
|
||||
allowed (a dropped ROM still routes by SHA-1 regardless of which tab shows).
|
||||
On **NX**, **Scan again** is stricter: it only starts an import whose SHA-1
|
||||
matches the open game tab, so a shared `imports/` folder with Red+Yellow
|
||||
cannot jump Yellow → Red.
|
||||
|
||||
- A game tab (`_drawGamePanel`) shows the ROM card, the SAVE FILES card, the
|
||||
Play button, and the SAVE SLOT card in a responsive two-column grid (see
|
||||
@@ -150,6 +153,17 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
|
||||
- `LauncherMods.uninstall(id)` removes `mods/<id>/` and clears
|
||||
`options.mods[id]` so a later reinstall starts from the loader's default
|
||||
(enabled). The mods panel Delete control calls this and re-derives the list.
|
||||
- A mod that declares `github` shows its total GitHub downloads (every
|
||||
release's summed asset `download_count`, from the same cached release
|
||||
fetch the update check uses) as a highlighted body line like "12,345
|
||||
downloads across all releases - Released 2024-05-31 - Updated 2026-07-01"
|
||||
(first and latest `published_at`). Old cache entries written before the
|
||||
counts existed show no line rather than a wrong zero; a manual check
|
||||
refreshes them.
|
||||
- The MODS panel sorts its rows by Name, Popularity (downloads),
|
||||
Release date (first release), or Last updated, chosen by chips under the
|
||||
header and persisted in `options.modSort`. Mods without release data
|
||||
(no `github` field, or a stale cache) sink to the bottom of data sorts.
|
||||
|
||||
## Import / Export save
|
||||
|
||||
@@ -157,9 +171,14 @@ The SAVE FILES card wires a raw Gen1 `.sav` battery image to the save slots
|
||||
through `src/import/SaveFileIO.lua`, which sits on top of
|
||||
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
|
||||
|
||||
- **Import save** is live once the game's ROM is imported (playable). It opens
|
||||
a native `.sav` picker (`chooseSav` on desktop; on Android,
|
||||
`love.system.pickFile("sav")` → `picked_save.sav`, same SAF path as ROMs).
|
||||
- **Import save** is live once the game's ROM is imported (playable).
|
||||
On desktop it opens a native `.sav` picker (`chooseSav`); on Android,
|
||||
`love.system.pickFile("sav")` → `picked_save.sav`, same SAF path as ROMs.
|
||||
On **NX (Switch)** there is no picker: copy a `.sav` into
|
||||
`getSaveDirectory()/imports/saves/<red|blue|yellow>/` via MTP / SD / FTP
|
||||
(one folder per game), then press **Import save** on that game’s tab to
|
||||
ensure the inbox and rescan (same pattern as the ROM `imports/` and mod
|
||||
`imports/mods/` inboxes). Hidden `._*.sav` AppleDouble sidecars are skipped.
|
||||
`SaveFileIO.importToSlot` reads the bytes (an absolute path, a save-dir
|
||||
relative name, a dropped LOVE file, or raw bytes),
|
||||
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
|
||||
@@ -167,19 +186,29 @@ through `src/import/SaveFileIO.lua`, which sits on top of
|
||||
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
|
||||
The meta stamp is re-stamped off `gen1_import` to the current numeric format
|
||||
so `SaveData.load`'s migration pass accepts the slot. On success the SAVE SLOT
|
||||
panel is refreshed with the new slot selected.
|
||||
panel is refreshed with the new slot selected. On **NX**, a successful inbox
|
||||
import retires the file to `*.sav.imported` and records a content hash in
|
||||
`imports/saves/<game>/.imported-sha1` so a second **Import save** (or the same
|
||||
bytes under a new name) does not clone slots; failures leave the original
|
||||
`.sav`. Only that game’s folder is scanned.
|
||||
- **Export save** is live only when the active slot actually holds a save
|
||||
(checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active
|
||||
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
|
||||
`rawImport`, so this is a zero-filled template export, which is valid), and
|
||||
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
|
||||
(`love.filesystem.createDirectory("exports")`). On desktop it returns the
|
||||
absolute path (`love.filesystem.getSaveDirectory()`), which the notice line
|
||||
shows with an "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
|
||||
writes `exports/<version>/gen1recomp-<version>-<slotId>.sav` under the same
|
||||
root `persistFs` writes slots to: the portable game folder when `portable.txt`
|
||||
marks the install, otherwise the save directory (`exports/` and
|
||||
`exports/<version>/` are created as needed; #752). On desktop it returns the
|
||||
absolute path (`SaveData.portableBaseDir()` when portable, else
|
||||
`love.filesystem.getSaveDirectory()`), which the notice line shows with an
|
||||
"Open folder" affordance (`love.system.openURL("file://" .. dir)`).
|
||||
On Android the bytes are also staged as `pending_export.sav` and
|
||||
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
|
||||
player can save to Downloads / Drive / etc.; on return `export_done.flag`
|
||||
makes focus show "Save exported."
|
||||
On **NX**, export success sets a notice with the `exports/<game>/` path and an
|
||||
MTP-oriented hint — no `openURL` / Open folder (pull the file via MTP /
|
||||
SD / FTP instead).
|
||||
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
|
||||
currently active game tab; when a non-game tab (mods, or the locked yellow
|
||||
placeholder) is showing it defaults to red, the always-present first game
|
||||
|
||||
@@ -174,6 +174,32 @@ It runs immediately before queued button edges are promoted, so input added by
|
||||
the wrapper is visible during that same fixed step. The callback receives
|
||||
`(next, game, dt)` and must call `next(game, dt)`.
|
||||
|
||||
`input.pointer` delivers uncaptured gameplay pointer events -- touches and
|
||||
real mouse input alike. The callback receives `(next, game, ev)` where `ev`
|
||||
is `{ phase, source, id, x, y, dx, dy, pressure, button }`: `phase` is
|
||||
`"pressed"`, `"moved"`, `"released"` or `"cancelled"`; `source` is `"touch"`
|
||||
or `"mouse"`; `id` is the LÖVE touch id or `"mouse"`; and the coordinates
|
||||
are LOVE window units, the same space `render.hud`'s viewport and the touch
|
||||
overlay lay out in. The on-screen touch controls keep first refusal: a
|
||||
pointer that begins on a virtual control belongs to the pad for its whole
|
||||
lifecycle and never reaches the hook, while one that begins outside stays
|
||||
visible even if it later crosses a control. A real mouse reaches the hook
|
||||
without `POKEPORT_TOUCH` (synthesized `istouch` mouse twins are dropped, so
|
||||
a mobile touch fires once), and focus or visibility loss and input recovery
|
||||
deliver a `"cancelled"` for every pointer the hook saw pressed but not yet
|
||||
released. Return `true` without calling `next` to consume the event.
|
||||
|
||||
`mod.input` presses GB buttons source-safely. `mod.input:tap(game, btn)`
|
||||
queues exactly one `wasPressed` edge for the next fixed step and holds
|
||||
nothing; `local token = mod.input:press(game, btn)` holds the button until
|
||||
`mod.input:release(token)`. Buttons are `up`, `down`, `left`, `right`, `a`,
|
||||
`b`, `start` and `select`. Every press is its own input source inside the
|
||||
engine's multi-source bookkeeping, so releasing a token never clears a hold
|
||||
the keyboard, a controller, the touch overlay or another mod still owns;
|
||||
`release` is idempotent and refuses tokens taken by another mod.
|
||||
Outstanding tokens are released automatically on entry-chunk rollback, hot
|
||||
reload and input recovery.
|
||||
|
||||
`ui.title_menu.items` receives `(next, game, items)` and follows the same
|
||||
decorate-after-`next` convention as `ui.start_menu.items`. It is the safe place
|
||||
for a tool to offer a fresh-session action before gameplay begins.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# New features (deliberate additions beyond the original)
|
||||
|
||||
Intentional enhancements this port adds on top of faithful Pokémon Red
|
||||
behavior. They have no Game Boy equivalent and are kept by design.
|
||||
Intentional enhancements this port adds on top of faithful Pokémon Red, Blue,
|
||||
and Yellow behavior. They have no Game Boy equivalent and are kept by design.
|
||||
Genuine divergences from the original (things still missing, wrong, or
|
||||
approximated) live in docs/known-differences.md; faithfully-ported
|
||||
behavior is in docs/behavior-porting-notes.md.
|
||||
approximated) live in docs/known-differences.md; faithfully-ported behavior is
|
||||
in docs/behavior-porting-notes.md.
|
||||
|
||||
## Survey zoom
|
||||
|
||||
@@ -332,6 +332,17 @@ 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.
|
||||
|
||||
## Screen orientation lock (Android)
|
||||
|
||||
Options → **ORIENTATION** (also in the launcher's gear menu) locks the
|
||||
screen to **PORTRAIT**, **LANDSCAPE** (either landscape, following the
|
||||
device), or **REVERSE LANDSCAPE**, or leaves it on **AUTO** (#592). AUTO
|
||||
allows every orientation but defers to the system: with auto-rotate turned
|
||||
off in Android's quick settings, the game stays put instead of following
|
||||
the sensor (#716). Changes apply immediately -- the screen rotates as the
|
||||
row is stepped -- and persist in `options.lua`. Android only: iOS follows
|
||||
the app's fixed orientation list, and desktop windows rotate nothing.
|
||||
|
||||
## Translation support
|
||||
|
||||
Every string the player can read is now reachable from a mod, so a
|
||||
@@ -365,6 +376,28 @@ plus a glyph-page and charmap stub, a naming-grid stub, and a
|
||||
be packed). `--refresh` re-harvests after an engine update, keeping
|
||||
existing translations and parking orphaned keys rather than dropping them.
|
||||
|
||||
A translation can also skip glyph pages entirely: scaffolding with
|
||||
`--pixel-font` (or registering `mod.content.font:register("ttf", {})` in
|
||||
an existing mod) renders text through a bundled TTF covering Latin with
|
||||
diacritics, Cyrillic, kana and CJK, while box borders and `<PK>`-style
|
||||
macro glyphs keep their tiles. The font is "Plain Pixel Font" by Douglas
|
||||
Vautour (Burpy Fresh), licensed under CC-BY 4.0 (5x11 base characters,
|
||||
11x11 double-width; see `assets/fonts/plainpixel/README.md`). Options on
|
||||
the registry entry: `file` for a mod-shipped TTF, `size` (the font's
|
||||
design em; Plain Pixel rasterizes cleanly only at multiples of 15),
|
||||
`spacing` added to every advance, `yOffset` for vertical alignment
|
||||
against the 8px cell grid, `bold`, which double-prints at a 1px
|
||||
offset for fonts whose strokes read too light, and `tiles`, the
|
||||
characters that keep their ROM tile instead of coming from the TTF.
|
||||
|
||||
`tiles` matters for a CJK translation. Sizing the font so a kana fills
|
||||
the 8px cell leaves Latin narrower than the tile font it replaces, which
|
||||
pulls the numeric columns out of line: the party menu's `:L12` stops
|
||||
sitting over `34/ 34`. Naming `"0123456789/:"` keeps those on the
|
||||
vanilla tiles, so numbers render exactly as they do in English while
|
||||
kana still come from the font. It takes a string of characters, or a
|
||||
list when a multi-character charmap sequence is meant.
|
||||
|
||||
See the wiki's Translations guide.
|
||||
|
||||
## Save editor (bundled, reachable from the launcher)
|
||||
@@ -447,6 +480,16 @@ automatically, so the tab opens on an "Add an index" prompt until you name
|
||||
one; paste an index URL or its `owner/repo` and it is remembered in
|
||||
`options.lua`. More than one index can be added, and the listings merge.
|
||||
|
||||
A feed author can publish per-mod release stats by adding three optional
|
||||
fields to an entry -- `downloads` (total across every release), and
|
||||
`first_release` / `last_release` (ISO days) -- which the listing shows in
|
||||
the same gold line the MODS tab uses. When a feed does not carry them,
|
||||
the row fetches the mod's own GitHub releases instead -- the same cached
|
||||
`ModUpdate` fetch the MODS tab uses, one entry per frame -- so the stats
|
||||
appear for any mod with a `github` field regardless of feed maintenance.
|
||||
The fields are additive: feeds that carry them stay readable by every
|
||||
build that predates them, and feeds that do not render exactly as before.
|
||||
|
||||
## Soft reset (all versions)
|
||||
|
||||
Holding A, B, START and SELECT together restarts the game the way flicking
|
||||
@@ -508,3 +551,59 @@ 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.
|
||||
|
||||
## A faster, higher-contrast launcher
|
||||
|
||||
The launcher and the save editor were rebuilt on one small immediate-mode UI
|
||||
kit (`src/ui/kit/`), replacing the vendored FlexLove layout engine. The
|
||||
visible result is that the launcher is quick: building and drawing a frame
|
||||
went from about 9 ms to under 1 ms on the same machine and the same data, at
|
||||
every window size, so the window keeps up with the pointer instead of
|
||||
trailing it. Measure it yourself with `POKEPORT_LAUNCHER_PROF=200 love .`.
|
||||
|
||||
**Nothing blocks the window any more.** Fetching a mod index, checking a mod
|
||||
for updates, listing versions, downloading an install and pulling thumbnails
|
||||
all run on background threads. Opening FIND MODS on a cold cache used to
|
||||
freeze the launcher for as long as the server took -- often minutes, with no
|
||||
indication anything was happening. Mod indexes are now also fetched at boot,
|
||||
so the tab is usually already populated by the time you reach it.
|
||||
|
||||
**Anything you wait on says so.** Every operation that takes time raises a
|
||||
loading panel with a spinner or a progress bar that cannot be clicked around
|
||||
or dismissed, so a half-finished install can never be interrupted by a stray
|
||||
click. Work that only affects one row (a mod's update check) shows a small
|
||||
spinner on that row instead and leaves the rest of the list usable.
|
||||
|
||||
**Lists page instead of scrolling.** Mods, Find Mods, save slots, settings,
|
||||
release notes and the version list all show a fixed number of rows with a
|
||||
pager underneath, and the number of rows comes from the window height -- a
|
||||
tall window shows more, a phone shows fewer. A long list costs exactly what a
|
||||
short one does. The mouse wheel turns pages.
|
||||
|
||||
**Updates live in the top right.** The in-app updater moved next to the
|
||||
settings gear and pulses when an update is waiting, instead of sitting in a
|
||||
banner at the bottom of the page that you had to scroll to notice. Checking
|
||||
for updates from there shows a loader like everything else.
|
||||
|
||||
**The look.** Black background, white outlines, no gradients or glows, and
|
||||
buttons that are solid colour-coded keys: green commits, blue navigates, red
|
||||
destroys, yellow wants attention. The three game tabs keep their red, blue
|
||||
and gold cartridge colours. Everything is about a third larger than before.
|
||||
The save editor follows the same theme, and adding an item there is now a
|
||||
searchable pop-up like adding a Pokemon, rather than a cramped list wedged
|
||||
into the tab.
|
||||
|
||||
**Reset rebinds.** Input rebinds are additive, so there was no in-game way to
|
||||
undo one. A RESET REBINDS row in Settings, and a matching button under Touch
|
||||
Controls on each game tab, restore the stock keyboard, gamepad and touch
|
||||
layout. Both ask twice.
|
||||
|
||||
## Launch options: boot straight into a game
|
||||
|
||||
`love . --game red` skips the launcher and starts that game; `--slot <id or
|
||||
number>` picks the save slot to load, and `--launcher` forces the launcher
|
||||
anyway. `POKEPORT_GAME` / `POKEPORT_SLOT` do the same for shortcuts that can
|
||||
only pass environment variables. This is for one-click entries: a desktop
|
||||
shortcut per game, a Steam entry, or a handheld frontend. Asking for a game
|
||||
whose ROM has not been imported opens the launcher on that game's tab rather
|
||||
than failing.
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
# What This Port Requires
|
||||
|
||||
The packaged desktop app requires one user-supplied input on first boot: a
|
||||
canonical 1 MiB US Pokemon Red ROM.
|
||||
canonical 1 MiB US Pokemon Red, Blue, or Yellow ROM.
|
||||
|
||||
The importer verifies SHA-1
|
||||
`ea9bcae617fdf159b045185467ae58b2e4a48b9a`. Other revisions, Virtual
|
||||
Console releases, and Pokemon Blue are rejected rather than decoded with
|
||||
incorrect addresses.
|
||||
The importer verifies the SHA-1 for the game (see `src/core/GameVersion.lua`
|
||||
for specific hashes). Other revisions and Virtual Console releases are rejected
|
||||
rather than decoded with incorrect addresses.
|
||||
|
||||
After verification, the app generates its private cache in the LÖVE save
|
||||
directory. It does not keep a copy of the ROM. Later boots use the cache.
|
||||
@@ -15,9 +14,11 @@ Python and Pillow are not required by the packaged app.
|
||||
## Bundled Metadata
|
||||
|
||||
Assembly removes high-level names and some relationships that the Lua port
|
||||
needs. `tools/rom_manifest.json` therefore contains:
|
||||
needs. The version-specific files `tools/rom_manifest.json`,
|
||||
`tools/rom_manifest_blue.json`, and `tools/rom_manifest_yellow.json` therefore
|
||||
contain:
|
||||
|
||||
- the 3,268 ROM symbol addresses actually read by the extractor
|
||||
- the ROM symbol addresses actually read by the extractor
|
||||
- symbolic IDs and ordering for maps, species, moves, items, and trainers
|
||||
- source-erased dimensions, image names, and map object integration names
|
||||
- hand-ported field/script integration tables
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# Build the Nintendo Switch NRO — contributor guide
|
||||
|
||||
Want to play a release build instead? Download the SD-ready zip and extract it
|
||||
at your microSD root — see [switch-install.md](switch-install.md).
|
||||
|
||||
This guide is for contributors who build Gen1Recomp for Switch from source.
|
||||
Hardware evidence, MTP operator loops, and deeper notes live in
|
||||
[switch-development.md](switch-development.md).
|
||||
|
||||
> Releases ship `gen1recomp-*-switch.zip` (SD tree under `switch/gen1recomp/`;
|
||||
> issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Hardware
|
||||
> evidence: **OLED** (author) and **V1 boot** (community). See
|
||||
> [switch-development.md](switch-development.md) for known limitations.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites by OS
|
||||
|
||||
All packaging entrypoints are **bash**. On Windows, use Git Bash, MSYS2, or
|
||||
WSL — not cmd.exe or PowerShell (AD-008).
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
1. Install [devkitPro pacman](https://devkitpro.org/wiki/devkitPro_pacman).
|
||||
2. Install Switch tools:
|
||||
|
||||
```sh
|
||||
sudo dkp-pacman -S switch-dev
|
||||
```
|
||||
|
||||
3. Ensure `nacptool` and `elf2nro` are on `PATH` (or under
|
||||
`$DEVKITPRO/tools/bin` — the fused script prepends that when set).
|
||||
|
||||
**Optional:** Install [Docker](https://docs.docker.com/get-docker/) so fused
|
||||
builds can fall back to the pinned image when native tools are missing.
|
||||
|
||||
### Windows (Git Bash / MSYS2 / WSL)
|
||||
|
||||
1. Use a bash environment:
|
||||
- **MSYS2** with the [devkitPro](https://devkitpro.org/wiki/devkitPro_pacman)
|
||||
packages (preferred for native `nacptool`/`elf2nro`), or
|
||||
- **WSL** (Ubuntu/etc.) with the Linux pacman flow above, or
|
||||
- **Git Bash** for `--fetch` / `--loose`; for `--fused` prefer MSYS2 or
|
||||
WSL if Docker bind-mounts from Git Bash paths misbehave.
|
||||
2. Install `switch-dev` (or rely on Docker fallback — see below).
|
||||
3. Do **not** expect `scripts/build_switch.sh` to run under cmd/PowerShell.
|
||||
|
||||
### What you must install yourself
|
||||
|
||||
| You install | Script does **not** install |
|
||||
| ----------- | --------------------------- |
|
||||
| bash, git, zip tooling the repo already expects | — |
|
||||
| `dkp-pacman` + `switch-dev` (native fused) | `dkp-pacman -S …` |
|
||||
| Docker (optional fused fallback) | Docker Engine |
|
||||
| A legal `.gb` ROM (to play) | Any ROM or game data |
|
||||
|
||||
---
|
||||
|
||||
## Mode glossary
|
||||
|
||||
`scripts/build_switch.sh` supports three modes (combinable as noted):
|
||||
|
||||
| Mode | What it does |
|
||||
| ---- | ------------ |
|
||||
| `--fetch` | Downloads pinned **love.nro** + **love.elf** into `.bazinga/love-nx/11.5-nx1/` and verifies SHA-256 against `scripts/switch/love-nx-11.5-nx1.sha256`. |
|
||||
| `--loose` | Packs `game.love`, copies pinned `love.nro` → `dist/switch/loose/` as `gen1recomp.nro` + `game.love` side by side. Needs the pin. |
|
||||
| `--fused` | Builds `dist/switch/gen1recomp-<ver>-switch.nro` (game in romfs) via `nacptool` + `elf2nro`, then packs `dist/switch/gen1recomp-<ver>-switch.zip` (SD-ready tree). Needs the pin + toolchain (native or Docker). GitHub Releases publish the **zip only**. |
|
||||
|
||||
Rules:
|
||||
|
||||
- `--fetch` alone is fine; combine as `--fetch --loose` or `--fetch --fused`.
|
||||
- `--loose` and `--fused` are **XOR** — pick one packaging path per run.
|
||||
- `--version X.Y.Z` sets the NACP / filename version (defaults to short git SHA).
|
||||
|
||||
### What `--fetch` downloads
|
||||
|
||||
Only the two pinned love-nx release assets (`love.nro`, `love.elf`). It does
|
||||
**not** install:
|
||||
|
||||
- devkitPro / `dkp-pacman` / `switch-dev`
|
||||
- Docker
|
||||
- ROMs, saves, or mods
|
||||
|
||||
---
|
||||
|
||||
## Native tools, then Docker
|
||||
|
||||
Fused packaging (`scripts/switch/build_fused.sh`):
|
||||
|
||||
1. Prefer native `nacptool` + `elf2nro` on `PATH` (or `$DEVKITPRO/tools/bin`).
|
||||
2. Else fall back to Docker using:
|
||||
- `GEN1_DKP_IMAGE` if set, otherwise
|
||||
- the image named in `scripts/switch/dkp-docker.image` (default
|
||||
`devkitpro/devkita64:latest`).
|
||||
|
||||
If neither native tools nor Docker work, the script exits non-zero with
|
||||
macOS / Linux / Windows / Docker hints and a pointer to this doc.
|
||||
|
||||
---
|
||||
|
||||
## Example commands
|
||||
|
||||
From the repo root:
|
||||
|
||||
```sh
|
||||
# Download pinned love-nx only
|
||||
scripts/build_switch.sh --fetch
|
||||
|
||||
# Loose pair for iteration (fetch + assemble)
|
||||
scripts/build_switch.sh --fetch --loose
|
||||
|
||||
# Single fused NRO + SD-ready zip for a release-like artifact
|
||||
scripts/build_switch.sh --fetch --fused --version 0.2.0
|
||||
```
|
||||
|
||||
Outputs land under `dist/switch/` (and `dist/switch/loose/` for loose mode).
|
||||
The fused path also writes `gen1recomp-<ver>-switch.nro.sha256` and
|
||||
`gen1recomp-<ver>-switch.zip` (+ `.sha256` sidecar for the zip).
|
||||
|
||||
Offline packaging smoke (no network, no nacptool required):
|
||||
|
||||
```sh
|
||||
bash scripts/switch/selftest_build_switch.sh
|
||||
bash scripts/switch/verify_payload.sh --self-test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI and release
|
||||
|
||||
Switch packaging has three automated surfaces (same policy as AD-010):
|
||||
|
||||
### Path-gated PR / push CI (`.github/workflows/ci.yml`)
|
||||
|
||||
When a change touches Switch packaging / Switch docs / NX runtime paths
|
||||
(`scripts/build_switch.sh`, `scripts/switch/**`, `docs/switch-*.md`,
|
||||
`tests/switch_ci_workflows_test.lua`, `tests/switch_transfer_docs_test.lua`,
|
||||
the NX runtime modules `src/core/NxAssetOverlay.lua`, `src/core/Platform.lua`,
|
||||
`src/core/GameVersion.lua`, `src/import/CacheFs.lua`, the NX engine suites
|
||||
`tests/engine/assets_version_fallback_test.lua`,
|
||||
`tests/engine/nx_generated_guard_test.lua`,
|
||||
`tests/engine/nx_yellow_boot_test.lua`,
|
||||
`tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`,
|
||||
or the Switch-related workflow YAML), CI runs:
|
||||
|
||||
1. **Offline selftest** on `ubuntu-latest` (forks **and** the canonical repo):
|
||||
`scripts/switch/selftest_build_switch.sh`,
|
||||
`scripts/switch/verify_payload.sh --self-test`,
|
||||
`luajit tests/switch_ci_workflows_test.lua`,
|
||||
`luajit tests/switch_transfer_docs_test.lua`, and the NX engine suites
|
||||
headlessly (`luajit tests/engine/assets_version_fallback_test.lua`,
|
||||
`luajit tests/engine/nx_generated_guard_test.lua`,
|
||||
`luajit tests/engine/nx_yellow_boot_test.lua`).
|
||||
2. **Fused NRO build** only on the **canonical** repository
|
||||
(`bryanthaboi/gen1recomp`), on the self-hosted Mac runner
|
||||
(`scripts/build_switch.sh --fetch --fused`), and only when the workflow
|
||||
head is that repo (same-repo push/PR). **Fork repository** CI never runs
|
||||
fused. **Fork → canonical PRs** also skip Switch fused (offline selftest
|
||||
still runs) so untrusted head code is not executed on the self-hosted Mac;
|
||||
iOS device build eligibility is unchanged. Fused also waits for a successful
|
||||
offline selftest before starting on the Mac runner.
|
||||
3. On successful PR fused builds, a follow-up workflow posts a PR comment
|
||||
linking the Actions artifact named `gen1recomp-switch-nro`
|
||||
(comment tag `switch-build-result`; see
|
||||
`.github/workflows/switch-artifact-comment.yml`).
|
||||
|
||||
Unrelated PRs do not burn the self-hosted Mac on Switch packaging.
|
||||
|
||||
### Release hard-fail (`.github/workflows/release.yml`)
|
||||
|
||||
GitHub Releases always build Switch on the same self-hosted Mac runner as the
|
||||
other platforms — this is a **hard gate** (no `continue-on-error`):
|
||||
|
||||
```sh
|
||||
scripts/build_switch.sh --fetch --fused --version "<release version>"
|
||||
```
|
||||
|
||||
A Switch packaging failure fails the entire release job. The release asset is
|
||||
`gen1recomp-<ver>-switch.zip` (SD-ready); the versioned `.nro` stays under
|
||||
`dist/switch/` for the packer and for PR CI artifacts.
|
||||
|
||||
### Runner provisioning
|
||||
|
||||
The self-hosted Mac runner must have **native switch-tools** (`nacptool` /
|
||||
`elf2nro`) **and/or Docker** available. CI and release do not silently run
|
||||
`dkp-pacman -S`; keep the runner image/host provisioned per this guide.
|
||||
|
||||
---
|
||||
|
||||
## Limitations / non-goals
|
||||
|
||||
These scripts and this guide do **not**:
|
||||
|
||||
- Push files to the console (no automated MTP / FTP / SD scripting)
|
||||
- Bundle or download any Pokémon ROM
|
||||
- Install `dkp-pacman` / `switch-dev` for you
|
||||
- Provide `nxlink` / netloader deploy (deferred — see [switch-transfer.md](switch-transfer.md))
|
||||
- Validate **Applet Mode** — use title override (hold **R**) for full memory
|
||||
|
||||
Player install steps: [switch-install.md](switch-install.md).
|
||||
Manual transfer (MTP / SD / FTP, macOS / Linux / Windows):
|
||||
[switch-transfer.md](switch-transfer.md).
|
||||
Hardware depth and evidence: [switch-development.md](switch-development.md),
|
||||
[switch-hardware-evidence.md](switch-hardware-evidence.md).
|
||||
@@ -0,0 +1,528 @@
|
||||
# Nintendo Switch development (love-nx)
|
||||
|
||||
> Fused NRO support for issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531).
|
||||
> Releases ship `gen1recomp-*-switch.zip` (SD-ready tree). Console copy is
|
||||
> extract/merge at microSD root; title override required. See
|
||||
> [Known limitations](#known-limitations-read-before-reviewing).
|
||||
|
||||
**Canonical install / build / transfer docs** (start here unless you need hardware depth):
|
||||
|
||||
- Players → [switch-install.md](switch-install.md)
|
||||
- Builders → [switch-build.md](switch-build.md) (`scripts/build_switch.sh --fetch` downloads the pinned love-nx pair)
|
||||
- Transfer (MTP / SD / FTP on macOS, Linux, Windows) → [switch-transfer.md](switch-transfer.md)
|
||||
|
||||
This document covers what landed, known limitations, how hardware was tested,
|
||||
vendor layout, build/deploy, and the contributor transfer loop (detail lives in
|
||||
the transfer runbook).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- **Port / love-nx packaging:** [andrewqsantos](https://github.com/andrewqsantos)
|
||||
- **Community hardware testing** (Switch V1 / Erista boot): [booshankles](https://github.com/booshankles)
|
||||
- **Method guidance:** [Dusklight Switch port](https://github.com/HayatoG/dusklight/tree/main/platforms/switch) / love-nx
|
||||
- **Upstream project:** [bryanthaboi](https://github.com/bryanthaboi) / Gen1Recomp
|
||||
|
||||
## Status
|
||||
|
||||
| Area | State |
|
||||
| ---- | ----- |
|
||||
| Feature | **Available** — playable fused NRO path (issue #531) |
|
||||
| Runtime | Pinned love-nx **`11.5-nx1`** |
|
||||
| Product artifact | Releases: SD-ready `gen1recomp-*-switch.zip`; local/PR: fused `.nro`; loose `nro`+`game.love` for iteration |
|
||||
| Hardware | **OLED** validated (author, title override); **V1 / Erista** boot confirmed (community). Lite, docked soak, and Pro Controller matrices welcome |
|
||||
| Deploy / install | Releases publish SD-ready zip; **extract/merge at microSD root** (MTP / SD / FTP — [switch-transfer.md](switch-transfer.md)); no `nxlink` path yet |
|
||||
| Contributor transfer | Documented for **macOS, Linux, and Windows**; OpenMTP on Mac is one example, not the only contract |
|
||||
| Network features on NX | Self-update / remote mod download **disabled** (`networkValidated == false`) |
|
||||
| Community help | Welcome — especially HOS / love-nx packaging and broader hardware coverage |
|
||||
|
||||
### What landed
|
||||
|
||||
- Detect `NX` via `src/core/Platform.lua` without reusing Android flags
|
||||
- Writable ROM inbox under `getSaveDirectory()/imports/` + per-tab “Scan again” (SHA-1 match for the open game)
|
||||
- Joy-Con / gamepad mapping shared by launcher and gameplay (Nintendo A/B UX on NX)
|
||||
- Launcher L/R tab switch; gameplay L/R game-speed cycle; Select+face display chords
|
||||
- Focus loss / joystick reconnect recovery; opt-in `switch-debug.txt` diagnostics
|
||||
- Loose assemble + fused NRO build scripts (`scripts/build_switch.sh`, `scripts/switch/*`)
|
||||
- Payload gates so ROM / generated cache / saves never enter `game.love`
|
||||
- Community mod zip inbox at `imports/mods/` (rescan installs; FIND MODS stays network-gated)
|
||||
- Raw `.sav` inbox at `imports/saves/{red,blue,yellow}/` (**Import save** rescan) + export pull path `exports/{red,blue,yellow}/` (MTP hint; no openURL)
|
||||
- Hardware evidence for Phase 0 probe, ROM import, naming A/B, save/suspend, fused NRO — see `docs/switch-hardware-evidence.md`
|
||||
- Path-gated CI selftest + canonical fused PR artifact; release Switch hard-fail
|
||||
- Save editor pad/touch input (virtual cursor, A click, B close) — see `tools/save-editor/README.md`
|
||||
- Dynamic display size on NX only: handheld **1280×720**, docked/TV **1920×1080** (`src/core/NxDisplay.lua` + resizable conf so love-nx SDL can follow dock/undock at runtime)
|
||||
|
||||
### Known gaps / welcome contributions
|
||||
|
||||
- Docked vs handheld soak (≥30 min) and Lite coverage — resolution switch is implemented; long soak still welcome
|
||||
- Switch Lite and fuller Pro Controller / third-party pad matrices
|
||||
- Applet Mode remains unsupported by design (title override required)
|
||||
- `nxlink` / netloader contrib fast-loop (deferred — see [switch-transfer.md](switch-transfer.md))
|
||||
|
||||
Transfer runbooks for Linux/Windows (and SD/FTP alternatives) are in
|
||||
[switch-transfer.md](switch-transfer.md). Community mod zip install OLED smoke
|
||||
is **pass** — see NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md).
|
||||
|
||||
## Design references (Dusklight)
|
||||
|
||||
This work borrowed method — not the native stack — from the [Dusklight Switch port](https://github.com/HayatoG/dusklight/tree/main/platforms/switch), especially [`LESSONS_AND_REUSE.md`](https://github.com/HayatoG/dusklight/blob/main/platforms/switch/LESSONS_AND_REUSE.md):
|
||||
|
||||
| Dusklight lesson | How Gen1Recomp applied it |
|
||||
| ---------------- | ------------------------- |
|
||||
| Emulators hide Tegra failures | Gate milestones on **real OLED hardware**, not Ryujinx/Yuzu alone |
|
||||
| Prove the lower layer first | `tools/switch-probe` before full launcher |
|
||||
| Know which binary ran | Embedded `build-info.json` (commit / love-nx tag) |
|
||||
| Cap continuous logs | Opt-in diagnostics, ≤1 Hz flush; Lua error log rotation |
|
||||
| Crash symbolization needs the exact ELF | Keep pinned `love.elf` with the NRO under test |
|
||||
| Full memory matters | Title override; Applet Mode is not the validation path |
|
||||
| Do not treat SD FS like desktop POSIX | Lua stays on `love.filesystem`; inbox + MTP for user files |
|
||||
| Isolate platform code | Capability module instead of Android flag overload |
|
||||
| NVK / WSI / `audren` stacks | **Not** copied — love-nx already supplies video/audio/input/FS |
|
||||
|
||||
The packaging goal matches Dusklight’s **single self-contained `.nro`**; contributor transfer stays multi-host (not Mac-only).
|
||||
|
||||
## Known limitations (read before reviewing)
|
||||
|
||||
1. **Transfer is manual and multi-method.** Runtime only needs files under the LÖVE save directory / NRO install folder. Use MTP, direct SD, or FTP per [switch-transfer.md](switch-transfer.md). macOS + OpenMTP is a documented example for OLED evidence — not “Switch requires a Mac.”
|
||||
2. **Deploy is manual.** There is no automated push to the console and no `nxlink` path yet. Operators build locally, transfer files, then title-override launch.
|
||||
3. **Hardware coverage.** Author P0/P1 pass rows were recorded on one Switch OLED; Switch V1 boot was confirmed independently. Treat Lite, docked soak, and other hosts as unknown until someone re-runs the checklist.
|
||||
4. **No ROM/save/mod zip bytes in git.** Legal dumps and third-party mods stay on the console (or local untracked folders).
|
||||
5. **AppleDouble sidecars** (`._*`) from some MTP clients can break zip/ROM/`.sav` scans — the launcher skips hidden `.*` names (including `._*.sav`); still prefer clean copies.
|
||||
|
||||
## How we tested
|
||||
|
||||
| Layer | What | Where |
|
||||
| ----- | ---- | ----- |
|
||||
| Unit / headless | Platform NX flags, RomImporter inbox, dual-path input, mod zip inbox, save `.sav` inbox, display chords, payload/self-tests | `tests/*`, `scripts/test.sh` |
|
||||
| Switch CI / packaging | Path-gated offline selftest (`selftest_build_switch.sh`, `verify_payload.sh --self-test`, `switch_ci_workflows_test.lua`); canonical fused PR artifact | `.github/workflows/ci.yml`, [switch-build.md](switch-build.md) § CI and release |
|
||||
| Probe on hardware | `getOS()==NX`, 1280×720, save path, Joy-Con events | `tools/switch-probe` → OLED |
|
||||
| Integration on hardware | MTP inbox ROM import, Play Red/Blue, naming A/B, quit/reopen save, suspend×10, reboot, fused NRO alone + NRO-only update | `docs/switch-hardware-evidence.md` |
|
||||
| Community hardware | Switch V1 / Erista boot with prebuilt NRO | [booshankles](https://github.com/booshankles) — see evidence log |
|
||||
| Known gaps | Docked soak, ≥30 min long-play, Lite, automated/`nxlink` deploy | Matrix deferred / absent rows |
|
||||
|
||||
Operator evidence must stay in `docs/switch-hardware-evidence.md`. **Do not invent passes** for hardware not run.
|
||||
|
||||
## love-nx 11.5-nx1 (pinned)
|
||||
|
||||
**Tag:** [11.5-nx1](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1)
|
||||
|
||||
**Local layout (not committed):**
|
||||
|
||||
```text
|
||||
.bazinga/love-nx/11.5-nx1/
|
||||
├── love.nro # homebrew launcher binary (loose mode: copied to gen1recomp.nro)
|
||||
└── love.elf # required for fused NRO builds (devkitPro nacptool/elf2nro)
|
||||
```
|
||||
|
||||
**Manifest:** `scripts/switch/love-nx-11.5-nx1.sha256` lists expected artifact names and SHA-256 checksums. Checksums are filled when binaries are fetched (`TBD_*` placeholders until then).
|
||||
|
||||
### Fetch instructions
|
||||
|
||||
Preferred (automated checksum verify):
|
||||
|
||||
```bash
|
||||
scripts/build_switch.sh --fetch
|
||||
```
|
||||
|
||||
That downloads pinned `love.nro` + `love.elf` into `.bazinga/love-nx/11.5-nx1/`
|
||||
and checks them against `scripts/switch/love-nx-11.5-nx1.sha256`. See
|
||||
[switch-build.md](switch-build.md) for the full mode glossary.
|
||||
|
||||
Manual fallback:
|
||||
|
||||
1. Open the [11.5-nx1 release](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1) and download `love.nro` and `love.elf`.
|
||||
2. Create the directory: `mkdir -p .bazinga/love-nx/11.5-nx1`
|
||||
3. Move both files into that directory.
|
||||
4. Confirm checksums match the manifest:
|
||||
|
||||
```bash
|
||||
shasum -a 256 .bazinga/love-nx/11.5-nx1/love.nro \
|
||||
.bazinga/love-nx/11.5-nx1/love.elf
|
||||
```
|
||||
|
||||
**Never commit** love-nx binaries, ROM dumps, or generated cache into git. The repo `.gitignore` excludes `.bazinga/` (vendor cache) and `/dist/` (build output).
|
||||
|
||||
## Loose-mode dist layout
|
||||
|
||||
Development builds place `gen1recomp.nro` and `game.love` side by side:
|
||||
|
||||
```text
|
||||
dist/switch/loose/
|
||||
├── gen1recomp.nro
|
||||
└── game.love
|
||||
```
|
||||
|
||||
Assemble with:
|
||||
|
||||
```bash
|
||||
scripts/build_switch.sh --loose
|
||||
```
|
||||
|
||||
(See `scripts/switch/assemble_loose.sh` for the underlying copy + checksum step.)
|
||||
|
||||
## Transfer & deploy (current contributor loop)
|
||||
|
||||
Detail for **macOS / Linux / Windows** and **MTP / SD / FTP** lives in
|
||||
[switch-transfer.md](switch-transfer.md). Summary:
|
||||
|
||||
| Layer | Intent |
|
||||
| ----- | ------ |
|
||||
| **Runtime / players** | Extract the release zip at microSD root (`switch/gen1recomp/`) and land ROMs/mods under the save-dir inboxes. The game does not hard-depend on OpenMTP or macOS. |
|
||||
| **Contributor loop** | Manual copy via MTP (DBI responder), direct SD (Hekate UMS / reader), or FTP. Fully manual — no CI deploy, no `nxlink` yet. |
|
||||
|
||||
The Mac + OpenMTP steps that remain below are the **OLED evidence reproduction** path; prefer the transfer runbook for day-to-day contrib on other hosts.
|
||||
|
||||
**Still avoided for routine evidence** (keeps SD handling honest):
|
||||
|
||||
- Treating `nxlink` / netloader as the release deploy story (deferred)
|
||||
- DBI `MicroSD install` / `NAND install` / NSP-style virtual folders for the `.love`/`.nro` pair
|
||||
|
||||
If MTP fails: check cable, USB port, DBI state, and that only one MTP client holds the device — then retry or switch to SD/FTP. Do not silently rewrite evidence using an untested path and claim parity with recorded SHA-256 round-trips.
|
||||
|
||||
### Manual deploy checklist (today)
|
||||
|
||||
1. Build on the contributor host (`scripts/build_switch.sh --loose` or fused).
|
||||
2. Close Gen1Recomp on the Switch; open DBI → `Run MTP responder`.
|
||||
3. Copy artifacts with your MTP client into `1: SD Card/switch/gen1recomp/` (and ROMs/mods into the save-dir inboxes when needed).
|
||||
4. Wait for the transfer queue; refresh; optionally round-trip SHA-256 on first artifacts of a type.
|
||||
5. Exit MTP; launch via **title override** (hold **R** on a title → hbmenu, not Applet Mode).
|
||||
|
||||
## OpenMTP + DBI transfer (loose build, Mac evidence example)
|
||||
|
||||
Full multi-OS / multi-method steps: [switch-transfer.md](switch-transfer.md).
|
||||
The numbered Mac loop below reproduces the OLED evidence path.
|
||||
|
||||
### On the Switch
|
||||
|
||||
1. Close Gen1Recomp if it is running.
|
||||
2. Open **DBI** from hbmenu.
|
||||
3. Select **`Run MTP responder`** (DBI documents `X` on the main screen).
|
||||
4. Keep DBI on that screen for the entire transfer.
|
||||
5. Connect the Switch to the Mac with a USB-C data cable.
|
||||
|
||||
### On the Mac
|
||||
|
||||
1. Close any other MTP clients.
|
||||
2. Open **OpenMTP** and select the DBI device.
|
||||
3. In the remote pane, open **`1: SD Card`**.
|
||||
4. Navigate to **`switch/`** and create **`gen1recomp/`** if needed.
|
||||
5. Enter **`1: SD Card/switch/gen1recomp/`**.
|
||||
6. Drag from the local pane:
|
||||
|
||||
```text
|
||||
dist/switch/loose/gen1recomp.nro
|
||||
dist/switch/loose/game.love
|
||||
```
|
||||
|
||||
7. Wait for the OpenMTP queue to finish completely.
|
||||
8. Refresh the remote listing and confirm file sizes match the local files.
|
||||
9. On the Switch, exit MTP responder normally in DBI before launching the app.
|
||||
|
||||
Expected layout on SD:
|
||||
|
||||
```text
|
||||
1: SD Card/
|
||||
└── switch/
|
||||
└── gen1recomp/
|
||||
├── gen1recomp.nro
|
||||
└── game.love
|
||||
```
|
||||
|
||||
## Round-trip SHA-256 verification
|
||||
|
||||
For the **first deploy** of each artifact type (loose pair, later fused NRO), verify MTP integrity:
|
||||
|
||||
1. **Before send** — record local hashes:
|
||||
|
||||
```bash
|
||||
shasum -a 256 dist/switch/loose/gen1recomp.nro \
|
||||
dist/switch/loose/game.love
|
||||
```
|
||||
|
||||
2. **After send** — in OpenMTP, copy the same files from `1: SD Card/switch/gen1recomp/` back to an empty local folder, e.g. `dist/switch/mtp-roundtrip/`.
|
||||
|
||||
3. **Compare** round-trip hashes:
|
||||
|
||||
```bash
|
||||
shasum -a 256 dist/switch/mtp-roundtrip/gen1recomp.nro \
|
||||
dist/switch/mtp-roundtrip/game.love
|
||||
```
|
||||
|
||||
4. Local pre-send and round-trip hashes **must match**. Record results in the test report template below.
|
||||
|
||||
Repeat whenever a cable glitch or interrupted transfer is suspected.
|
||||
|
||||
## Title override launch (full memory)
|
||||
|
||||
Applet Mode is **not** the primary validation path. Use **title override** so hbmenu runs with full memory:
|
||||
|
||||
1. Confirm the OpenMTP transfer queue finished.
|
||||
2. Exit MTP responder in DBI; disconnect USB if desired.
|
||||
3. Hold **`R`** while launching any legitimately installed title.
|
||||
4. Keep holding until **hbmenu** appears.
|
||||
5. Confirm hbmenu does **not** show **Applet Mode**.
|
||||
6. Launch **`gen1recomp`** (or the probe NRO during Phase 0).
|
||||
|
||||
Album / applet launches are only useful to document applet-specific limitations; P0/P1 gates use title override.
|
||||
|
||||
## Phase 0 hardware checklist
|
||||
|
||||
Complete **in order** on OLED hardware. Operator fills evidence fields — leave blank until tested.
|
||||
|
||||
| Step | Action | Pass | Evidence / notes |
|
||||
| ---- | ------ | ---- | ---------------- |
|
||||
| P0-0a | Fetch love-nx 11.5-nx1; record manifest SHA-256 | yes | See `scripts/switch/love-nx-11.5-nx1.sha256` |
|
||||
| P0-0b | Build `switch-probe.love` per `tools/switch-probe/README.md` | yes | |
|
||||
| P0-0c | Assemble loose probe (`game.love` = probe) to `dist/switch/loose/` | yes | |
|
||||
| P0-0d | MTP deploy to `1: SD Card/switch/gen1recomp/`; round-trip SHA-256 | yes | nro `8290ac15…5918f5`; love `9f198637…fa2e34f` |
|
||||
| P0-0e | Title override → probe boots; `getOS()` shows `NX` | yes | `getOS()`=`NX`, `love._os`=`NX` |
|
||||
| P0-0f | Probe lists 1280×720 (or documented dims), save path, gamepad/touch log | yes | save `sdmc:/switch/gen1recomp/switch-probe`; Joy-Con Y→#3 X→#4 |
|
||||
| P0-1a | Replace `game.love` with unpatched Gen1Recomp build | yes | feat/switch-nx inbox build |
|
||||
| P0-1b | MTP replace `game.love` only; round-trip SHA-256 | yes | |
|
||||
| P0-1c | Title override → launcher reaches import screen | yes | |
|
||||
| P0-1d | Joy-Con: can navigate launcher (no touch-only) | yes | Full report: `docs/switch-hardware-evidence.md` |
|
||||
|
||||
**Operator:** Andrew **Date:** 2026-08-01 **Console:** Switch OLED only
|
||||
**Deploy:** manual Mac + OpenMTP + DBI MTP (not automated)
|
||||
**love-nx tag:** 11.5-nx1 **gen1recomp commit:** `df7cea4`
|
||||
|
||||
## Phase 0 test report template
|
||||
|
||||
Copy this block into your hardware notes or PR evidence. **Do not commit ROM files or ROM hashes of private dumps.**
|
||||
|
||||
```markdown
|
||||
## Switch Phase 0 — hardware report
|
||||
|
||||
- Operator:
|
||||
- Date:
|
||||
- Console model:
|
||||
- Atmosphère / HOS version:
|
||||
- gen1recomp commit:
|
||||
- love-nx tag: 11.5-nx1
|
||||
- love.nro SHA-256 (local):
|
||||
- game.love SHA-256 (local, pre-send):
|
||||
- MTP round-trip SHA-256 (gen1recomp.nro):
|
||||
- MTP round-trip SHA-256 (game.love):
|
||||
- Title override used: yes / no
|
||||
- Applet Mode observed: yes / no (should be no for P0)
|
||||
- Probe getOS():
|
||||
- Probe dimensions:
|
||||
- Probe save directory shown:
|
||||
- Gamepad events logged: yes / no
|
||||
- Touch events logged: yes / no
|
||||
- Unpatched launcher boot: pass / fail
|
||||
- Joy-Con launcher navigation: pass / fail / not tested
|
||||
- Notes:
|
||||
```
|
||||
|
||||
## Fast dev loop (loose mode)
|
||||
|
||||
While iterating on Lua/assets:
|
||||
|
||||
1. Edit on Mac; run `scripts/test.sh --quick`.
|
||||
2. Rebuild `.bazinga/work/game.love` (`scripts/build.sh mac --no-notarize` or project pack step).
|
||||
3. Close Gen1Recomp on Switch.
|
||||
4. DBI → `Run MTP responder`.
|
||||
5. OpenMTP → `1: SD Card/switch/gen1recomp/`.
|
||||
6. Replace **only** `game.love`; wait for queue + refresh listing.
|
||||
7. Exit MTP responder; launch via title override.
|
||||
8. Keep `gen1recomp.nro` unchanged until the love-nx pin changes.
|
||||
|
||||
```bash
|
||||
scripts/test.sh --quick
|
||||
scripts/build.sh mac --no-notarize
|
||||
scripts/build_switch.sh --loose
|
||||
shasum -a 256 .bazinga/work/game.love
|
||||
```
|
||||
|
||||
## Controller input mapping (NX)
|
||||
|
||||
Measured on Switch OLED (`feat/switch-nx`, love-nx `11.5-nx1`, 1280×720). Both `joystickpressed` and `gamepadpressed` fire for Joy-Con; prefer the gamepad path when `joystick:isGamepad()` is true.
|
||||
|
||||
| Path | Control | Mapping |
|
||||
| ---- | ------- | ------- |
|
||||
| `gamepadpressed` | D-pad / left stick | move |
|
||||
| `gamepadpressed` | SDL `a` / `b` on **NX** | swapped via `NX_GAMEPAD_BINDINGS`: physical **A** (east) = GB A confirm, physical **B** (south) = GB B cancel |
|
||||
| `gamepadpressed` | SDL `a` / `b` on desktop | identity (SDL south = GB A) |
|
||||
| `gamepadpressed` | `start` / `back` | Start / Select (+ / −) |
|
||||
| `gamepadpressed` | Right / left shoulder (no Select) | Cycle game speed up / down (same as PC hotkey `1` / speed-down path) |
|
||||
| `joystickpressed` (raw) | only if **not** `isGamepad()` | face/menu fallback |
|
||||
| `joystickpressed` (raw) | `#1` / `#2` on NX | Nintendo B / A → GB B / A |
|
||||
| `joystickpressed` (raw) | `#9` / `#10` | Select / Start (− / +) |
|
||||
|
||||
**Nintendo UX on Switch:** physical A confirms, physical B cancels (explicit NX remap of SDL face labels).
|
||||
|
||||
**Launcher extras** (`RomImporter`): physical **A** clicks at the virtual cursor; **L** / **R** switch tabs; **Start** / **Select** start Play when a ROM is ready (else open Choose ROM). D-pad / left stick move the virtual cursor.
|
||||
|
||||
**Dual-path rule:** love-nx emits both `gamepadpressed` and `joystickpressed` for Joy-Con. When `joystick:isGamepad()` is true, Input and RomImporter **ignore raw** face/menu so NamingScreen does not see A+B in one frame. `NamingScreen` also prefers A over B if both edges still fire.
|
||||
|
||||
Implementation: `src/core/GamepadMap.lua` (`NX_RAW_*`, `ignoreRawForJoystick`, `displayChordDigit`), `src/core/Game.lua` (shoulder speed), `src/import/RomImporter.lua` (launcher tabs). Launcher and gameplay share the same converter.
|
||||
|
||||
## ROM inbox (NX)
|
||||
|
||||
Legal dumps land in a shared MTP inbox; **Scan again** is tab-scoped:
|
||||
|
||||
| Item | Value |
|
||||
| ---- | ----- |
|
||||
| Save-relative path | `imports/` (also accepts loose `.gb`/`.gbc` at the save-dir root) |
|
||||
| MTP destination | `1: SD Card/<save identity>/imports/` (see launcher notice for the live `getSaveDirectory()` path) |
|
||||
| Candidates | `*.gb` / `*.gbc` (hidden `.*` AppleDouble names skipped) |
|
||||
| Rescan | Game tab → **Scan again** — imports only the dump whose SHA-1 matches that tab (`GameVersion.forSha1`). Other known dumps stay for their own tabs |
|
||||
| Already ready | Same SHA already imported → “No new ROM found.” |
|
||||
|
||||
Players may drop Red, Blue, and Yellow into the same folder. Opening Yellow and pressing **Scan again** must not start a Red import.
|
||||
|
||||
## Mod zip inbox (NX)
|
||||
|
||||
Community mods install from a **separate** MTP inbox (not mixed into the ROM `imports/` scan):
|
||||
|
||||
| Item | Value |
|
||||
| ---- | ----- |
|
||||
| Save-relative path | `imports/mods/` |
|
||||
| MTP destination | `1: SD Card/<save identity>/imports/mods/` (see launcher notice for the live `getSaveDirectory()` path) |
|
||||
| Candidates | `*.zip` only |
|
||||
| Rescan | MODS tab → **Scan again** (installs each zip via `LauncherMods.installZip`; source zips are retained on success and failure) |
|
||||
| FIND MODS | Remains network-gated / hidden on NX (`networkValidated == false`) |
|
||||
|
||||
Do **not** commit third-party mod zip bytes into git. Drop the zip over MTP, rescan, enable in MODS, then Play.
|
||||
|
||||
**MTP tip (esp. macOS clients):** OpenMTP/Finder often creates AppleDouble sidecars named `._Something.zip` / `._cart.gb` / `._foo.sav`. Those are not real archives, ROMs, or saves — the launcher ignores hidden `.*` names under `imports/`, `imports/mods/`, and `imports/saves/<game>/`. If install still fails with “could not be opened” / “not a zip file”, delete any `._*` under the inbox and confirm the real zip starts with the `PK` magic (re-copy the release asset if unsure). This is a host-side annoyance of the current manual MTP loop, not something players should need forever.
|
||||
|
||||
Drop any community release `.zip` into `imports/mods/`, rescan, enable.
|
||||
Player-facing install steps: [switch-install.md](switch-install.md#community-mods).
|
||||
Mods own their OPTIONS / rebinds — do not duplicate third-party control tables here.
|
||||
|
||||
## Save `.sav` inbox (NX)
|
||||
|
||||
Raw Gen1 battery images use a **separate** MTP inbox (not mixed into ROM `imports/` or mod `imports/mods/`):
|
||||
|
||||
| Item | Value |
|
||||
| ---- | ----- |
|
||||
| Save-relative path | `imports/saves/red/`, `imports/saves/blue/`, `imports/saves/yellow/` |
|
||||
| MTP destination | `1: SD Card/<save identity>/imports/saves/<game>/` (see launcher notice for the live `getSaveDirectory()` path) |
|
||||
| Candidates | non-hidden `*.sav` only in **that game’s** folder |
|
||||
| Rescan | SAVE FILES → **Import save** on the matching game tab (scans only that folder) |
|
||||
| After success | Retire to `*.sav.imported` + append content hash to `imports/saves/<game>/.imported-sha1` |
|
||||
| Exports | **Export save** writes under `exports/<game>/gen1recomp-<game>-<slot>.sav`; NX shows an MTP path notice (no `openURL`) |
|
||||
|
||||
Do **not** commit `.sav` bytes into git. Drop the file into the matching game folder over MTP, press **Import save** on that tab, then play. Pull exports from `exports/<game>/`.
|
||||
|
||||
**MTP tip:** the same AppleDouble `._*.sav` rule applies — see the mod inbox tip above.
|
||||
|
||||
## Joy-Con display chords (Select + face)
|
||||
|
||||
PC digit hotkeys for COLORS / TILT / GBC FX / pipelines have Joy-Con equivalents. Hold **Select** (`back` / −) and press a face/shoulder button; the engine runs the same path as `Game:keypressed` for that digit (including `writeOptions` / Pipelines parity).
|
||||
|
||||
| Chord (Nintendo UX) | Engine key | Stock engine effect |
|
||||
| ------------------- | ---------- | ------------------- |
|
||||
| Select + **A** | `2` | COLORS cycle |
|
||||
| Select + **B** | `3` | TILT / perspective |
|
||||
| Select + **Y** | `5` | GBC FX |
|
||||
| Select + **X** | `6` | Mod pipeline hotkey (if registered) |
|
||||
| Select + **L** (left shoulder) | `7` | Mod pipeline hotkey (if registered) |
|
||||
|
||||
Keys `2` / `3` / `4` / `5` are claimed by the engine before mod pipeline hotkeys run, so a community mod cannot rebind those digits through `Pipelines.hotkey`. Mods that need their own controls should use OPTIONS rows or unclaimed hotkeys.
|
||||
|
||||
Without Select held, face buttons keep normal GB A/B gameplay mapping (no accidental color/tilt cycles). The **Options** menu remains available for the same settings — chords are optional shortcuts, not the only path.
|
||||
|
||||
On NX, A/B chords resolve through the Nintendo UX face remap so physical **A** → key `2` and physical **B** → key `3` match this table.
|
||||
|
||||
**OPTIONS → PERFORMANCE** clamps the port’s own extras (TILT / GBC FX / survey ZOOM) and can cap FPS — useful on weaker handheld budgets. Details: [new-features.md — Performance tier](new-features.md#performance-tier-low-end-devices).
|
||||
|
||||
Community mod zip install smoke (MODS inbox + Play): NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md).
|
||||
|
||||
**Opt-in diagnostics:** create an empty `switch-debug.txt` in the save directory; events flush to `switch.log` at ≤1 Hz with build identity (no ROM/save bytes).
|
||||
|
||||
**NX asset probe (always on Play):** every Switch Play writes `nx-asset-probe.log` in the save directory (`pokemon-love2d/`). It lists whether `assets/generated/…` vs `yellow|blue/assets/generated/…` exist, what `Assets.resolve` returns, and whether `newImage` / `newImageData` open — for Yellow/Blue blank-sprite triage. No ROM bytes.
|
||||
|
||||
**Blue/Yellow cache overlay (NX):** fused love-nx cannot reliably mount `yellow|blue/assets/generated` onto the un-prefixed path, so `src/core/NxAssetOverlay.lua` wraps EVERY read-side love API that accepts a filesystem path (`filesystem.read/load/lines/newFileData/getInfo`, `graphics.newImage/newFont`, `image.newImageData`, `audio.newSource`, `sound.newSoundData`, `font.newFontData`) once at boot — only when `Platform.isNX()`. Covering the whole read surface (not just the loaders the boot needs today) keeps future states and mods inside the fallback automatically; write-side functions stay stock. Core code must NOT call love loaders on literal `assets/generated` paths (enforced by `tests/engine/nx_generated_guard_test.lua`); the chip-audio worker is a separate Lua state and gets the prefix explicitly via `audio.programPrefix` from `ChipAudio.slimAudio`.
|
||||
|
||||
**Hardware re-test:** T16 **pass** @ `2699c9a` (naming A=confirm / B=cancel). T19 **pass** (quit/reopen, suspend×10, reboot) — operator 2026-08-01.
|
||||
|
||||
**Suspend/resume audio:** after resume, chip music is stopped to avoid duplicate streams; confirm on hardware during P0-09/10 (T19).
|
||||
|
||||
## Lua error log (save directory)
|
||||
|
||||
On any uncaught Lua error, Gen1Recomp appends a redacted trace to `lua-error.log` in the LÖVE save directory (`love.filesystem.getSaveDirectory()`). The on-screen error overlay includes a hint pointing at that file. Logs rotate to `lua-error.log.1` when the active file exceeds 32 KiB. ROM/save bytes and non-printable data are stripped — never commit or share logs that might contain private paths without reviewing them first.
|
||||
|
||||
## Native crash triage (love-nx / Atmosphère)
|
||||
|
||||
love-nx native faults land under the console’s `crash_reports/` folder on SD (reachable via the same manual MTP workflow used for game deploys).
|
||||
|
||||
1. **Collect** — DBI → `Run MTP responder`; copy `sdmc:/crash_reports/*.bin` (or the dated subfolder) to the contributor host. Prefer keeping the microSD in-console for routine pulls.
|
||||
2. **Redact** — delete any attached screenshots or notes that mention ROM filenames, save paths, or private hashes before sharing logs publicly.
|
||||
3. **Symbolize** — use the **pinned** `love.elf` from `.bazinga/love-nx/11.5-nx1/` that matches `build-info.json` / `scripts/switch/love-nx-11.5-nx1.sha256`. Never use a “latest” download.
|
||||
|
||||
```bash
|
||||
# Example: aarch64-none-elf-addr2line from devkitPro
|
||||
aarch64-none-elf-addr2line -e .bazinga/love-nx/11.5-nx1/love.elf -f -C 0xADDRESS_FROM_CRASH_REPORT
|
||||
```
|
||||
|
||||
4. **Correlate** — compare `gitCommit` / `loveNxTag` from embedded `build-info.json` with the operator’s hardware notes.
|
||||
|
||||
If `addr2line` cannot resolve an address, archive the crash `.bin` with the exact `love.elf` SHA-256 used for the build — addresses are only meaningful against that ELF.
|
||||
|
||||
## P0 / P1 hardware matrix (ADR §9)
|
||||
|
||||
Operator evidence lives in `docs/switch-hardware-evidence.md`. **Do not invent passes** for rows that require hardware not yet run.
|
||||
|
||||
| ID | Requirement | Status | Evidence |
|
||||
| -- | ----------- | ------ | -------- |
|
||||
| P0-0a–f | love-nx pin, probe, MTP, title override | **pass** | Phase 0 checklist above; T4 |
|
||||
| P0-1a–d | Unpatched launcher boot + Joy-Con nav | **pass** | T4 / `docs/switch-hardware-evidence.md` |
|
||||
| P0-02 | MTP inbox import path shown | **pass** | T12 |
|
||||
| P0-03 | Rescan imports ROM | **pass** | T12 |
|
||||
| P0-04 | Canonical hash routes version | **pass** | T12 |
|
||||
| P0-05 | Source dump retained in inbox | **pass** | T12 |
|
||||
| P0-06 | Play reaches game after import | **pass** | T12 |
|
||||
| P0-07 | Joy-Con launcher navigation | **pass** | T16 @ `2699c9a` |
|
||||
| P0-08 | Joy-Con gameplay (incl. naming A/B) | **pass** | T16 @ `2699c9a` |
|
||||
| P0-09 | Save survives quit + reopen | **pass** | T19 |
|
||||
| P0-10 | ≥10 suspend cycles, no stuck input/dup audio | **pass** | T19 (operator 2026-08-01) |
|
||||
| P0-12 | Fused NRO boots without adjacent `game.love` | **pass** | T24 — `docs/switch-hardware-evidence.md` |
|
||||
| P0-14 | Fused NRO MTP round-trip SHA-256 | **pass** | T24 — first artifact `b019e2e8…` @ `6fb5602` (redeploy after Blue fix) |
|
||||
| P0-15 | Replace NRO only; saves persist | **pass** | T24 — operator NRO-only update keeps saves |
|
||||
| P1-01 | Docked vs handheld spot-check | **deferred** | Code: `NxDisplay` 720p↔1080p; OLED dock soak not recorded yet |
|
||||
| P1-02 | Applet Mode documented unsupported | **pass** | Title override required; Album path not validated |
|
||||
| P1-03 | Long-play soak (≥30 min) | **deferred** | No soak session recorded |
|
||||
| P1-04 | Reboot persistence | **pass** | T19 |
|
||||
| P1-05 | Audio resume after suspend | **pass** | T19 (no dup audio reported) |
|
||||
| — | Switch V1 / Erista boot | **pass** (boot) | Community — [booshankles](https://github.com/booshankles); see evidence log |
|
||||
| — | Switch Lite / docked soak | **untested** / **deferred** | Welcome contributions |
|
||||
| — | Automated / `nxlink` deploy | **absent** | Manual MTP / SD / FTP only (AD-009) |
|
||||
| — | Multi-OS transfer runbooks | **pass** | [switch-transfer.md](switch-transfer.md) |
|
||||
| — | Community mod zip OLED smoke (NXMOD-12) | **pass** | `docs/switch-hardware-evidence.md` |
|
||||
|
||||
## Review guidance
|
||||
|
||||
Maintainers may review as one PR or split later. Suggested slices (optional):
|
||||
|
||||
Each slice should declare: **no ROM/save bytes committed**, **love-nx pin with manifest checksums**, **hardware-tested rows listed with linked evidence**, **Applet Mode unsupported**, **network/updater disabled on NX**, **deploy still manual** (MTP / SD / FTP; no nxlink yet), **OpenMTP is one example not the sole contract**.
|
||||
|
||||
### Slice 1 — Platform + import (`platform/import`)
|
||||
|
||||
- `src/core/Platform.lua`, `conf.lua` NX branch
|
||||
- `src/import/RomImporter.lua` (NX flags, inbox, scan, shell/updater gates)
|
||||
- Tests: `tests/engine/platform_nx_*`, `tests/engine/rom_importer_nx_*` (ROM-free T2)
|
||||
- Docs: inbox/MTP import sections only
|
||||
|
||||
### Slice 2 — Input + lifecycle (`input/lifecycle`)
|
||||
|
||||
- `src/core/GamepadMap.lua`, `Input.lua`, `main.lua` focus/joystick hooks
|
||||
- `src/debug/SwitchDiagnostics.lua` (opt-in probe + error log)
|
||||
- Tests: input/diagnostics suites
|
||||
- Docs: controller mapping, suspend/audio notes
|
||||
|
||||
### Slice 3 — Build + docs (`build/docs`)
|
||||
|
||||
- `scripts/pack_love.sh`, `scripts/build_switch.sh`, `scripts/switch/*`
|
||||
- `assets/switch/icon.jpg`, `docs/switch-development.md`, hardware evidence templates
|
||||
- Gates: `pack_love.sh --dry-run`, `verify_payload.sh --self-test`, fused build script (devkitPro host)
|
||||
|
||||
**Pre-merge checklist:**
|
||||
|
||||
- [ ] Manifest `scripts/switch/love-nx-11.5-nx1.sha256` filled; binaries not in git
|
||||
- [ ] `verify_payload.sh` rejects generated cache / ROM / `.sav` / `.bak`
|
||||
- [ ] P0 matrix rows marked pass only with linked hardware evidence
|
||||
- [x] Fused NRO P0-12/14/15 pass with T24 evidence (`docs/switch-hardware-evidence.md`)
|
||||
- [ ] Updater / remote mod download hidden on NX (`networkValidated == false`)
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
# Switch hardware evidence (Phase 0 + import + input)
|
||||
|
||||
> **Hardware evidence log.** Author passes below were recorded on **one
|
||||
> Nintendo Switch OLED** with a **manual** Mac → DBI MTP deploy loop. A
|
||||
> separate community row records Switch V1 / Erista boot. These rows do
|
||||
> **not** claim Lite, docked soak, or automated install. See
|
||||
> `docs/switch-development.md` for status and limitations.
|
||||
|
||||
**love-nx:** `11.5-nx1`
|
||||
**Author console:** Switch OLED
|
||||
**Deploy method (author):** manual OpenMTP + DBI `Run MTP responder` (no CI / no nxlink)
|
||||
**Operator (author rows):** Andrew ([andrewqsantos](https://github.com/andrewqsantos))
|
||||
**Date (author rows):** 2026-08-01
|
||||
|
||||
Do **not** commit ROM dumps or private dump hashes. Do **not** mark a row **pass** without hardware notes for that row.
|
||||
|
||||
---
|
||||
|
||||
## Community — Switch V1 / Erista boot — pass (boot)
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| Console | Nintendo Switch V1 (Erista) |
|
||||
| Check | Prebuilt fused NRO boots under title override |
|
||||
| Tester | [booshankles](https://github.com/booshankles) |
|
||||
| Notes | Community confirmation only — not a full P0/P1 matrix re-run on V1 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — probe (T4) — pass
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| Commit (import era) | `df7cea4` |
|
||||
| `getOS()` / `love._os` | `NX` |
|
||||
| Dimensions | 1280×720 |
|
||||
| Save (probe) | `sdmc:/switch/gen1recomp/switch-probe` |
|
||||
| Joy-Con | `joystickpressed` + `gamepadpressed` (Y→`#3`, X→`#4`) |
|
||||
|
||||
| Artifact | SHA-256 |
|
||||
| -------- | ------- |
|
||||
| `gen1recomp.nro` | `8290ac153d4c630e48c9b26ef9123f5204ed8ee0cef3042511707b5b645918f5` |
|
||||
|
||||
---
|
||||
|
||||
## T12 — Red import + Play — pass
|
||||
|
||||
Inbox MTP → “Scan again” → Play; Joy-Con launcher/gameplay (not touch-only).
|
||||
|
||||
---
|
||||
|
||||
# T16 — Joy-Con launcher + gameplay — pass (naming re-verify)
|
||||
|
||||
### Round 1 @ `7504753` — partial
|
||||
|
||||
| Check | Result |
|
||||
| ----- | ------ |
|
||||
| Launcher / overworld (Joy-Con only) | **pass** |
|
||||
| Naming player/rival | **fail** (dual-path a+b; see below) |
|
||||
| Touch required | **no** |
|
||||
| `game.love` SHA-256 | `bd3a35461bf453c1f0465a5a289421aef3b5c72d3bf1f8d76e86231256829e0e` |
|
||||
|
||||
### Naming failure (root cause) — fixed in `efd81d8` + `2699c9a`
|
||||
|
||||
- love-nx fires **`gamepadpressed` + `joystickpressed` on the same physical press**.
|
||||
- `NamingScreen` tested `wasPressed("b")` before `"a"` → if both true in one frame, always deletes.
|
||||
- Dual-path fix: ignore raw when `isGamepad()` (`efd81d8`).
|
||||
- SDL-only UX then had physical B confirm / A erase; NX face remap (`2699c9a`) restores Nintendo A=confirm / B=cancel.
|
||||
|
||||
### Round 2 @ `2699c9a` — pass (Nintendo UX)
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| Commit tested | `2699c9a` |
|
||||
| `game.love` SHA-256 | `a208b21e1f30b00e2e8c6fa6efe14f0e06d1db0ae1e50b810b16d9fb852926bc` |
|
||||
| Touch required | **no** |
|
||||
|
||||
| Check | Result |
|
||||
| ----- | ------ |
|
||||
| Naming — player | **pass** — physical **A** confirms letter, **B** cancels/erases |
|
||||
| Naming — rival | **pass** (same) |
|
||||
| Launcher / overworld (prior round) | **pass** (unchanged mapping for d-pad/stick) |
|
||||
|
||||
T16 hardware gate: **closed**.
|
||||
|
||||
---
|
||||
|
||||
## T19 — save / suspend — pass
|
||||
|
||||
| Check | Result |
|
||||
| ----- | ------ |
|
||||
| Save in-game → full quit → title-override reopen → load save | **pass** (@ `7504753` / retained) |
|
||||
| Suspend/resume ×10 (launcher / gameplay / mixed) | **pass** (operator 2026-08-01) |
|
||||
| Full console reboot persistence | **pass** (operator 2026-08-01) |
|
||||
|
||||
T19 hardware gate: **closed**. No stuck input, duplicate audio, or crash reported.
|
||||
|
||||
---
|
||||
|
||||
## T24 — fused NRO alone + NRO-only update — **pass**
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| First fused attempt | `6fb5602` (Blue Play failed — mount) |
|
||||
| Fix commits | `b1ad7c7` (logs/generated overlay), `ac6dfe7` (Blue/Yellow mount) |
|
||||
| Deploy | isolated folder, no adjacent `game.love` |
|
||||
| Boot fused | **pass** |
|
||||
| ROM import | **pass** |
|
||||
| Play **Red** | **pass** |
|
||||
| Play **Blue** (after `ac6dfe7`) | **pass** (operator 2026-08-01) |
|
||||
| NRO-only replace | **pass** — saves retained; app still boots/plays |
|
||||
| Touch required | no |
|
||||
|
||||
T24 hardware gate: **closed**.
|
||||
|
||||
---
|
||||
|
||||
## SWBLD — `build_switch.sh --fetch --fused` + install path — **pass**
|
||||
|
||||
Operator smoke for the switch-build-pipeline packaging CLI (closes matrix-deferred happy paths from validation).
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| Command | `scripts/build_switch.sh --fetch --fused --version 0.0.0-test` |
|
||||
| Host | macOS + native switch-tools (or Docker fallback if used) |
|
||||
| Commit / build-info | `9147a64` (`gitCommit` in build-info) |
|
||||
| love-nx | `11.5-nx1` (manifest checksums match) |
|
||||
| Artifact | `dist/switch/gen1recomp-0.0.0-test-switch.nro` |
|
||||
| NRO SHA-256 | `210efb884a8d27443dc1c64ed8f071b0f862d8d0c9b140ad8185093c4e4027db` |
|
||||
| Install doc | `docs/switch-install.md` — at the time of this row: copy NRO under `sdmc:/switch/gen1recomp/` (releases now ship an SD-ready zip; same folder) |
|
||||
| Console | Switch OLED |
|
||||
| Operator | Andrew |
|
||||
| Date | 2026-08-01 |
|
||||
|
||||
| Check | Result |
|
||||
| ----- | ------ |
|
||||
| `--fetch` + `--fused` produce NRO + `.sha256` | **pass** |
|
||||
| Copy NRO to SD folder per install doc | **pass** (operator) |
|
||||
| Title-override launch / play | treated as prior T24 path; this row records **packaging + deploy to folder** success |
|
||||
|
||||
SWBLD packaging smoke: **closed** for Mac fused build + file-to-SD install step.
|
||||
|
||||
---
|
||||
|
||||
## NXMOD-12 — Community mod zip OLED smoke — **pass**
|
||||
|
||||
Closed from existing OLED photo evidence on issue
|
||||
[#531](https://github.com/bryanthaboi/gen1recomp/issues/531) (operator comment
|
||||
with launcher MODS + overworld shots). Photos live on the orphan branch
|
||||
[`switch-oled-photos`](https://github.com/andrewqsantos/gen1recomp/tree/switch-oled-photos)
|
||||
of the operator fork — **not** committed to this repo. Do **not** commit
|
||||
third-party mod `.zip` bytes. Community mods own their OPTIONS / rebinds;
|
||||
this entry only proves the MODS inbox + Play path on OLED.
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| Status | **pass** |
|
||||
| gen1recomp commit | evidence era on `feat/switch-nx` (see #531); packaging pin love-nx `11.5-nx1` |
|
||||
| love-nx tag | `11.5-nx1` |
|
||||
| Console | Switch OLED |
|
||||
| Mod | community release `.zip` (not vendored; not named here) |
|
||||
| Zip committed to git? | **no** |
|
||||
| Photo evidence | [#531 comment](https://github.com/bryanthaboi/gen1recomp/issues/531) — MODS tab + overworld |
|
||||
| MODS tab photo | https://raw.githubusercontent.com/andrewqsantos/gen1recomp/switch-oled-photos/IMG_1766.jpg |
|
||||
| Overworld photo | https://raw.githubusercontent.com/andrewqsantos/gen1recomp/switch-oled-photos/IMG_1771.jpg |
|
||||
| Operator | Andrew |
|
||||
| Date | 2026-08-01 |
|
||||
|
||||
### Checklist
|
||||
|
||||
| Step | Pass / fail / pending | Notes |
|
||||
| ---- | --------------------- | ----- |
|
||||
| MTP zip into save `imports/mods/` | **pass** | Photo evidence + prior inbox path |
|
||||
| MODS → Scan again → mod listed | **pass** | IMG_1766 — community mod installed |
|
||||
| Enable mod + Play Red boots without crash | **pass** | Overworld / Pallet / Oak lab photos on #531 |
|
||||
| Overworld Select+A → visible colors change | **pass** | Stock COLORS chord path exercised |
|
||||
| Overworld Select+B → visible tilt/perspective change | **pass** | Stock TILT chord path exercised (IMG_1771) |
|
||||
|
||||
### Evidence notes
|
||||
|
||||
```text
|
||||
Operator: Andrew
|
||||
Date: 2026-08-01
|
||||
Commit tested: feat/switch-nx era documented on issue #531
|
||||
Pass / fail summary: PASS — MODS zip install + Play on Switch OLED
|
||||
Photo branch: andrewqsantos/gen1recomp@switch-oled-photos
|
||||
```
|
||||
@@ -0,0 +1,164 @@
|
||||
# Install Gen1Recomp on Nintendo Switch
|
||||
|
||||
Every GitHub Release that includes Switch support ships an SD-ready zip:
|
||||
`gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install
|
||||
**or** update — same steps), launch with **title override**, then import your
|
||||
own legal `.gb` ROM.
|
||||
|
||||
> You need a console that can run Switch homebrew (custom firmware / hbmenu).
|
||||
> This project does not help you set that up. Tracks issue
|
||||
> [#531](https://github.com/bryanthaboi/gen1recomp/issues/531).
|
||||
> Hardware: **OLED** validated by the porter; **V1 / Erista** boot confirmed
|
||||
> by the community. Lite and other setups welcome more reports.
|
||||
> See [switch-development.md](switch-development.md) for limitations.
|
||||
|
||||
Prefer building from source? See [switch-build.md](switch-build.md).
|
||||
|
||||
Port by [andrewqsantos](https://github.com/andrewqsantos). Community testing
|
||||
help from [booshankles](https://github.com/booshankles).
|
||||
|
||||
## 1. Download the zip
|
||||
|
||||
1. Open
|
||||
[Releases](https://github.com/bryanthaboi/gen1recomp/releases).
|
||||
2. Download `gen1recomp-*-switch.zip` for the version you want.
|
||||
(Optional: verify against `sha256sums.txt` in the same release.)
|
||||
|
||||
## 2. Extract onto the microSD
|
||||
|
||||
Extract the zip at the **root** of the microSD so you get:
|
||||
|
||||
```text
|
||||
sdmc:/switch/gen1recomp/gen1recomp.nro
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/mods/
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/...
|
||||
```
|
||||
|
||||
Merge folders if your OS asks. Any method works: **MTP** (DBI → Run MTP
|
||||
responder + a client), **direct SD** (Hekate UMS or a card reader), or **FTP**.
|
||||
Exit MTP / unmount / stop FTP cleanly before launching. Step-by-step for
|
||||
macOS, Linux, and Windows: [switch-transfer.md](switch-transfer.md).
|
||||
|
||||
### Updating
|
||||
|
||||
Use the **same** extract/merge. It replaces `gen1recomp.nro` (and the small
|
||||
help `README.txt` / `INSTALL.txt` files). Saves, imported ROMs, mods, and
|
||||
options live under `pokemon-love2d/` — **do not delete that folder** when
|
||||
updating, or you will lose progress.
|
||||
|
||||
## 3. Launch with title override
|
||||
|
||||
**Applet Mode is not supported** for this game (not enough memory).
|
||||
|
||||
1. On the Switch HOME menu, highlight any installed title.
|
||||
2. Hold **R** and launch that title — this opens hbmenu with full memory
|
||||
(title override).
|
||||
3. From hbmenu, open `gen1recomp`.
|
||||
|
||||
Do **not** launch from the Album applet path for normal play.
|
||||
|
||||
## 4. Import your ROM
|
||||
|
||||
This project ships **no** game data. On first launch:
|
||||
|
||||
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), or Yellow
|
||||
(`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
||||
launcher also shows the live save-dir path). All three can sit in the
|
||||
same folder.
|
||||
2. Use **Scan again** on that game’s tab (Red / Blue / Yellow). Rescan
|
||||
matches by ROM SHA-1 for the open tab only — a Red dump never imports
|
||||
from the Yellow tab (and vice versa).
|
||||
|
||||
## 5. Import / Export a raw `.sav`
|
||||
|
||||
Continue a cart or PC battery save (or pull a slot off-console) via MTP /
|
||||
SD / FTP — same transfer methods as ROMs. Paths are **per game**:
|
||||
|
||||
| Game | Import inbox | Export folder |
|
||||
| ---- | ------------ | ------------- |
|
||||
| Red | `imports/saves/red/` | `exports/red/` |
|
||||
| Blue | `imports/saves/blue/` | `exports/blue/` |
|
||||
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
|
||||
|
||||
(Under the save dir `pokemon-love2d/` — the zip already creates these folders.)
|
||||
|
||||
1. Copy a Gen1 `.sav` (32 KB) into that game’s inbox under the save dir
|
||||
([switch-transfer.md](switch-transfer.md)).
|
||||
2. With the game’s ROM already imported, open **that game’s tab** →
|
||||
**SAVE FILES** → **Import save**. Only that folder is scanned.
|
||||
3. A successful import retires the file to `*.sav.imported` and records its
|
||||
content hash so pressing **Import save** again does not clone slots.
|
||||
Failed imports leave the original `.sav` in place.
|
||||
4. To pull a slot off the console, use **Export save**, then copy the file
|
||||
from that game’s **`exports/<game>/`** folder via MTP / SD / FTP.
|
||||
|
||||
Do not put `.sav` files into git. Prefer clean copies — some MTP clients
|
||||
create `._*.sav` AppleDouble sidecars that are not real saves.
|
||||
|
||||
## Controls
|
||||
|
||||
### Gameplay
|
||||
|
||||
| Control | Action |
|
||||
| ------- | ------ |
|
||||
| D-pad / left stick | Move |
|
||||
| **A** | Confirm |
|
||||
| **B** | Cancel |
|
||||
| **+** (Start) | Start |
|
||||
| **−** (Select) | Select |
|
||||
| **R** (no Select held) | Cycle game speed up |
|
||||
| **L** (no Select held) | Cycle game speed down |
|
||||
|
||||
### Launcher
|
||||
|
||||
| Control | Action |
|
||||
| ------- | ------ |
|
||||
| D-pad / left stick | Move virtual cursor |
|
||||
| **A** | Click at cursor |
|
||||
| **L** / **R** | Previous / next tab |
|
||||
| **Start** / **Select** | Play if a ROM is ready; otherwise Choose ROM |
|
||||
|
||||
### System
|
||||
|
||||
| Control | Action |
|
||||
| ------- | ------ |
|
||||
| Hold **R** on HOME, then open from hbmenu | Title override (full memory) |
|
||||
|
||||
## Community mods
|
||||
|
||||
Mods install from a zip inbox (same transfer methods as ROMs):
|
||||
|
||||
1. Copy a release `.zip` into the save-dir **`imports/mods/`** path the
|
||||
launcher shows (MTP / SD / FTP — [switch-transfer.md](switch-transfer.md)).
|
||||
2. In the launcher, open **MODS** → **Scan again** → enable the mod →
|
||||
**Play**.
|
||||
|
||||
Remote **FIND MODS** / GitHub download stays **off** on Switch. Do not put
|
||||
mod zips into git. Community mods ship their own OPTIONS / rebinds — this port
|
||||
does not document third-party control tables.
|
||||
|
||||
### Joy-Con shortcuts (Select + face)
|
||||
|
||||
Hold **Select** (−) and press a face/shoulder button. Without Select, A/B stay
|
||||
normal gameplay confirm/cancel. These chords are the stock engine display
|
||||
hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs).
|
||||
|
||||
| Chord | Same as PC key | Stock engine effect |
|
||||
| ----- | -------------- | ------------------- |
|
||||
| Select + **A** | `2` | COLORS |
|
||||
| Select + **B** | `3` | TILT |
|
||||
| Select + **Y** | `5` | GBC FX |
|
||||
| Select + **X** | `6` | Mod pipeline hotkey (if a mod registers `6`) |
|
||||
| Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) |
|
||||
|
||||
If the handheld stutters with extras on, try **OPTIONS → PERFORMANCE** →
|
||||
`LOW` or `BALANCED`. Full chord notes for contributors:
|
||||
[switch-development.md](switch-development.md#joy-con-display-chords-select--face).
|
||||
|
||||
## Prefer building it yourself?
|
||||
|
||||
Building the fused NRO (and SD-ready zip) from source is covered in
|
||||
[switch-build.md](switch-build.md). Copying artifacts and inbox files
|
||||
(MTP / SD / FTP on macOS, Linux, Windows): [switch-transfer.md](switch-transfer.md).
|
||||
Status, limitations, and how we tested: [switch-development.md](switch-development.md).
|
||||
@@ -0,0 +1,168 @@
|
||||
# Switch file transfer (MTP / SD / FTP)
|
||||
|
||||
Canonical ways to put Gen1Recomp artifacts and inbox files onto a Nintendo
|
||||
Switch. **Any method is valid** if the bytes land in the destinations below.
|
||||
|
||||
This is the home runbook for contributors on **macOS, Linux, and Windows**.
|
||||
Player install (what to download, title override) stays in
|
||||
[switch-install.md](switch-install.md). Packaging stays in
|
||||
[switch-build.md](switch-build.md). Hardware evidence lives in
|
||||
[switch-hardware-evidence.md](switch-hardware-evidence.md).
|
||||
|
||||
> **Not supported yet:** `nxlink` / hbmenu netloader automation. Useful later
|
||||
> for a fast contrib rebuild loop; deferred on purpose (AD-009). Do not treat
|
||||
> netloader as the release or ROM/mod install path.
|
||||
|
||||
---
|
||||
|
||||
## Destinations (shared by every method)
|
||||
|
||||
| What | Where on the console |
|
||||
| ---- | -------------------- |
|
||||
| SD-ready release zip | Extract at microSD **root** → `sdmc:/switch/gen1recomp/gen1recomp.nro` plus `pokemon-love2d/` inbox folders. Install and update use the same merge; do **not** delete `pokemon-love2d/` |
|
||||
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
|
||||
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
|
||||
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
|
||||
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that game’s SAVE FILES → **Import save** |
|
||||
| Save exports | Same save dir → `exports/red\|blue\|yellow/` (pull after **Export save**; MTP / SD / FTP) |
|
||||
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
||||
| Lua error log | `lua-error.log` in the save dir |
|
||||
|
||||
Saves persist across zip re-extract / NRO replacements as long as
|
||||
`pokemon-love2d/` is left in place. Never commit ROM dumps, `.sav`
|
||||
files, or third-party mod zips to git.
|
||||
|
||||
---
|
||||
|
||||
## Canonical methods
|
||||
|
||||
### 1. MTP (DBI responder + host client)
|
||||
|
||||
On the Switch: close Gen1Recomp → open **DBI** → **Run MTP responder** (often
|
||||
**X** on the main screen) → keep that screen up → USB-C data cable to the host.
|
||||
|
||||
On the host: open **one** MTP client, navigate to **`1: SD Card`**, then the
|
||||
paths above. Wait for the transfer queue; refresh; exit MTP on the Switch
|
||||
before launching.
|
||||
|
||||
#### macOS (example: OpenMTP)
|
||||
|
||||
[OpenMTP](https://github.com/ganeshrvel/openmtp) is the loop used for OLED
|
||||
hardware evidence — **one contributor example**, not a Mac-only product rule.
|
||||
|
||||
1. Quit other MTP clients.
|
||||
2. Open OpenMTP → select the DBI device → **`1: SD Card`**.
|
||||
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
|
||||
(or copy NRO / `game.love` for loose).
|
||||
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
|
||||
`imports/saves/<red|blue|yellow>/`, or `exports/<red|blue|yellow>/` path the
|
||||
launcher prints.
|
||||
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
||||
|
||||
macOS clients often create AppleDouble sidecars (`._Something.zip`,
|
||||
`._cart.gb`, `._foo.sav`). Those are not real archives or saves — the
|
||||
launcher skips hidden `.*` names. Delete `._*` junk if a zip/ROM/`.sav`
|
||||
fails to open.
|
||||
|
||||
#### Linux
|
||||
|
||||
1. Install desktop MTP support if needed (e.g. `gvfs-mtp` on GNOME/GTK
|
||||
desktops, or your distro’s KDE MTP stack).
|
||||
2. With DBI MTP active, open **Files** / **Dolphin** / **Thunar** and select
|
||||
the Switch / DBI device → **`1: SD Card`**.
|
||||
3. Extract the release zip at SD root (merge), or copy into `switch/gen1recomp/`
|
||||
and the save-dir inboxes as above.
|
||||
4. Use **only one** MTP accessor at a time. If `mtp-tools` / `mtpfs` reports
|
||||
“device is busy”, close the file manager’s MTP mount (or the CLI mount)
|
||||
and retry with a single client.
|
||||
5. Eject/unmount cleanly; exit MTP on the Switch; title-override launch.
|
||||
|
||||
If MTP is unavailable or flaky on Linux, use **direct SD** (Hekate UMS or a
|
||||
card reader) or **FTP** instead — same destinations in the table above.
|
||||
|
||||
#### Windows
|
||||
|
||||
1. With DBI MTP active, open **This PC** / **File Explorer** and look under
|
||||
**Portable Devices** for the Switch / DBI MTP volume → **`1: SD Card`**.
|
||||
2. Copy / extract into `switch\gen1recomp\` and the save-dir inboxes.
|
||||
3. Optional: [OpenMTP](https://github.com/ganeshrvel/openmtp) on Windows if
|
||||
Explorer is flaky.
|
||||
4. If Windows does not show an MTP device: Device Manager → find DBI / Switch
|
||||
→ Update driver → **MTP USB Device** (or Standard MTP Device). Prefer a
|
||||
data-capable USB-C cable and a direct port.
|
||||
5. Safely disconnect; exit MTP on the Switch; title-override launch.
|
||||
|
||||
If MTP is unavailable or flaky on Windows, use **direct SD** (Hekate UMS or a
|
||||
card reader) or **FTP** instead — same destinations in the table above.
|
||||
|
||||
### 2. Direct SD (Hekate UMS or card reader)
|
||||
|
||||
Same destinations; no MTP client required.
|
||||
|
||||
- **Hekate UMS** (preferred when available): expose the microSD to the host
|
||||
while the card stays in the console; mount the volume; copy files; **cleanly
|
||||
unmount** before leaving UMS.
|
||||
- **Physical reader**: power off / remove the microSD, copy on the host,
|
||||
**eject safely**, reinsert, boot CFW, title-override launch.
|
||||
|
||||
Do not yank the card or unplug UMS mid-write.
|
||||
|
||||
### 3. FTP (any SD-exposing Switch FTP)
|
||||
|
||||
Any homebrew FTP server that can write the microSD is fine — for example
|
||||
**DBI’s own FTP**, **sys-ftpd-light**, or **Sphaira** (names are illustrations
|
||||
only; pick what your CFW setup already uses).
|
||||
|
||||
1. Start the FTP server on the Switch; note IP/port/credentials from that app.
|
||||
2. From the host, connect with any FTP client and upload to the same
|
||||
`switch/gen1recomp/`, `imports/`, `imports/mods/`, `imports/saves/<game>/`,
|
||||
and `exports/<game>/` paths.
|
||||
3. Stop the FTP server cleanly before launching Gen1Recomp.
|
||||
|
||||
If credentials or chroots differ by app, trust the **destination paths**, not
|
||||
a single vendor tutorial.
|
||||
|
||||
---
|
||||
|
||||
## After every transfer
|
||||
|
||||
1. Exit MTP / unmount SD / stop FTP cleanly.
|
||||
2. Launch via **title override** (hold **R** on a title → hbmenu). **Applet
|
||||
Mode is not supported** (not enough memory).
|
||||
3. For ROMs: open the matching game tab → **Scan again** if the file was
|
||||
added after boot (SHA-1 must match that tab; other dumps in `imports/`
|
||||
stay for their own tabs). For mods: MODS → **Scan again** → enable →
|
||||
Play. For saves: SAVE FILES → **Import save** (rescans
|
||||
`imports/saves/<game>/`). Pull exported `.sav` files from
|
||||
`exports/<game>/`. Joy-Con display chords (stock engine):
|
||||
[switch-install.md](switch-install.md#joy-con-shortcuts-select--face).
|
||||
|
||||
### Optional NRO integrity check
|
||||
|
||||
For the first deploy of a given artifact (or after a flaky cable):
|
||||
|
||||
```bash
|
||||
shasum -a 256 path/to/gen1recomp.nro # or sha256sum
|
||||
```
|
||||
|
||||
Copy the file back from the SD and compare hashes. Round-trip must match.
|
||||
|
||||
---
|
||||
|
||||
## Failure modes (quick)
|
||||
|
||||
| Symptom | What to try |
|
||||
| ------- | ----------- |
|
||||
| Device busy / no MTP volume | One client only; different cable/port; Windows MTP USB Device driver; alternate method (SD or FTP) |
|
||||
| Zip/ROM/`.sav` “could not be opened” | Delete `._*` sidecars (including `._*.sav`); confirm real zip starts with `PK` |
|
||||
| Half-copied NRO / crash on boot | Re-copy; verify SHA-256; exit transfer mode before launch |
|
||||
| App opens in Applet Mode | Use title override (hold **R**), not Album |
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- Players: [switch-install.md](switch-install.md)
|
||||
- Builders: [switch-build.md](switch-build.md)
|
||||
- Status / hardware matrix: [switch-development.md](switch-development.md)
|
||||
- Evidence log: [switch-hardware-evidence.md](switch-hardware-evidence.md)
|
||||
@@ -1,188 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,686 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,385 +0,0 @@
|
||||
--- 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
|
||||
@@ -1,346 +0,0 @@
|
||||
---@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
|
||||
@@ -1,596 +0,0 @@
|
||||
---@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
|
||||
@@ -1,171 +0,0 @@
|
||||
-- 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 }
|
||||
@@ -1,843 +0,0 @@
|
||||
---@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
|
||||
@@ -1,232 +0,0 @@
|
||||
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
|
||||
@@ -1,269 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,583 +0,0 @@
|
||||
---@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
|
||||
@@ -1,336 +0,0 @@
|
||||
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
|
||||
@@ -1,160 +0,0 @@
|
||||
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
|
||||
@@ -1,380 +0,0 @@
|
||||
---@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
|
||||
@@ -1,174 +0,0 @@
|
||||
-- ====================
|
||||
-- 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
|
||||
@@ -1,88 +0,0 @@
|
||||
---@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
|
||||
@@ -1,748 +0,0 @@
|
||||
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
|
||||
@@ -1,697 +0,0 @@
|
||||
---@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
|
||||
@@ -1,202 +0,0 @@
|
||||
---@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
|
||||
@@ -1,217 +0,0 @@
|
||||
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
|
||||
@@ -1,351 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,560 +0,0 @@
|
||||
---@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
|
||||
@@ -1,505 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,124 +0,0 @@
|
||||
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
|
||||
@@ -1,719 +0,0 @@
|
||||
---@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
|
||||
@@ -1,790 +0,0 @@
|
||||
---@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
|
||||
@@ -1,183 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
---@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
|
||||
@@ -1,335 +0,0 @@
|
||||
--- 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
|
||||
@@ -1,35 +0,0 @@
|
||||
---@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
|
||||
@@ -1,245 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,344 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,282 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,132 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,264 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,206 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,576 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,178 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,662 +0,0 @@
|
||||
---@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
|
||||
@@ -1,319 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -10,6 +10,24 @@
|
||||
|
||||
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
||||
|
||||
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
||||
local LaunchOptions = require("src.core.LaunchOptions")
|
||||
local NxDisplay = require("src.core.NxDisplay")
|
||||
|
||||
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
||||
do
|
||||
local defaultErrorHandler = love.errorhandler
|
||||
function love.errorhandler(msg)
|
||||
local hint = SwitchDiagnostics.logLuaError(msg)
|
||||
if hint and type(msg) == "string" then
|
||||
msg = msg .. "\n\n" .. hint
|
||||
end
|
||||
if defaultErrorHandler then
|
||||
return defaultErrorHandler(msg)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local Game, EditorApp, Importer, TouchEditor
|
||||
|
||||
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
|
||||
@@ -103,6 +121,11 @@ local function openEditor(version, slotId)
|
||||
require("src.import.CacheFs").mountVersion(version)
|
||||
editorVersion = version
|
||||
editorHost = Importer
|
||||
-- Drop launcher pad/FlexLove so the save editor owns input (NX shim +
|
||||
-- virtual cursor + system hand cursor). Desktop park is a light no-op.
|
||||
if Importer and Importer.prepareOverlayHandoff then
|
||||
Importer:prepareOverlayHandoff()
|
||||
end
|
||||
Importer = nil
|
||||
editorMode = true
|
||||
resizeForEditor()
|
||||
@@ -129,6 +152,9 @@ function closeEditor()
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
editorHost = nil
|
||||
if Importer and Importer.resumeAfterOverlay then
|
||||
Importer:resumeAfterOverlay()
|
||||
end
|
||||
if Importer and version and Importer.savesChanged then
|
||||
Importer:savesChanged(version)
|
||||
end
|
||||
@@ -142,6 +168,9 @@ local closeTouchControlsEditor -- forward declaration
|
||||
|
||||
local function openTouchControlsEditor()
|
||||
touchEditorHost = Importer
|
||||
if Importer and Importer.prepareOverlayHandoff then
|
||||
Importer:prepareOverlayHandoff()
|
||||
end
|
||||
Importer = nil
|
||||
TouchEditor = require("src.ui.TouchControlsEditor")
|
||||
TouchEditor.load({ onClose = function() closeTouchControlsEditor() end })
|
||||
@@ -152,6 +181,9 @@ function closeTouchControlsEditor()
|
||||
TouchEditor = nil
|
||||
Importer = touchEditorHost
|
||||
touchEditorHost = nil
|
||||
if Importer and Importer.resumeAfterOverlay then
|
||||
Importer:resumeAfterOverlay()
|
||||
end
|
||||
end
|
||||
|
||||
local function bootGame(version)
|
||||
@@ -161,7 +193,16 @@ local function bootGame(version)
|
||||
-- data, so data/generated + assets/generated resolve to that version's files.
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red")
|
||||
require("src.import.CacheFs").mountVersion(GameVersion.get())
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
-- Keep CacheFs.prefix aligned for any CacheFs.read fallback during Data:load
|
||||
-- (Blue/Yellow caches live under blue/ / yellow/).
|
||||
CacheFs.prefix = GameVersion.cachePrefix()
|
||||
CacheFs.mountVersion(GameVersion.get())
|
||||
-- NX: always write nx-asset-probe.log so Yellow/Blue art failures are
|
||||
-- diagnosable from the SD without enabling switch-debug.txt.
|
||||
pcall(function()
|
||||
require("src.debug.SwitchDiagnostics").probeAssets(GameVersion.get())
|
||||
end)
|
||||
if love.window and love.window.setTitle then
|
||||
local Version = require("src.core.Version")
|
||||
love.window.setTitle(Version.title(
|
||||
@@ -189,6 +230,13 @@ function love.load(args)
|
||||
-- of each flashing their own cmd.exe window (#606). No-op elsewhere.
|
||||
require("src.core.HostShell").hideHostConsole()
|
||||
|
||||
-- NX fused mounts are unreliable for the blue|yellow cache overlay: wrap
|
||||
-- the love loaders once so every generated-asset read falls back to the
|
||||
-- versioned save-dir copy. Never installed on desktop/Android/iOS.
|
||||
if require("src.core.Platform").isNX() then
|
||||
require("src.core.NxAssetOverlay").install()
|
||||
end
|
||||
|
||||
-- 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).
|
||||
@@ -208,6 +256,16 @@ function love.load(args)
|
||||
end
|
||||
end
|
||||
love.graphics.setDefaultFilter("nearest", "nearest")
|
||||
-- NX: handheld 720p / docked 1080p. Runs for every boot path (launcher,
|
||||
-- editor, scripted); no-op on desktop/mobile.
|
||||
NxDisplay.sync()
|
||||
|
||||
-- Apply the persisted Android orientation lock (#592) before the launcher
|
||||
-- shows: SDL created the window with no orientation hint, so without this
|
||||
-- the launcher would rotate freely until Game:applyOptions runs at boot.
|
||||
-- No-op on desktop / iOS / when options.lua does not exist yet.
|
||||
require("src.core.Orientation").applyOptions(
|
||||
require("src.core.SaveData").loadOptions())
|
||||
|
||||
-- Standalone editor. A bare `--editor` run has no launcher behind it, so
|
||||
-- Close quits; --save points it at a specific file, otherwise it opens the
|
||||
@@ -256,6 +314,38 @@ function love.load(args)
|
||||
return
|
||||
end
|
||||
|
||||
-- The launcher draws before any game boots, so the mod loader has not run
|
||||
-- and Strings has no catalog. Routing the launcher's text through Strings
|
||||
-- (#767) only pays off if something fills that catalog this early, and no
|
||||
-- restart could: the ordering is the same on every launch. Read the
|
||||
-- enabled mods' string catalogs -- data only, no entry chunk -- so a
|
||||
-- translation reaches the launcher too. Game:load replaces this with the
|
||||
-- real merged catalog once a version boots.
|
||||
do
|
||||
local preload = require("src.mods.LauncherMods").translationStrings()
|
||||
if preload then require("src.core.Strings").load({ strings = preload }) end
|
||||
end
|
||||
|
||||
-- LAUNCH OPTIONS: skip the launcher and boot a game directly.
|
||||
-- --game red|blue|yellow (or POKEPORT_GAME / POKEPORT_LAUNCH)
|
||||
-- --slot <id> optional; picks the save slot to load
|
||||
-- --launcher force the launcher even if a game is set
|
||||
-- This is what a desktop shortcut, a Steam entry, or a frontend like
|
||||
-- EmulationStation needs: one click into the game the player wants, with no
|
||||
-- menu in between. A game that is not imported falls through to the
|
||||
-- launcher on its tab rather than booting into nothing.
|
||||
local launchGame, launchSlot = LaunchOptions.resolve(arg)
|
||||
if launchGame and not LaunchOptions.forceLauncher(arg) then
|
||||
if RomImporter.isReady(launchGame) then
|
||||
if launchSlot then LaunchOptions.selectSlot(launchGame, launchSlot) end
|
||||
bootGame(launchGame)
|
||||
return
|
||||
end
|
||||
-- Not importable yet: open the launcher already showing that game, so the
|
||||
-- shortcut still lands the player where they meant to go.
|
||||
LaunchOptions.pendingTab = launchGame
|
||||
end
|
||||
|
||||
-- Interactive: the launcher always runs. Red, Blue, and Yellow are each
|
||||
-- live: a column shows Play when that game's ROM is already imported, or
|
||||
-- Choose ROM / drag-drop when it is not. Any dropped .gb is routed by its
|
||||
@@ -273,6 +363,9 @@ function love.load(args)
|
||||
end
|
||||
|
||||
function love.update(dt)
|
||||
SwitchDiagnostics.maybeFlush(false)
|
||||
-- NX only (no-op elsewhere): follow dock/undock without waiting for SDL.
|
||||
NxDisplay.sync()
|
||||
if editorMode then return EditorApp.update(dt) end
|
||||
if TouchEditor then return TouchEditor.update(dt) end
|
||||
if Importer then return Importer:update(dt) end
|
||||
@@ -347,48 +440,140 @@ function love.keyreleased(key)
|
||||
end
|
||||
|
||||
function love.gamepadpressed(joystick, button)
|
||||
if editorMode or TouchEditor then return end
|
||||
SwitchDiagnostics.onJoystickEvent("gamepadpressed", joystick, button)
|
||||
if editorMode then
|
||||
if EditorApp and EditorApp.gamepadpressed then
|
||||
return EditorApp.gamepadpressed(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if TouchEditor then
|
||||
if TouchEditor.gamepadpressed then
|
||||
return TouchEditor.gamepadpressed(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Importer then return Importer:gamepadpressed(joystick, button) end
|
||||
Game:gamepadpressed(joystick, button)
|
||||
end
|
||||
|
||||
function love.gamepadreleased(joystick, button)
|
||||
if editorMode or TouchEditor then return end
|
||||
SwitchDiagnostics.onJoystickEvent("gamepadreleased", joystick, button)
|
||||
if editorMode then
|
||||
if EditorApp and EditorApp.gamepadreleased then
|
||||
return EditorApp.gamepadreleased(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if TouchEditor then
|
||||
if TouchEditor.gamepadreleased then
|
||||
return TouchEditor.gamepadreleased(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Importer then return Importer:gamepadreleased(joystick, button) end
|
||||
Game:gamepadreleased(joystick, button)
|
||||
end
|
||||
|
||||
function love.gamepadaxis(joystick, axis, value)
|
||||
if editorMode or TouchEditor then return end
|
||||
SwitchDiagnostics.onJoystickEvent("gamepadaxis", joystick, axis, { value = value })
|
||||
if editorMode then
|
||||
if EditorApp and EditorApp.gamepadaxis then
|
||||
return EditorApp.gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
return
|
||||
end
|
||||
if TouchEditor then
|
||||
if TouchEditor.gamepadaxis then
|
||||
return TouchEditor.gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
||||
Game:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
function love.joystickpressed(joystick, button)
|
||||
if editorMode or TouchEditor then return end
|
||||
SwitchDiagnostics.onJoystickEvent("joystickpressed", joystick, button)
|
||||
if editorMode then
|
||||
if EditorApp and EditorApp.joystickpressed then
|
||||
return EditorApp.joystickpressed(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if TouchEditor then
|
||||
if TouchEditor.joystickpressed then
|
||||
return TouchEditor.joystickpressed(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Importer then return Importer:joystickpressed(joystick, button) end
|
||||
Game:joystickpressed(joystick, button)
|
||||
end
|
||||
|
||||
function love.joystickreleased(joystick, button)
|
||||
if editorMode or TouchEditor then return end
|
||||
SwitchDiagnostics.onJoystickEvent("joystickreleased", joystick, button)
|
||||
if editorMode then
|
||||
if EditorApp and EditorApp.joystickreleased then
|
||||
return EditorApp.joystickreleased(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if TouchEditor then
|
||||
if TouchEditor.joystickreleased then
|
||||
return TouchEditor.joystickreleased(joystick, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Importer then return Importer:joystickreleased(joystick, button) end
|
||||
Game:joystickreleased(joystick, button)
|
||||
end
|
||||
|
||||
function love.joystickaxis(joystick, axis, value)
|
||||
if editorMode or TouchEditor then return end
|
||||
SwitchDiagnostics.onJoystickEvent("joystickaxis", joystick, axis, { value = value })
|
||||
if editorMode then
|
||||
if EditorApp and EditorApp.joystickaxis then
|
||||
return EditorApp.joystickaxis(joystick, axis, value)
|
||||
end
|
||||
return
|
||||
end
|
||||
if TouchEditor then
|
||||
if TouchEditor.joystickaxis then
|
||||
return TouchEditor.joystickaxis(joystick, axis, value)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Importer then return Importer:joystickaxis(joystick, axis, value) end
|
||||
Game:joystickaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
function love.joystickhat(joystick, hat, direction)
|
||||
if editorMode or TouchEditor then return end
|
||||
SwitchDiagnostics.onJoystickEvent("joystickhat", joystick, hat, { direction = direction })
|
||||
if editorMode then
|
||||
if EditorApp and EditorApp.joystickhat then
|
||||
return EditorApp.joystickhat(joystick, hat, direction)
|
||||
end
|
||||
return
|
||||
end
|
||||
if TouchEditor then
|
||||
if TouchEditor.joystickhat then
|
||||
return TouchEditor.joystickhat(joystick, hat, direction)
|
||||
end
|
||||
return
|
||||
end
|
||||
if Importer then return Importer:joystickhat(joystick, hat, direction) end
|
||||
Game:joystickhat(joystick, hat, direction)
|
||||
end
|
||||
|
||||
function love.joystickadded(joystick)
|
||||
SwitchDiagnostics.onJoystickEvent("joystickadded", joystick)
|
||||
if editorMode or TouchEditor then return end
|
||||
if Importer then return end
|
||||
Game:joystickadded(joystick)
|
||||
end
|
||||
|
||||
function love.joystickremoved(joystick)
|
||||
SwitchDiagnostics.onJoystickEvent("joystickremoved", joystick)
|
||||
if editorMode or TouchEditor then return end
|
||||
if Importer then return end
|
||||
Game:joystickremoved(joystick)
|
||||
@@ -400,6 +585,7 @@ end
|
||||
function love.focus(f)
|
||||
if editorMode or TouchEditor then return end
|
||||
if Importer then
|
||||
require("src.core.Input"):reset()
|
||||
if Importer.focus then Importer:focus(f) end
|
||||
return
|
||||
end
|
||||
@@ -409,12 +595,29 @@ end
|
||||
-- v is true when the window becomes visible again, false on minimize.
|
||||
function love.visible(v)
|
||||
if editorMode or TouchEditor then return end
|
||||
if Importer then return end
|
||||
if Importer then
|
||||
require("src.core.Input"):reset()
|
||||
return
|
||||
end
|
||||
Game:visible(v)
|
||||
end
|
||||
|
||||
function love.lowmemory()
|
||||
if editorMode or TouchEditor or Importer then return end
|
||||
if Game then Game:onResume() end
|
||||
end
|
||||
|
||||
function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||
if editorMode then return end
|
||||
if editorMode then
|
||||
-- iOS synthesizes mousepressed for the primary touch; forwarding here
|
||||
-- would double-fire. Android / NX need the explicit touch → click path
|
||||
-- (love-nx does not synthesize mouse for the editor the way desktop does).
|
||||
if love.system.getOS() == "iOS" then return end
|
||||
if EditorApp and EditorApp.mousepressed then
|
||||
return EditorApp.mousepressed(x, y, 1)
|
||||
end
|
||||
return
|
||||
end
|
||||
if TouchEditor then
|
||||
-- iOS synthesizes mousepressed for the primary touch (same as the
|
||||
-- launcher); Android drives the editor through love.touch directly.
|
||||
@@ -422,12 +625,12 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||
return TouchEditor.touchpressed(id, x, y)
|
||||
end
|
||||
if Importer then
|
||||
if love.system.getOS() == "iOS" then
|
||||
return Importer:touchpressed(id, x, y)
|
||||
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)
|
||||
Game:touchpressed(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
|
||||
function love.touchmoved(id, x, y, dx, dy, pressure)
|
||||
@@ -437,12 +640,9 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
|
||||
return TouchEditor.touchmoved(id, x, y)
|
||||
end
|
||||
if Importer then
|
||||
if love.system.getOS() == "iOS" then
|
||||
return Importer:touchmoved(id, x, y)
|
||||
end
|
||||
return
|
||||
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
Game:touchmoved(id, x, y)
|
||||
Game:touchmoved(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
|
||||
function love.touchreleased(id, x, y, dx, dy, pressure)
|
||||
@@ -452,12 +652,9 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
|
||||
return TouchEditor.touchreleased(id, x, y)
|
||||
end
|
||||
if Importer then
|
||||
if love.system.getOS() == "iOS" then
|
||||
return Importer:touchreleased(id, x, y)
|
||||
end
|
||||
return
|
||||
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
Game:touchreleased(id, x, y)
|
||||
Game:touchreleased(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
|
||||
function love.wheelmoved(x, y)
|
||||
@@ -478,33 +675,35 @@ 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.
|
||||
-- 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
|
||||
-- Same Android double-fire guard: touchpressed already clicked for the
|
||||
-- save editor; a synthesized mouse press must not fire again.
|
||||
if istouch and love.system.getOS() == "Android" then return end
|
||||
return EditorApp.mousepressed(x, y, button)
|
||||
end
|
||||
if mouseTouch and Game and button == 1 then
|
||||
Game:touchpressed("mouse", x, y)
|
||||
if mouseTouch then
|
||||
-- the mouse is standing in for a finger: the touch path owns it, and
|
||||
-- feeding the same press back in as a mouse pointer would double it
|
||||
if Game and button == 1 then Game:touchpressed("mouse", x, y) end
|
||||
return
|
||||
end
|
||||
-- #807: a real mouse reaches gameplay as a pointer event for mods; Game
|
||||
-- drops synthesized istouch twins so a mobile touch that already arrived
|
||||
-- through love.touchpressed cannot fire twice
|
||||
if Game then Game:mousepressed(x, y, button, istouch) end
|
||||
end
|
||||
|
||||
function love.mousereleased(x, y, button)
|
||||
function love.mousereleased(x, y, button, istouch)
|
||||
if TouchEditor then
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousereleased(x, y, button)
|
||||
@@ -513,20 +712,24 @@ function love.mousereleased(x, y, button)
|
||||
if editorMode and EditorApp.mousereleased then
|
||||
return EditorApp.mousereleased(x, y, button)
|
||||
end
|
||||
if mouseTouch and Game and button == 1 then
|
||||
Game:touchreleased("mouse", x, y)
|
||||
if mouseTouch then
|
||||
if Game and button == 1 then Game:touchreleased("mouse", x, y) end
|
||||
return
|
||||
end
|
||||
if Game then Game:mousereleased(x, y, button, istouch) end
|
||||
end
|
||||
|
||||
function love.mousemoved(x, y)
|
||||
function love.mousemoved(x, y, dx, dy, istouch)
|
||||
if TouchEditor then
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousemoved(x, y)
|
||||
end
|
||||
if editorMode or Importer then return end
|
||||
if mouseTouch and Game and love.mouse.isDown(1) then
|
||||
Game:touchmoved("mouse", x, y)
|
||||
if mouseTouch then
|
||||
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
|
||||
return
|
||||
end
|
||||
if Game then Game:mousemoved(x, y, dx, dy, istouch) end
|
||||
end
|
||||
|
||||
function love.textinput(text)
|
||||
@@ -537,10 +740,31 @@ function love.textinput(text)
|
||||
end
|
||||
end
|
||||
|
||||
-- #785: set once love.quit has routed a window close into HostShell.restart,
|
||||
-- so the follow-up quit event the restart itself raises (quit("restart") on
|
||||
-- desktop; AppImage and Android relaunch the process instead, #575) falls
|
||||
-- through to the normal shutdown below instead of restarting forever.
|
||||
local quitToLauncher = false
|
||||
|
||||
function love.quit()
|
||||
if editorMode and EditorApp.quit then
|
||||
return EditorApp.quit() -- return true to abort quit
|
||||
end
|
||||
-- Closing the window of a running game returns to the launcher instead of
|
||||
-- exiting the app, so testing a mod does not need a relaunch every time
|
||||
-- (#785). Game is only non-nil once bootGame ran; Importer non-nil means
|
||||
-- the launcher (or its import) owns the window and its close still quits.
|
||||
-- Scripted and headless runs (autopilot, frame driver, import-only, ROM
|
||||
-- path import) keep the plain exit so they terminate as before. Nothing
|
||||
-- is saved here on purpose: a window close never wrote the save, and the
|
||||
-- restart path must be no worse than that, not quietly better.
|
||||
local scripted = os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER")
|
||||
or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or os.getenv("POKEPORT_IMPORT_ROM")
|
||||
if Game and not Importer and not quitToLauncher and not scripted then
|
||||
quitToLauncher = true
|
||||
require("src.core.HostShell").restart()
|
||||
return true -- abort this quit; the restart lands back in the launcher
|
||||
end
|
||||
pcall(function()
|
||||
require("src.core.DiscordPresence").shutdown()
|
||||
end)
|
||||
@@ -554,6 +778,12 @@ function love.quit()
|
||||
if package.loaded["src.update.Check"] then
|
||||
pcall(package.loaded["src.update.Check"].shutdown)
|
||||
end
|
||||
-- The launcher's fetch pool is the same story: its workers idle in
|
||||
-- Channel:demand(), which never returns on its own, so a launcher that ever
|
||||
-- touched the network would hang the process on exit (#339's shape again).
|
||||
if package.loaded["src.net.Fetch"] then
|
||||
pcall(package.loaded["src.net.Fetch"].shutdown)
|
||||
end
|
||||
end
|
||||
|
||||
function love.filedropped(file)
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 42 KiB |
@@ -259,7 +259,19 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
|
||||
return false;
|
||||
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
// NOT FindClass: this is the one bridge called off the main thread
|
||||
// (love.thread workers in src/net/fetch_worker.lua and
|
||||
// src/update/check_worker.lua). A worker is a raw pthread whose JNI
|
||||
// class loader is the system one, which cannot see app classes, so
|
||||
// FindClass("org/love2d/android/GameActivity") left a pending
|
||||
// ClassNotFoundException and the next JNI call aborted the process --
|
||||
// opening FIND MODS killed the app on the first stats fetch. Resolving
|
||||
// through the live activity instance works from any attached thread.
|
||||
jobject activityObj = (jobject) SDL_AndroidGetActivity();
|
||||
if (activityObj == nullptr)
|
||||
return false;
|
||||
jclass activity = env->GetObjectClass(activityObj);
|
||||
env->DeleteLocalRef(activityObj);
|
||||
|
||||
// Old APK / new liblove skew: report "no transport" the same way a
|
||||
// missing curl does, instead of aborting on a missing method (#597).
|
||||
|
||||
@@ -12,6 +12,34 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.64",
|
||||
"date": "2026-08-03",
|
||||
"size": 9317129,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.64/gen1recomp-0.1.64-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.63",
|
||||
"date": "2026-08-03",
|
||||
"size": 9316977,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.63/gen1recomp-0.1.63-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #623 Game Corner Prize Exchange various issues\n- #624 Coins purschase menu missing your current money and coins\n- #636 Inaccurate Old Man tutorial in yellow + misc issues relating to him\n- #637 True color broken on Hall of Fame and Party Stats when using the wide battle option\n- #639 Pokédex can't show the correct stats when you catch 100 or more pokémon + overly simplified layout\n- #650 Rocket Hideout Progression Bug - Stuck with door closed after defeating Jessie and James in Yellow\n- #697 Missaligned HUD on Hall of Fame\n- #704 Player doesn't follow Prof. Oak to the Hall of Fame on the first screen\n- #722 Issues with Giovanni in Silph C.O.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.62",
|
||||
"date": "2026-08-03",
|
||||
"size": 9309934,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.62/gen1recomp-0.1.62-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.61",
|
||||
"date": "2026-08-03",
|
||||
"size": 9052744,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.61/gen1recomp-0.1.61-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #325 Launcher crashes importing a mod .zip with non-ASCII characters in the filename (Windows)\n- #646 Fuchsia City Pokemon not being added to Pokedex.\n- #651 Parasect trading NPC mute and deaf\n- #665 fix: file path sanitizing is needed\n- #706 Champion theme playing too late\n- #709 Fainted Pokemon plays its normal cry instead of the pitched-down, slower faint cry\n- #719 Silph C.O workers dialog bug.\n\n## Contributors\n\n- @bryanthaboi\n- @castdrian\n- @jherediagu\n- @johnjohto\n- @ShaneMcGovernIE\n- hernan"
|
||||
},
|
||||
{
|
||||
"version": "0.1.60",
|
||||
"date": "2026-08-02",
|
||||
|
||||
@@ -30,6 +30,45 @@ public final class GRPickerBridge: NSObject {
|
||||
// (<sandbox>/Library/Application Support/<identity>).
|
||||
private static let loveIdentity = "pokemon-love2d"
|
||||
|
||||
@objc(httpDownloadWithUrl:destination:userAgent:accept:)
|
||||
public static func httpDownload(url: UnsafePointer<CChar>?,
|
||||
destination: UnsafePointer<CChar>?,
|
||||
userAgent: UnsafePointer<CChar>?,
|
||||
accept: UnsafePointer<CChar>?) -> Bool {
|
||||
guard let url, let destination,
|
||||
let requestURL = URL(string: String(cString: url)) else { return false }
|
||||
var request = URLRequest(url: requestURL)
|
||||
request.timeoutInterval = 300
|
||||
if let userAgent, userAgent.pointee != 0 {
|
||||
request.setValue(String(cString: userAgent), forHTTPHeaderField: "User-Agent")
|
||||
}
|
||||
if let accept, accept.pointee != 0 {
|
||||
request.setValue(String(cString: accept), forHTTPHeaderField: "Accept")
|
||||
}
|
||||
let target = URL(fileURLWithPath: String(cString: destination))
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var succeeded = false
|
||||
let task = URLSession.shared.downloadTask(with: request) { temporary, response, error in
|
||||
defer { semaphore.signal() }
|
||||
guard error == nil, let temporary,
|
||||
let http = response as? HTTPURLResponse,
|
||||
(200..<300).contains(http.statusCode) else { return }
|
||||
try? FileManager.default.removeItem(at: target)
|
||||
do {
|
||||
try FileManager.default.moveItem(at: temporary, to: target)
|
||||
succeeded = true
|
||||
} catch {
|
||||
succeeded = false
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
guard semaphore.wait(timeout: .now() + 305) == .success else {
|
||||
task.cancel()
|
||||
return false
|
||||
}
|
||||
return succeeded
|
||||
}
|
||||
|
||||
// MARK: - Entry points called from liblove (C strings on purpose)
|
||||
|
||||
@objc(presentPickerWithKind:saveDir:)
|
||||
|
||||
@@ -103,6 +103,7 @@ WRAP_REGISTRATION = """#ifdef LOVE_IOS
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "httpDownload", w_httpDownload },
|
||||
#endif
|
||||
"""
|
||||
|
||||
@@ -144,6 +145,32 @@ int w_syncHealthSteps(lua_State *L)
|
||||
|
||||
WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "httpDownload", w_httpDownload },
|
||||
#endif
|
||||
"""
|
||||
|
||||
BRIDGE_EXTRA_FUNCS = """
|
||||
#ifdef LOVE_IOS
|
||||
int w_httpDownload(lua_State *L)
|
||||
{
|
||||
const char *url = luaL_checkstring(L, 1);
|
||||
const char *destination = luaL_checkstring(L, 2);
|
||||
const char *userAgent = luaL_optstring(L, 3, "gen1recomp");
|
||||
const char *accept = luaL_optstring(L, 4, "");
|
||||
Class cls = objc_getClass("GRPickerBridge");
|
||||
if (cls == nullptr)
|
||||
{
|
||||
lua_pushboolean(L, 0);
|
||||
return 1;
|
||||
}
|
||||
typedef signed char (*GRDownload)(Class, SEL, const char *, const char *,
|
||||
const char *, const char *);
|
||||
signed char ok = ((GRDownload)objc_msgSend)(
|
||||
cls, sel_registerName("httpDownloadWithUrl:destination:userAgent:accept:"),
|
||||
url, destination, userAgent, accept);
|
||||
lua_pushboolean(L, ok != 0);
|
||||
return 1;
|
||||
}
|
||||
#endif
|
||||
"""
|
||||
|
||||
@@ -216,7 +243,8 @@ def patch_wrap_system():
|
||||
if anchor not in text:
|
||||
fail(f"anchor not found in {WRAP_SYSTEM}")
|
||||
has_native_picker = re.search(r"\bint w_pickFile\s*\(", text) is not None
|
||||
text = text.replace(anchor, (WRAP_SYNC_FUNCS if has_native_picker else WRAP_FUNCS) + anchor, 1)
|
||||
bridge_funcs = WRAP_SYNC_FUNCS if has_native_picker else WRAP_FUNCS
|
||||
text = text.replace(anchor, bridge_funcs + BRIDGE_EXTRA_FUNCS + anchor, 1)
|
||||
reg_anchor = '\t{ "vibrate", w_vibrate },\n'
|
||||
if reg_anchor not in text:
|
||||
fail(f"registration anchor not found in {WRAP_SYSTEM}")
|
||||
@@ -224,7 +252,7 @@ def patch_wrap_system():
|
||||
text = text.replace(reg_anchor, reg_anchor + registration, 1)
|
||||
WRAP_SYSTEM.write_text(text)
|
||||
print("patch_love_src: wrap_System.cpp patched "
|
||||
"(pickFile/createFile/syncHealthSteps)")
|
||||
"(pickFile/createFile/syncHealthSteps/httpDownload)")
|
||||
|
||||
|
||||
def patch_pbxproj():
|
||||
|
||||
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,119 @@
|
||||
# Gen1Recomp Xbox UWP build notes
|
||||
|
||||
This is the Xbox Dev Mode package for Gen1Recomp.
|
||||
|
||||
The rough shape is:
|
||||
|
||||
- `Gen1RecompUWP.exe` starts LÖVE through SDL's WinRT wrapper
|
||||
- the bundled LÖVE 11.5 UWP backend provides LuaJIT and the Xbox file picker
|
||||
- the bundled SDL2 runtime contains the Xbox controller mapping
|
||||
- ANGLE provides OpenGL ES over D3D11
|
||||
- the bundled runtime contains the audio, font, video, and compression libraries
|
||||
|
||||
## What You Need
|
||||
|
||||
The tested toolchain is:
|
||||
|
||||
- Visual Studio 2022 17.14
|
||||
- MSVC v143 x64/x86 build tools
|
||||
- C++ Universal Windows Platform tools
|
||||
- Windows 11 SDK `10.0.26100.0`
|
||||
- CMake 3.24 or newer
|
||||
- Git for Windows
|
||||
- Info-ZIP `zip` and `unzip`
|
||||
|
||||
Use Visual Studio Installer to add **Universal Windows Platform development**, the v143 C++ tools, CMake tools for Windows, and Windows SDK `10.0.26100.0`.
|
||||
|
||||
The x64 UWP dependencies are committed under `third_party`. Their versions,
|
||||
source revisions, licences, and hashes are recorded in `third_party/manifest.json`.
|
||||
No additional checkout or environment variable is required for a normal game
|
||||
build.
|
||||
|
||||
## Rebuild the Dependencies
|
||||
|
||||
Run the dependency rebuild from the repository root:
|
||||
|
||||
```powershell
|
||||
.\scripts\xbox-uwp\rebuild_dependencies.ps1
|
||||
```
|
||||
|
||||
The script clones the pinned SDL2, LÖVE, LuaJIT, vcpkg, depot_tools, and ANGLE
|
||||
sources when they are missing. It applies the Xbox SDL2 patch, builds the x64
|
||||
UWP Release libraries, stages the required DLLs, import libraries, headers, and
|
||||
licences under `third_party`, updates every SHA-256 entry in the manifest, then
|
||||
builds the Release MSIX.
|
||||
|
||||
The generated source checkouts are ignored by Git. A fresh ANGLE sync is about
|
||||
10 GB, so allow at least 20 GB of free disk space for all sources and build
|
||||
outputs. Use `-SkipAngle` to retain the existing pinned ANGLE runtime while
|
||||
rebuilding SDL2, LÖVE, LuaJIT, and the vcpkg libraries. Use `-SkipPackage` when
|
||||
only the dependency bundle needs to be refreshed. The rebuild stops if a source
|
||||
checkout has local changes. Remove that generated `source` directory to restore
|
||||
the pinned revision.
|
||||
|
||||
## Build the MSIX
|
||||
|
||||
Run the Xbox build from Git Bash at the repository root:
|
||||
|
||||
```bash
|
||||
scripts/build_xbox_uwp.sh --release --version 1.2.3
|
||||
```
|
||||
|
||||
The build uses `scripts/pack_love.sh` to create and verify the same ROM-free
|
||||
`game.love` payload used by the other release targets. It then links the UWP
|
||||
host and stages LÖVE, LuaJIT, SDL2, ANGLE, and the vcpkg runtime DLLs.
|
||||
|
||||
Use `--relwithdebinfo` for a package with symbols. To package a `.love` produced
|
||||
by another build or downloaded from CI, pass `--game-love path/to/game.love`.
|
||||
The upstream `X.Y.Z` release becomes `X.Y.Z.0` in the generated MSIX manifest.
|
||||
Neither the manifest template nor `src/core/Version.lua` is edited in place.
|
||||
|
||||
The manifest publisher must match the signing certificate subject. Pass it
|
||||
when preparing a signed package:
|
||||
|
||||
```bash
|
||||
scripts/build_xbox_uwp.sh --release --version 1.2.3 \
|
||||
--publisher "CN=Gen1Recomp"
|
||||
```
|
||||
|
||||
The normal build is unsigned. Release CI supplies the private PFX and password
|
||||
from `XBOX_UWP_SIGNING_CERTIFICATE` and `XBOX_UWP_SIGNING_PASSWORD`; neither may
|
||||
be committed. The public certificate is safe to include with the release.
|
||||
|
||||
Run the offline packaging checks from Git Bash:
|
||||
|
||||
```bash
|
||||
bash scripts/xbox-uwp/selftest_build_xbox_uwp.sh
|
||||
```
|
||||
|
||||
## Build Output
|
||||
|
||||
Visual Studio package output lands under:
|
||||
|
||||
```text
|
||||
ports\uwp\build\release\AppPackages\Gen1RecompUWP
|
||||
```
|
||||
|
||||
The build also stages the distributable archive and checksum under:
|
||||
|
||||
```text
|
||||
dist\xbox-uwp\gen1recomp-X.Y.Z-xbox-uwp.zip
|
||||
dist\xbox-uwp\gen1recomp-X.Y.Z-xbox-uwp.zip.sha256
|
||||
```
|
||||
|
||||
The archive contains the MSIX, framework dependencies, build provenance and,
|
||||
for a signed release, the public certificate. The third-party notices are
|
||||
packaged inside the MSIX. Install the MSIX and dependency packages through
|
||||
Xbox Device Portal.
|
||||
|
||||
## Runtime Data
|
||||
|
||||
The package contains no ROM, generated cache, save or mod data. The Xbox file
|
||||
picker copies user-selected files into LocalState and the launcher imports them
|
||||
from there. Saves, ROM cache and installed mods remain under the LÖVE save
|
||||
directory in LocalState.
|
||||
|
||||
LuaJIT requires the `codeGeneration` capability. `removableStorage` exposes
|
||||
external media to the Xbox picker. The network capabilities support relay play
|
||||
and direct hosting. The package does not request full trust or broad filesystem
|
||||
access.
|
||||
@@ -0,0 +1,149 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(Gen1RecompUWP LANGUAGES CXX)
|
||||
|
||||
if(NOT CMAKE_SYSTEM_NAME STREQUAL "WindowsStore")
|
||||
message(FATAL_ERROR "Configure with a WindowsStore preset.")
|
||||
endif()
|
||||
|
||||
get_filename_component(GAME_ROOT "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE)
|
||||
set(THIRD_PARTY_ROOT "${CMAKE_CURRENT_LIST_DIR}/third_party")
|
||||
set(LOVE_ROOT "${THIRD_PARTY_ROOT}/love")
|
||||
set(SDL2_ROOT "${THIRD_PARTY_ROOT}/sdl2")
|
||||
set(ANGLE_ROOT "${THIRD_PARTY_ROOT}/angle")
|
||||
set(RUNTIME_ROOT "${THIRD_PARTY_ROOT}/runtime")
|
||||
|
||||
set(GEN1RECOMP_VERSION "0.0.0" CACHE STRING "Gen1Recomp release version")
|
||||
if(NOT GEN1RECOMP_VERSION MATCHES "^[0-9]+\\.[0-9]+\\.[0-9]+$")
|
||||
message(FATAL_ERROR "GEN1RECOMP_VERSION must use X.Y.Z format.")
|
||||
endif()
|
||||
string(REPLACE "." ";" VERSION_PARTS "${GEN1RECOMP_VERSION}")
|
||||
foreach(part IN LISTS VERSION_PARTS)
|
||||
if(part GREATER 65535)
|
||||
message(FATAL_ERROR "MSIX version components cannot exceed 65535.")
|
||||
endif()
|
||||
endforeach()
|
||||
set(GEN1RECOMP_UWP_PUBLISHER "CN=Gen1Recomp" CACHE STRING
|
||||
"Publisher subject from the MSIX signing certificate")
|
||||
set(UWP_PUBLISHER_XML "${GEN1RECOMP_UWP_PUBLISHER}")
|
||||
string(REPLACE "&" "&" UWP_PUBLISHER_XML "${UWP_PUBLISHER_XML}")
|
||||
string(REPLACE "\"" """ UWP_PUBLISHER_XML "${UWP_PUBLISHER_XML}")
|
||||
string(REPLACE "<" "<" UWP_PUBLISHER_XML "${UWP_PUBLISHER_XML}")
|
||||
string(REPLACE ">" ">" UWP_PUBLISHER_XML "${UWP_PUBLISHER_XML}")
|
||||
set(UWP_PACKAGE_VERSION "${GEN1RECOMP_VERSION}.0")
|
||||
set(PACKAGE_MANIFEST "${CMAKE_CURRENT_BINARY_DIR}/Package.appxmanifest")
|
||||
configure_file(
|
||||
"${CMAKE_CURRENT_LIST_DIR}/Package.appxmanifest.in"
|
||||
"${PACKAGE_MANIFEST}"
|
||||
@ONLY
|
||||
)
|
||||
|
||||
set(REQUIRED_FILES
|
||||
"${THIRD_PARTY_ROOT}/manifest.json"
|
||||
"${LOVE_ROOT}/lib/lovestatic.lib"
|
||||
"${LOVE_ROOT}/lib/liblove.lib"
|
||||
"${LOVE_ROOT}/lib/lua51.lib"
|
||||
"${LOVE_ROOT}/bin/love.dll"
|
||||
"${LOVE_ROOT}/bin/lua51.dll"
|
||||
"${SDL2_ROOT}/include/SDL2/SDL.h"
|
||||
"${SDL2_ROOT}/lib/SDL2.lib"
|
||||
"${SDL2_ROOT}/bin/SDL2.dll"
|
||||
"${ANGLE_ROOT}/bin/libEGL.dll"
|
||||
"${ANGLE_ROOT}/bin/libGLESv2.dll"
|
||||
"${ANGLE_ROOT}/bin/d3dcompiler_47.dll"
|
||||
)
|
||||
foreach(path IN LISTS REQUIRED_FILES)
|
||||
if(NOT EXISTS "${path}")
|
||||
message(FATAL_ERROR "Missing UWP dependency: ${path}")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
set(GEN1RECOMP_LOVE "${GAME_ROOT}/.bazinga/work/game.love" CACHE FILEPATH
|
||||
"Path to the game.love payload produced by scripts/pack_love.sh")
|
||||
if(NOT EXISTS "${GEN1RECOMP_LOVE}")
|
||||
message(FATAL_ERROR
|
||||
"Missing game.love payload: ${GEN1RECOMP_LOVE}\n"
|
||||
"Build it with scripts/build_xbox_uwp.sh or scripts/pack_love.sh.")
|
||||
endif()
|
||||
|
||||
set(GAME_ARCHIVE "${CMAKE_CURRENT_BINARY_DIR}/gen1recomp.love")
|
||||
add_custom_command(
|
||||
OUTPUT "${GAME_ARCHIVE}"
|
||||
COMMAND "${CMAKE_COMMAND}" -E copy_if_different
|
||||
"${GEN1RECOMP_LOVE}" "${GAME_ARCHIVE}"
|
||||
DEPENDS "${GEN1RECOMP_LOVE}"
|
||||
VERBATIM
|
||||
)
|
||||
add_custom_target(gen1recomp_love ALL DEPENDS "${GAME_ARCHIVE}")
|
||||
set_source_files_properties("${GAME_ARCHIVE}" PROPERTIES GENERATED TRUE)
|
||||
|
||||
add_custom_target(verify_uwp_dependencies
|
||||
COMMAND powershell -NoProfile -ExecutionPolicy Bypass -File
|
||||
"${GAME_ROOT}/scripts/xbox-uwp/verify_dependencies.ps1"
|
||||
VERBATIM
|
||||
)
|
||||
|
||||
add_executable(${PROJECT_NAME} WIN32 "app/main.cpp")
|
||||
add_dependencies(${PROJECT_NAME} gen1recomp_love verify_uwp_dependencies)
|
||||
set_target_properties(${PROJECT_NAME} PROPERTIES
|
||||
CXX_STANDARD 17
|
||||
CXX_STANDARD_REQUIRED YES
|
||||
VS_GLOBAL_DefaultLanguage "en-US"
|
||||
VS_SDK_REFERENCES "Microsoft.VCLibs, Version=14.0"
|
||||
)
|
||||
target_include_directories(${PROJECT_NAME} PRIVATE "${SDL2_ROOT}/include/SDL2")
|
||||
target_link_libraries(${PROJECT_NAME} PRIVATE
|
||||
"${LOVE_ROOT}/lib/lovestatic.lib"
|
||||
"${LOVE_ROOT}/lib/liblove.lib"
|
||||
"${SDL2_ROOT}/lib/SDL2.lib"
|
||||
"${LOVE_ROOT}/lib/lua51.lib"
|
||||
WindowsApp.lib
|
||||
)
|
||||
|
||||
set(PACKAGE_ROOT_FILES
|
||||
"${PACKAGE_MANIFEST}"
|
||||
"${GAME_ARCHIVE}"
|
||||
"${LOVE_ROOT}/bin/love.dll"
|
||||
"${LOVE_ROOT}/bin/lua51.dll"
|
||||
"${SDL2_ROOT}/bin/SDL2.dll"
|
||||
"${ANGLE_ROOT}/bin/libEGL.dll"
|
||||
"${ANGLE_ROOT}/bin/libGLESv2.dll"
|
||||
"${ANGLE_ROOT}/bin/d3dcompiler_47.dll"
|
||||
)
|
||||
set_source_files_properties("${PACKAGE_MANIFEST}" PROPERTIES GENERATED TRUE)
|
||||
|
||||
set(RUNTIME_NAMES
|
||||
brotlicommon.dll brotlidec.dll bz2.dll fmt.dll freetype.dll libpng16.dll
|
||||
OpenAL32.dll theora.dll theoradec.dll vorbis.dll vorbisfile.dll ogg.dll z.dll
|
||||
)
|
||||
foreach(name IN LISTS RUNTIME_NAMES)
|
||||
set(runtime "${RUNTIME_ROOT}/bin/${name}")
|
||||
if(NOT EXISTS "${runtime}")
|
||||
message(FATAL_ERROR "Missing UWP runtime DLL: ${name}")
|
||||
endif()
|
||||
list(APPEND PACKAGE_ROOT_FILES "${runtime}")
|
||||
endforeach()
|
||||
|
||||
set_source_files_properties(${PACKAGE_ROOT_FILES} PROPERTIES
|
||||
VS_COPY_TO_OUT_DIR Always
|
||||
VS_DEPLOYMENT_CONTENT TRUE
|
||||
VS_DEPLOYMENT_LOCATION "."
|
||||
)
|
||||
|
||||
file(GLOB PACKAGE_ASSETS CONFIGURE_DEPENDS "${CMAKE_CURRENT_LIST_DIR}/Assets/*.png")
|
||||
set_source_files_properties(${PACKAGE_ASSETS} PROPERTIES
|
||||
VS_DEPLOYMENT_CONTENT TRUE
|
||||
VS_DEPLOYMENT_LOCATION "Assets"
|
||||
)
|
||||
|
||||
file(GLOB PACKAGE_LICENSES CONFIGURE_DEPENDS "${THIRD_PARTY_ROOT}/licenses/*.txt")
|
||||
set_source_files_properties(${PACKAGE_LICENSES} PROPERTIES
|
||||
VS_TOOL_OVERRIDE "Content"
|
||||
VS_COPY_TO_OUT_DIR Always
|
||||
VS_DEPLOYMENT_CONTENT TRUE
|
||||
VS_DEPLOYMENT_LOCATION "licenses"
|
||||
)
|
||||
target_sources(${PROJECT_NAME} PRIVATE
|
||||
${PACKAGE_ROOT_FILES}
|
||||
${PACKAGE_ASSETS}
|
||||
${PACKAGE_LICENSES}
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"version": 6,
|
||||
"configurePresets": [
|
||||
{
|
||||
"name": "uwp-common",
|
||||
"hidden": true,
|
||||
"generator": "Visual Studio 17 2022",
|
||||
"architecture": "x64",
|
||||
"cacheVariables": {
|
||||
"CMAKE_SYSTEM_NAME": "WindowsStore",
|
||||
"CMAKE_SYSTEM_VERSION": "10.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "uwp-relwithdebinfo",
|
||||
"inherits": "uwp-common",
|
||||
"displayName": "Gen1Recomp Xbox UWP (RelWithDebInfo)",
|
||||
"binaryDir": "${sourceDir}/build/relwithdebinfo"
|
||||
},
|
||||
{
|
||||
"name": "uwp-release",
|
||||
"inherits": "uwp-common",
|
||||
"displayName": "Gen1Recomp Xbox UWP (Release)",
|
||||
"binaryDir": "${sourceDir}/build/release"
|
||||
}
|
||||
],
|
||||
"buildPresets": [
|
||||
{
|
||||
"name": "uwp-relwithdebinfo",
|
||||
"configurePreset": "uwp-relwithdebinfo",
|
||||
"configuration": "RelWithDebInfo",
|
||||
"jobs": 8
|
||||
},
|
||||
{
|
||||
"name": "uwp-release",
|
||||
"configurePreset": "uwp-release",
|
||||
"configuration": "Release",
|
||||
"jobs": 8
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Package
|
||||
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
|
||||
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||
IgnorableNamespaces="uap mp">
|
||||
<Identity Name="Gen1RecompUWP" Publisher="@UWP_PUBLISHER_XML@" Version="@UWP_PACKAGE_VERSION@" />
|
||||
<mp:PhoneIdentity PhoneProductId="5074018d-a46b-4dd3-b7fa-93a1e39858ad" PhonePublisherId="00000000-0000-0000-0000-000000000000" />
|
||||
<Properties>
|
||||
<DisplayName>Gen1Recomp</DisplayName>
|
||||
<PublisherDisplayName>Gen1Recomp</PublisherDisplayName>
|
||||
<Logo>Assets\StoreLogo.png</Logo>
|
||||
</Properties>
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.19041.0" MaxVersionTested="10.0.26100.0" />
|
||||
</Dependencies>
|
||||
<Resources>
|
||||
<Resource Language="x-generate" />
|
||||
</Resources>
|
||||
<Applications>
|
||||
<Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="Gen1RecompUWP.App">
|
||||
<uap:VisualElements
|
||||
DisplayName="Gen1Recomp"
|
||||
Description="Gen1Recomp for Xbox Dev Mode"
|
||||
BackgroundColor="#101820"
|
||||
Square150x150Logo="Assets\Square150x150Logo.png"
|
||||
Square44x44Logo="Assets\Square44x44Logo.png">
|
||||
<uap:DefaultTile
|
||||
Square71x71Logo="Assets\SmallTile.png"
|
||||
Wide310x150Logo="Assets\WideTile.png"
|
||||
Square310x310Logo="Assets\LargeTile.png" />
|
||||
<uap:SplashScreen Image="Assets\SplashScreen.png" BackgroundColor="#101820" />
|
||||
</uap:VisualElements>
|
||||
</Application>
|
||||
</Applications>
|
||||
<Capabilities>
|
||||
<Capability Name="internetClient" />
|
||||
<Capability Name="internetClientServer" />
|
||||
<Capability Name="privateNetworkClientServer" />
|
||||
<Capability Name="codeGeneration" />
|
||||
<uap:Capability Name="removableStorage" />
|
||||
</Capabilities>
|
||||
</Package>
|
||||
@@ -0,0 +1,30 @@
|
||||
#include <Windows.h>
|
||||
#include <SDL.h>
|
||||
#include <string>
|
||||
#include <winrt/Windows.ApplicationModel.h>
|
||||
#include <winrt/Windows.Storage.h>
|
||||
|
||||
extern "C" int SDL_main(int argc, char **argv);
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
int runLove(int, char **)
|
||||
{
|
||||
std::wstring packagePath = winrt::Windows::ApplicationModel::Package::Current()
|
||||
.InstalledLocation().Path().c_str();
|
||||
std::string gamePath = winrt::to_string(packagePath + L"\\gen1recomp.love");
|
||||
|
||||
char executable[] = "Gen1RecompUWP";
|
||||
char fused[] = "--fused";
|
||||
char *loveArgv[] = {executable, gamePath.data(), fused, nullptr};
|
||||
return SDL_main(3, loveArgv);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int CALLBACK WinMain(HINSTANCE, HINSTANCE, LPSTR, int)
|
||||
{
|
||||
SDL_SetHint(SDL_HINT_WINRT_HANDLE_BACK_BUTTON, "1");
|
||||
return SDL_WinRTRunApp(runLove, nullptr);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# UWP dependencies
|
||||
|
||||
This directory contains the complete x64 UWP Release dependency bundle used by the package build:
|
||||
|
||||
- `love` contains the LÖVE 11.5 and LuaJIT binaries.
|
||||
- `sdl2` contains the matching SDL headers, import library, and runtime.
|
||||
- `angle` contains the EGL and GLES runtime.
|
||||
- `runtime` contains the codec, font, compression, and audio DLLs used by LÖVE.
|
||||
- `licenses` contains the corresponding third party notices.
|
||||
|
||||
`manifest.json` pins the source revisions and SHA-256 hashes. Run `scripts/xbox-uwp/verify_dependencies.ps1` from the repository root after updating any dependency.
|
||||
@@ -0,0 +1,89 @@
|
||||
# This is the official list of The ANGLE Project Authors
|
||||
# for copyright purposes.
|
||||
# This file is distinct from the CONTRIBUTORS files.
|
||||
# See the latter for an explanation.
|
||||
|
||||
# Names should be added to this file as
|
||||
# Name or Organization
|
||||
# Email addresses for individuals are tracked elsewhere to avoid spam.
|
||||
|
||||
Google Inc.
|
||||
TransGaming Inc.
|
||||
3DLabs Inc. Ltd.
|
||||
|
||||
Adobe Systems Inc.
|
||||
Autodesk, Inc.
|
||||
BlackBerry Limited
|
||||
Cable Television Laboratories, Inc.
|
||||
Collabora, Ltd.
|
||||
Cloud Party, Inc.
|
||||
Igalia, S.L.
|
||||
Imagination Technologies Ltd.
|
||||
Intel Corporation
|
||||
LunarG, Inc.
|
||||
Mozilla Corporation
|
||||
Turbulenz
|
||||
Klarälvdalens Datakonsult AB
|
||||
Microsoft Corporation
|
||||
Microsoft Open Technologies, Inc.
|
||||
NVIDIA Corporation
|
||||
Opera Software ASA
|
||||
The Qt Company Ltd.
|
||||
Advanced Micro Devices, Inc.
|
||||
LG Electronics, Inc.
|
||||
IBM Inc.
|
||||
AdaptVis GmbH
|
||||
Samsung Electronics, Inc.
|
||||
Arm Ltd.
|
||||
Broadcom Inc.
|
||||
Facebook, Inc.
|
||||
The Khronos Group, Inc.
|
||||
Numfum GmbH
|
||||
Yandex LLC
|
||||
Rive
|
||||
Institute of Software, Chinese Academy of Sciences
|
||||
Guangdong OPPO Mobile Telecommunications Corp., Ltd
|
||||
Qualcomm Innovation Center, Inc.
|
||||
|
||||
Jacek Caban
|
||||
Mark Callow
|
||||
Ginn Chen
|
||||
Tibor den Ouden
|
||||
Régis Fénéon
|
||||
James Hauxwell
|
||||
Sam Hocevar
|
||||
Pierre Leveille
|
||||
Jonathan Liu
|
||||
Boying Lu
|
||||
Aitor Moreno
|
||||
Yuri O'Donnell
|
||||
Josh Soref
|
||||
Ma Aiguo
|
||||
Maks Naumov
|
||||
Jinyoung Hur
|
||||
Sebastian Bergstein
|
||||
James Ross-Gowan
|
||||
Nickolay Artamonov
|
||||
Ihsan Akmal
|
||||
Andrei Volykhin
|
||||
Jérôme Duval
|
||||
Руслан Ижбулатов
|
||||
Thomas Miller
|
||||
Till Rathmann
|
||||
Nick Shaforostov
|
||||
Jaime Bernardo
|
||||
Le Hoang Quyen
|
||||
Lu Yahan
|
||||
Ethan Lee
|
||||
Renaud Lepage
|
||||
Artem Bolgar
|
||||
Wander Lairson Costa
|
||||
Stephan Hartmann
|
||||
SeongHwan Park
|
||||
Xiaopeng Li
|
||||
Akihiko Odaki
|
||||
Ho Cheung
|
||||
Tao Wang
|
||||
Phan Quang Minh
|
||||
Hongchen Yan
|
||||
Andrew Sumsion
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2018 The ANGLE Project Authors.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Redistribution and use in source and binary forms, with or without
|
||||
// modification, are permitted provided that the following conditions
|
||||
// are met:
|
||||
//
|
||||
// Redistributions of source code must retain the above copyright
|
||||
// notice, this list of conditions and the following disclaimer.
|
||||
//
|
||||
// Redistributions in binary form must reproduce the above
|
||||
// copyright notice, this list of conditions and the following
|
||||
// disclaimer in the documentation and/or other materials provided
|
||||
// with the distribution.
|
||||
//
|
||||
// Neither the name of TransGaming Inc., Google Inc., 3DLabs Inc.
|
||||
// Ltd., nor the names of their contributors may be used to endorse
|
||||
// or promote products derived from this software without specific
|
||||
// prior written permission.
|
||||
//
|
||||
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
|
||||
// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
// COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||
// INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||
// BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||
// LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
|
||||
// ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
// POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,7 @@
|
||||
# ANGLE UWP runtime
|
||||
|
||||
`libEGL.dll` and `libGLESv2.dll` were built for x64 UWP from [SternXD/angle](https://github.com/SternXD/angle) commit `45b0b1e03400b7a10aaa9a077e196d1abcddafce`. ANGLE version `2.1.25011` and source hash `45b0b1e03400`.
|
||||
|
||||
`d3dcompiler_47.dll` is the x64 Direct3D HLSL compiler redistributable from Windows SDK `10.0.26100.7705`. Current hashes are recorded in `../manifest.json`.
|
||||
|
||||
ANGLE's upstream `LICENSE` and `AUTHORS` files are included beside its binaries.
|
||||