mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 07:41:21 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8b7c468a4 |
@@ -181,196 +181,6 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 7
|
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|LauncherView|RomImporter)\.lua$|src/update/Check\.lua$|tests/engine/(platform_nx|uwp_baseroms|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
|
|
||||||
|
|
||||||
linux-arm64-changes:
|
|
||||||
name: detect Linux arm64 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_linux_arm64\.sh$|scripts/linux-arm64/|scripts/pack_love\.sh$|docs/linux-arm64-build\.md$|\.github/workflows/(ci|release)\.yml$)'; then
|
|
||||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
linux-arm64-selftest:
|
|
||||||
name: Linux arm64 offline selftest
|
|
||||||
needs: linux-arm64-changes
|
|
||||||
if: needs.linux-arm64-changes.outputs.changed == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
# Deliberately on x86_64: everything this gate checks (pins, the
|
|
||||||
# host-arch guard, the dependency exclude list, the AppRun fusion
|
|
||||||
# contract) is answerable without a container or an aarch64 machine,
|
|
||||||
# so the slow native job below only ever starts on a sane tree.
|
|
||||||
- name: Linux arm64 offline selftest
|
|
||||||
run: bash scripts/linux-arm64/selftest_build_linux_arm64.sh
|
|
||||||
|
|
||||||
linux-arm64-build:
|
|
||||||
name: Linux arm64 AppImage build
|
|
||||||
needs: [linux-arm64-changes, linux-arm64-selftest]
|
|
||||||
if: |
|
|
||||||
always()
|
|
||||||
&& needs.linux-arm64-changes.outputs.changed == 'true'
|
|
||||||
&& needs.linux-arm64-selftest.result == 'success'
|
|
||||||
# No fork restriction, unlike switch-build: this needs no secrets and no
|
|
||||||
# self-hosted hardware, just GitHub's free arm64 runner for public repos,
|
|
||||||
# so contributors get the same coverage on their own PRs.
|
|
||||||
runs-on: ubuntu-24.04-arm
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
- name: Build the aarch64 AppImage
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
scripts/build_linux_arm64.sh --version 0.0.0
|
|
||||||
- name: Verify the AppImage is self-contained and bullseye-compatible
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
image="dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage"
|
|
||||||
|
|
||||||
# --appimage-extract needs no FUSE, so this works on a runner
|
|
||||||
# without /dev/fuse and still exercises the real payload.
|
|
||||||
"$image" --appimage-extract >/dev/null
|
|
||||||
for required in AppRun bin/love game.love lib/liblove-11.5.so; do
|
|
||||||
[ -e "squashfs-root/$required" ] \
|
|
||||||
|| { echo "::error::AppImage is missing $required"; exit 1; }
|
|
||||||
done
|
|
||||||
|
|
||||||
# Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
|
|
||||||
# applied; an unresolved soname here is a user-visible launch crash.
|
|
||||||
#
|
|
||||||
# This runs on a HEADLESS runner on purpose, and that is the point.
|
|
||||||
# The first version of this build bundled Debian's SDL2, which
|
|
||||||
# hard-links libpulse/libasound/libX11/libwayland, so it only ever
|
|
||||||
# started on a full desktop -- a bare runner is what exposed it.
|
|
||||||
missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \
|
|
||||||
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
|
||||||
| grep 'not found' || true)"
|
|
||||||
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
|
|
||||||
|
|
||||||
# Nothing may hard-link a driver, session or audio-stack library:
|
|
||||||
# those must be reached through dlopen so the AppImage runs on a box
|
|
||||||
# with only ALSA, only Wayland, or only KMSDRM.
|
|
||||||
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do
|
|
||||||
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}'
|
|
||||||
done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
|
|
||||||
[ -z "$linked" ] \
|
|
||||||
|| { echo "::error::these must be dlopened, not linked:"; echo "$linked"; exit 1; }
|
|
||||||
|
|
||||||
# The whole point of compiling on bullseye. If a future change moves
|
|
||||||
# the builder to a newer base, the glibc floor silently rises and
|
|
||||||
# every user on an older distro gets "GLIBC_2.xx not found" -- catch
|
|
||||||
# it here instead of in a release.
|
|
||||||
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
|
||||||
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
|
|
||||||
echo "highest required glibc symbol version: $floor"
|
|
||||||
[ -n "$floor" ] \
|
|
||||||
|| { echo "::error::found no versioned glibc symbols -- objdump read nothing"; exit 1; }
|
|
||||||
highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)"
|
|
||||||
[ "$highest" = "GLIBC_2.31" ] \
|
|
||||||
|| { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; }
|
|
||||||
- name: Upload the AppImage
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: gen1recomp-linux-arm64
|
|
||||||
path: |
|
|
||||||
dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage
|
|
||||||
dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage.sha256
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
headless:
|
headless:
|
||||||
name: headless suites (no ROM)
|
name: headless suites (no ROM)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
+28
-197
@@ -1,9 +1,9 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
|
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
|
||||||
# IPA, a Nintendo Switch SD-ready zip (experimental), Xbox UWP, and the Anbernic
|
# IPA, a Nintendo Switch SD-ready zip (experimental), and the Anbernic RG34XXSP
|
||||||
# RG34XXSP (Stock OS 64-bit MOD / PortMaster) port, then publishes them as a
|
# (Stock OS 64-bit MOD / PortMaster) port on the self-hosted Mac runner, and
|
||||||
# GitHub Release.
|
# publishes them as a GitHub Release.
|
||||||
#
|
#
|
||||||
# Versioning:
|
# Versioning:
|
||||||
# - First ever release is 0.1.0.
|
# - First ever release is 0.1.0.
|
||||||
@@ -43,17 +43,22 @@ concurrency:
|
|||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
version:
|
release:
|
||||||
name: determine release version
|
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
version: ${{ steps.ver.outputs.version }}
|
|
||||||
tag: ${{ steps.ver.outputs.tag }}
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
# 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:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
fetch-tags: true
|
fetch-tags: true
|
||||||
|
|
||||||
- name: Determine version
|
- name: Determine version
|
||||||
id: ver
|
id: ver
|
||||||
env:
|
env:
|
||||||
@@ -61,6 +66,7 @@ jobs:
|
|||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
semver_re='^[0-9]+\.[0-9]+\.[0-9]+$'
|
semver_re='^[0-9]+\.[0-9]+\.[0-9]+$'
|
||||||
|
|
||||||
# 1) Explicit override from a manual run.
|
# 1) Explicit override from a manual run.
|
||||||
@@ -90,6 +96,7 @@ jobs:
|
|||||||
| grep -E "$semver_re" \
|
| grep -E "$semver_re" \
|
||||||
| sort -t. -k1,1n -k2,2n -k3,3n \
|
| sort -t. -k1,1n -k2,2n -k3,3n \
|
||||||
| tail -1 || true)"
|
| tail -1 || true)"
|
||||||
|
|
||||||
if [ -z "$latest" ]; then
|
if [ -z "$latest" ]; then
|
||||||
version="0.1.0"
|
version="0.1.0"
|
||||||
echo "No existing release tag; starting at $version"
|
echo "No existing release tag; starting at $version"
|
||||||
@@ -117,157 +124,10 @@ jobs:
|
|||||||
echo "::error::Release $tag already exists. Pick a different version."
|
echo "::error::Release $tag already exists. Pick a different version."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
echo "tag=$tag" >> "$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
|
|
||||||
|
|
||||||
linux-arm64:
|
|
||||||
name: build Linux arm64 AppImage
|
|
||||||
needs: [version, love-payload]
|
|
||||||
# GitHub's free arm64 runner for public repos. It has to be arm64: the
|
|
||||||
# AppImage compiles LÖVE natively inside a Debian bullseye arm64
|
|
||||||
# container, and the qemu-emulated alternative takes hours.
|
|
||||||
runs-on: ubuntu-24.04-arm
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
- name: Download shared payload
|
|
||||||
uses: actions/download-artifact@v8
|
|
||||||
with:
|
|
||||||
name: gen1recomp-release-love
|
|
||||||
path: .bazinga/work
|
|
||||||
- name: Build Linux arm64 AppImage
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
scripts/build_linux_arm64.sh \
|
|
||||||
--version "${{ needs.version.outputs.version }}" \
|
|
||||||
--game-love .bazinga/work/game.love
|
|
||||||
- name: Upload Linux arm64 release
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: gen1recomp-linux-arm64-release
|
|
||||||
path: |
|
|
||||||
dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage
|
|
||||||
dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage.sha256
|
|
||||||
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, linux-arm64]
|
|
||||||
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
|
- name: Import signing certificate into a temporary keychain
|
||||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||||
run: |
|
run: |
|
||||||
@@ -312,12 +172,12 @@ jobs:
|
|||||||
# notarize separately below so it uses secret credentials, not a
|
# notarize separately below so it uses secret credentials, not a
|
||||||
# login-keychain profile. "all" also builds the Linux AppImage,
|
# login-keychain profile. "all" also builds the Linux AppImage,
|
||||||
# which needs no signing/notarization.
|
# which needs no signing/notarization.
|
||||||
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize
|
scripts/build.sh all --version "${{ steps.ver.outputs.version }}" --no-notarize
|
||||||
|
|
||||||
- name: Build Android
|
- name: Build Android
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
scripts/build_android.sh --version "${{ needs.version.outputs.version }}"
|
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
||||||
|
|
||||||
- name: Install xcbeautify
|
- name: Install xcbeautify
|
||||||
run: |
|
run: |
|
||||||
@@ -331,10 +191,10 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ "$CANONICAL_REPOSITORY" = true ]; then
|
if [ "$CANONICAL_REPOSITORY" = true ]; then
|
||||||
scripts/build_ios.sh --fetch --device --release \
|
scripts/build_ios.sh --fetch --device --release \
|
||||||
--version "${{ needs.version.outputs.version }}"
|
--version "${{ steps.ver.outputs.version }}"
|
||||||
else
|
else
|
||||||
scripts/build_ios.sh --fetch --release \
|
scripts/build_ios.sh --fetch --release \
|
||||||
--version "${{ needs.version.outputs.version }}"
|
--version "${{ steps.ver.outputs.version }}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Build Switch
|
- name: Build Switch
|
||||||
@@ -346,14 +206,14 @@ jobs:
|
|||||||
# Needs native switch-tools (nacptool/elf2nro) and/or Docker on the
|
# Needs native switch-tools (nacptool/elf2nro) and/or Docker on the
|
||||||
# Mac self-hosted runner; see docs/switch-build.md.
|
# Mac self-hosted runner; see docs/switch-build.md.
|
||||||
scripts/build_switch.sh --fetch --fused \
|
scripts/build_switch.sh --fetch --fused \
|
||||||
--version "${{ needs.version.outputs.version }}"
|
--version "${{ steps.ver.outputs.version }}"
|
||||||
|
|
||||||
- name: Build Anbernic RG34XXSP port
|
- name: Build Anbernic RG34XXSP port
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5
|
# Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5
|
||||||
# runtime from PortMaster-GUI, so it needs no signing/notarization.
|
# runtime from PortMaster-GUI, so it needs no signing/notarization.
|
||||||
./build-rg34xxsp.sh --version "${{ needs.version.outputs.version }}"
|
./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}"
|
||||||
|
|
||||||
- name: Notarize & staple macOS app
|
- name: Notarize & staple macOS app
|
||||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||||
@@ -390,41 +250,18 @@ jobs:
|
|||||||
ditto -c -k --sequesterRsrc --keepParent "$app" "$zip"
|
ditto -c -k --sequesterRsrc --keepParent "$app" "$zip"
|
||||||
echo "Notarized + stapled ✓"
|
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: Download Linux arm64 release
|
|
||||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
|
||||||
uses: actions/download-artifact@v8
|
|
||||||
with:
|
|
||||||
name: gen1recomp-linux-arm64-release
|
|
||||||
path: dist/linux-arm64
|
|
||||||
|
|
||||||
- name: Stage release assets
|
- name: Stage release assets
|
||||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||||
id: assets
|
id: assets
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
v="${{ needs.version.outputs.version }}"
|
v="${{ steps.ver.outputs.version }}"
|
||||||
outdir="dist/release"
|
outdir="dist/release"
|
||||||
rm -rf "$outdir"
|
rm -rf "$outdir"
|
||||||
mkdir -p "$outdir"
|
mkdir -p "$outdir"
|
||||||
cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip"
|
cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip"
|
||||||
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
|
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
|
||||||
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip"
|
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip"
|
||||||
|
|
||||||
# arm64 desktop Linux (Raspberry Pi, Armbian, arm64 VMs). Built on
|
|
||||||
# its own runner because LÖVE publishes no aarch64 binary and the
|
|
||||||
# AppImage has to be compiled natively; ships as a runnable
|
|
||||||
# AppImage rather than a zip so `chmod +x && ./it` just works.
|
|
||||||
arm64_appimage="dist/linux-arm64/gen1recomp-${v}-linux-arm64.AppImage"
|
|
||||||
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; }
|
|
||||||
cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
|
||||||
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
|
||||||
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
||||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||||
@@ -439,10 +276,6 @@ jobs:
|
|||||||
# Local fused .nro stays under dist/switch/ for PR CI / debug; release
|
# Local fused .nro stays under dist/switch/ for PR CI / debug; release
|
||||||
# publishes the SD-ready zip only.
|
# 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
|
# Anbernic handheld port (suffix names the CFW it targets, so a
|
||||||
# future RG35XX/other-CFW pack can ship alongside it).
|
# future RG35XX/other-CFW pack can ship alongside it).
|
||||||
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
|
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
|
||||||
@@ -469,8 +302,8 @@ jobs:
|
|||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
v="${{ needs.version.outputs.version }}"
|
v="${{ steps.ver.outputs.version }}"
|
||||||
tag="${{ needs.version.outputs.tag }}"
|
tag="${{ steps.ver.outputs.tag }}"
|
||||||
|
|
||||||
# Issues this release closes. Three sources, deduped by number:
|
# Issues this release closes. Three sources, deduped by number:
|
||||||
# 1. GitHub's own "closing issues" links on every PR whose
|
# 1. GitHub's own "closing issues" links on every PR whose
|
||||||
@@ -558,11 +391,9 @@ jobs:
|
|||||||
"dist/release/gen1recomp-${v}-macos.zip"
|
"dist/release/gen1recomp-${v}-macos.zip"
|
||||||
"dist/release/gen1recomp-${v}-windows.zip"
|
"dist/release/gen1recomp-${v}-windows.zip"
|
||||||
"dist/release/gen1recomp-${v}-linux.zip"
|
"dist/release/gen1recomp-${v}-linux.zip"
|
||||||
"dist/release/gen1recomp-${v}-linux-arm64.AppImage"
|
|
||||||
"dist/release/gen1recomp-${v}-android.apk"
|
"dist/release/gen1recomp-${v}-android.apk"
|
||||||
"dist/release/gen1recomp-${v}-ios.ipa"
|
"dist/release/gen1recomp-${v}-ios.ipa"
|
||||||
"dist/release/gen1recomp-${v}-switch.zip"
|
"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}-rg34xxsp-stockos64-mod.zip"
|
||||||
"dist/release/gen1recomp-${v}.love"
|
"dist/release/gen1recomp-${v}.love"
|
||||||
"dist/release/sha256sums.txt"
|
"dist/release/sha256sums.txt"
|
||||||
@@ -580,7 +411,7 @@ jobs:
|
|||||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
v="${{ needs.version.outputs.version }}"
|
v="${{ steps.ver.outputs.version }}"
|
||||||
ipa="dist/release/gen1recomp-${v}-ios.ipa"
|
ipa="dist/release/gen1recomp-${v}-ios.ipa"
|
||||||
app_repo="mobile/ios/app-repo.json"
|
app_repo="mobile/ios/app-repo.json"
|
||||||
[ -f "$ipa" ] || { echo "::error::$ipa not found"; exit 1; }
|
[ -f "$ipa" ] || { echo "::error::$ipa not found"; exit 1; }
|
||||||
|
|||||||
+1
-23
@@ -31,29 +31,12 @@ mobile/ios/love-src/
|
|||||||
mobile/ios/cache/
|
mobile/ios/cache/
|
||||||
mobile/ios/build/
|
mobile/ios/build/
|
||||||
|
|
||||||
# love-nx vendor binaries (fetch per docs/switch-build.md; also covered by .*)
|
# love-nx vendor binaries (fetch per docs/switch-development.md; also covered by .*)
|
||||||
.bazinga/love-nx/
|
.bazinga/love-nx/
|
||||||
|
|
||||||
# Final packaged build artifacts (mac/win/web/android/ios/switch) — see scripts/build.sh
|
# Final packaged build artifacts (mac/win/web/android/ios/switch) — see scripts/build.sh
|
||||||
/dist/
|
/dist/
|
||||||
|
|
||||||
# Switch OTA launcher build outputs
|
|
||||||
ports/switch/ota-launcher/build/
|
|
||||||
ports/switch/ota-launcher/build-host/
|
|
||||||
ports/switch/ota-launcher/*.nro
|
|
||||||
ports/switch/ota-launcher/*.nacp
|
|
||||||
ports/switch/ota-launcher/*.elf
|
|
||||||
ports/switch/ota-launcher/*.map
|
|
||||||
ports/switch/ota-launcher/romfs/logo.rgba
|
|
||||||
ports/switch/ota-launcher/romfs/logo.png
|
|
||||||
ports/switch/ota-launcher/romfs/cacert.pem
|
|
||||||
ports/switch/ota-launcher/romfs/ota-bootstrap.nro
|
|
||||||
ports/switch/ota-bootstrap/build/
|
|
||||||
ports/switch/ota-bootstrap/*.nro
|
|
||||||
ports/switch/ota-bootstrap/*.nacp
|
|
||||||
ports/switch/ota-bootstrap/*.elf
|
|
||||||
ports/switch/ota-bootstrap/*.map
|
|
||||||
|
|
||||||
# Legacy manual convenience-copy location (superseded by /dist/android/)
|
# Legacy manual convenience-copy location (superseded by /dist/android/)
|
||||||
mobile/dist/
|
mobile/dist/
|
||||||
|
|
||||||
@@ -63,8 +46,3 @@ mobile/dist/
|
|||||||
|
|
||||||
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
|
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
|
||||||
mobile/ios/bundle_id.local
|
mobile/ios/bundle_id.local
|
||||||
|
|
||||||
# Xbox UWP build output
|
|
||||||
/ports/uwp/build/
|
|
||||||
/ports/uwp/third_party/*/source/
|
|
||||||
/ports/uwp/third_party/angle/depot_tools/
|
|
||||||
|
|||||||
@@ -205,39 +205,6 @@ even on a different computer, as long as the same folder comes along.
|
|||||||
already written to either location is touched automatically, so copy files
|
already written to either location is touched automatically, so copy files
|
||||||
over yourself if you want to carry existing progress across the switch.
|
over yourself if you want to carry existing progress across the switch.
|
||||||
|
|
||||||
## Launch Options
|
|
||||||
|
|
||||||
By default the app opens the launcher so you can pick a game. Launch options
|
|
||||||
skip it and start one game directly, which is what you want for a one-click
|
|
||||||
entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
|
|
||||||
|
|
||||||
| Option | Effect |
|
|
||||||
| --- | --- |
|
|
||||||
| `--game=red` | boot Red, skipping the launcher (`blue` and `yellow` too, or just `r` / `b` / `y`) |
|
|
||||||
| `--slot=2` | load that save slot; takes a slot number or a slot id |
|
|
||||||
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
|
|
||||||
|
|
||||||
|
|
||||||
## Linux on arm64 (Raspberry Pi)
|
|
||||||
|
|
||||||
Alongside the x86_64 `gen1recomp-*-linux.zip`, every release ships
|
|
||||||
`gen1recomp-*-linux-arm64.AppImage` for 64-bit ARM desktop Linux — Raspberry
|
|
||||||
Pi 4/5, Armbian and other SBC distros, and arm64 VMs on Apple Silicon:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
chmod +x gen1recomp-*-linux-arm64.AppImage
|
|
||||||
./gen1recomp-*-linux-arm64.AppImage
|
|
||||||
```
|
|
||||||
|
|
||||||
LÖVE publishes no aarch64 binary of any kind, so this artifact compiles the
|
|
||||||
engine — and SDL2, OpenAL and the codecs — from source inside a Debian
|
|
||||||
bullseye arm64 container. It needs only glibc 2.29+, libstdc++, freetype and
|
|
||||||
zlib on the host; OpenGL, X11, Wayland, KMSDRM, ALSA and PulseAudio are all
|
|
||||||
dlopened, so the same image runs on a full desktop, a Wayland-only session or
|
|
||||||
a KMSDRM handheld with no X server. Build instructions and the reasoning are
|
|
||||||
in [docs/linux-arm64-build.md](docs/linux-arm64-build.md).
|
|
||||||
|
|
||||||
|
|
||||||
## iOS
|
## iOS
|
||||||
|
|
||||||
Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
|
Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
|
||||||
@@ -255,40 +222,6 @@ build and install from source on a Mac instead, see
|
|||||||
<a href="https://github.com/bryanthaboi/gen1recomp/releases/latest"><img src="./.github/resources/github-badge.png" alt="Download from GitHub" height="60"></a>
|
<a href="https://github.com/bryanthaboi/gen1recomp/releases/latest"><img src="./.github/resources/github-badge.png" alt="Download from GitHub" height="60"></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Xbox Dev Mode
|
|
||||||
|
|
||||||
Every release ships `gen1recomp-*-xbox-uwp.zip` for Xbox One and Xbox Series
|
|
||||||
consoles in Developer Mode. It cannot be installed in retail mode.
|
|
||||||
|
|
||||||
Extract the archive, then use Xbox Device Portal to install the `.msix` and
|
|
||||||
the x64 package under `Dependencies`.
|
|
||||||
|
|
||||||
### External setup
|
|
||||||
|
|
||||||
1. Put your legally obtained Red, Blue, or Yellow ROMs on an external drive.
|
|
||||||
Mod ZIPs can go on the same drive.
|
|
||||||
2. Connect the drive to the Xbox and open Gen1Recomp.
|
|
||||||
3. Select **Import ROM** or **Import Mod**, then choose the file with the Xbox
|
|
||||||
file picker.
|
|
||||||
4. Repeat the ROM import for each version you want to use.
|
|
||||||
|
|
||||||
### Internal setup
|
|
||||||
|
|
||||||
1. Create a folder named `baseroms` on your PC and place your legally obtained
|
|
||||||
Red, Blue, or Yellow ROMs inside it.
|
|
||||||
2. ZIP the folder, keeping `baseroms` at the top level of the archive.
|
|
||||||
3. Launch Gen1Recomp once, then close it.
|
|
||||||
4. Open Xbox Device Portal and upload the ZIP to
|
|
||||||
`Gen1Recomp/LocalState/pokemon-love2d/`.
|
|
||||||
5. Choose **Yes** when Device Portal asks whether to extract the archive.
|
|
||||||
6. Open Gen1Recomp. The launcher checks baseroms once at startup. When it finds a compatible ROM, that game’s tab shows ROM FOUND and an Import detected ROM button.
|
|
||||||
|
|
||||||
ROMs, generated game data, saves, and mods remain in LocalState and are not
|
|
||||||
included in the app.
|
|
||||||
|
|
||||||
Source builds and package details are covered in
|
|
||||||
[the Xbox UWP build notes](ports/uwp/BUILD.md).
|
|
||||||
|
|
||||||
## Handhelds
|
## Handhelds
|
||||||
|
|
||||||
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
|
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
|
||||||
@@ -298,18 +231,24 @@ Install steps, controls, and troubleshooting live in
|
|||||||
|
|
||||||
## Nintendo Switch
|
## Nintendo Switch
|
||||||
|
|
||||||
Releases ship an SD-ready `gen1recomp-*-switch.zip`. Runtime target is pinned
|
Releases ship an SD-ready `gen1recomp-*-switch.zip` (issue
|
||||||
[love-nx](https://github.com/retronx-team/love-nx) `11.5-nx1`. Requires a
|
[#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Runtime target
|
||||||
console that can run Switch homebrew.
|
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
|
- Players: [docs/switch-install.md](docs/switch-install.md) — download the
|
||||||
zip, extract at the microSD root (install or update), title-override
|
zip, extract at the microSD root (install or update), title-override
|
||||||
launch, import your own legal ROM, Joy-Con controls and shortcuts.
|
launch, import your own legal ROM, Joy-Con controls and shortcuts.
|
||||||
- Builders: [docs/switch-build.md](docs/switch-build.md). `--fetch` /
|
- Builders: [docs/switch-build.md](docs/switch-build.md) — `--fetch` /
|
||||||
`--loose` / `--fused`, toolchain, Docker fallback, and CI vs release
|
`--loose` / `--fused`, toolchain, Docker fallback, and **CI vs release**
|
||||||
(path-gated ubuntu selftest, fused PR artifact on the main repo, release
|
(path-gated ubuntu selftest, canonical fused PR artifact, release hard-fail).
|
||||||
hard-fail).
|
|
||||||
- File transfer (MTP / SD / FTP): [docs/switch-transfer.md](docs/switch-transfer.md).
|
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
|
## Modding
|
||||||
|
|
||||||
@@ -358,4 +297,7 @@ This project would not be possible without [pret](https://github.com/pret) >
|
|||||||
the pret band of decompiling maniacs > and their
|
the pret band of decompiling maniacs > and their
|
||||||
[pokered](https://github.com/pret/pokered) disassembly.
|
[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>
|
<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>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
@@ -16,13 +16,9 @@ local function push(game, s, done)
|
|||||||
game.stack:push(TextBox.new(game, s, done))
|
game.stack:push(TextBox.new(game, s, done))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- PrintText on a text_end string returns with the box still drawn and
|
|
||||||
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
|
|
||||||
-- engine/menus/text_box.asm); no A press clears the question first. Ride
|
|
||||||
-- TextBox's opts.choice, the same as Commands.ask (#854).
|
|
||||||
local function ask(game, s, cb)
|
local function ask(game, s, cb)
|
||||||
local TextBox = require("src.render.TextBox")
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||||
end
|
end
|
||||||
|
|
||||||
M.PEWTER_CITY = {
|
M.PEWTER_CITY = {
|
||||||
|
|||||||
@@ -23,13 +23,9 @@ local function push(game, s, done)
|
|||||||
game.stack:push(TextBox.new(game, s, done))
|
game.stack:push(TextBox.new(game, s, done))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- PrintText on a text_end string returns with the box still drawn and
|
|
||||||
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
|
|
||||||
-- engine/menus/text_box.asm); no A press clears the question first. Ride
|
|
||||||
-- TextBox's opts.choice, the same as Commands.ask (#854).
|
|
||||||
local function ask(game, s, cb)
|
local function ask(game, s, cb)
|
||||||
local TextBox = require("src.render.TextBox")
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||||
end
|
end
|
||||||
|
|
||||||
M.VIRIDIAN_CITY = {
|
M.VIRIDIAN_CITY = {
|
||||||
|
|||||||
+14
-37
@@ -18,26 +18,6 @@ local M = {
|
|||||||
VIRIDIAN_GYM = { city = "VIRIDIAN CITY", leader = "GIOVANNI", badge = "EARTHBADGE" },
|
VIRIDIAN_GYM = { city = "VIRIDIAN CITY", leader = "GIOVANNI", badge = "EARTHBADGE" },
|
||||||
}
|
}
|
||||||
|
|
||||||
-- The originals' middle branch shared by every leader's text_asm: beaten
|
|
||||||
-- but EVENT_GOT_TM* unset means the bag was full when the victory script
|
|
||||||
-- ran GiveItem, so talking to the leader re-runs the ReceiveTM script.
|
|
||||||
-- Returns true when the retry took over the talk. A save from before
|
|
||||||
-- #797 already holds the TM without the flag; treat the owned TM as
|
|
||||||
-- received so those saves fall through to the advice text instead of
|
|
||||||
-- collecting a second copy.
|
|
||||||
local function retryTmGive(game, ow, victoryKey, done)
|
|
||||||
local reward = require("data.scripts.victories")[victoryKey]
|
|
||||||
if not (reward and reward.gotFlag) then return false end
|
|
||||||
if game.save.flags[reward.gotFlag] then return false end
|
|
||||||
local owned = game.save.inventory and game.save.inventory[reward.item] or 0
|
|
||||||
if owned > 0 then
|
|
||||||
game.save.flags[reward.gotFlag] = true
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
ow:offerGymTm(reward, done)
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
|
|
||||||
-- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent
|
-- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent
|
||||||
-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints
|
-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints
|
||||||
-- _PewterGymBrockPreBattleText and engages the leader battle
|
-- _PewterGymBrockPreBattleText and engages the leader battle
|
||||||
@@ -45,14 +25,12 @@ end
|
|||||||
-- badge/TM34 rewards and EVENT_BEAT_BROCK come from
|
-- badge/TM34 rewards and EVENT_BEAT_BROCK come from
|
||||||
-- data/scripts/victories.lua OPP_BROCK#1). After the badge his
|
-- data/scripts/victories.lua OPP_BROCK#1). After the badge his
|
||||||
-- .afterBeat branch prints _PewterGymBrockPostBattleAdviceText ("Go to
|
-- .afterBeat branch prints _PewterGymBrockPostBattleAdviceText ("Go to
|
||||||
-- the GYM in CERULEAN..."). The middle branch (beat but TM34 not yet
|
-- the GYM in CERULEAN..."). The original's middle branch (beat but
|
||||||
-- handed over, CheckEventReuseA EVENT_GOT_TM34 -> call
|
-- TM34 not yet handed over, CheckEventReuseA EVENT_GOT_TM34) is
|
||||||
-- PewterGymScriptReceiveTM34) retries the TM give when the bag was full
|
-- unreachable in the port: the TM is granted with the victory.
|
||||||
-- at the victory (#797).
|
|
||||||
M.PEWTER_GYM.talk = {
|
M.PEWTER_GYM.talk = {
|
||||||
TEXT_PEWTERGYM_BROCK = function(game, ow, npc, done)
|
TEXT_PEWTERGYM_BROCK = function(game, ow, npc, done)
|
||||||
if game.save.flags.EVENT_BEAT_BROCK then
|
if game.save.flags.EVENT_BEAT_BROCK then
|
||||||
if retryTmGive(game, ow, "OPP_BROCK#1", done) then return end
|
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
game.data.text._PewterGymBrockPostBattleAdviceText
|
game.data.text._PewterGymBrockPostBattleAdviceText
|
||||||
@@ -70,17 +48,16 @@ M.PEWTER_GYM.talk = {
|
|||||||
-- (engageTrainer shows that same pre-battle text via resolveText; the
|
-- (engageTrainer shows that same pre-battle text via resolveText; the
|
||||||
-- badge/TM rewards and the beat flag come from data/scripts/victories.lua)
|
-- badge/TM rewards and the beat flag come from data/scripts/victories.lua)
|
||||||
-- -- and once beaten print the post-battle advice text. As with Brock,
|
-- -- and once beaten print the post-battle advice text. As with Brock,
|
||||||
-- the middle branch (beaten but the TM not yet handed over,
|
-- the originals' middle branch (beaten but the TM not yet handed over,
|
||||||
-- CheckEventReuseA EVENT_GOT_TM*) retries the TM give when the bag was
|
-- CheckEventReuseA EVENT_GOT_TM*) is unreachable in the port: the TM is
|
||||||
-- full at the victory.
|
-- granted with the victory.
|
||||||
-- afterAdvice, when given, takes over `done`: it is handed (game, ow, npc,
|
-- afterAdvice, when given, takes over `done`: it is handed (game, ow, npc,
|
||||||
-- done) and must call done() itself once whatever it's doing (e.g. a fade
|
-- done) and must call done() itself once whatever it's doing (e.g. a fade
|
||||||
-- around a HideObject) finishes, rather than having it invoked
|
-- around a HideObject) finishes, rather than having it invoked
|
||||||
-- automatically. Only Giovanni's farewell uses this.
|
-- automatically. Only Giovanni's farewell uses this.
|
||||||
local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice, victoryKey)
|
local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice)
|
||||||
return function(game, ow, npc, done)
|
return function(game, ow, npc, done)
|
||||||
if game.save.flags[beatFlag] then
|
if game.save.flags[beatFlag] then
|
||||||
if victoryKey and retryTmGive(game, ow, victoryKey, done) then return end
|
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local finish = done
|
local finish = done
|
||||||
if afterAdvice then
|
if afterAdvice then
|
||||||
@@ -102,42 +79,42 @@ end
|
|||||||
M.CERULEAN_GYM.talk = {
|
M.CERULEAN_GYM.talk = {
|
||||||
TEXT_CERULEANGYM_MISTY = leaderTalk("EVENT_BEAT_MISTY",
|
TEXT_CERULEANGYM_MISTY = leaderTalk("EVENT_BEAT_MISTY",
|
||||||
"_CeruleanGymMistyTM11ExplanationText",
|
"_CeruleanGymMistyTM11ExplanationText",
|
||||||
"TM11 teaches\nBUBBLEBEAM!", nil, "OPP_MISTY#1"),
|
"TM11 teaches\nBUBBLEBEAM!"),
|
||||||
}
|
}
|
||||||
|
|
||||||
-- scripts/VermilionGym.asm VermilionGymLTSurgeText .got_tm24_already
|
-- scripts/VermilionGym.asm VermilionGymLTSurgeText .got_tm24_already
|
||||||
M.VERMILION_GYM.talk = {
|
M.VERMILION_GYM.talk = {
|
||||||
TEXT_VERMILIONGYM_LT_SURGE = leaderTalk("EVENT_BEAT_LT_SURGE",
|
TEXT_VERMILIONGYM_LT_SURGE = leaderTalk("EVENT_BEAT_LT_SURGE",
|
||||||
"_VermilionGymLTSurgePostBattleAdviceText",
|
"_VermilionGymLTSurgePostBattleAdviceText",
|
||||||
"A little word of\nadvice, kid!", nil, "OPP_LT_SURGE#1"),
|
"A little word of\nadvice, kid!"),
|
||||||
}
|
}
|
||||||
|
|
||||||
-- scripts/CeladonGym.asm CeladonGymErikaText .afterBeat
|
-- scripts/CeladonGym.asm CeladonGymErikaText .afterBeat
|
||||||
M.CELADON_GYM.talk = {
|
M.CELADON_GYM.talk = {
|
||||||
TEXT_CELADONGYM_ERIKA = leaderTalk("EVENT_BEAT_ERIKA",
|
TEXT_CELADONGYM_ERIKA = leaderTalk("EVENT_BEAT_ERIKA",
|
||||||
"_CeladonGymErikaPostBattleAdviceText",
|
"_CeladonGymErikaPostBattleAdviceText",
|
||||||
"You are cataloging\nPOKéMON? I must\nsay I'm impressed.", nil, "OPP_ERIKA#1"),
|
"You are cataloging\nPOKéMON? I must\nsay I'm impressed."),
|
||||||
}
|
}
|
||||||
|
|
||||||
-- scripts/FuchsiaGym.asm FuchsiaGymKogaText .afterBeat
|
-- scripts/FuchsiaGym.asm FuchsiaGymKogaText .afterBeat
|
||||||
M.FUCHSIA_GYM.talk = {
|
M.FUCHSIA_GYM.talk = {
|
||||||
TEXT_FUCHSIAGYM_KOGA = leaderTalk("EVENT_BEAT_KOGA",
|
TEXT_FUCHSIAGYM_KOGA = leaderTalk("EVENT_BEAT_KOGA",
|
||||||
"_FuchsiaGymKogaPostBattleAdviceText",
|
"_FuchsiaGymKogaPostBattleAdviceText",
|
||||||
"When afflicted by\nTOXIC, POKéMON\nsuffer more and\nmore as battle\nprogresses!", nil, "OPP_KOGA#1"),
|
"When afflicted by\nTOXIC, POKéMON\nsuffer more and\nmore as battle\nprogresses!"),
|
||||||
}
|
}
|
||||||
|
|
||||||
-- scripts/SaffronGym.asm SaffronGymSabrinaText .afterBeat
|
-- scripts/SaffronGym.asm SaffronGymSabrinaText .afterBeat
|
||||||
M.SAFFRON_GYM.talk = {
|
M.SAFFRON_GYM.talk = {
|
||||||
TEXT_SAFFRONGYM_SABRINA = leaderTalk("EVENT_BEAT_SABRINA",
|
TEXT_SAFFRONGYM_SABRINA = leaderTalk("EVENT_BEAT_SABRINA",
|
||||||
"_SaffronGymSabrinaPostBattleAdviceText",
|
"_SaffronGymSabrinaPostBattleAdviceText",
|
||||||
"Everyone has\npsychic power!\nPeople just don't\nrealize it!", nil, "OPP_SABRINA#1"),
|
"Everyone has\npsychic power!\nPeople just don't\nrealize it!"),
|
||||||
}
|
}
|
||||||
|
|
||||||
-- scripts/CinnabarGym.asm CinnabarGymBlaineText .afterBeat
|
-- scripts/CinnabarGym.asm CinnabarGymBlaineText .afterBeat
|
||||||
M.CINNABAR_GYM.talk = {
|
M.CINNABAR_GYM.talk = {
|
||||||
TEXT_CINNABARGYM_BLAINE = leaderTalk("EVENT_BEAT_BLAINE",
|
TEXT_CINNABARGYM_BLAINE = leaderTalk("EVENT_BEAT_BLAINE",
|
||||||
"_CinnabarGymBlainePostBattleAdviceText",
|
"_CinnabarGymBlainePostBattleAdviceText",
|
||||||
"FIRE BLAST is the\nultimate fire\ntechnique!", nil, "OPP_BLAINE#1"),
|
"FIRE BLAST is the\nultimate fire\ntechnique!"),
|
||||||
}
|
}
|
||||||
|
|
||||||
-- scripts/ViridianGym.asm ViridianGymGiovanniText .afterBeat: after the
|
-- scripts/ViridianGym.asm ViridianGymGiovanniText .afterBeat: after the
|
||||||
@@ -165,7 +142,7 @@ M.VIRIDIAN_GYM.talk = {
|
|||||||
"VIRIDIAN_GYM", "VIRIDIANGYM_GIOVANNI")
|
"VIRIDIAN_GYM", "VIRIDIANGYM_GIOVANNI")
|
||||||
end
|
end
|
||||||
end, done))
|
end, done))
|
||||||
end, "OPP_GIOVANNI#3"),
|
end),
|
||||||
}
|
}
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|||||||
+36
-57
@@ -800,13 +800,9 @@ M.SILPH_CO_11F = {
|
|||||||
-- line) would touch, and the whole Silph ending -- the flag, the Master
|
-- line) would touch, and the whole Silph ending -- the flag, the Master
|
||||||
-- Ball, the Saffron streets clearing -- silently never happened.
|
-- Ball, the Saffron streets clearing -- silently never happened.
|
||||||
--
|
--
|
||||||
-- SilphCo11FDefaultScript orders it DisplayTextID TEXT_SILPHCO11F_GIOVANNI
|
-- engageTrainer shows TEXT_SILPHCO11F_GIOVANNI as the battle text and,
|
||||||
-- FIRST, then MoveSprite .GiovanniMovement: he speaks from behind the desk
|
-- via victories.lua OPP_GIOVANNI#2, sets the event on a win; a loss
|
||||||
-- and only then walks the three tiles down. Moving him before the box made
|
-- sets nothing, so the trigger re-arms exactly as vanilla does.
|
||||||
-- him cross the room in silence and deliver the speech point-blank (#869),
|
|
||||||
-- so the box comes first here and engageTrainer skips its own battle text.
|
|
||||||
-- victories.lua OPP_GIOVANNI#2 sets the event on a win; a loss sets
|
|
||||||
-- nothing, so the trigger re-arms exactly as vanilla does.
|
|
||||||
onStep = function(game, ow, x, y)
|
onStep = function(game, ow, x, y)
|
||||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then return false end
|
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then return false end
|
||||||
if not ((x == 6 and y == 13) or (x == 7 and y == 12)) then return false end
|
if not ((x == 6 and y == 13) or (x == 7 and y == 12)) then return false end
|
||||||
@@ -815,28 +811,21 @@ M.SILPH_CO_11F = {
|
|||||||
if npc.def and npc.def.name == "SILPHCO11F_GIOVANNI" then gio = npc break end
|
if npc.def and npc.def.name == "SILPHCO11F_GIOVANNI" then gio = npc break end
|
||||||
end
|
end
|
||||||
if not gio or ow:trainerDefeated(gio) then return false end
|
if not gio or ow:trainerDefeated(gio) then return false end
|
||||||
local TextBox = require("src.render.TextBox")
|
ow:scriptMove(gio, "down", 3, function()
|
||||||
game.stack:push(TextBox.new(game,
|
gio:facePlayer(ow.player)
|
||||||
game.data.text._SilphCo11FGiovanniText
|
ow:engageTrainer(gio, function()
|
||||||
or "Ah {PLAYER}!\nSo we meet again!",
|
-- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!" speech,
|
||||||
function()
|
-- then SilphCo11FTeamRocketLeavesScript behind a fade so every Silph
|
||||||
ow:scriptMove(gio, "down", 3, function()
|
-- rocket leaves off-screen (the street rockets are handled by
|
||||||
gio:facePlayer(ow.player)
|
-- M.SAFFRON_CITY.onEnter in story4.lua). Queued, not run here: the
|
||||||
ow:engageTrainer(gio, function()
|
-- battle's own callbacks are still unwinding, so queueScript starts
|
||||||
-- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!"
|
-- it on the first idle overworld frame -- after the end-battle
|
||||||
-- speech, then SilphCo11FTeamRocketLeavesScript behind a fade so
|
-- "Arrgh!!" box victories.lua OPP_GIOVANNI#2 pushes (#722).
|
||||||
-- every Silph rocket leaves off-screen (the street rockets are
|
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||||
-- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not
|
ow:queueScript(silphAftermathRows())
|
||||||
-- run here: the battle's own callbacks are still unwinding, so
|
end
|
||||||
-- queueScript starts it on the first idle overworld frame --
|
end)
|
||||||
-- after the end-battle "Arrgh!!" box victories.lua OPP_GIOVANNI#2
|
end)
|
||||||
-- pushes (#722).
|
|
||||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
|
||||||
ow:queueScript(silphAftermathRows())
|
|
||||||
end
|
|
||||||
end, nil, true)
|
|
||||||
end)
|
|
||||||
end))
|
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
@@ -1042,31 +1031,23 @@ local championsRoomRivalScript = {
|
|||||||
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 10
|
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 10
|
||||||
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
|
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
|
||||||
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
|
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
|
||||||
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in.
|
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in
|
||||||
-- audio/alternate_tempo.asm Music_Cities1AlternateTempo is not a plain
|
{ "play_music", "Music_Cities1", { keep = true } }, -- 11
|
||||||
-- PlayMusic: it fades the current song out (wAudioFadeOutControl = 10),
|
{ "show_text", "_ChampionsRoomOakText" }, -- 12
|
||||||
-- waits 100 frames for the fade, then restarts Cities1 with channel 1
|
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 13
|
||||||
-- pointed at Music_Cities1_Ch1_AlternateTempo -- `tempo 232` where the
|
{ "move_npc", 2, "up", 5 }, -- 14 OakEntranceAfterVictoryMovement
|
||||||
-- normal Music_Cities1_Ch1 opens `tempo 144`, i.e. the slower, heavier
|
|
||||||
-- reading of the town theme this scene is known for (#847).
|
|
||||||
{ "fade_music", 10 }, -- 11
|
|
||||||
{ "wait", 100 }, -- 12
|
|
||||||
{ "play_music", "Music_Cities1", { keep = true, tempo = 232 } }, -- 13
|
|
||||||
{ "show_text", "_ChampionsRoomOakText" }, -- 14
|
|
||||||
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 15
|
|
||||||
{ "move_npc", 2, "up", 5 }, -- 16 OakEntranceAfterVictoryMovement
|
|
||||||
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
|
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
|
||||||
{ "face_object", 1, "left" }, -- 17
|
{ "face_object", 1, "left" }, -- 15
|
||||||
{ "face_object", 2, "down" }, -- 18
|
{ "face_object", 2, "down" }, -- 16
|
||||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 19
|
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 17
|
||||||
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
||||||
{ "face_object", 2, "right" }, -- 20
|
{ "face_object", 2, "right" }, -- 18
|
||||||
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 21
|
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 19
|
||||||
-- OakComeWithMeScript: Oak faces down again, then exits up
|
-- OakComeWithMeScript: Oak faces down again, then exits up
|
||||||
{ "face_object", 2, "down" }, -- 22
|
{ "face_object", 2, "down" }, -- 20
|
||||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
|
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 21
|
||||||
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
|
{ "move_npc", 2, "up", 2 }, -- 22 OakExitChampionsRoomMovement
|
||||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
|
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 23
|
||||||
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement
|
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement
|
||||||
-- (PAD_UP 4, PAD_LEFT 1): the player walks out after Oak instead of the
|
-- (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
|
-- screen just fading on the spot (#704). The entrance walk leaves the
|
||||||
@@ -1076,14 +1057,12 @@ local championsRoomRivalScript = {
|
|||||||
-- trailing UP/LEFT are dropped. Scripted steps ignore collision here just
|
-- trailing UP/LEFT are dropped. Scripted steps ignore collision here just
|
||||||
-- as they do in the original (CollisionCheckOnLand skips its checks while
|
-- as they do in the original (CollisionCheckOnLand skips its checks while
|
||||||
-- wSimulatedJoypadStatesIndex is non-zero), so stepping through the
|
-- wSimulatedJoypadStatesIndex is non-zero), so stepping through the
|
||||||
-- rival's cell at (4,2) is the ported behavior, not a clip. Re-reported
|
-- rival's cell at (4,2) is the ported behavior, not a clip.
|
||||||
-- as a clip in #847 and re-checked against home/overworld.asm
|
{ "move_player", "up", 3 }, -- 24
|
||||||
-- CollisionCheckOnLand, which is still the authority: do not "fix" it.
|
|
||||||
{ "move_player", "up", 3 }, -- 26
|
|
||||||
-- hand the induction off to the HALL_OF_FAME room (consumed by its
|
-- 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)
|
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
|
||||||
{ "set_field", "pendingHallOfFame", true }, -- 27
|
{ "set_field", "pendingHallOfFame", true }, -- 25
|
||||||
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 28
|
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 26
|
||||||
}
|
}
|
||||||
|
|
||||||
M.CHAMPIONS_ROOM = {
|
M.CHAMPIONS_ROOM = {
|
||||||
|
|||||||
+15
-66
@@ -151,27 +151,9 @@ M.POKEMON_TOWER_6F = {
|
|||||||
-- trick, and the speedrun route this bot follows depends on it.
|
-- trick, and the speedrun route this bot follows depends on it.
|
||||||
if result == "win" or battle.pokeDollEscape then
|
if result == "win" or battle.pokeDollEscape then
|
||||||
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true
|
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true
|
||||||
-- PokemonTower6FMarowakDepartedText (scripts/PokemonTower6F.asm)
|
game.stack:push(TextBox.new(game,
|
||||||
-- is two texts, not one: the CUBONE's-mother line first, then
|
t._PokemonTower6FSoulWasCalmedText
|
||||||
-- PlayCry RESTLESS_SOUL (EQU MAROWAK, constants/pokemon_constants
|
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!"))
|
||||||
-- .asm:209) + WaitForSoundToFinish + DelayFrames 30 before the
|
|
||||||
-- calmed line; the port dropped the first text and the cry
|
|
||||||
-- (#867). play_cry arms the next show_text, so the cry rides
|
|
||||||
-- the calmed box's open with the button prompt kept, and the
|
|
||||||
-- wait row stands in for the asm's 30-frame gap.
|
|
||||||
local rows = {
|
|
||||||
{ "show_text", t._PokemonTower6FGhostWasCubonesMotherText
|
|
||||||
or "The GHOST was the\nrestless soul of\vCUBONE's mother!" },
|
|
||||||
{ "play_cry", "MAROWAK", true },
|
|
||||||
{ "wait", 30 },
|
|
||||||
{ "show_text", t._PokemonTower6FSoulWasCalmedText
|
|
||||||
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!" },
|
|
||||||
}
|
|
||||||
if ow.runner then
|
|
||||||
ow.runner:run(rows)
|
|
||||||
elseif ow.queueScript then
|
|
||||||
ow:queueScript(rows)
|
|
||||||
end
|
|
||||||
elseif result ~= "lose" then
|
elseif result ~= "lose" then
|
||||||
-- .did_not_defeat: one simulated step right, off the trigger,
|
-- .did_not_defeat: one simulated step right, off the trigger,
|
||||||
-- so fleeing does not leave you standing on a cell that
|
-- so fleeing does not leave you standing on a cell that
|
||||||
@@ -535,14 +517,6 @@ M.GAME_CORNER = {
|
|||||||
done()
|
done()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- GameCornerRocketText hands the battle its own loss line through
|
|
||||||
-- SaveEndBattleTextPointers (.BattleEndText ->
|
|
||||||
-- _GameCornerRocketBattleEndText, "Dang!"), and PrintEndBattleText
|
|
||||||
-- prints it ON the battle screen between TrainerDefeatedText and
|
|
||||||
-- MoneyForWinningText (engine/battle/core.asm TrainerBattleVictory).
|
|
||||||
-- He is a text_asm trainer with no def_trainers header, so there is no
|
|
||||||
-- header.won for engageTrainer to find and the line has to be handed
|
|
||||||
-- over here or it never shows at all (#862).
|
|
||||||
ow:engageTrainer(npc, function()
|
ow:engageTrainer(npc, function()
|
||||||
if not ow:trainerDefeated(npc) then
|
if not ow:trainerDefeated(npc) then
|
||||||
done()
|
done()
|
||||||
@@ -553,44 +527,19 @@ M.GAME_CORNER = {
|
|||||||
game.data.text._GameCornerRocketAfterBattleText
|
game.data.text._GameCornerRocketAfterBattleText
|
||||||
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
|
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
|
||||||
function()
|
function()
|
||||||
-- #198/#862: GameCornerRocketBattleScript (scripts/GameCorner.asm)
|
-- #198: GameCornerRocketExitScript (scripts/GameCorner.asm)
|
||||||
-- picks the exit walk from where the player is standing, because
|
-- ApplyMovementData walks the grunt one tile UP into the poster
|
||||||
-- the grunt on (9,5) has to get past him: wYCoord == 6 (talked to
|
-- (the hideout's secret entrance at 9,4) before HideObject, so
|
||||||
-- from the south) or wXCoord == 8 (from the west) leaves the row
|
-- he leaves the floor rather than popping out of existence on
|
||||||
-- clear and takes GameCornerMovement_Rocket_WalkDirect, five steps
|
-- (9,5). scriptMove locks player input (#scriptMoves>0) and
|
||||||
-- RIGHT; otherwise the player is east of him on (10,5) and
|
-- ignores collision, so we despawn + unfreeze (done) only once
|
||||||
-- GameCornerMovement_Rocket_WalkAroundPlayer steps DOWN, right, UP
|
-- the step lands.
|
||||||
-- and right again to go AROUND him. pokeyellow's copy of the
|
ow:scriptMove(npc, "up", 1, function()
|
||||||
-- around-path takes one extra RIGHT on the lower row before coming
|
hideRocket()
|
||||||
-- back up (it also has to clear Pikachu); both versions end on
|
done()
|
||||||
-- (15,5). He never steps UP: (9,4) is the poster wall, which is
|
end)
|
||||||
-- where the old single UP step sent him.
|
|
||||||
local px = ow.player and ow.player.cellX
|
|
||||||
local py = ow.player and ow.player.cellY
|
|
||||||
local path
|
|
||||||
if py == 6 or px == 8 then
|
|
||||||
path = { { "right", 5 } }
|
|
||||||
elseif require("src.core.GameVersion").isYellow() then
|
|
||||||
path = { { "down", 1 }, { "right", 3 }, { "up", 1 }, { "right", 3 } }
|
|
||||||
else
|
|
||||||
path = { { "down", 1 }, { "right", 2 }, { "up", 1 }, { "right", 4 } }
|
|
||||||
end
|
|
||||||
-- GameCornerRocketExitScript only HideObjects him once
|
|
||||||
-- BIT_SCRIPTED_NPC_MOVEMENT clears, i.e. after the last step.
|
|
||||||
-- scriptMove locks player input (#scriptMoves>0) and ignores
|
|
||||||
-- collision, so the despawn + unfreeze (done) ride the final step.
|
|
||||||
local function step(i)
|
|
||||||
if i > #path then
|
|
||||||
hideRocket()
|
|
||||||
done()
|
|
||||||
return
|
|
||||||
end
|
|
||||||
ow:scriptMove(npc, path[i][1], path[i][2],
|
|
||||||
function() step(i + 1) end)
|
|
||||||
end
|
|
||||||
step(1)
|
|
||||||
end))
|
end))
|
||||||
end, game.data.text._GameCornerRocketBattleEndText or "Dang!")
|
end)
|
||||||
end,
|
end,
|
||||||
-- GameCornerClerk1Text (scripts/GameCorner.asm): the offer, a
|
-- GameCornerClerk1Text (scripts/GameCorner.asm): the offer, a
|
||||||
-- YesNoChoice, then ¥1000 for 50 coins. Yellow drops the "1" from the
|
-- YesNoChoice, then ¥1000 for 50 coins. Yellow drops the "1" from the
|
||||||
|
|||||||
+15
-31
@@ -13,18 +13,9 @@ local function push(game, s, done)
|
|||||||
game.stack:push(TextBox.new(game, s, done))
|
game.stack:push(TextBox.new(game, s, done))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The question stays on screen under the YES/NO menu. The dojo prize
|
|
||||||
-- balls are the clearest case: FightingDojoHitmonleePokeBallText
|
|
||||||
-- (scripts/FightingDojo.asm) is `call PrintText` on a text_end string --
|
|
||||||
-- no prompt, so no WaitForTextScrollButtonPress -- immediately followed
|
|
||||||
-- by `call YesNoChoice`, and InitYesNoTextBoxParameters
|
|
||||||
-- (engine/menus/text_box.asm) puts the menu above the dialogue box
|
|
||||||
-- rather than replacing it. Ride TextBox's opts.choice, the same as
|
|
||||||
-- Commands.ask, instead of popping the box with an A press and leaving a
|
|
||||||
-- bare ChoiceBox over the overworld (#854).
|
|
||||||
local function ask(game, s, cb)
|
local function ask(game, s, cb)
|
||||||
local TextBox = require("src.render.TextBox")
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- fill the extracted text placeholders ({NUM:...}, {RAM:...}, {PLAYER})
|
-- fill the extracted text placeholders ({NUM:...}, {RAM:...}, {PLAYER})
|
||||||
@@ -164,26 +155,19 @@ local function dojoBall(species, ownBall, otherBall, askKey)
|
|||||||
push(game, "You'll have to\nbeat the master\nfirst!", done)
|
push(game, "You'll have to\nbeat the master\nfirst!", done)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- Examining a ball shows that species' POKéDEX entry first
|
ask(game, t[askKey] or ("You want\n" .. species .. "?"), function(yes)
|
||||||
-- (DisplayPokedex in FightingDojo.asm, which also marks it seen),
|
if not yes then done() return end
|
||||||
-- then the yes/no take-it prompt (#853).
|
flags["EVENT_GOT_" .. species] = true
|
||||||
local Commands = require("src.script.Commands")
|
flags.EVENT_DEFEATED_FIGHTING_DOJO = true
|
||||||
local ctx = { save = game.save, game = game, overworld = ow }
|
local Commands = require("src.script.Commands")
|
||||||
Commands.mark_seen(ctx, species)
|
local ctx = { save = game.save, game = game, overworld = ow }
|
||||||
local DexEntryMenu = require("src.ui.DexEntryMenu")
|
Commands.give_pokemon(ctx, species, 30)
|
||||||
game.stack:push(DexEntryMenu.new(game, species, function()
|
-- Hide ONLY the chosen ball; the other stays (FightingDojo.asm hides
|
||||||
ask(game, t[askKey] or ("You want\n" .. species .. "?"), function(yes)
|
-- just the picked object's index) and routes to the greedy line above
|
||||||
if not yes then done() return end
|
-- when talked to (#197).
|
||||||
flags["EVENT_GOT_" .. species] = true
|
Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall)
|
||||||
flags.EVENT_DEFEATED_FIGHTING_DOJO = true
|
push(game, ("%s got\n%s!"):format(game.save.player.name, species), done)
|
||||||
Commands.give_pokemon(ctx, species, 30)
|
end)
|
||||||
-- Hide ONLY the chosen ball; the other stays (FightingDojo.asm hides
|
|
||||||
-- just the picked object's index) and routes to the greedy line above
|
|
||||||
-- when talked to (#197).
|
|
||||||
Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall)
|
|
||||||
push(game, ("%s got\n%s!"):format(game.save.player.name, species), done)
|
|
||||||
end)
|
|
||||||
end))
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+2
-12
@@ -49,7 +49,7 @@ local function gift(opts)
|
|||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
|
if opts.pre then say(opts.pre, "", give) else give() end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -118,21 +118,11 @@ M.CINNABAR_LAB_METRONOME_ROOM = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher). The fisher's
|
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher; no pre text)
|
||||||
-- 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 = {
|
M.VIRIDIAN_CITY = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_VIRIDIANCITY_FISHER = gift({
|
TEXT_VIRIDIANCITY_FISHER = gift({
|
||||||
flag = "EVENT_GOT_TM42", item = "TM_DREAM_EATER",
|
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",
|
received = "_ViridianCityFisherReceivedTM42Text",
|
||||||
explain = "_ViridianCityFisherTM42ExplanationText",
|
explain = "_ViridianCityFisherTM42ExplanationText",
|
||||||
noRoom = "_ViridianCityFisherTM42NoRoomText",
|
noRoom = "_ViridianCityFisherTM42NoRoomText",
|
||||||
|
|||||||
@@ -12,13 +12,9 @@ local function push(game, s, done)
|
|||||||
game.stack:push(TextBox.new(game, s, done))
|
game.stack:push(TextBox.new(game, s, done))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- PrintText on a text_end string returns with the box still drawn and
|
|
||||||
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
|
|
||||||
-- engine/menus/text_box.asm); no A press clears the question first. Ride
|
|
||||||
-- TextBox's opts.choice, the same as Commands.ask (#854).
|
|
||||||
local function ask(game, s, cb)
|
local function ask(game, s, cb)
|
||||||
local TextBox = require("src.render.TextBox")
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|||||||
@@ -18,16 +18,6 @@
|
|||||||
-- script). Leaders are not def_trainers entries, so engageTrainer has
|
-- script). Leaders are not def_trainers entries, so engageTrainer has
|
||||||
-- no header.won -- checkVictoryRewards shows this chain instead of a
|
-- no header.won -- checkVictoryRewards shows this chain instead of a
|
||||||
-- synthetic "received badge/TM" stub.
|
-- synthetic "received badge/TM" stub.
|
||||||
--
|
|
||||||
-- Gym entries split the TM hand-over out of `dialogue`, mirroring the
|
|
||||||
-- originals' GiveItem check (`call GiveItem` / `jr nc, .BagFull`):
|
|
||||||
-- `tmPre` is the ReceiveTM script's lead-in (badge info / "Wait! Take
|
|
||||||
-- this!"), shown at the victory and again when a beaten leader retries
|
|
||||||
-- the hand-over; `tmDialogue` shows only when the TM actually goes in
|
|
||||||
-- the bag; `noRoom` is the "make room" line shown instead when the bag
|
|
||||||
-- is full; `gotFlag` (pokered's EVENT_GOT_TM*) is set only on a
|
|
||||||
-- successful give, which is what makes the leader's talk script retry
|
|
||||||
-- later (gyms.lua).
|
|
||||||
|
|
||||||
local function range(prefix, first, last)
|
local function range(prefix, first, last)
|
||||||
local t = {}
|
local t = {}
|
||||||
@@ -43,8 +33,6 @@ return {
|
|||||||
-- escort NPC and the first Route 22 rival stay gone after the badge.
|
-- escort NPC and the first Route 22 rival stay gone after the badge.
|
||||||
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
|
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
|
||||||
item = "TM_BIDE",
|
item = "TM_BIDE",
|
||||||
gotFlag = "EVENT_GOT_TM34",
|
|
||||||
noRoom = "_PewterGymTM34NoRoomText",
|
|
||||||
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" },
|
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" },
|
||||||
hide = {
|
hide = {
|
||||||
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
|
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
|
||||||
@@ -53,99 +41,69 @@ return {
|
|||||||
dialogue = {
|
dialogue = {
|
||||||
"_PewterGymBrockReceivedBoulderBadgeText",
|
"_PewterGymBrockReceivedBoulderBadgeText",
|
||||||
"_PewterGymBrockBoulderBadgeInfoText",
|
"_PewterGymBrockBoulderBadgeInfoText",
|
||||||
},
|
"_PewterGymBrockWaitTakeThisText",
|
||||||
tmPre = { "_PewterGymBrockWaitTakeThisText" },
|
|
||||||
tmDialogue = {
|
|
||||||
"_PewterGymReceivedTM34Text",
|
"_PewterGymReceivedTM34Text",
|
||||||
"_TM34ExplanationText",
|
"_TM34ExplanationText",
|
||||||
} },
|
} },
|
||||||
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
|
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
|
||||||
item = "TM_BUBBLEBEAM",
|
item = "TM_BUBBLEBEAM",
|
||||||
gotFlag = "EVENT_GOT_TM11",
|
|
||||||
noRoom = "_CeruleanGymMistyTM11NoRoomText",
|
|
||||||
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
|
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_CeruleanGymMistyReceivedCascadeBadgeText",
|
"_CeruleanGymMistyReceivedCascadeBadgeText",
|
||||||
},
|
"_CeruleanGymMistyCascadeBadgeInfoText",
|
||||||
tmPre = { "_CeruleanGymMistyCascadeBadgeInfoText" },
|
|
||||||
tmDialogue = {
|
|
||||||
"_CeruleanGymMistyReceivedTM11Text",
|
"_CeruleanGymMistyReceivedTM11Text",
|
||||||
} },
|
} },
|
||||||
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
|
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
|
||||||
item = "TM_THUNDERBOLT",
|
item = "TM_THUNDERBOLT",
|
||||||
gotFlag = "EVENT_GOT_TM24",
|
|
||||||
noRoom = "_VermilionGymLTSurgeTM24NoRoomText",
|
|
||||||
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
|
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_VermilionGymLTSurgeReceivedThunderBadgeText",
|
"_VermilionGymLTSurgeReceivedThunderBadgeText",
|
||||||
},
|
"_VermilionGymLTSurgeThunderBadgeInfoText",
|
||||||
tmPre = { "_VermilionGymLTSurgeThunderBadgeInfoText" },
|
|
||||||
tmDialogue = {
|
|
||||||
"_VermilionGymLTSurgeReceivedTM24Text",
|
"_VermilionGymLTSurgeReceivedTM24Text",
|
||||||
"_TM24ExplanationText",
|
"_TM24ExplanationText",
|
||||||
} },
|
} },
|
||||||
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
|
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
|
||||||
item = "TM_MEGA_DRAIN",
|
item = "TM_MEGA_DRAIN",
|
||||||
gotFlag = "EVENT_GOT_TM21",
|
|
||||||
noRoom = "_CeladonGymTM21NoRoomText",
|
|
||||||
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
|
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_CeladonGymErikaReceivedRainbowBadgeText",
|
"_CeladonGymErikaReceivedRainbowBadgeText",
|
||||||
},
|
"_CeladonGymRainbowBadgeInfoText",
|
||||||
tmPre = { "_CeladonGymRainbowBadgeInfoText" },
|
|
||||||
tmDialogue = {
|
|
||||||
"_CeladonGymReceivedTM21Text",
|
"_CeladonGymReceivedTM21Text",
|
||||||
"_TM21ExplanationText",
|
"_TM21ExplanationText",
|
||||||
} },
|
} },
|
||||||
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
|
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
|
||||||
item = "TM_TOXIC",
|
item = "TM_TOXIC",
|
||||||
gotFlag = "EVENT_GOT_TM06",
|
|
||||||
noRoom = "_FuchsiaGymKogaTM06NoRoomText",
|
|
||||||
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
|
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_FuchsiaGymKogaReceivedSoulBadgeText",
|
"_FuchsiaGymKogaReceivedSoulBadgeText",
|
||||||
},
|
"_FuchsiaGymKogaSoulBadgeInfoText",
|
||||||
tmPre = { "_FuchsiaGymKogaSoulBadgeInfoText" },
|
|
||||||
tmDialogue = {
|
|
||||||
"_FuchsiaGymKogaReceivedTM06Text",
|
"_FuchsiaGymKogaReceivedTM06Text",
|
||||||
"_FuchsiaGymKogaTM06ExplanationText",
|
"_FuchsiaGymKogaTM06ExplanationText",
|
||||||
} },
|
} },
|
||||||
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
|
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
|
||||||
item = "TM_PSYWAVE",
|
item = "TM_PSYWAVE",
|
||||||
gotFlag = "EVENT_GOT_TM46",
|
|
||||||
noRoom = "_SaffronGymSabrinaTM46NoRoomText",
|
|
||||||
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
|
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_SaffronGymSabrinaReceivedMarshBadgeText",
|
"_SaffronGymSabrinaReceivedMarshBadgeText",
|
||||||
},
|
"_SaffronGymSabrinaMarshBadgeInfoText",
|
||||||
tmPre = { "_SaffronGymSabrinaMarshBadgeInfoText" },
|
|
||||||
tmDialogue = {
|
|
||||||
"_SaffronGymSabrinaReceivedTM46Text",
|
"_SaffronGymSabrinaReceivedTM46Text",
|
||||||
"_TM46ExplanationText",
|
"_TM46ExplanationText",
|
||||||
} },
|
} },
|
||||||
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
|
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
|
||||||
item = "TM_FIRE_BLAST",
|
item = "TM_FIRE_BLAST",
|
||||||
gotFlag = "EVENT_GOT_TM38",
|
|
||||||
noRoom = "_CinnabarGymBlaineTM38NoRoomText",
|
|
||||||
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
|
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
|
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
|
||||||
},
|
"_CinnabarGymBlaineVolcanoBadgeInfoText",
|
||||||
tmPre = { "_CinnabarGymBlaineVolcanoBadgeInfoText" },
|
|
||||||
tmDialogue = {
|
|
||||||
"_CinnabarGymBlaineReceivedTM38Text",
|
"_CinnabarGymBlaineReceivedTM38Text",
|
||||||
"_CinnabarGymBlaineTM38ExplanationText",
|
"_CinnabarGymBlaineTM38ExplanationText",
|
||||||
} },
|
} },
|
||||||
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
|
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
|
||||||
item = "TM_FISSURE",
|
item = "TM_FISSURE",
|
||||||
gotFlag = "EVENT_GOT_TM27",
|
|
||||||
noRoom = "_ViridianGymGiovanniTM27NoRoomText",
|
|
||||||
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
|
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
|
||||||
dialogue = {
|
dialogue = {
|
||||||
"_ViridianGymGiovanniReceivedEarthBadgeText",
|
"_ViridianGymGiovanniReceivedEarthBadgeText",
|
||||||
},
|
"_ViridianGymGiovanniEarthBadgeInfoText",
|
||||||
tmPre = { "_ViridianGymGiovanniEarthBadgeInfoText" },
|
|
||||||
tmDialogue = {
|
|
||||||
"_ViridianGymGiovanniReceivedTM27Text",
|
"_ViridianGymGiovanniReceivedTM27Text",
|
||||||
"_ViridianGymGiovanniTM27ExplanationText",
|
"_ViridianGymGiovanniTM27ExplanationText",
|
||||||
} },
|
} },
|
||||||
|
|||||||
@@ -62,15 +62,10 @@ M.MT_MOON_B2F = {
|
|||||||
{ "walk_npc", 6, { "left", "left", "left", "left", "left" } },
|
{ "walk_npc", 6, { "left", "left", "left", "left", "left" } },
|
||||||
{ "face_object", 6, "left" },
|
{ "face_object", 6, "left" },
|
||||||
{ "show_text", "_MtMoonJessieJamesText2" },
|
{ "show_text", "_MtMoonJessieJamesText2" },
|
||||||
-- MtMoonB2FScript12 arms _MtMoonJessieJamesText3 with
|
|
||||||
-- SaveEndBattleTextPointers before it sets wCurOpponent, so
|
|
||||||
-- TrainerBattleVictory prints it on the battle screen as "ROCKET: A
|
|
||||||
-- brat beat us?" between TrainerDefeatedText and MoneyForWinningText.
|
|
||||||
-- Its one-word first line only reads right behind that tag (#866).
|
|
||||||
{ "save_end_battle_text", "_MtMoonJessieJamesText3" },
|
|
||||||
{ "start_battle", "trainer", "OPP_ROCKET", 42 },
|
{ "start_battle", "trainer", "OPP_ROCKET", 42 },
|
||||||
{ "check_battle_result", "win" },
|
{ "check_battle_result", "win" },
|
||||||
{ "jump_if_false", "end" },
|
{ "jump_if_false", "end" },
|
||||||
|
{ "show_text", "_MtMoonJessieJamesText3" },
|
||||||
{ "show_text", "_MtMoonJessieJamesText4" },
|
{ "show_text", "_MtMoonJessieJamesText4" },
|
||||||
{ "stop_music" },
|
{ "stop_music" },
|
||||||
{ "play_music", "Music_MeetJessieJames" },
|
{ "play_music", "Music_MeetJessieJames" },
|
||||||
@@ -90,8 +85,7 @@ M.MT_MOON_B2F = {
|
|||||||
-- motto plays from off-screen FIRST, then the duo pops in at (25,10) /
|
-- motto plays from off-screen FIRST, then the duo pops in at (25,10) /
|
||||||
-- (24,10) and whichever of them shares the player's column ($18=24 or
|
-- (24,10) and whichever of them shares the player's column ($18=24 or
|
||||||
-- $19=25, EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT) walks the three
|
-- $19=25, EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT) walks the three
|
||||||
-- tiles down to loom over the player while the other walks four and ends
|
-- tiles down to loom over the player while the other steps one. A loss
|
||||||
-- up beside him. A loss
|
|
||||||
-- re-hides them (RocketHideoutB4FResetScripts via EVENT_6A0), so the
|
-- re-hides them (RocketHideoutB4FResetScripts via EVENT_6A0), so the
|
||||||
-- trigger re-arms clean.
|
-- trigger re-arms clean.
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
@@ -112,7 +106,7 @@ M.ROCKET_HIDEOUT_B4F = {
|
|||||||
if f.EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES then return false end
|
if f.EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES then return false end
|
||||||
-- ON_LEFT: player under James's column (25); movement data pairs
|
-- ON_LEFT: player under James's column (25); movement data pairs
|
||||||
-- RocketHideoutB4FJessieJamesMovementData_45605/45606 swap so the
|
-- RocketHideoutB4FJessieJamesMovementData_45605/45606 swap so the
|
||||||
-- column-mate walks 3, the other 4.
|
-- column-mate walks 3, the other 1.
|
||||||
local onLeft = (x == 25)
|
local onLeft = (x == 25)
|
||||||
ow.runner:run({
|
ow.runner:run({
|
||||||
{ "stop_music" },
|
{ "stop_music" },
|
||||||
@@ -122,30 +116,16 @@ M.ROCKET_HIDEOUT_B4F = {
|
|||||||
{ "emote", "player", "shock", 30 },
|
{ "emote", "player", "shock", 30 },
|
||||||
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
|
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
|
||||||
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
|
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
|
||||||
-- James (object 2) then Jessie (object 3), Script4..Script9 order.
|
-- James (object 2) then Jessie (object 3), Script4..Script9 order
|
||||||
-- RocketHideoutB4FJessieJamesMovementData_45605 is a lone $4 that FALLS
|
{ "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } },
|
||||||
-- THROUGH into _45606 ($4 $4 $4 $ff), so MoveSprite_ (home/pathfinding.asm)
|
|
||||||
-- reads _45605 as FOUR steps and _45606 as three; $4 is DOWN in Yellow's
|
|
||||||
-- Func_5288 lookup (engine/overworld/movement.asm), which walks with no
|
|
||||||
-- collision test. From (25,10)/(24,10) against a player on y=14 the
|
|
||||||
-- column-mate stops three down, right above him, and the other walks the
|
|
||||||
-- full four to stand alongside -- which is what the facings below assume.
|
|
||||||
-- Reading _45605 as a single step stranded whoever was off-column three
|
|
||||||
-- tiles away, so James never reached the player (#865).
|
|
||||||
{ "walk_npc", 2, onLeft and { "down", "down", "down" }
|
|
||||||
or { "down", "down", "down", "down" } },
|
|
||||||
{ "face_object", 2, onLeft and "down" or "left" },
|
{ "face_object", 2, onLeft and "down" or "left" },
|
||||||
{ "walk_npc", 3, onLeft and { "down", "down", "down", "down" }
|
{ "walk_npc", 3, onLeft and { "down" } or { "down", "down", "down" } },
|
||||||
or { "down", "down", "down" } },
|
|
||||||
{ "face_object", 3, onLeft and "right" or "down" },
|
{ "face_object", 3, onLeft and "right" or "down" },
|
||||||
{ "show_text", "_RocketHideoutJessieJamesText2" },
|
{ "show_text", "_RocketHideoutJessieJamesText2" },
|
||||||
-- RocketHideoutB4FScript10 saves _RocketHideoutJessieJamesText3 as the
|
|
||||||
-- end-battle text, so it prints as "ROCKET: Such a dreadful twerp!" on
|
|
||||||
-- the battle screen ahead of MoneyForWinningText (#866).
|
|
||||||
{ "save_end_battle_text", "_RocketHideoutJessieJamesText3" },
|
|
||||||
{ "start_battle", "trainer", "OPP_ROCKET", 43 },
|
{ "start_battle", "trainer", "OPP_ROCKET", 43 },
|
||||||
{ "check_battle_result", "win" },
|
{ "check_battle_result", "win" },
|
||||||
{ "jump_if_false", "lost" },
|
{ "jump_if_false", "lost" },
|
||||||
|
{ "show_text", "_RocketHideoutJessieJamesText3" },
|
||||||
{ "show_text", "_RocketHideoutJessieJamesText4" },
|
{ "show_text", "_RocketHideoutJessieJamesText4" },
|
||||||
{ "stop_music" },
|
{ "stop_music" },
|
||||||
{ "play_music", "Music_MeetJessieJames" },
|
{ "play_music", "Music_MeetJessieJames" },
|
||||||
@@ -195,27 +175,16 @@ M.POKEMON_TOWER_7F = {
|
|||||||
{ "show_text", "_PokemonTowerJessieJamesText1" },
|
{ "show_text", "_PokemonTowerJessieJamesText1" },
|
||||||
{ "face_player_dir", "up" },
|
{ "face_player_dir", "up" },
|
||||||
{ "emote", "player", "shock", 30 },
|
{ "emote", "player", "shock", 30 },
|
||||||
-- Jessie (object 1) then James (object 2), Script1..Script6 order.
|
-- Jessie (object 1) then James (object 2), Script1..Script6 order
|
||||||
-- Same fall-through blob as the hideout: PokemonTower7FMovementData_60d7a
|
{ "walk_npc", 1, onLeft and { "down" } or { "down", "down", "down" } },
|
||||||
-- is a lone $4 running into _60d7b ($4 $4 $4 $FF), so _60d7a is FOUR
|
|
||||||
-- steps and _60d7b is three. From (10,8)/(11,8) against a player on
|
|
||||||
-- y=12 the column-mate halts one tile above him and the other closes the
|
|
||||||
-- full four to his side; the single-step reading is why James only
|
|
||||||
-- "moved a bit" here (#865).
|
|
||||||
{ "walk_npc", 1, onLeft and { "down", "down", "down", "down" }
|
|
||||||
or { "down", "down", "down" } },
|
|
||||||
{ "face_object", 1, onLeft and "right" or "down" },
|
{ "face_object", 1, onLeft and "right" or "down" },
|
||||||
{ "walk_npc", 2, onLeft and { "down", "down", "down" }
|
{ "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } },
|
||||||
or { "down", "down", "down", "down" } },
|
|
||||||
{ "face_object", 2, onLeft and "down" or "left" },
|
{ "face_object", 2, onLeft and "down" or "left" },
|
||||||
{ "show_text", "_PokemonTowerJessieJamesText2" },
|
{ "show_text", "_PokemonTowerJessieJamesText2" },
|
||||||
-- PokemonTower7FScript7 saves _PokemonTowerJessieJamesText3 as the
|
|
||||||
-- end-battle text: "ROCKET: You will regret this!" on the battle screen,
|
|
||||||
-- before the prize money (#866).
|
|
||||||
{ "save_end_battle_text", "_PokemonTowerJessieJamesText3" },
|
|
||||||
{ "start_battle", "trainer", "OPP_ROCKET", 44 },
|
{ "start_battle", "trainer", "OPP_ROCKET", 44 },
|
||||||
{ "check_battle_result", "win" },
|
{ "check_battle_result", "win" },
|
||||||
{ "jump_if_false", "end" },
|
{ "jump_if_false", "end" },
|
||||||
|
{ "show_text", "_PokemonTowerJessieJamesText3" },
|
||||||
{ "show_text", "_PokemonTowerJessieJamesText4" },
|
{ "show_text", "_PokemonTowerJessieJamesText4" },
|
||||||
{ "stop_music" },
|
{ "stop_music" },
|
||||||
{ "play_music", "Music_MeetJessieJames" },
|
{ "play_music", "Music_MeetJessieJames" },
|
||||||
@@ -285,12 +254,10 @@ M.SILPH_CO_11F = {
|
|||||||
{ "walk_npc", 6, jessieDirs },
|
{ "walk_npc", 6, jessieDirs },
|
||||||
{ "face_object", 6, jessieFace },
|
{ "face_object", 6, jessieFace },
|
||||||
{ "show_text", "_SilphCoJessieJamesText2" },
|
{ "show_text", "_SilphCoJessieJamesText2" },
|
||||||
-- SilphCo11FScript11 saves _SilphCoJessieJamesText3 (SilphCo11FText_624c2)
|
|
||||||
-- as the end-battle text: "ROCKET: Like always..." before the money (#866).
|
|
||||||
{ "save_end_battle_text", "_SilphCoJessieJamesText3" },
|
|
||||||
{ "start_battle", "trainer", "OPP_ROCKET", 45 },
|
{ "start_battle", "trainer", "OPP_ROCKET", 45 },
|
||||||
{ "check_battle_result", "win" },
|
{ "check_battle_result", "win" },
|
||||||
{ "jump_if_false", "end" },
|
{ "jump_if_false", "end" },
|
||||||
|
{ "show_text", "_SilphCoJessieJamesText3" },
|
||||||
{ "show_text", "_SilphCoJessieJamesText4" },
|
{ "show_text", "_SilphCoJessieJamesText4" },
|
||||||
{ "stop_music" },
|
{ "stop_music" },
|
||||||
{ "play_music", "Music_MeetJessieJames" },
|
{ "play_music", "Music_MeetJessieJames" },
|
||||||
|
|||||||
@@ -234,12 +234,8 @@ What was ported from pokered's engine code and where it came from.
|
|||||||
pre-battle text and engages the leader battle (badge/TM via
|
pre-battle text and engages the leader battle (badge/TM via
|
||||||
data/scripts/victories.lua); post-badge talk prints the leader's
|
data/scripts/victories.lua); post-badge talk prints the leader's
|
||||||
post-battle advice text (Misty's is her TM11 explanation). The
|
post-battle advice text (Misty's is her TM11 explanation). The
|
||||||
originals' middle branch (beaten but TM not handed over,
|
originals' middle branch (beaten but TM not handed over) is
|
||||||
CheckEventReuseA EVENT_GOT_TM*) is ported too: the victory's GiveItem
|
unreachable since the TM is granted with the victory. Giovanni's
|
||||||
goes through the bag's capacity check, a full bag shows the leader's
|
|
||||||
"make room" text instead of the received lines and leaves
|
|
||||||
EVENT_GOT_TM* unset, and talking to the leader re-runs the ReceiveTM
|
|
||||||
script until the TM goes in (#797). Giovanni's
|
|
||||||
farewell (`ViridianGymGiovanniText` .afterBeat) hides him inside a
|
farewell (`ViridianGymGiovanniText` .afterBeat) hides him inside a
|
||||||
fade-to-black/fade-in Transition matching ViridianGym.asm's
|
fade-to-black/fade-in Transition matching ViridianGym.asm's
|
||||||
GBFadeOutToBlack → HideObject → GBFadeInFromBlack, persisted
|
GBFadeOutToBlack → HideObject → GBFadeInFromBlack, persisted
|
||||||
|
|||||||
+5
-7
@@ -195,13 +195,11 @@ through `src/import/SaveFileIO.lua`, which sits on top of
|
|||||||
(checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active
|
(checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active
|
||||||
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
|
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
|
||||||
`rawImport`, so this is a zero-filled template export, which is valid), and
|
`rawImport`, so this is a zero-filled template export, which is valid), and
|
||||||
writes `exports/<version>/gen1recomp-<version>-<slotId>.sav` under the same
|
writes `exports/<version>/gen1recomp-<version>-<slotId>.sav` in the save
|
||||||
root `persistFs` writes slots to: the portable game folder when `portable.txt`
|
directory (`exports/` and `exports/<version>/` are created as needed). On
|
||||||
marks the install, otherwise the save directory (`exports/` and
|
desktop it returns the absolute path (`love.filesystem.getSaveDirectory()`),
|
||||||
`exports/<version>/` are created as needed; #752). On desktop it returns the
|
which the notice line shows with an "Open folder" affordance
|
||||||
absolute path (`SaveData.portableBaseDir()` when portable, else
|
(`love.system.openURL("file://" .. dir)`).
|
||||||
`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
|
On Android the bytes are also staged as `pending_export.sav` and
|
||||||
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
|
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
|
||||||
player can save to Downloads / Drive / etc.; on return `export_done.flag`
|
player can save to Downloads / Drive / etc.; on return `export_done.flag`
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
# Linux arm64 (aarch64) AppImage
|
|
||||||
|
|
||||||
Releases ship `gen1recomp-<version>-linux-arm64.AppImage` alongside the
|
|
||||||
existing x86_64 `gen1recomp-<version>-linux.zip`. It targets 64-bit ARM
|
|
||||||
desktop Linux: Raspberry Pi 4/5 running Raspberry Pi OS, Armbian and other
|
|
||||||
SBC distros, arm64 VMs on Apple Silicon, Ampere/Graviton desktops, and the
|
|
||||||
aarch64 handhelds that run a full distro.
|
|
||||||
|
|
||||||
> The Anbernic RG34XXSP has its own PortMaster-style pack
|
|
||||||
> (`gen1recomp-*-rg34xxsp-stockos64-mod.zip`, see
|
|
||||||
> [anbernic-rg34xxsp.md](anbernic-rg34xxsp.md)). That one bundles PortMaster's
|
|
||||||
> LÖVE runtime and expects the device's own SDL; this AppImage is the generic
|
|
||||||
> desktop-Linux artifact and shares nothing with it but the `game.love`.
|
|
||||||
|
|
||||||
## For players
|
|
||||||
|
|
||||||
```sh
|
|
||||||
chmod +x gen1recomp-*-linux-arm64.AppImage
|
|
||||||
./gen1recomp-*-linux-arm64.AppImage
|
|
||||||
```
|
|
||||||
|
|
||||||
Then use **Import ROM** in the launcher to point it at your own legal Red /
|
|
||||||
Blue / Yellow cartridge dump, exactly as on every other platform.
|
|
||||||
|
|
||||||
If your system has no FUSE (`dlopen(): error loading libfuse.so.2`), either
|
|
||||||
install it (`sudo apt install libfuse2`) or run without it:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
./gen1recomp-*-linux-arm64.AppImage --appimage-extract-and-run
|
|
||||||
```
|
|
||||||
|
|
||||||
### What the host has to provide
|
|
||||||
|
|
||||||
Very little, and this is enforced by an assertion in the build rather than by
|
|
||||||
good intentions. The only libraries the AppImage requires at startup are:
|
|
||||||
|
|
||||||
```
|
|
||||||
glibc 2.29+ libstdc++ libfreetype6 zlib
|
|
||||||
```
|
|
||||||
|
|
||||||
Everything else — OpenGL/Mesa, X11, Wayland, KMSDRM, ALSA, PulseAudio — is
|
|
||||||
**dlopened**, so it is used when present and skipped when absent. That means
|
|
||||||
one image runs on a full desktop, on a Wayland-only session, on a
|
|
||||||
KMSDRM-only handheld with no X server, and on a box with ALSA but no
|
|
||||||
PulseAudio, without a different build for each.
|
|
||||||
|
|
||||||
That property does not come for free from Debian's packages, and getting it
|
|
||||||
is most of what the build below is doing; see
|
|
||||||
[Why five libraries are built from source](#why-five-libraries-are-built-from-source).
|
|
||||||
|
|
||||||
## For builders
|
|
||||||
|
|
||||||
```sh
|
|
||||||
scripts/build_linux_arm64.sh --version 0.1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
Output:
|
|
||||||
|
|
||||||
```
|
|
||||||
dist/linux-arm64/gen1recomp-<version>-linux-arm64.AppImage
|
|
||||||
dist/linux-arm64/gen1recomp-<version>-linux-arm64.AppImage.sha256
|
|
||||||
```
|
|
||||||
|
|
||||||
Useful flags: `--game-love PATH` reuses an already-packed payload (CI does
|
|
||||||
this so every platform ships identical bytes), `--rebuild-image` forces the
|
|
||||||
builder container to rebuild, `--clean-cache` throws away the pinned
|
|
||||||
downloads and the compiled LÖVE prefix.
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
|
|
||||||
An **aarch64 host** with **docker or podman**. A Raspberry Pi 5 is the
|
|
||||||
reference machine (a cold build takes about 10 minutes on one — six libraries
|
|
||||||
plus the engine; rebuilds reuse the cached prefix and take seconds). Apple Silicon with Docker
|
|
||||||
Desktop and GitHub's `ubuntu-24.04-arm` runner both work too.
|
|
||||||
|
|
||||||
The script refuses to run on x86_64 rather than falling back to qemu-user
|
|
||||||
emulation: that path takes hours and has produced miscompiled LuaJIT.
|
|
||||||
|
|
||||||
### Why this is not just another `scripts/build.sh` target
|
|
||||||
|
|
||||||
`scripts/build.sh linux` downloads LÖVE's official `love-11.5-x86_64.AppImage`,
|
|
||||||
unpacks its squashfs, drops `game.love` in, and glues it back together. That
|
|
||||||
trick is not available here — **LÖVE publishes no aarch64 binary at all.** The
|
|
||||||
11.5 release has win32, win64, macOS, Android, iOS and one x86_64 AppImage,
|
|
||||||
and that is the entire list.
|
|
||||||
|
|
||||||
So this build compiles LÖVE 11.5 from the official `linux-src` tarball and
|
|
||||||
assembles the AppImage from scratch. Every pinned input — the LÖVE source, the
|
|
||||||
five libraries built alongside it, and the AppImage type-2 runtime — is
|
|
||||||
SHA-256 verified on the host before the container ever sees it, and the
|
|
||||||
container itself runs with no network access.
|
|
||||||
|
|
||||||
### Why the build happens in a Debian bullseye container
|
|
||||||
|
|
||||||
glibc is backward compatible but not forward compatible: a binary linked
|
|
||||||
against glibc 2.41 will not start on a system with 2.31, and there is no way
|
|
||||||
to fix that after the fact. Compiling on the oldest base we support is
|
|
||||||
therefore the only thing that makes one artifact work everywhere.
|
|
||||||
|
|
||||||
Bullseye (glibc 2.31) is that base. The resulting binaries actually come out
|
|
||||||
needing only **glibc 2.29** and **GLIBCXX_3.4.21**, so the AppImage covers
|
|
||||||
everything from Ubuntu 20.04 and Raspberry Pi OS bullseye through current
|
|
||||||
trixie.
|
|
||||||
|
|
||||||
This is a statement about the *compile environment*, not about where the
|
|
||||||
artifact runs — building on your own newer distro would silently raise that
|
|
||||||
floor and strand every user on an older one, with no symptom until they
|
|
||||||
download it. CI enforces the floor: `linux-arm64-build` fails if the highest
|
|
||||||
required glibc symbol version climbs above 2.31.
|
|
||||||
|
|
||||||
### Why five libraries are built from source
|
|
||||||
|
|
||||||
SDL2, OpenAL, libtheora, libogg/libvorbis and libmpg123 are compiled rather
|
|
||||||
than installed from bullseye. In every case the reason is *correctness*, not
|
|
||||||
a newer version number — Debian builds these for a system where every
|
|
||||||
dependency is installed and co-versioned, which is the opposite of an
|
|
||||||
AppImage's situation. Each one broke the build in a different way, and all
|
|
||||||
three failure modes are now assertions that fail the build instead of
|
|
||||||
shipping.
|
|
||||||
|
|
||||||
**1. Hard-linked backends (SDL2, OpenAL).** Debian's `libSDL2` lists
|
|
||||||
`libpulse`, `libasound`, `libX11` and `libwayland-client` as `DT_NEEDED` —
|
|
||||||
resolved by the loader at startup, not dlopened. An AppImage bundling it
|
|
||||||
refuses to start unless the host has *all four*. It appeared to work in
|
|
||||||
testing only because a desktop Pi has all four; a headless CI runner is what
|
|
||||||
exposed it. Debian's OpenAL does the same via `libsndio`, which itself
|
|
||||||
hard-links `libasound`. Built from source with `--enable-*-shared` and
|
|
||||||
`ALSOFT_DLOPEN`, both dlopen their backends instead.
|
|
||||||
|
|
||||||
**2. A stray link (libtheora).** Debian's `libtheoradec.so.1` is linked
|
|
||||||
against `libcairo.so.2` — a packaging artifact, since a video decoder has no
|
|
||||||
business drawing vector graphics — and cairo drags in X11, xcb, fontconfig
|
|
||||||
and freetype. `--disable-examples` produces a `libtheoradec` needing only
|
|
||||||
`libogg`.
|
|
||||||
|
|
||||||
**3. SONAME collision with the host (ogg, vorbis, mpg123).** The subtle one.
|
|
||||||
OpenAL dlopens ALSA, ALSA's config loads its PulseAudio hook plugin, and that
|
|
||||||
plugin pulls the *host's* `libsndfile` into our process. `libsndfile` links
|
|
||||||
`libogg`, `libvorbis` and `libmpg123` — the same three we bundle. The loader
|
|
||||||
resolves a SONAME exactly once per process, so the host's `libsndfile` binds
|
|
||||||
to *our* copies:
|
|
||||||
|
|
||||||
```
|
|
||||||
openal -> libasound -> libasound_module_conf_pulse -> libsndfile (host, new)
|
|
||||||
`-> mpg123_info2 -> libmpg123 (ours, bullseye 1.26)
|
|
||||||
```
|
|
||||||
|
|
||||||
`mpg123_info2` arrived in mpg123 1.32, so the plugin failed to relocate, ALSA
|
|
||||||
config collapsed, and the game ran with **no audio device at all**. Not
|
|
||||||
bundling these instead would make `libogg`/`libvorbis`/`libmpg123` mandatory
|
|
||||||
host packages; building them current means our copies *satisfy* the host's
|
|
||||||
`libsndfile` rather than starving it.
|
|
||||||
|
|
||||||
The same collision is why the font stack — freetype, fontconfig, libpng,
|
|
||||||
brotli, zlib — is left to the host entirely. Bundling a bullseye freetype
|
|
||||||
2.10.4 meant a host `libcairo` could not find `FT_Get_Transform` (added in
|
|
||||||
2.11) and the game died at startup. Leaving the whole stack to the host keeps
|
|
||||||
it self-consistent, while `liblove` — compiled against 2.10.4 — only ever
|
|
||||||
asks for symbols every supported host already has.
|
|
||||||
|
|
||||||
The general rule this all reduces to: **never bundle a library the host's own
|
|
||||||
stack may also load, unless yours is at least as new as theirs.**
|
|
||||||
|
|
||||||
### CI
|
|
||||||
|
|
||||||
Three jobs, path-gated on `scripts/build_linux_arm64.sh`,
|
|
||||||
`scripts/linux-arm64/`, `scripts/pack_love.sh` and this document:
|
|
||||||
|
|
||||||
- **`linux-arm64-selftest`** (`ubuntu-latest`, x86_64) — offline gate. Checks
|
|
||||||
the pins are real digests on a dated tag rather than the moving
|
|
||||||
`continuous` one, that the Dockerfile still builds on bullseye, that the
|
|
||||||
exclude list still classifies known sonames correctly, that AppRun still
|
|
||||||
launches `game.love` with `--fused`, and that the host-arch guard actually
|
|
||||||
fires. Needs no container and no arm64 machine.
|
|
||||||
- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then extracts
|
|
||||||
the artifact and asserts the layout, that every bundled object resolves
|
|
||||||
under AppRun's `LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31.
|
|
||||||
Uploads the AppImage for 7 days.
|
|
||||||
- **release** — `linux-arm64` runs on `ubuntu-24.04-arm`, reuses the shared
|
|
||||||
`game.love` from the `love-payload` job, and the AppImage is staged and
|
|
||||||
published like every other release asset.
|
|
||||||
|
|
||||||
Unlike the Switch job, none of this needs secrets or self-hosted hardware, so
|
|
||||||
it runs on fork PRs too.
|
|
||||||
|
|
||||||
### Updating the pins
|
|
||||||
|
|
||||||
Both pins live in `scripts/linux-arm64/common.sh`:
|
|
||||||
|
|
||||||
- `LOVE_VERSION` / `LOVE_SRC_SHA256` — bumping any version invalidates the
|
|
||||||
cached prefix automatically (its name is keyed by every source version at
|
|
||||||
once, so a partial rebuild cannot mix vintages). Check that bullseye still
|
|
||||||
has `-dev` packages new enough for the new release; `build_appimage.sh`
|
|
||||||
asserts every optional module actually linked, because LÖVE's `configure`
|
|
||||||
exits 0 and silently drops a module when one is missing.
|
|
||||||
- `SDL2_*`, `OPENAL_*`, `THEORA_*`, `OGG_*`, `VORBIS_*`, `MPG123_*` — the
|
|
||||||
source-built libraries. Bumping these is usually safe and occasionally
|
|
||||||
necessary: `libmpg123` in particular must stay at least as new as what a
|
|
||||||
target host's `libsndfile` expects, which is asserted for `mpg123_info2`.
|
|
||||||
- `APPIMAGE_RUNTIME_TAG` / `APPIMAGE_RUNTIME_SHA256` — always a dated tag
|
|
||||||
from [AppImage/type2-runtime](https://github.com/AppImage/type2-runtime/releases).
|
|
||||||
The selftest fails the build if this ever points at `continuous`.
|
|
||||||
@@ -174,32 +174,6 @@ 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
|
the wrapper is visible during that same fixed step. The callback receives
|
||||||
`(next, game, dt)` and must call `next(game, dt)`.
|
`(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
|
`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
|
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.
|
for a tool to offer a fresh-session action before gameplay begins.
|
||||||
@@ -226,12 +200,5 @@ for driving a second physical display. This is what lets a mod lay the two
|
|||||||
passes out as two stacked Game Boy screens, or push one onto a second screen,
|
passes out as two stacked Game Boy screens, or push one onto a second screen,
|
||||||
without the engine knowing the layout.
|
without the engine knowing the layout.
|
||||||
|
|
||||||
`screen.render_visible` receives `(next, state)` while the main screen is being
|
|
||||||
composed. Return `false` to omit that state from drawing, opacity selection and
|
|
||||||
palette-zone ownership. The state remains on the stack and keeps its normal
|
|
||||||
update and input ownership, so a mod can mirror a native menu on another
|
|
||||||
display without reimplementing it. The default is `true`. Treat the wrapper as
|
|
||||||
a pure predicate: the renderer may ask it more than once per frame.
|
|
||||||
|
|
||||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||||
that reach outside their permission set.
|
that reach outside their permission set.
|
||||||
|
|||||||
+540
-23
@@ -1,26 +1,543 @@
|
|||||||
# New Features
|
# New features (deliberate additions beyond the original)
|
||||||
|
|
||||||
Features intentionally added beyond the original Pokémon Red, Blue, and Yellow games:
|
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.
|
||||||
|
|
||||||
* **Survey zoom** with connected-map rendering and configurable void fill
|
## Survey zoom
|
||||||
* **Perspective tilt mode** for an HD-2D-style overworld
|
|
||||||
* **Multiple color modes**, including original, SGB, advanced GBC, monochrome, and classic green
|
|
||||||
* **Optional GBC screen effects**, including pixel grids, shadows, glare, and transparency
|
|
||||||
* **Performance presets** and configurable FPS limits
|
|
||||||
* **Peer-to-peer link play** for trades and battles between Red, Blue, and Yellow
|
|
||||||
* **Persistent custom options** stored separately from game saves
|
|
||||||
* **Optional widescreen battle layout**
|
|
||||||
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
|
||||||
* **Translation and custom font support**
|
|
||||||
* **Built-in save editor** for parties, boxes, items, events, maps, and Pokédex data
|
|
||||||
* **Tiled map editing tools** for mod authors
|
|
||||||
* **Pokédex diploma and printer image exports**
|
|
||||||
* **Community mod browser**
|
|
||||||
* **Soft reset button combination**
|
|
||||||
* **Keyboard and controller rebinding**
|
|
||||||
* **Mod profiles** with separate mod settings and save slots
|
|
||||||
* **Improved launcher and save editor UI**, including background downloads and update checks
|
|
||||||
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
|
|
||||||
* **Custom boot branding**
|
|
||||||
|
|
||||||
Actual approximations, and missing original behavior are documented separately in `docs/known-differences.md`.
|
The mouse wheel (or `-`/`=`), the Options **ZOOM** row, or hotkey `4`
|
||||||
|
zooms the overworld between 1 pixel per world pixel (full survey) and 2×
|
||||||
|
the window fit scale (close-up), in crisp integer steps. This has no Game
|
||||||
|
Boy equivalent:
|
||||||
|
|
||||||
|
- Connected maps render their full bodies, and their NPCs appear as
|
||||||
|
visual-only "ghosts", they wander but have no sight lines, triggers,
|
||||||
|
dialogue, or collision until the map is actually entered.
|
||||||
|
- Menus, text boxes, and battles draw at normal scale on top of the
|
||||||
|
zoomed world. Zoom input is ignored while a script, menu, or battle is
|
||||||
|
active; the zoom offset is persisted as `save.options.zoom` (default
|
||||||
|
`0` = FIT) and survives New Game via `options.lua`.
|
||||||
|
- Hotkey `4` ticks through every integer zoom level (survey → FIT →
|
||||||
|
close-up → wrap). The Options row shows `FIT` / `OUTn` / `INn`.
|
||||||
|
- Beyond the border ring the void fill repeats indefinitely (see VOID
|
||||||
|
FILL below); interiors keep their own border block. Each visible map
|
||||||
|
area is colorized with its own SGB palette (the original recolored the
|
||||||
|
whole screen per map).
|
||||||
|
- Neighbor maps load two connection hops out so corner-adjacent maps
|
||||||
|
don't pop in and out, and ghost NPCs share instances with the real ones
|
||||||
|
so their wander positions persist across seamless connection crossings
|
||||||
|
(a warp or fresh map entry still respawns everything at its script
|
||||||
|
position, like the original's per-entry sprite init).
|
||||||
|
|
||||||
|
## VOID FILL
|
||||||
|
|
||||||
|
The Options **VOID FILL** row picks what paints the infinite beyond-edge
|
||||||
|
space on OVERWORLD-tileset maps during survey zoom:
|
||||||
|
|
||||||
|
- **TREES** (default): solid tree wall block `$0F`.
|
||||||
|
- **WATER**: animated water tile `$14` (same hshift cycle as on-map water).
|
||||||
|
- **BLACK**: solid black.
|
||||||
|
|
||||||
|
Other tilesets are unchanged (house/cave borders stay as authored).
|
||||||
|
Persisted as `save.options.voidFill`.
|
||||||
|
|
||||||
|
## Tilt mode
|
||||||
|
|
||||||
|
The `3` key (and the Options menu TILT row) cycles a visual-only perspective
|
||||||
|
tilt of the overworld through **OFF → 15° → 35° → 50° → OFF** for an HD-2D /
|
||||||
|
diorama look. Like survey zoom this is purely presentational and has no
|
||||||
|
Game Boy equivalent:
|
||||||
|
|
||||||
|
- The entire map tilts as one rigid ground plane, paths, grass, water,
|
||||||
|
floors, and every background-tile structure (buildings, trees, fences,
|
||||||
|
signs; in Gen 1 these are baked into the tile layer, not sprites), so
|
||||||
|
rows above the player recede and rows below come toward the viewer. Only
|
||||||
|
things that actually *stand* on the ground draw as upright billboards,
|
||||||
|
unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
|
||||||
|
and the standing FX attached to them (emote bubbles, the fishing rod,
|
||||||
|
the FLY bird). The Poké Center heal-machine overlay stays on the ground
|
||||||
|
plane with the machine tiles (it is OAM glued to a BG graphic, not a
|
||||||
|
standing sprite). An earlier revision tried
|
||||||
|
billboarding buildings/trees/signs too (cutting them out of the ground
|
||||||
|
per hand-curated per-tileset tables); that chased an endless tail of
|
||||||
|
special cases, dense tree canopy, fences fused into grass, building
|
||||||
|
facades with their own baked-in fake perspective, because Gen 1's art
|
||||||
|
was never drawn with a clean seam between ground and standing scenery. It
|
||||||
|
wasn't merged; tilting everything but the characters as one plane is the
|
||||||
|
simpler, shipped tradeoff (buildings recede/foreshorten with the ground
|
||||||
|
like a photo of a diorama, rather than standing fully upright next to
|
||||||
|
a full-height character).
|
||||||
|
- Cycling tweens the angle between levels over ~0.25s rather than snapping;
|
||||||
|
with tilt fully off the world pass drops back onto the flat blit path, so
|
||||||
|
flat rendering stays pixel-identical to tilt-off and off costs nothing.
|
||||||
|
- Tilt input is gated exactly like survey zoom, honored only while
|
||||||
|
free-roaming, ignored while a script, menu, or battle is active, and it
|
||||||
|
composes with survey zoom (the zoom scale feeds the projection). The tilt
|
||||||
|
level is persisted in `save.options.tilt` (default OFF).
|
||||||
|
- It applies everywhere the overworld draws, interiors and caves included.
|
||||||
|
Menus, text boxes, and battles render flat on top, unaffected, and the
|
||||||
|
infinite beyond-the-border-ring fill stays flat by design.
|
||||||
|
- Collision, movement, sight lines, triggers, encounters, and scripts are
|
||||||
|
untouched; nothing about the tilt reaches gameplay.
|
||||||
|
|
||||||
|
## Colors mode
|
||||||
|
|
||||||
|
The `2` key (and the Options menu COLORS row) cycles the display mode
|
||||||
|
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**
|
||||||
|
(on Blue the first slot labels **OG BLUE**; on Yellow, **OG YELLOW**).
|
||||||
|
The first three are the real colorizations; the rest are DMG-shade novelties:
|
||||||
|
|
||||||
|
- **OG RED** / **OG BLUE**: the Game Boy Color boot-ROM look for that cart --
|
||||||
|
one global BG palette + one OBJ palette, every map, no per-map variation
|
||||||
|
(Red/Blue ship no CGB code, so on a GBC the boot ROM colors them globally).
|
||||||
|
The player/NPCs keep the boot-ROM OBJ color over the terrain via the OBP
|
||||||
|
bake + post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`, or Blue's blue/pink
|
||||||
|
pair).
|
||||||
|
- **OG YELLOW** (Yellow playthrough, same `ogred` save id): Pokemon Yellow's
|
||||||
|
authentic GBC look from `CGBBasePalettes` (`data/palettes_yellow.lua`,
|
||||||
|
sourced from pret/pokeyellow). Per-map / per-species colors, not a single
|
||||||
|
boot-ROM ramp -- Yellow was CGB-enhanced.
|
||||||
|
- **SGB** (default): the per-map Super Game Boy region palettes
|
||||||
|
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
|
||||||
|
real SGB. (This is the mode formerly mislabeled "GBC".)
|
||||||
|
- **ADVANCED**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
|
||||||
|
per-species mon colors (`data/palettes_gbc.lua`). (Formerly labeled
|
||||||
|
"RED++"; it is the richest colorization rather than anything Red-specific.)
|
||||||
|
- **OG**: force the four DMG grays (colorization off).
|
||||||
|
- **OG INV**: inverted DMG grays.
|
||||||
|
- **SGB INV**: each SGB zone palette with shade order reversed.
|
||||||
|
- **CLASSIC**: original Game Boy pea-soup greens
|
||||||
|
(`#9BBC0F` / `#8BAC0F` / `#306230` / `#0F380F`).
|
||||||
|
|
||||||
|
The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so
|
||||||
|
it covers overworld, menus, battles, and tilt upright billboards. OG RED's
|
||||||
|
global BG palette is supplied by `OverworldState:overworldBgColors` (per-map
|
||||||
|
override in the overworld pass). Persisted as `save.options.colors`; the
|
||||||
|
`gbc` / `gbc_inv` / `redpp` save ids are kept for back-compat under the new
|
||||||
|
labels.
|
||||||
|
|
||||||
|
## GBC FX
|
||||||
|
|
||||||
|
The `5` key (and the Options menu GBC FX row) cycles a "played on real
|
||||||
|
unlit-GBC hardware" post-process through **OFF → 1 → 2 → 3 → 4**. The
|
||||||
|
levels are a cumulative ladder:
|
||||||
|
|
||||||
|
- **1**: reflective-screen backing transparency.
|
||||||
|
- **2**: + LCD pixel grid.
|
||||||
|
- **3**: + pixel drop shadows.
|
||||||
|
- **4**: + sunlight glare and rainbow shimmer with a drifting light.
|
||||||
|
|
||||||
|
It runs as a final present pass after world + UI composite in
|
||||||
|
`Renderer:endFrame`, inspired by the Pixel Transparency RetroArch shader
|
||||||
|
([github.com/mattakins/Pixel_Transparency](https://github.com/mattakins/Pixel_Transparency)).
|
||||||
|
Default OFF; persisted as `save.options.gbcfx`.
|
||||||
|
|
||||||
|
Mobile GPUs often compile the pass but present a black frame, so Android and
|
||||||
|
iOS hide the row entirely, pin the level to OFF, and rewrite a level already
|
||||||
|
persisted in `options.lua` (issue #136). `POKEPORT_GBCFX` overrides that
|
||||||
|
decision either way, same tri-state as `POKEPORT_TOUCH`: `=0` refuses the
|
||||||
|
effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
|
||||||
|
its launcher because the device reports `"Linux"` while its GPU is in the
|
||||||
|
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
|
||||||
|
|
||||||
|
## Performance tier (low-end devices)
|
||||||
|
|
||||||
|
The Options **PERFORMANCE** row scales the port's optional presentation
|
||||||
|
extras down for weaker hardware. The extras it governs are the three
|
||||||
|
heaviest things the port adds on top of the original -- the whole-screen 3D
|
||||||
|
**TILT** (transforms the entire map as a ground plane), the **GBC FX**
|
||||||
|
post-process shader (a fullscreen pass), and survey **ZOOM** (zooming out
|
||||||
|
renders the connected neighbor maps, a lot of extra overdraw) -- plus a hard
|
||||||
|
FPS ceiling. None of this touches game logic, which is fixed-step off `dt`
|
||||||
|
(`src/core/FixedStep.lua`), so every tier plays identically; they differ
|
||||||
|
only in how much eye-candy the renderer is allowed to do.
|
||||||
|
|
||||||
|
| Tier | TILT | GBC FX | Survey ZOOM | Extra FPS ceiling |
|
||||||
|
| ------------ | ---- | ------ | ----------- | ----------------- |
|
||||||
|
| **HIGH** | on | on | on | none |
|
||||||
|
| **BALANCED** | off | off | on | none |
|
||||||
|
| **LOW** | off | off | off | 60 |
|
||||||
|
| **AUTO** | picks a default from the device (below) |||
|
||||||
|
|
||||||
|
- **AUTO** (the default) reads the device once at boot: ARM Linux handhelds
|
||||||
|
(e.g. the RG34XXSP) resolve to **LOW**, phones/tablets and very-low-core
|
||||||
|
desktops to **BALANCED**, and everything else -- a normal desktop, and
|
||||||
|
every existing `options.lua` that predates this option -- to **HIGH**,
|
||||||
|
so the common case is unchanged. See `src/core/Performance.detect`.
|
||||||
|
- AUTO only chooses the *default*; all four tiers are selectable, so a
|
||||||
|
wrong guess is one row away from being overridden.
|
||||||
|
- The clamps are applied **live** against your stored options and never
|
||||||
|
rewrite them (`Game:applyOptions`), so a lower tier hides your TILT / GBC
|
||||||
|
FX / ZOOM without forgetting them -- raising the tier restores exactly
|
||||||
|
what you had. (This is why the TILT / GBC FX / ZOOM rows still show your
|
||||||
|
saved choice on a clamped tier: it's your preference, waiting for a tier
|
||||||
|
that can afford it.)
|
||||||
|
- Persisted as `save.options.performance` (`auto` | `high` | `balanced` |
|
||||||
|
`low`); unit-tested in `tests/engine/performance_tiers.lua`.
|
||||||
|
|
||||||
|
## Peer-to-peer link play (lua-enet)
|
||||||
|
|
||||||
|
Trades and link battles connect two copies of the game directly over
|
||||||
|
lua-enet (ENet ships inside LÖVE, nothing to install, no server to run)
|
||||||
|
on a reliable-ordered channel, replacing the original standalone Python
|
||||||
|
room-code relay (`tools/relay_server.py`, deleted). HOST A GAME shows the
|
||||||
|
host's LAN address (UDP 7777; `POKEPORT_LINK_PORT` overrides); JOIN A
|
||||||
|
GAME enters it. Closing performs a graceful ENet disconnect so the final
|
||||||
|
confirm/bye always lands; a vanished peer exits with "The link was
|
||||||
|
broken." Internet play needs a forwarded UDP port or a VPN (deliberate
|
||||||
|
tradeoff vs. the relay). Headless tests drive the protocol over an
|
||||||
|
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
|
||||||
|
also exercises real UDP pairing.
|
||||||
|
|
||||||
|
Red, Blue, and Yellow copies link with each other, as the real cable
|
||||||
|
does. The compatibility fingerprint hashes only data a link mode can
|
||||||
|
actually read, so Yellow's Dragonair/Dragonite catch-rate retunes (the
|
||||||
|
only R/B/Y link-surface difference) no longer read as different games
|
||||||
|
(issue #511). Moving the fingerprint is a link parity change: builds
|
||||||
|
from before this fix will refuse to pair with builds after it.
|
||||||
|
|
||||||
|
## Fair play in link and online matches
|
||||||
|
|
||||||
|
A link session is decided by the battle and nothing else, so for its
|
||||||
|
duration:
|
||||||
|
|
||||||
|
- **Game speed is pinned to normal.** The GAME SPEED option and
|
||||||
|
`POKEPORT_SPEED` are ignored from the moment LINK PLAY opens until it
|
||||||
|
closes, and apply again after. Fast-forward otherwise runs one peer's
|
||||||
|
queue faster than the peer it is locked to and drains a tournament shot
|
||||||
|
clock faster than the opponent racing it.
|
||||||
|
- **Online play runs vanilla, except for your language.** Picking ONLINE
|
||||||
|
MATCH or TOURNAMENT with mods enabled offers to switch the gameplay ones
|
||||||
|
off and relaunch (mods merge at boot, so a restart is the only way). The
|
||||||
|
restart is confirmed, not silent. They stay listed as disabled, ready to
|
||||||
|
switch back on. A mod that declares itself a translation and provably
|
||||||
|
writes nothing but text stays on: the two games hash the same link
|
||||||
|
surface, so a Spanish install and an English one can battle and trade,
|
||||||
|
each reading the game in its own language and naming the other player's
|
||||||
|
party out of its own text.
|
||||||
|
- **Only a meaningful split ends a match.** The per-turn state signature
|
||||||
|
both peers exchange is split three ways: `actives` and `bench` carry
|
||||||
|
species, HP, status, stat stages, PP and the rest of the party, and a
|
||||||
|
divergence there ends the match as a draw. `volatile` carries per-turn
|
||||||
|
flags both sides recompute anyway - a divergence there is logged and
|
||||||
|
reported to mods, and play continues.
|
||||||
|
|
||||||
|
The relay logs which component diverged on which turn, so a desync report
|
||||||
|
names something specific.
|
||||||
|
|
||||||
|
## Custom boot text
|
||||||
|
|
||||||
|
The boot sequence replaces the Nintendo / GAME FREAK identifiers with
|
||||||
|
"bois club" / "bryanthaboi", a deliberate branding customization. The
|
||||||
|
rest of the boot beats (copyright splash, "presents" shooting-star, the
|
||||||
|
Nidorino-vs-Gengar attract scene) mirror the original.
|
||||||
|
|
||||||
|
|
||||||
|
## Custom Options
|
||||||
|
|
||||||
|
Options persist in a standalone `options.lua` (separate from the game
|
||||||
|
progress `save.lua`), so audio/display/battle preferences survive New Game
|
||||||
|
and aren't wiped when a save slot is cleared. Changing a row in the Options
|
||||||
|
menu or cycling hotkeys `2`/`3`/`4`/`5` writes immediately; an in-game save also
|
||||||
|
flushes the live options. Old saves that still embed an `options` table are
|
||||||
|
migrated once into `options.lua` on load.
|
||||||
|
|
||||||
|
- Music / SFX volume
|
||||||
|
- PIKACHU VOL (0-7, Yellow only): trims Pikachu's PCM voice clips under the
|
||||||
|
SFX level, so the follower's constant chatter, the title-screen cry and
|
||||||
|
every in-battle "Pika!" can be pulled down (or muted at 0) without
|
||||||
|
quieting the rest of the sound effects. The row is hidden on Red/Blue,
|
||||||
|
which have no voice clips.
|
||||||
|
- Music Filter
|
||||||
|
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
||||||
|
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
|
||||||
|
- COLORS (OG RED / SGB / ADVANCED / OG / OG INV / SGB INV / CLASSIC), also
|
||||||
|
hotkey `2` (OG RED = GBC boot-ROM look; ADVANCED uses pokered-gbc
|
||||||
|
SuperPalettes + per-species mon colors)
|
||||||
|
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
|
||||||
|
- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and
|
||||||
|
`-`/`=` step one level and save
|
||||||
|
- VOID FILL (TREES / WATER / BLACK) for OVERWORLD beyond-edge space
|
||||||
|
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
|
||||||
|
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60),
|
||||||
|
a hard render frame-rate cap (`save.options.fpsCap`).
|
||||||
|
|
||||||
|
## Battle transition cascade + white battle letterbox
|
||||||
|
|
||||||
|
Into-battle wipes still run the original eight styles inside the classic
|
||||||
|
160×144 letterbox. On wide/tall windows (survey zoom), matching black 8×8
|
||||||
|
blocks cascade outward from that square into the surrounding world so the
|
||||||
|
void outside the OG wipe fills in lockstep. Once the battle state is up,
|
||||||
|
letterbox voids around the battle canvas fill **white** instead of black
|
||||||
|
so the whole window reads as one continuous battle screen.
|
||||||
|
|
||||||
|
## Widescreen battle layout
|
||||||
|
|
||||||
|
Options **BATTLE LAYOUT** picks the battle screen's composition: **OG**
|
||||||
|
(the default: the original 160×144 arrangement, unchanged) or **WIDE**,
|
||||||
|
which gives battles a 304×144 native-pixel surface and a Gen 3-style
|
||||||
|
arrangement on it:
|
||||||
|
|
||||||
|
- the foe's status box upper left, the foe's picture upper right;
|
||||||
|
- the player's picture lower left, the player's status box lower right,
|
||||||
|
with a longer HP bar and the numeric HP under it;
|
||||||
|
- a full-width message window;
|
||||||
|
- a split "What will X do?" prompt / 2×2 command window;
|
||||||
|
- a 2×2 move menu, navigated with all four directions, with a PP and type
|
||||||
|
panel attached to its right.
|
||||||
|
|
||||||
|
Only the composition changes. Pictures, palettes, HP-bar colors, font
|
||||||
|
pages, window borders, sounds, animations, timing and every battle rule
|
||||||
|
stay the engine's, so a COLORS mode or an asset mod still owns the look.
|
||||||
|
Each side's picture keeps its original pixels and placement math and is
|
||||||
|
composited into its own region of the wider battlefield -- nothing is
|
||||||
|
scaled or squeezed -- and animations, which are authored in the original
|
||||||
|
160-pixel space, shift as one rigid group onto whichever side they play
|
||||||
|
on. The whole screen is drawn at the window's integer fit scale for the
|
||||||
|
wider surface, so a 304-pixel screen is drawn a step smaller than a
|
||||||
|
160-pixel one in the same window.
|
||||||
|
|
||||||
|
The wide surface is live only while the battle itself is the screen on
|
||||||
|
top: a party menu, the bag or a nickname prompt is a 160×144 screen and
|
||||||
|
brings the classic surface back with it.
|
||||||
|
|
||||||
|
## On-screen touch controls (mobile)
|
||||||
|
|
||||||
|
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
|
||||||
|
buttons (bottom-right, Game Boy diagonal), and +/- START/SELECT (bottom
|
||||||
|
center) over the frame, using Xelu's CC0 controller prompts
|
||||||
|
(`assets/touch/`). Real buttons, not gestures: press lands the frame the
|
||||||
|
finger does, sliding on the d-pad changes direction without lifting, and
|
||||||
|
multi-touch chords (e.g. hold a direction + tap B) work. The overlay only
|
||||||
|
appears while no controller is being used: the first gamepad button or
|
||||||
|
stick push hides it, the next screen touch brings it back, and unplugging
|
||||||
|
the last controller restores it immediately. Layout re-derives from the
|
||||||
|
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
|
||||||
|
the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
||||||
|
|
||||||
|
The launcher's **Touch Controls** button opens a drag editor: move each
|
||||||
|
button freely, resize the whole pad with **-/+** (60% to 160%), **Disable**
|
||||||
|
to hide the overlay permanently (for controllers / emulation handhelds --
|
||||||
|
distinct from the temporary gamepad auto-hide), **Reset** for defaults,
|
||||||
|
**Done** to save into `options.lua` as normalized window fractions so a
|
||||||
|
different screen keeps the relative placement.
|
||||||
|
|
||||||
|
Portrait and landscape are edited and saved separately (#633): the editor
|
||||||
|
follows whichever orientation is on screen, and **Reset** only clears that
|
||||||
|
one, so a layout that works held upright does not have to double as the
|
||||||
|
one used sideways. An `options.lua` from before this split keeps its single
|
||||||
|
layout in both orientations until one of them is edited. In-game, Options →
|
||||||
|
**TOUCH PAD** toggles the same on/off flag without leaving a play session.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
translation is an ordinary content mod rather than a fork.
|
||||||
|
|
||||||
|
Two things had to change. Text layout stopped counting bytes: the dialogue
|
||||||
|
box measures a line in glyphs (charmap sequences), so a 3-byte character
|
||||||
|
costs one column, a cut never lands inside a character, and a page with a
|
||||||
|
non-default `advance` re-measures instead of overflowing. That also fixed
|
||||||
|
25 vanilla English lines that were wrapping early because `é` in POKéMON
|
||||||
|
and POKéDEX costs two bytes ("I study POKéMON as" is 19 bytes and 18
|
||||||
|
glyphs, and the box was breaking it).
|
||||||
|
|
||||||
|
Second, the text the engine writes itself - battle messages, item results,
|
||||||
|
menu labels, the link-play screens - moved behind `src/core/Strings.lua`
|
||||||
|
and the new `strings` registry. Extracted script text was already
|
||||||
|
overridable through `text`; this covers the other half. Entries are keyed
|
||||||
|
by the English source, so a translation that has not reached a string yet
|
||||||
|
keeps rendering in English and a half-finished translation stays playable.
|
||||||
|
|
||||||
|
Authors generate the whole thing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/modkit.py translation francais --language "Francais"
|
||||||
|
```
|
||||||
|
|
||||||
|
That scaffolds a mod with every translatable string as an empty catalog,
|
||||||
|
plus a glyph-page and charmap stub, a naming-grid stub, and a
|
||||||
|
`francais-worksheet/` directory holding the English to translate from
|
||||||
|
(deliberately outside the mod: extracted text is ROM content and must not
|
||||||
|
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)
|
||||||
|
|
||||||
|
The save editor ships inside every build instead of being a developer-only
|
||||||
|
script, and the launcher's SAVE SLOT card grows an **Edit** label next to
|
||||||
|
Delete on every slot that actually holds a save. Edit suspends the
|
||||||
|
launcher, opens that slot's file in the editor, and **Close** hands the
|
||||||
|
process back to the launcher with the slot list re-read (a rename, a badge
|
||||||
|
or a dex change shows up on the row immediately). Unsaved edits arm a
|
||||||
|
confirm first, so leaving cannot lose work. `love . --editor` still opens
|
||||||
|
it standalone, where Close quits instead; `--save <path>` points it at any
|
||||||
|
file, and a save can be dragged onto the window.
|
||||||
|
|
||||||
|
The editor now wears the launcher's visual language - the same navy radial
|
||||||
|
field, 16px translucent cards, tri-colour version rail and green/yellow/red
|
||||||
|
semantics - so the two windows read as one app. Six tabs:
|
||||||
|
|
||||||
|
- **Party**: the roster with sprites, HP bars and level chips on the left,
|
||||||
|
and the mon inspector permanently docked on the right instead of floating
|
||||||
|
over the list. Species, level, DVs and moves all round-trip through the
|
||||||
|
Gen 1 formulas, so the inspector can never show illegal stats.
|
||||||
|
- **Boxes**: the 12 PC boxes as a 5x4 grid with a fill meter per box and a
|
||||||
|
party dock, so deposit and withdraw live in one place. Empty slots are
|
||||||
|
clickable and create a mon there.
|
||||||
|
- **Items**: money, a searchable item picker (replacing the arrows that
|
||||||
|
cycled one id at a time through ~250 items), the configurable bag (20 slots
|
||||||
|
by default), PC storage
|
||||||
|
with no slot cap, and the eight badges as toggle chips. The picker, the bag
|
||||||
|
and PC storage all scroll under the mouse wheel, so the whole catalog is
|
||||||
|
reachable one-handed without typing a query.
|
||||||
|
- **Events**: flags, defeated trainers, taken items and per-map object
|
||||||
|
toggles, with a real filter field and a two-column paged grid.
|
||||||
|
- **Map**: any map rendered with the game's own renderer, warps followable,
|
||||||
|
and the player / lastHeal / lastOutdoor spawn points settable by clicking
|
||||||
|
a cell. Setting lastOutdoor on a map the game would not accept as an
|
||||||
|
outdoor source is refused with the reason.
|
||||||
|
- **Dex**: seen / owned completion meters and a four-column grid; owning
|
||||||
|
implies seen and un-seeing clears owned, exactly as the game requires.
|
||||||
|
|
||||||
|
Two rules run through all of it. Every mutation goes through one funnel
|
||||||
|
that sets the dirty flag and writes the status line together, so nothing
|
||||||
|
changes silently and no branch can quietly no-op - "Party is full", "Bag is
|
||||||
|
full", "click a cell first" all say so. And every destructive verb (Remove,
|
||||||
|
Release, Clear all, Wipe dex) arms on the first click and commits on the
|
||||||
|
second, relabelling itself to `Confirm?` in between.
|
||||||
|
|
||||||
|
A validation pill in the tab rail mirrors what the running game would
|
||||||
|
quarantine on load; clicking it jumps to the tab holding the first problem.
|
||||||
|
|
||||||
|
## Tiled map editing (mod authoring)
|
||||||
|
|
||||||
|
`tools/tiled_export.py` turns the imported ROM cache into a Tiled workspace,
|
||||||
|
so maps can be edited in a real map editor and exported back out as a mod.
|
||||||
|
It has its own document: docs/tiled-map-editing.md.
|
||||||
|
|
||||||
|
## Pokédex diploma (both versions)
|
||||||
|
|
||||||
|
The Celadon Mansion 3F game designer shows the dex-completion diploma
|
||||||
|
once 150 species are owned. On Yellow, the graphic artist next to him
|
||||||
|
then offers to print it, saving the certificate as a PNG under `prints/`
|
||||||
|
in the save directory, and Bill's PC gains Yellow's PRINT BOX item which
|
||||||
|
exports the current box list the same way.
|
||||||
|
|
||||||
|
## Pokédex printing (Yellow)
|
||||||
|
|
||||||
|
Yellow's Game Boy Printer PRNT option in the Pokédex side menu is stood in
|
||||||
|
for by an image export: choosing PRNT renders the mon's entry page (sprite,
|
||||||
|
kind, number, height/weight, dex text) to a PNG at 4x scale under
|
||||||
|
`prints/` in the save directory, then reports the filename in a dialog.
|
||||||
|
No printer hardware or link cable emulation involved; the file is the
|
||||||
|
printout.
|
||||||
|
|
||||||
|
## Find Mods (community mod indexes)
|
||||||
|
|
||||||
|
A FIND MODS tab sits beside MODS in the launcher and browses a published
|
||||||
|
mod index: a metadata-only feed listing mods that live in their authors'
|
||||||
|
own repositories. No index ships with the launcher and none is ever added
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Soft reset (all versions)
|
||||||
|
|
||||||
|
Holding A, B, START and SELECT together restarts the game the way flicking
|
||||||
|
a Game Boy's power switch did, dropping straight back to the title screen.
|
||||||
|
It works from anywhere, including mid-battle, which the QUIT entry on the
|
||||||
|
start menu cannot do: the original combo is how stationary and gift
|
||||||
|
Pokemon get their stats rerolled without sitting through a full relaunch.
|
||||||
|
Unsaved progress is discarded, exactly as on hardware.
|
||||||
|
|
||||||
|
As on the original, the four buttons have to stay held for 16 straight
|
||||||
|
polls (better than a quarter of a second) and any direction in the mix
|
||||||
|
cancels it, so it is hard to hit by accident -- including on the on-screen
|
||||||
|
touch controls, where it would take four fingers held on four separate
|
||||||
|
controls.
|
||||||
|
|
||||||
|
## Controls rebinding (CONTROLS screen)
|
||||||
|
|
||||||
|
OPTIONS -> CONTROLS lists every Game Boy button with its current keyboard
|
||||||
|
key and controller button side by side (Z/A). Press A on a row, then press
|
||||||
|
and release the key or pad button you want; the rebind commits on the
|
||||||
|
release. If that input already belongs to another row, the two rows swap,
|
||||||
|
so no button is ever stranded without an input and no input ever serves
|
||||||
|
two buttons. Holding a second key or pad button while the first is still
|
||||||
|
down backs out of the capture without touching a keyboard; Escape still
|
||||||
|
cancels too. SELECT clears one row back to its default, and START resets
|
||||||
|
every binding after a confirmation.
|
||||||
|
|
||||||
|
Controllers a system has no mapping for (common on Linux handhelds and
|
||||||
|
off-brand pads) report bare button numbers rather than names. Those are
|
||||||
|
rebindable on the same screen and show up as JOY1, JOY2 and so on in the
|
||||||
|
controller column. Recognized controllers are read only through their
|
||||||
|
named buttons, so a rebind on those is never shadowed by the factory
|
||||||
|
layout underneath it.
|
||||||
|
|
||||||
|
## Mod profiles (#593)
|
||||||
|
|
||||||
|
The mod manager's PROFILES tab holds named setups. A profile remembers which
|
||||||
|
mods are on, every mod's own options, and which save slot each game version
|
||||||
|
plays, so swapping profiles swaps the whole playthrough and not just the mod
|
||||||
|
list. The setup that existed before profiles shipped becomes PROFILE 1 the
|
||||||
|
first time the manager opens.
|
||||||
|
|
||||||
|
EXPORT.. writes the selected profile to `profiles/<NAME>.g1rmodlist` in the
|
||||||
|
save directory; drop a `.g1rmodlist` someone shared into that folder and
|
||||||
|
IMPORT.. adds it. Imported profiles never overwrite an existing one (a name
|
||||||
|
clash gets a number). Mods the shared profile names but that are not installed
|
||||||
|
are reported when the profile is applied; installing them is still a manual
|
||||||
|
trip through the mods list or Find Mods.
|
||||||
|
|
||||||
|
## Windows: no console windows on launcher actions
|
||||||
|
|
||||||
|
Checking for updates, browsing a mod index, adding a mod repo, installing a
|
||||||
|
mod and picking a ROM all run a host tool (curl, PowerShell) in a child
|
||||||
|
process. On Windows those children used to each open their own console
|
||||||
|
window, so a session could end up buried under half a dozen of them. The
|
||||||
|
game now claims one console for itself at boot and hides it; the children
|
||||||
|
inherit that invisible console and nothing pops up. Nothing else changes:
|
||||||
|
file pickers are ordinary desktop dialogs and still appear normally, and a
|
||||||
|
run started from a terminal (`lovec.exe`, what `scripts\run.ps1` prefers)
|
||||||
|
keeps its terminal and its printed output. Set `POKEPORT_CONSOLE=1` to opt
|
||||||
|
out.
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
# RFC 0002 — Let mods hide an active screen state from the main render
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Proposed. Engine: `StateStack.lua`, `Game.lua`. Tests:
|
|
||||||
`screen_render_visible.lua`.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
A mod can render a native menu on a companion display through
|
|
||||||
`render.compose`, but it cannot remove that menu from the main display without
|
|
||||||
also popping it. Popping transfers update and input ownership and forces the
|
|
||||||
mod to reimplement native menu behavior.
|
|
||||||
|
|
||||||
## The decision it extends
|
|
||||||
|
|
||||||
No prior D-number. Extends the render-hook plan in `docs/modding.md` and the
|
|
||||||
state-stack rendering contract in `docs/architecture.md`.
|
|
||||||
|
|
||||||
## The exact API delta
|
|
||||||
|
|
||||||
Backward-compatible, additive-only.
|
|
||||||
|
|
||||||
### `screen.render_visible`
|
|
||||||
|
|
||||||
New hook called with `(state) -> boolean` through the public wrapper signature
|
|
||||||
`(next, state)`. Its vanilla result is `true`.
|
|
||||||
|
|
||||||
Returning `false` excludes the state from the main draw, from opaque-base
|
|
||||||
selection and from palette-zone ownership. It does not remove the state or
|
|
||||||
change update, input, push or pop behavior. The call sites are
|
|
||||||
`StateStack:visibleBase`, `StateStack:draw` and the equivalent draw and palette
|
|
||||||
walks in `Game:draw`.
|
|
||||||
|
|
||||||
The hook is guarded by `Runtime.wantsHook`, so the no-subscriber path allocates
|
|
||||||
nothing. It is a pure render predicate and may be evaluated more than once per
|
|
||||||
frame.
|
|
||||||
|
|
||||||
## Migration note for existing mods
|
|
||||||
|
|
||||||
**Nothing.** With no subscriber every state remains visible, and the existing
|
|
||||||
state-stack, event and hook behavior is unchanged.
|
|
||||||
|
|
||||||
## Parity tests
|
|
||||||
|
|
||||||
- **No-mod:** the topmost opaque state still owns drawing and palette zones,
|
|
||||||
and `Runtime.wantsHook("screen.render_visible")` stays false.
|
|
||||||
- **Mod-API:** a fixture mod registers through `mod.hooks:wrap`, hides one
|
|
||||||
opaque state and proves the state beneath draws and owns the palette while
|
|
||||||
the hidden state remains topmost and continues updating.
|
|
||||||
|
|
||||||
## Deprecation etiquette
|
|
||||||
|
|
||||||
Nothing deprecated. This is one additive hook with a `true` vanilla default.
|
|
||||||
+39
-79
@@ -1,66 +1,38 @@
|
|||||||
# Build Gen1Recomp for Nintendo Switch
|
# Build the Nintendo Switch NRO — contributor guide
|
||||||
|
|
||||||
Want to play a release build instead? Download the SD-ready zip and extract it
|
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).
|
at your microSD root — see [switch-install.md](switch-install.md).
|
||||||
|
|
||||||
This guide is for contributors who build Gen1Recomp for Switch from source.
|
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/`).
|
> Releases ship `gen1recomp-*-switch.zip` (SD tree under `switch/gen1recomp/`;
|
||||||
> Runtime target is pinned [love-nx](https://github.com/retronx-team/love-nx)
|
> issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Hardware
|
||||||
> `11.5-nx1`. Player install and limitations: [switch-install.md](switch-install.md).
|
> evidence: **OLED** (author) and **V1 boot** (community). See
|
||||||
|
> [switch-development.md](switch-development.md) for known limitations.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Prerequisites by OS
|
## Prerequisites by OS
|
||||||
|
|
||||||
All packaging entrypoints are **bash**. On Windows, use Git Bash, MSYS2, or
|
All packaging entrypoints are **bash**. On Windows, use Git Bash, MSYS2, or
|
||||||
WSL, not cmd.exe or PowerShell (AD-008).
|
WSL — not cmd.exe or PowerShell (AD-008).
|
||||||
|
|
||||||
### macOS / Linux
|
### macOS / Linux
|
||||||
|
|
||||||
1. Install [devkitPro pacman](https://devkitpro.org/wiki/devkitPro_pacman).
|
1. Install [devkitPro pacman](https://devkitpro.org/wiki/devkitPro_pacman).
|
||||||
2. Install Switch tools (**required for `--fused`**):
|
2. Install Switch tools:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo dkp-pacman -S switch-dev
|
sudo dkp-pacman -S switch-dev
|
||||||
```
|
```
|
||||||
|
|
||||||
3. OTA launcher toolchain, **native or Docker** (either is fine):
|
3. Ensure `nacptool` and `elf2nro` are on `PATH` (or under
|
||||||
|
`$DEVKITPRO/tools/bin` — the fused script prepends that when set).
|
||||||
|
|
||||||
```sh
|
**Optional:** Install [Docker](https://docs.docker.com/get-docker/) so fused
|
||||||
bash scripts/switch/install_devkitpro_deps.sh # native
|
builds can fall back to the pinned image when native tools are missing.
|
||||||
# or install Docker (same pin as fused builds)
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Ensure `DEVKITPRO` is exported (typical macOS: `/opt/devkitpro`) and
|
|
||||||
`nacptool` / `elf2nro` are on `PATH` (or under `$DEVKITPRO/tools/bin`).
|
|
||||||
|
|
||||||
Fused game builds can also use Docker when native `nacptool`/`elf2nro` are absent.
|
|
||||||
|
|
||||||
### Native OTA launcher (included in `--fused`)
|
|
||||||
|
|
||||||
In-console OTA uses a **separate DEVKITPRO NRO** (not LÖVE). The LÖVE
|
|
||||||
self-updater (`Check.lua`) is disabled on NX. Source:
|
|
||||||
`ports/switch/ota-launcher/`. Host protocol tests (no toolchain):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
make -C ports/switch/ota-launcher host-test
|
|
||||||
# or
|
|
||||||
scripts/switch/build_ota_launcher.sh # host-test first; NRO needs DEVKITPRO/Docker
|
|
||||||
```
|
|
||||||
|
|
||||||
`--fused` always builds the fused game, native OTA launcher, and dual-NRO SD
|
|
||||||
zip. The same `*-switch.zip` is the OTA download asset. **DEVKITPRO is
|
|
||||||
required.** OTA launcher: native packages **or** Docker. Both are supported.
|
|
||||||
|
|
||||||
Release-like build from repo root:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
scripts/build_switch.sh --fetch --fused --version X.Y.Z
|
|
||||||
```
|
|
||||||
|
|
||||||
See `ports/switch/ota-launcher/README.md` and
|
|
||||||
`scripts/switch/ota_launcher.manifest`.
|
|
||||||
|
|
||||||
### Windows (Git Bash / MSYS2 / WSL)
|
### Windows (Git Bash / MSYS2 / WSL)
|
||||||
|
|
||||||
@@ -70,15 +42,16 @@ See `ports/switch/ota-launcher/README.md` and
|
|||||||
- **WSL** (Ubuntu/etc.) with the Linux pacman flow above, or
|
- **WSL** (Ubuntu/etc.) with the Linux pacman flow above, or
|
||||||
- **Git Bash** for `--fetch` / `--loose`; for `--fused` prefer MSYS2 or
|
- **Git Bash** for `--fetch` / `--loose`; for `--fused` prefer MSYS2 or
|
||||||
WSL if Docker bind-mounts from Git Bash paths misbehave.
|
WSL if Docker bind-mounts from Git Bash paths misbehave.
|
||||||
2. Install `switch-dev` (or rely on Docker fallback; see below).
|
2. Install `switch-dev` (or rely on Docker fallback — see below).
|
||||||
3. Do **not** expect `scripts/build_switch.sh` to run under cmd/PowerShell.
|
3. Do **not** expect `scripts/build_switch.sh` to run under cmd/PowerShell.
|
||||||
|
|
||||||
### What you must install yourself
|
### What you must install yourself
|
||||||
|
|
||||||
| You install | Script does **not** install |
|
| You install | Script does **not** install |
|
||||||
| ----------- | --------------------------- |
|
| ----------- | --------------------------- |
|
||||||
| bash, git, zip tooling the repo already expects | (none) |
|
| bash, git, zip tooling the repo already expects | — |
|
||||||
| `dkp-pacman` + `switch-dev` + OTA packages **or** Docker | `dkp-pacman -S …` |
|
| `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 |
|
| A legal `.gb` ROM (to play) | Any ROM or game data |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -91,12 +64,12 @@ See `ports/switch/ota-launcher/README.md` and
|
|||||||
| ---- | ------------ |
|
| ---- | ------------ |
|
||||||
| `--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`. |
|
| `--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. |
|
| `--loose` | Packs `game.love`, copies pinned `love.nro` → `dist/switch/loose/` as `gen1recomp.nro` + `game.love` side by side. Needs the pin. |
|
||||||
| `--fused` | Builds fused game NRO, OTA launcher NRO, and dual-NRO SD zip. **Requires DEVKITPRO** + `switch-dev`. OTA launcher: native packages or Docker. GitHub Releases publish the **zip only**. |
|
| `--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:
|
Rules:
|
||||||
|
|
||||||
- `--fetch` alone is fine; combine as `--fetch --loose` or `--fetch --fused`.
|
- `--fetch` alone is fine; combine as `--fetch --loose` or `--fetch --fused`.
|
||||||
- `--loose` and `--fused` are **XOR**. Pick one packaging path per run.
|
- `--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).
|
- `--version X.Y.Z` sets the NACP / filename version (defaults to short git SHA).
|
||||||
|
|
||||||
### What `--fetch` downloads
|
### What `--fetch` downloads
|
||||||
@@ -136,15 +109,13 @@ scripts/build_switch.sh --fetch
|
|||||||
# Loose pair for iteration (fetch + assemble)
|
# Loose pair for iteration (fetch + assemble)
|
||||||
scripts/build_switch.sh --fetch --loose
|
scripts/build_switch.sh --fetch --loose
|
||||||
|
|
||||||
# Fused game + OTA launcher + dual-NRO SD zip for a release-like artifact
|
# Single fused NRO + SD-ready zip for a release-like artifact
|
||||||
scripts/build_switch.sh --fetch --fused --version 0.2.0
|
scripts/build_switch.sh --fetch --fused --version 0.2.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Outputs land under `dist/switch/` (and `dist/switch/loose/` for loose mode).
|
Outputs land under `dist/switch/` (and `dist/switch/loose/` for loose mode).
|
||||||
The fused path also writes `gen1recomp-<ver>-switch.nro` (game),
|
The fused path also writes `gen1recomp-<ver>-switch.nro.sha256` and
|
||||||
`gen1recomp-<ver>-launcher.nro`, `gen1recomp-<ver>-game.nro`,
|
`gen1recomp-<ver>-switch.zip` (+ `.sha256` sidecar for the zip).
|
||||||
`gen1recomp-<ver>-switch.nro.sha256`, and `gen1recomp-<ver>-switch.zip`
|
|
||||||
(+ `.sha256` sidecar for the zip).
|
|
||||||
|
|
||||||
Offline packaging smoke (no network, no nacptool required):
|
Offline packaging smoke (no network, no nacptool required):
|
||||||
|
|
||||||
@@ -172,7 +143,7 @@ the NX runtime modules `src/core/NxAssetOverlay.lua`, `src/core/Platform.lua`,
|
|||||||
`tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`,
|
`tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`,
|
||||||
or the Switch-related workflow YAML), CI runs:
|
or the Switch-related workflow YAML), CI runs:
|
||||||
|
|
||||||
1. **Offline selftest** on `ubuntu-latest` (forks **and** the main repo):
|
1. **Offline selftest** on `ubuntu-latest` (forks **and** the canonical repo):
|
||||||
`scripts/switch/selftest_build_switch.sh`,
|
`scripts/switch/selftest_build_switch.sh`,
|
||||||
`scripts/switch/verify_payload.sh --self-test`,
|
`scripts/switch/verify_payload.sh --self-test`,
|
||||||
`luajit tests/switch_ci_workflows_test.lua`,
|
`luajit tests/switch_ci_workflows_test.lua`,
|
||||||
@@ -180,10 +151,14 @@ or the Switch-related workflow YAML), CI runs:
|
|||||||
headlessly (`luajit tests/engine/assets_version_fallback_test.lua`,
|
headlessly (`luajit tests/engine/assets_version_fallback_test.lua`,
|
||||||
`luajit tests/engine/nx_generated_guard_test.lua`,
|
`luajit tests/engine/nx_generated_guard_test.lua`,
|
||||||
`luajit tests/engine/nx_yellow_boot_test.lua`).
|
`luajit tests/engine/nx_yellow_boot_test.lua`).
|
||||||
2. **Fused NRO build** only on the **main** repository
|
2. **Fused NRO build** only on the **canonical** repository
|
||||||
(`bryanthaboi/gen1recomp`), on the self-hosted Mac runner
|
(`bryanthaboi/gen1recomp`), on the self-hosted Mac runner
|
||||||
(`scripts/build_switch.sh --fetch --fused`), and only when the workflow
|
(`scripts/build_switch.sh --fetch --fused`), and only when the workflow
|
||||||
head is that repo (same-repo push/PR). Fork CI never runs fused. Fork PRs into the main repo 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.
|
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
|
3. On successful PR fused builds, a follow-up workflow posts a PR comment
|
||||||
linking the Actions artifact named `gen1recomp-switch-nro`
|
linking the Actions artifact named `gen1recomp-switch-nro`
|
||||||
(comment tag `switch-build-result`; see
|
(comment tag `switch-build-result`; see
|
||||||
@@ -194,7 +169,7 @@ Unrelated PRs do not burn the self-hosted Mac on Switch packaging.
|
|||||||
### Release hard-fail (`.github/workflows/release.yml`)
|
### Release hard-fail (`.github/workflows/release.yml`)
|
||||||
|
|
||||||
GitHub Releases always build Switch on the same self-hosted Mac runner as the
|
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`):
|
other platforms — this is a **hard gate** (no `continue-on-error`):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
scripts/build_switch.sh --fetch --fused --version "<release version>"
|
scripts/build_switch.sh --fetch --fused --version "<release version>"
|
||||||
@@ -206,26 +181,9 @@ A Switch packaging failure fails the entire release job. The release asset is
|
|||||||
|
|
||||||
### Runner provisioning
|
### Runner provisioning
|
||||||
|
|
||||||
The self-hosted Mac runner **must** have **DEVKITPRO** installed and exported.
|
The self-hosted Mac runner must have **native switch-tools** (`nacptool` /
|
||||||
`--fused` preflight fails early with setup steps if it is missing.
|
`elf2nro`) **and/or Docker** available. CI and release do not silently run
|
||||||
|
`dkp-pacman -S`; keep the runner image/host provisioned per this guide.
|
||||||
**One-time setup on the runner** (if not already present):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# devkitPro pacman installer from https://devkitpro.org/wiki/devkitPro_pacman
|
|
||||||
sudo dkp-pacman -S switch-dev
|
|
||||||
export DEVKITPRO=/opt/devkitpro
|
|
||||||
export PATH="$DEVKITPRO/tools/bin:$PATH"
|
|
||||||
|
|
||||||
# OTA launcher: pick one
|
|
||||||
bash scripts/switch/install_devkitpro_deps.sh # native
|
|
||||||
# or ensure Docker is installed (same pin as fused builds)
|
|
||||||
```
|
|
||||||
|
|
||||||
CI and release still run `scripts/build_switch.sh --fetch --fused`. Preflight
|
|
||||||
requires DEVKITPRO and either native OTA packages or Docker. Without all of
|
|
||||||
that, the job fails with the setup steps above. Scripts never auto-run
|
|
||||||
`dkp-pacman -S` during CI.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -236,9 +194,11 @@ These scripts and this guide do **not**:
|
|||||||
- Push files to the console (no automated MTP / FTP / SD scripting)
|
- Push files to the console (no automated MTP / FTP / SD scripting)
|
||||||
- Bundle or download any Pokémon ROM
|
- Bundle or download any Pokémon ROM
|
||||||
- Install `dkp-pacman` / `switch-dev` for you
|
- Install `dkp-pacman` / `switch-dev` for you
|
||||||
- Provide `nxlink` / netloader deploy (deferred; see [switch-transfer.md](switch-transfer.md))
|
- Provide `nxlink` / netloader deploy (deferred — see [switch-transfer.md](switch-transfer.md))
|
||||||
- Validate **Applet Mode**. Use title override (hold **R**) for full memory
|
- Validate **Applet Mode** — use title override (hold **R**) for full memory
|
||||||
|
|
||||||
Player install steps: [switch-install.md](switch-install.md).
|
Player install steps: [switch-install.md](switch-install.md).
|
||||||
Manual transfer (MTP / SD / FTP, macOS / Linux / Windows):
|
Manual transfer (MTP / SD / FTP, macOS / Linux / Windows):
|
||||||
[switch-transfer.md](switch-transfer.md).
|
[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
|
||||||
|
```
|
||||||
+25
-65
@@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
Every GitHub Release that includes Switch support ships an SD-ready zip:
|
Every GitHub Release that includes Switch support ships an SD-ready zip:
|
||||||
`gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install
|
`gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install
|
||||||
or update, same steps), launch with **title override**, then import your
|
**or** update — same steps), launch with **title override**, then import your
|
||||||
own legal `.gb` ROM.
|
own legal `.gb` ROM.
|
||||||
|
|
||||||
> You need a console that can run Switch homebrew (custom firmware / hbmenu).
|
> You need a console that can run Switch homebrew (custom firmware / hbmenu).
|
||||||
> This project does not help you set that up.
|
> 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).
|
Prefer building from source? See [switch-build.md](switch-build.md).
|
||||||
|
|
||||||
@@ -25,18 +29,12 @@ help from [booshankles](https://github.com/booshankles).
|
|||||||
Extract the zip at the **root** of the microSD so you get:
|
Extract the zip at the **root** of the microSD so you get:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
sdmc:/switch/gen1recomp/gen1recomp.nro # native OTA launcher (hbmenu entry)
|
sdmc:/switch/gen1recomp/gen1recomp.nro
|
||||||
sdmc:/switch/gen1recomp/gen1recomp-game.nro # fused LÖVE game
|
|
||||||
sdmc:/switch/gen1recomp/version.txt
|
|
||||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/
|
sdmc:/switch/gen1recomp/pokemon-love2d/imports/
|
||||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/mods/
|
sdmc:/switch/gen1recomp/pokemon-love2d/imports/mods/
|
||||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/...
|
sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/...
|
||||||
```
|
```
|
||||||
|
|
||||||
Older single-NRO zips only had `gen1recomp.nro` (the fused game). Current
|
|
||||||
releases use the dual-NRO layout above. Open `gen1recomp` in hbmenu (the
|
|
||||||
launcher).
|
|
||||||
|
|
||||||
Merge folders if your OS asks. Any method works: **MTP** (DBI → Run MTP
|
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**.
|
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
|
Exit MTP / unmount / stop FTP cleanly before launching. Step-by-step for
|
||||||
@@ -44,44 +42,17 @@ macOS, Linux, and Windows: [switch-transfer.md](switch-transfer.md).
|
|||||||
|
|
||||||
### Updating
|
### Updating
|
||||||
|
|
||||||
#### Native OTA launcher (in-console)
|
Use the **same** extract/merge. It replaces `gen1recomp.nro` (and the small
|
||||||
|
help `README.txt` / `INSTALL.txt` files). Saves, imported ROMs, mods, and
|
||||||
Switch OTA runs in a separate **native launcher NRO** (libnx + curl), not the
|
options live under `pokemon-love2d/` — **do not delete that folder** when
|
||||||
LÖVE self-updater (`src/update/Check.lua`). hbmenu opens `gen1recomp.nro`.
|
updating, or you will lose progress.
|
||||||
|
|
||||||
When a newer release exists, the launcher downloads the same install zip
|
|
||||||
(`gen1recomp-*-switch.zip`), checks SHA-256 against `sha256sums.txt`, replaces
|
|
||||||
both `gen1recomp-game.nro` and `gen1recomp.nro` (keeps NACP version in sync
|
|
||||||
for hbmenu and Sphaira), then loads the game with `envSetNextLoad`.
|
|
||||||
|
|
||||||
If you are up to date or offline, it skips straight to the game with no
|
|
||||||
prompt. If an update is available, you get a short prompt styled like the
|
|
||||||
in-game launcher: black background, RGB rail, logo, A/B buttons. Saves under
|
|
||||||
`pokemon-love2d/` are not touched. See `src/update/SwitchOta.lua` for the
|
|
||||||
wire format.
|
|
||||||
|
|
||||||
The LÖVE self-updater stays **disabled** on NX (`networkValidated == false`).
|
|
||||||
|
|
||||||
**Sphaira forwarder (HOME shortcut):** Sphaira copies name/version/icon into
|
|
||||||
the installed forwarder at creation time. After an OTA (or zip) update, the
|
|
||||||
`.nro` on the microSD already has the new version, but the HOME shortcut
|
|
||||||
keeps the old badge until you **reinstall the forwarder once** in Sphaira
|
|
||||||
(Install Forwarder again on `gen1recomp.nro`). Browsing the NRO in Sphaira /
|
|
||||||
hbmenu always shows the live file version.
|
|
||||||
|
|
||||||
#### Manual zip (fallback)
|
|
||||||
|
|
||||||
Use the **same** extract/merge of `gen1recomp-*-switch.zip`. It replaces the
|
|
||||||
NROs (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
|
## 3. Launch with title override
|
||||||
|
|
||||||
**Applet Mode is not supported** for this game (not enough memory).
|
**Applet Mode is not supported** for this game (not enough memory).
|
||||||
|
|
||||||
1. On the Switch HOME menu, highlight any installed title.
|
1. On the Switch HOME menu, highlight any installed title.
|
||||||
2. Hold **R** and launch that title. This opens hbmenu with full memory
|
2. Hold **R** and launch that title — this opens hbmenu with full memory
|
||||||
(title override).
|
(title override).
|
||||||
3. From hbmenu, open `gen1recomp`.
|
3. From hbmenu, open `gen1recomp`.
|
||||||
|
|
||||||
@@ -95,14 +66,14 @@ This project ships **no** game data. On first launch:
|
|||||||
(`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
(`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
||||||
launcher also shows the live save-dir path). All three can sit in the
|
launcher also shows the live save-dir path). All three can sit in the
|
||||||
same folder.
|
same folder.
|
||||||
2. Use **Scan again** on that game's tab (Red / Blue / Yellow). Rescan
|
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
|
matches by ROM SHA-1 for the open tab only — a Red dump never imports
|
||||||
from the Yellow tab (and vice versa).
|
from the Yellow tab (and vice versa).
|
||||||
|
|
||||||
## 5. Import / Export a raw `.sav`
|
## 5. Import / Export a raw `.sav`
|
||||||
|
|
||||||
Continue a cart or PC battery save (or pull a slot off-console) via MTP /
|
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**:
|
SD / FTP — same transfer methods as ROMs. Paths are **per game**:
|
||||||
|
|
||||||
| Game | Import inbox | Export folder |
|
| Game | Import inbox | Export folder |
|
||||||
| ---- | ------------ | ------------- |
|
| ---- | ------------ | ------------- |
|
||||||
@@ -110,19 +81,19 @@ SD / FTP, same transfer methods as ROMs. Paths are **per game**:
|
|||||||
| Blue | `imports/saves/blue/` | `exports/blue/` |
|
| Blue | `imports/saves/blue/` | `exports/blue/` |
|
||||||
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
|
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
|
||||||
|
|
||||||
(Under the save dir `pokemon-love2d/`. The zip already creates these folders.)
|
(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
|
1. Copy a Gen1 `.sav` (32 KB) into that game’s inbox under the save dir
|
||||||
([switch-transfer.md](switch-transfer.md)).
|
([switch-transfer.md](switch-transfer.md)).
|
||||||
2. With the game's ROM already imported, open **that game's tab** →
|
2. With the game’s ROM already imported, open **that game’s tab** →
|
||||||
**SAVE FILES** → **Import save**. Only that folder is scanned.
|
**SAVE FILES** → **Import save**. Only that folder is scanned.
|
||||||
3. A successful import retires the file to `*.sav.imported` and records its
|
3. A successful import retires the file to `*.sav.imported` and records its
|
||||||
content hash so pressing **Import save** again does not clone slots.
|
content hash so pressing **Import save** again does not clone slots.
|
||||||
Failed imports leave the original `.sav` in place.
|
Failed imports leave the original `.sav` in place.
|
||||||
4. To pull a slot off the console, use **Export save**, then copy the file
|
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.
|
from that game’s **`exports/<game>/`** folder via MTP / SD / FTP.
|
||||||
|
|
||||||
Do not put `.sav` files into git. Prefer clean copies. Some MTP clients
|
Do not put `.sav` files into git. Prefer clean copies — some MTP clients
|
||||||
create `._*.sav` AppleDouble sidecars that are not real saves.
|
create `._*.sav` AppleDouble sidecars that are not real saves.
|
||||||
|
|
||||||
## Controls
|
## Controls
|
||||||
@@ -159,12 +130,12 @@ create `._*.sav` AppleDouble sidecars that are not real saves.
|
|||||||
Mods install from a zip inbox (same transfer methods as ROMs):
|
Mods install from a zip inbox (same transfer methods as ROMs):
|
||||||
|
|
||||||
1. Copy a release `.zip` into the save-dir **`imports/mods/`** path the
|
1. Copy a release `.zip` into the save-dir **`imports/mods/`** path the
|
||||||
launcher shows (MTP / SD / FTP. See [switch-transfer.md](switch-transfer.md)).
|
launcher shows (MTP / SD / FTP — [switch-transfer.md](switch-transfer.md)).
|
||||||
2. In the launcher, open **MODS** → **Scan again** → enable the mod →
|
2. In the launcher, open **MODS** → **Scan again** → enable the mod →
|
||||||
**Play**.
|
**Play**.
|
||||||
|
|
||||||
Remote **FIND MODS** / GitHub download stays **off** on Switch. Do not put
|
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
|
mod zips into git. Community mods ship their own OPTIONS / rebinds — this port
|
||||||
does not document third-party control tables.
|
does not document third-party control tables.
|
||||||
|
|
||||||
### Joy-Con shortcuts (Select + face)
|
### Joy-Con shortcuts (Select + face)
|
||||||
@@ -182,23 +153,12 @@ hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs).
|
|||||||
| Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) |
|
| Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) |
|
||||||
|
|
||||||
If the handheld stutters with extras on, try **OPTIONS → PERFORMANCE** →
|
If the handheld stutters with extras on, try **OPTIONS → PERFORMANCE** →
|
||||||
`LOW` or `BALANCED`.
|
`LOW` or `BALANCED`. Full chord notes for contributors:
|
||||||
|
[switch-development.md](switch-development.md#joy-con-display-chords-select--face).
|
||||||
## Limitations
|
|
||||||
|
|
||||||
- You need homebrew (custom firmware, hbmenu). This project does not set that
|
|
||||||
up.
|
|
||||||
- Launch with title override (hold **R** on a title). Applet Mode (Album) is
|
|
||||||
not supported. The game needs full memory.
|
|
||||||
- ROMs, mods, and saves are copied manually via MTP, direct SD, or FTP. There
|
|
||||||
is no automated deploy.
|
|
||||||
- Updates use the native OTA launcher only. The LÖVE self-updater and remote
|
|
||||||
**FIND MODS** stay off on Switch.
|
|
||||||
- Tested on Switch OLED. Switch V1 / Erista boot confirmed by the community.
|
|
||||||
Other models may work but are less tested.
|
|
||||||
|
|
||||||
## Prefer building it yourself?
|
## Prefer building it yourself?
|
||||||
|
|
||||||
Building the fused NRO (and SD-ready zip) from source is covered in
|
Building the fused NRO (and SD-ready zip) from source is covered in
|
||||||
[switch-build.md](switch-build.md). Copying artifacts and inbox files
|
[switch-build.md](switch-build.md). Copying artifacts and inbox files
|
||||||
(MTP / SD / FTP on macOS, Linux, Windows): [switch-transfer.md](switch-transfer.md).
|
(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).
|
||||||
|
|||||||
+22
-19
@@ -1,16 +1,17 @@
|
|||||||
# Switch file transfer (MTP / SD / FTP)
|
# Switch file transfer (MTP / SD / FTP)
|
||||||
|
|
||||||
Ways to put Gen1Recomp artifacts and inbox files onto a Nintendo Switch.
|
Canonical ways to put Gen1Recomp artifacts and inbox files onto a Nintendo
|
||||||
**Any method is valid** if the bytes land in the destinations below.
|
Switch. **Any method is valid** if the bytes land in the destinations below.
|
||||||
|
|
||||||
This is the runbook for contributors on **macOS, Linux, and Windows**.
|
This is the home runbook for contributors on **macOS, Linux, and Windows**.
|
||||||
Player install (what to download, title override) stays in
|
Player install (what to download, title override) stays in
|
||||||
[switch-install.md](switch-install.md). Packaging stays in
|
[switch-install.md](switch-install.md). Packaging stays in
|
||||||
[switch-build.md](switch-build.md).
|
[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
|
> **Not supported yet:** `nxlink` / hbmenu netloader automation. Useful later
|
||||||
> for a fast contrib rebuild loop; deferred (AD-009). Do not treat netloader as
|
> for a fast contrib rebuild loop; deferred on purpose (AD-009). Do not treat
|
||||||
> the release or ROM/mod install path.
|
> netloader as the release or ROM/mod install path.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ Player install (what to download, title override) stays in
|
|||||||
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
|
| 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/`) |
|
| 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** |
|
| 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 `.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) |
|
| 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` |
|
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
||||||
| Lua error log | `lua-error.log` in the save dir |
|
| Lua error log | `lua-error.log` in the save dir |
|
||||||
@@ -33,7 +34,7 @@ files, or third-party mod zips to git.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Transfer methods
|
## Canonical methods
|
||||||
|
|
||||||
### 1. MTP (DBI responder + host client)
|
### 1. MTP (DBI responder + host client)
|
||||||
|
|
||||||
@@ -46,8 +47,8 @@ before launching.
|
|||||||
|
|
||||||
#### macOS (example: OpenMTP)
|
#### macOS (example: OpenMTP)
|
||||||
|
|
||||||
[OpenMTP](https://github.com/ganeshrvel/openmtp) is a documented example for
|
[OpenMTP](https://github.com/ganeshrvel/openmtp) is the loop used for OLED
|
||||||
macOS, not a Mac-only requirement.
|
hardware evidence — **one contributor example**, not a Mac-only product rule.
|
||||||
|
|
||||||
1. Quit other MTP clients.
|
1. Quit other MTP clients.
|
||||||
2. Open OpenMTP → select the DBI device → **`1: SD Card`**.
|
2. Open OpenMTP → select the DBI device → **`1: SD Card`**.
|
||||||
@@ -59,25 +60,25 @@ macOS, not a Mac-only requirement.
|
|||||||
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
||||||
|
|
||||||
macOS clients often create AppleDouble sidecars (`._Something.zip`,
|
macOS clients often create AppleDouble sidecars (`._Something.zip`,
|
||||||
`._cart.gb`, `._foo.sav`). Those are not real archives or saves. The
|
`._cart.gb`, `._foo.sav`). Those are not real archives or saves — the
|
||||||
launcher skips hidden `.*` names. Delete `._*` junk if a zip/ROM/`.sav`
|
launcher skips hidden `.*` names. Delete `._*` junk if a zip/ROM/`.sav`
|
||||||
fails to open.
|
fails to open.
|
||||||
|
|
||||||
#### Linux
|
#### Linux
|
||||||
|
|
||||||
1. Install desktop MTP support if needed (e.g. `gvfs-mtp` on GNOME/GTK
|
1. Install desktop MTP support if needed (e.g. `gvfs-mtp` on GNOME/GTK
|
||||||
desktops, or your distro's KDE MTP stack).
|
desktops, or your distro’s KDE MTP stack).
|
||||||
2. With DBI MTP active, open **Files** / **Dolphin** / **Thunar** and select
|
2. With DBI MTP active, open **Files** / **Dolphin** / **Thunar** and select
|
||||||
the Switch / DBI device → **`1: SD Card`**.
|
the Switch / DBI device → **`1: SD Card`**.
|
||||||
3. Extract the release zip at SD root (merge), or copy into `switch/gen1recomp/`
|
3. Extract the release zip at SD root (merge), or copy into `switch/gen1recomp/`
|
||||||
and the save-dir inboxes as above.
|
and the save-dir inboxes as above.
|
||||||
4. Use **only one** MTP accessor at a time. If `mtp-tools` / `mtpfs` reports
|
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)
|
“device is busy”, close the file manager’s MTP mount (or the CLI mount)
|
||||||
and retry with a single client.
|
and retry with a single client.
|
||||||
5. Eject/unmount cleanly; exit MTP on the Switch; title-override launch.
|
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
|
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.
|
card reader) or **FTP** instead — same destinations in the table above.
|
||||||
|
|
||||||
#### Windows
|
#### Windows
|
||||||
|
|
||||||
@@ -92,7 +93,7 @@ card reader) or **FTP** instead. Same destinations in the table above.
|
|||||||
5. Safely disconnect; exit MTP on the Switch; title-override launch.
|
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
|
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.
|
card reader) or **FTP** instead — same destinations in the table above.
|
||||||
|
|
||||||
### 2. Direct SD (Hekate UMS or card reader)
|
### 2. Direct SD (Hekate UMS or card reader)
|
||||||
|
|
||||||
@@ -108,9 +109,9 @@ Do not yank the card or unplug UMS mid-write.
|
|||||||
|
|
||||||
### 3. FTP (any SD-exposing Switch FTP)
|
### 3. FTP (any SD-exposing Switch FTP)
|
||||||
|
|
||||||
Any homebrew FTP server that can write the microSD is fine. For example
|
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
|
**DBI’s own FTP**, **sys-ftpd-light**, or **Sphaira** (names are illustrations
|
||||||
only; pick what your CFW setup already uses.
|
only; pick what your CFW setup already uses).
|
||||||
|
|
||||||
1. Start the FTP server on the Switch; note IP/port/credentials from that app.
|
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
|
2. From the host, connect with any FTP client and upload to the same
|
||||||
@@ -153,7 +154,7 @@ Copy the file back from the SD and compare hashes. Round-trip must match.
|
|||||||
| Symptom | What to try |
|
| 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) |
|
| 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` |
|
| 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 |
|
| 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 |
|
| App opens in Applet Mode | Use title override (hold **R**), not Album |
|
||||||
|
|
||||||
@@ -163,3 +164,5 @@ Copy the file back from the SD and compare hashes. Round-trip must match.
|
|||||||
|
|
||||||
- Players: [switch-install.md](switch-install.md)
|
- Players: [switch-install.md](switch-install.md)
|
||||||
- Builders: [switch-build.md](switch-build.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)
|
||||||
|
|||||||
@@ -124,8 +124,3 @@ bundled game, in that case.
|
|||||||
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
|
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
|
||||||
`"0.0.0-dev"` placeholder that always reports up to date, so a source
|
`"0.0.0-dev"` placeholder that always reports up to date, so a source
|
||||||
checkout is always "the game" itself; updating it means pulling the repo.
|
checkout is always "the game" itself; updating it means pulling the repo.
|
||||||
- **Nintendo Switch does not use this LÖVE self-updater.** On NX,
|
|
||||||
`Platform.networkValidated()` is `false`, so `Boot.run` / `Check` never
|
|
||||||
download `.love` payloads. In-console OTA uses the **native OTA launcher**
|
|
||||||
(DEVKITPRO), documented in [switch-install.md](switch-install.md). Wire
|
|
||||||
format: `src/update/SwitchOta.lua`. Manual zip install remains the fallback.
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
|||||||
Copyright (c) 2009, 2010, 2013-2016 by the Brotli Authors.
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Mike Freno
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
@@ -7,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|||||||
copies of the Software, and to permit persons to whom the Software is
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
furnished to do so, subject to the following conditions:
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
The above copyright notice and this permission notice shall be included in all
|
||||||
all copies or substantial portions of the Software.
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
THE SOFTWARE.
|
SOFTWARE.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,188 @@
|
|||||||
|
-- modules/Behavior.lua
|
||||||
|
--
|
||||||
|
-- Base module for the pluggable behavior system that drives the Behavior &
|
||||||
|
-- Mode Unification refactor.
|
||||||
|
--
|
||||||
|
-- A *behavior* is a small, stateless table produced by `Behavior.new(spec)`
|
||||||
|
-- that implements a fixed lifecycle hook set. Concrete behaviors (Clickable,
|
||||||
|
-- Scrollable, TextEditable, Selectable, ...) each live in their own module and
|
||||||
|
-- are attached to an Element. The Element's `update`/`draw`/save-restore paths
|
||||||
|
-- iterate `element.behaviors` and dispatch to the appropriate hooks, replacing
|
||||||
|
-- the swarm of `if self.scrollable` / immediate-mode-branch checks previously
|
||||||
|
-- hard-coded in Element.lua.
|
||||||
|
--
|
||||||
|
-- Element.new iterates a registry of behavior prototypes and auto-attaches
|
||||||
|
-- whichever return true from `shouldAttach(props)`. Element therefore never
|
||||||
|
-- needs to know what an individual behavior does — only that it conforms to
|
||||||
|
-- this interface.
|
||||||
|
--
|
||||||
|
-- Design constraints (locked — tasks 02-13 depend on this API):
|
||||||
|
-- * Pure Lua — NO `love` import, NO dependency on utils/Color/Units/ErrorHandler.
|
||||||
|
-- Stays fully stub-testable standalone (see testing/__tests__/behavior_test.lua).
|
||||||
|
-- * Minimal interface — exactly 6 lifecycle hooks + a `shouldAttach` predicate.
|
||||||
|
-- Do NOT add hooks "just in case"; new capabilities become new behaviors,
|
||||||
|
-- not new hooks. Extending HOOK_NAMES is an architectural decision that must
|
||||||
|
-- be mirrored by every concrete behavior.
|
||||||
|
-- * Immutable instances — behavior tables are produced once and treated as
|
||||||
|
-- read-only. Per-element runtime state lives on the element (or a subsystem
|
||||||
|
-- the behavior attaches), NEVER on the behavior instance itself, so a single
|
||||||
|
-- behavior instance can be shared across many elements.
|
||||||
|
--
|
||||||
|
-- Lifecycle hook contract (each receives the owning element as first argument):
|
||||||
|
-- onAttach(element) — called once when the behavior is attached
|
||||||
|
-- (element fully constructed). Allocate
|
||||||
|
-- subsystems / register listeners here.
|
||||||
|
-- onDetach(element) — called once when the behavior is detached
|
||||||
|
-- (element destroyed / mode switch). Tear
|
||||||
|
-- down anything onAttach created.
|
||||||
|
-- onUpdate(element, dt) — called every frame from Element:update.
|
||||||
|
-- onDraw(element, ctx) — called every frame from Element:draw; `ctx`
|
||||||
|
-- is the draw context (viewport transform,
|
||||||
|
-- scissor state, theme renderer, ...).
|
||||||
|
-- saveState(element) -> state — called during Element save-state; returns
|
||||||
|
-- a serializable snapshot (or nil) so the
|
||||||
|
-- behavior's runtime state survives the
|
||||||
|
-- immediate-mode recreation cycle.
|
||||||
|
-- restoreState(element, state) — called after reconstruction with the
|
||||||
|
-- snapshot previously returned by saveState.
|
||||||
|
--
|
||||||
|
-- shouldAttach(props) -> boolean — class-level predicate (not a hook): given
|
||||||
|
-- an element's props table, return true if
|
||||||
|
-- this behavior should be auto-attached.
|
||||||
|
-- Defaults to false (opt-in).
|
||||||
|
|
||||||
|
--- A behavior instance: a frozen table of lifecycle hooks + a shouldAttach
|
||||||
|
--- predicate. All hooks are always present (custom override or no-op default).
|
||||||
|
---@class Behavior
|
||||||
|
---@field onAttach fun(element:table)
|
||||||
|
---@field onDetach fun(element:table)
|
||||||
|
---@field onUpdate fun(element:table, dt:number)
|
||||||
|
---@field onDraw fun(element:table, ctx:table)
|
||||||
|
---@field saveState fun(element:table):any
|
||||||
|
---@field restoreState fun(element:table, state:any)
|
||||||
|
---@field shouldAttach fun(props:table):boolean
|
||||||
|
|
||||||
|
local Behavior = {}
|
||||||
|
|
||||||
|
-- The fixed, ordered lifecycle hook set. Order is preserved so downstream tasks
|
||||||
|
-- (Element behavior iteration) can rely on a deterministic dispatch sequence.
|
||||||
|
-- HOOK_NAMES is intentionally NOT extended casually — see file header.
|
||||||
|
Behavior.HOOK_NAMES = {
|
||||||
|
"onAttach",
|
||||||
|
"onDetach",
|
||||||
|
"onUpdate",
|
||||||
|
"onDraw",
|
||||||
|
"saveState",
|
||||||
|
"restoreState",
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Allowlist of spec keys accepted by Behavior.new. Anything else is rejected so
|
||||||
|
-- a typo (e.g. `onUpdat`) surfaces immediately instead of silently no-op'ing.
|
||||||
|
-- Hook keys (HOOK_NAMES + shouldAttach) MUST be functions; metadata keys
|
||||||
|
-- (drawLayer) may hold any value.
|
||||||
|
local ALLOWED_KEYS = {
|
||||||
|
onAttach = true,
|
||||||
|
onDetach = true,
|
||||||
|
onUpdate = true,
|
||||||
|
onDraw = true,
|
||||||
|
saveState = true,
|
||||||
|
restoreState = true,
|
||||||
|
shouldAttach = true,
|
||||||
|
drawLayer = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Spec keys whose values are NOT required to be functions (passive metadata
|
||||||
|
-- consumed by dispatch sites, e.g. Element:draw's pre/post-children split).
|
||||||
|
local NON_FUNCTION_KEYS = {
|
||||||
|
drawLayer = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Default no-op hook. Behaviors override only the hooks they need; every other
|
||||||
|
-- hook resolves to this so dispatch sites never have to nil-check.
|
||||||
|
local function noop() end
|
||||||
|
|
||||||
|
-- Default shouldAttach predicate: never auto-attach unless the behavior opts in
|
||||||
|
-- by providing its own predicate. This is the safe default — a behavior with no
|
||||||
|
-- opinion about which elements it applies to stays inert in the auto-attach
|
||||||
|
-- pass (it can still be attached explicitly by name in a later task).
|
||||||
|
local function defaultShouldAttach()
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Module-level default predicate exposed for callers/tests that want to
|
||||||
|
-- reference the base default directly without constructing an instance.
|
||||||
|
Behavior.shouldAttach = defaultShouldAttach
|
||||||
|
|
||||||
|
--- Factory: create a frozen behavior instance from a spec table.
|
||||||
|
---
|
||||||
|
--- `spec` is a table whose keys may be any subset of the 6 lifecycle hook names
|
||||||
|
--- plus `shouldAttach`; each value (when present) must be a function. The
|
||||||
|
--- returned table contains every lifecycle hook (custom override OR no-op) and
|
||||||
|
--- a `shouldAttach` predicate (custom OR always-false default), so dispatch
|
||||||
|
--- sites can call any hook unconditionally without nil-checking.
|
||||||
|
---
|
||||||
|
--- Unknown spec keys and non-function values raise an error immediately so
|
||||||
|
--- mistakes fail fast at construction rather than as silent no-ops later.
|
||||||
|
---
|
||||||
|
---@param spec table|nil spec table overriding select hooks / shouldAttach
|
||||||
|
---@return Behavior
|
||||||
|
function Behavior.new(spec)
|
||||||
|
spec = spec or {}
|
||||||
|
|
||||||
|
-- Validate spec keys up front so typos surface here, not as silent no-ops.
|
||||||
|
for key, value in pairs(spec) do
|
||||||
|
if not ALLOWED_KEYS[key] then
|
||||||
|
error(string.format("Behavior.new: unknown spec key '%s'", tostring(key)), 2)
|
||||||
|
end
|
||||||
|
if not NON_FUNCTION_KEYS[key] and type(value) ~= "function" then
|
||||||
|
error(string.format("Behavior.new: spec key '%s' must be a function, got %s", tostring(key), type(value)), 2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local instance = {}
|
||||||
|
|
||||||
|
-- Populate every lifecycle hook: custom override when provided, no-op default
|
||||||
|
-- otherwise. Guarantees `instance.hook` is always callable.
|
||||||
|
for _, hook in ipairs(Behavior.HOOK_NAMES) do
|
||||||
|
instance[hook] = spec[hook] or noop
|
||||||
|
end
|
||||||
|
|
||||||
|
-- shouldAttach defaults to always-false; behaviors opt in by supplying one.
|
||||||
|
instance.shouldAttach = spec.shouldAttach or defaultShouldAttach
|
||||||
|
|
||||||
|
-- drawLayer: optional metadata field (default nil = "background"/pre-children).
|
||||||
|
-- Dispatch sites (Element:draw) use it to split rendering into pre-children
|
||||||
|
-- (background layers) and post-children (overlay layers, e.g. scrollbars).
|
||||||
|
instance.drawLayer = spec.drawLayer
|
||||||
|
|
||||||
|
-- Freeze: prevent adding new fields. Behavior instances are shared, stateless
|
||||||
|
-- objects; runtime state belongs on the element, never on the behavior.
|
||||||
|
-- (Reassigning an existing hook is still possible via direct index write —
|
||||||
|
-- Lua metatables cannot intercept that — but the freeze communicates intent
|
||||||
|
-- and catches accidental field additions.)
|
||||||
|
local mt = {
|
||||||
|
__newindex = function(_, key)
|
||||||
|
error(string.format("Behavior: behavior instances are immutable (cannot set '%s')", tostring(key)), 2)
|
||||||
|
end,
|
||||||
|
--- Mark the metatable so consumers can detect a Behavior instance.
|
||||||
|
---@return string
|
||||||
|
__tostring = function()
|
||||||
|
return "Behavior"
|
||||||
|
end,
|
||||||
|
__metatable = "Behavior",
|
||||||
|
}
|
||||||
|
setmetatable(instance, mt)
|
||||||
|
|
||||||
|
return instance
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Type guard: returns true if `value` is a Behavior instance produced by
|
||||||
|
--- `Behavior.new`. Used by Element's attach path to validate registry entries
|
||||||
|
--- without depending on identity.
|
||||||
|
---@param value any
|
||||||
|
---@return boolean
|
||||||
|
function Behavior.isBehavior(value)
|
||||||
|
return type(value) == "table" and getmetatable(value) == "Behavior"
|
||||||
|
end
|
||||||
|
|
||||||
|
return Behavior
|
||||||
@@ -0,0 +1,686 @@
|
|||||||
|
-- Lua 5.2+ compatibility for unpack
|
||||||
|
local unpack = table.unpack or unpack
|
||||||
|
|
||||||
|
-- Warning cache to prevent duplicate warnings for the same element
|
||||||
|
local warningCache = {}
|
||||||
|
|
||||||
|
local Cache = {
|
||||||
|
canvases = {},
|
||||||
|
quads = {},
|
||||||
|
blurInstances = {}, -- Cache blur instances by quality
|
||||||
|
blurredCanvases = {}, -- Cache pre-blurred canvases for immediate mode
|
||||||
|
MAX_CANVAS_SIZE = 20,
|
||||||
|
MAX_QUAD_SIZE = 20,
|
||||||
|
MAX_BLURRED_CANVAS_CACHE = 50, -- Maximum cached blurred canvases
|
||||||
|
RADIUS_THRESHOLD = 0.5, -- Skip blur below this radius
|
||||||
|
LARGE_BLUR_THRESHOLD = 250 * 250, -- Warn if blur area exceeds this (250x250px)
|
||||||
|
}
|
||||||
|
|
||||||
|
--- Round canvas size to nearest bucket for better reuse
|
||||||
|
---@param size number Size to bucket
|
||||||
|
---@return number bucketSize Bucketed size
|
||||||
|
local function bucketSize(size)
|
||||||
|
if size <= 128 then
|
||||||
|
return math.ceil(size / 32) * 32
|
||||||
|
elseif size <= 512 then
|
||||||
|
return math.ceil(size / 64) * 64
|
||||||
|
elseif size <= 1024 then
|
||||||
|
return math.ceil(size / 128) * 128
|
||||||
|
else
|
||||||
|
return math.ceil(size / 256) * 256
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get or create a canvas from cache
|
||||||
|
---@param width number Canvas width
|
||||||
|
---@param height number Canvas height
|
||||||
|
---@return love.Canvas canvas The cached or new canvas
|
||||||
|
function Cache.getCanvas(width, height)
|
||||||
|
-- Use bucketed sizes for better cache reuse
|
||||||
|
local bucketedWidth = bucketSize(width)
|
||||||
|
local bucketedHeight = bucketSize(height)
|
||||||
|
local key = string.format("%dx%d", bucketedWidth, bucketedHeight)
|
||||||
|
|
||||||
|
if not Cache.canvases[key] then
|
||||||
|
Cache.canvases[key] = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local cache = Cache.canvases[key]
|
||||||
|
|
||||||
|
for i, entry in ipairs(cache) do
|
||||||
|
if not entry.inUse then
|
||||||
|
entry.inUse = true
|
||||||
|
return entry.canvas
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local canvas = love.graphics.newCanvas(bucketedWidth, bucketedHeight)
|
||||||
|
table.insert(cache, { canvas = canvas, inUse = true })
|
||||||
|
|
||||||
|
if #cache > Cache.MAX_CANVAS_SIZE then
|
||||||
|
local removed = table.remove(cache, 1)
|
||||||
|
if removed and removed.canvas then
|
||||||
|
removed.canvas:release()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return canvas
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Release a canvas back to the cache
|
||||||
|
---@param canvas love.Canvas Canvas to release
|
||||||
|
function Cache.releaseCanvas(canvas)
|
||||||
|
for _, sizeCache in pairs(Cache.canvases) do
|
||||||
|
for _, entry in ipairs(sizeCache) do
|
||||||
|
if entry.canvas == canvas then
|
||||||
|
entry.inUse = false
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get or create a quad from cache
|
||||||
|
---@param x number X position
|
||||||
|
---@param y number Y position
|
||||||
|
---@param width number Quad width
|
||||||
|
---@param height number Quad height
|
||||||
|
---@param sw number Source width
|
||||||
|
---@param sh number Source height
|
||||||
|
---@return love.Quad quad The cached or new quad
|
||||||
|
function Cache.getQuad(x, y, width, height, sw, sh)
|
||||||
|
local key = string.format("%d,%d,%d,%d,%d,%d", x, y, width, height, sw, sh)
|
||||||
|
|
||||||
|
if not Cache.quads[key] then
|
||||||
|
Cache.quads[key] = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local cache = Cache.quads[key]
|
||||||
|
|
||||||
|
for i, entry in ipairs(cache) do
|
||||||
|
if not entry.inUse then
|
||||||
|
entry.inUse = true
|
||||||
|
return entry.quad
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local quad = love.graphics.newQuad(x, y, width, height, sw, sh)
|
||||||
|
table.insert(cache, { quad = quad, inUse = true })
|
||||||
|
|
||||||
|
if #cache > Cache.MAX_QUAD_SIZE then
|
||||||
|
table.remove(cache, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
return quad
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Release a quad back to the cache
|
||||||
|
---@param quad love.Quad Quad to release
|
||||||
|
function Cache.releaseQuad(quad)
|
||||||
|
for _, keyCache in pairs(Cache.quads) do
|
||||||
|
for _, entry in ipairs(keyCache) do
|
||||||
|
if entry.quad == quad then
|
||||||
|
entry.inUse = false
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Generate cache key for blurred canvas
|
||||||
|
---@param elementId string Element ID
|
||||||
|
---@param x number X position
|
||||||
|
---@param y number Y position
|
||||||
|
---@param width number Width
|
||||||
|
---@param height number Height
|
||||||
|
---@param radius number Blur radius
|
||||||
|
---@param quality number Blur quality
|
||||||
|
---@param isBackdrop boolean Whether this is backdrop blur
|
||||||
|
---@return string key Cache key
|
||||||
|
function Cache.generateBlurCacheKey(elementId, x, y, width, height, radius, quality, isBackdrop)
|
||||||
|
return string.format(
|
||||||
|
"%s:%d:%d:%d:%d:%.1f:%d:%s",
|
||||||
|
elementId,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
radius,
|
||||||
|
quality,
|
||||||
|
tostring(isBackdrop)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get cached blurred canvas
|
||||||
|
---@param key string Cache key
|
||||||
|
---@return love.Canvas|nil canvas Cached canvas or nil
|
||||||
|
function Cache.getBlurredCanvas(key)
|
||||||
|
local entry = Cache.blurredCanvases[key]
|
||||||
|
if entry then
|
||||||
|
entry.lastUsed = os.time()
|
||||||
|
return entry.canvas
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Store blurred canvas in cache
|
||||||
|
---@param key string Cache key
|
||||||
|
---@param canvas love.Canvas Canvas to cache
|
||||||
|
function Cache.setBlurredCanvas(key, canvas)
|
||||||
|
-- Limit cache size
|
||||||
|
local count = 0
|
||||||
|
for _ in pairs(Cache.blurredCanvases) do
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
if count >= Cache.MAX_BLURRED_CANVAS_CACHE then
|
||||||
|
-- Remove oldest entry
|
||||||
|
local oldestKey = nil
|
||||||
|
local oldestTime = math.huge
|
||||||
|
for k, v in pairs(Cache.blurredCanvases) do
|
||||||
|
if v.lastUsed < oldestTime then
|
||||||
|
oldestTime = v.lastUsed
|
||||||
|
oldestKey = k
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if oldestKey then
|
||||||
|
if Cache.blurredCanvases[oldestKey].canvas then
|
||||||
|
Cache.blurredCanvases[oldestKey].canvas:release()
|
||||||
|
end
|
||||||
|
Cache.blurredCanvases[oldestKey] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Cache.blurredCanvases[key] = {
|
||||||
|
canvas = canvas,
|
||||||
|
lastUsed = os.time(),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Clear blurred canvas cache for specific element
|
||||||
|
---@param elementId string Element ID to clear cache for
|
||||||
|
function Cache.clearBlurredCanvasesForElement(elementId)
|
||||||
|
for key, entry in pairs(Cache.blurredCanvases) do
|
||||||
|
if key:match("^" .. elementId .. ":") then
|
||||||
|
if entry.canvas then
|
||||||
|
entry.canvas:release()
|
||||||
|
end
|
||||||
|
Cache.blurredCanvases[key] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Clear all caches
|
||||||
|
function Cache.clear()
|
||||||
|
-- Release all blurred canvases
|
||||||
|
for _, entry in pairs(Cache.blurredCanvases) do
|
||||||
|
if entry.canvas then
|
||||||
|
entry.canvas:release()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Cache.canvases = {}
|
||||||
|
Cache.quads = {}
|
||||||
|
Cache.blurInstances = {}
|
||||||
|
Cache.blurredCanvases = {}
|
||||||
|
warningCache = {} -- Clear warning cache on cache clear
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- SHADER BUILDER
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
local ShaderBuilder = {}
|
||||||
|
|
||||||
|
--- Build Gaussian blur shader with given parameters
|
||||||
|
---@param taps number Number of samples (must be odd, >= 3)
|
||||||
|
---@param offset number Offset value
|
||||||
|
---@param offsetType string "weighted" or "center"
|
||||||
|
---@param sigma number Sigma value for Gaussian distribution
|
||||||
|
---@return love.Shader shader The compiled blur shader
|
||||||
|
function ShaderBuilder.build(taps, offset, offsetType, sigma)
|
||||||
|
taps = math.floor(taps)
|
||||||
|
sigma = sigma >= 1 and sigma or (taps - 1) * offset / 6
|
||||||
|
sigma = math.max(sigma, 1)
|
||||||
|
|
||||||
|
local steps = (taps + 1) / 2
|
||||||
|
|
||||||
|
local gOffsets = {}
|
||||||
|
local gWeights = {}
|
||||||
|
for i = 1, steps do
|
||||||
|
gOffsets[i] = offset * (i - 1)
|
||||||
|
gWeights[i] = math.exp(-0.5 * (gOffsets[i] - 0) ^ 2 * 1 / sigma ^ 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
local offsets = {}
|
||||||
|
local weights = {}
|
||||||
|
for i = #gWeights, 2, -2 do
|
||||||
|
local oA, oB = gOffsets[i], gOffsets[i - 1]
|
||||||
|
local wA, wB = gWeights[i], gWeights[i - 1]
|
||||||
|
wB = oB == 0 and wB / 2 or wB
|
||||||
|
local weight = wA + wB
|
||||||
|
offsets[#offsets + 1] = offsetType == "center" and (oA + oB) / 2 or (oA * wA + oB * wB) / weight
|
||||||
|
weights[#weights + 1] = weight
|
||||||
|
end
|
||||||
|
|
||||||
|
local code = {
|
||||||
|
[[
|
||||||
|
extern vec2 direction;
|
||||||
|
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {]],
|
||||||
|
}
|
||||||
|
|
||||||
|
local norm = 0
|
||||||
|
if #gWeights % 2 == 0 then
|
||||||
|
code[#code + 1] = "vec4 c = vec4( 0.0 );"
|
||||||
|
else
|
||||||
|
local weight = gWeights[1]
|
||||||
|
norm = norm + weight
|
||||||
|
code[#code + 1] = string.format("vec4 c = %f * texture2D(tex, tc);", weight)
|
||||||
|
end
|
||||||
|
|
||||||
|
local template = "c += %f * ( texture2D(tex, tc + %f * direction)+ texture2D(tex, tc - %f * direction));\n"
|
||||||
|
for i = 1, #offsets do
|
||||||
|
local offset = offsets[i]
|
||||||
|
local weight = weights[i]
|
||||||
|
norm = norm + weight * 2
|
||||||
|
code[#code + 1] = string.format(template, weight, offset, offset)
|
||||||
|
end
|
||||||
|
code[#code + 1] = string.format("return c * vec4(%f) * color; }", 1 / norm)
|
||||||
|
|
||||||
|
local shaderCode = table.concat(code)
|
||||||
|
return love.graphics.newShader(shaderCode)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get or create a blur instance from cache
|
||||||
|
---@param quality number Quality level (1-10)
|
||||||
|
---@return table blurData Cached blur data {shader, taps}
|
||||||
|
function Cache.getBlurInstance(quality)
|
||||||
|
if not Cache.blurInstances[quality] then
|
||||||
|
local taps = 3 + (quality - 1) * 1.5
|
||||||
|
taps = math.floor(taps)
|
||||||
|
if taps % 2 == 0 then
|
||||||
|
taps = taps + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local shader = ShaderBuilder.build(taps, 1.0, "weighted", -1)
|
||||||
|
Cache.blurInstances[quality] = {
|
||||||
|
shader = shader,
|
||||||
|
taps = taps,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return Cache.blurInstances[quality]
|
||||||
|
end
|
||||||
|
|
||||||
|
---@class BlurProps
|
||||||
|
---@field quality number? Quality level (1-10, default: 5)
|
||||||
|
|
||||||
|
---@class Blur
|
||||||
|
---@field shader love.Shader The blur shader
|
||||||
|
---@field quality number Quality level (1-10)
|
||||||
|
---@field taps number Number of shader taps
|
||||||
|
---@field _ErrorHandler table? Reference to ErrorHandler module
|
||||||
|
local Blur = {}
|
||||||
|
Blur.__index = Blur
|
||||||
|
|
||||||
|
--- Check if we should warn about large blur area in immediate mode
|
||||||
|
---@param elementId string|nil Element ID for caching warnings
|
||||||
|
---@param width number Blur area width
|
||||||
|
---@param height number Blur area height
|
||||||
|
---@param blurType string "content" or "backdrop"
|
||||||
|
local function checkLargeBlurWarning(elementId, width, height, blurType)
|
||||||
|
-- Skip if no ErrorHandler available
|
||||||
|
if not Blur._ErrorHandler then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Skip if not in immediate mode
|
||||||
|
if not Blur._blurOptimizations then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Calculate blur area
|
||||||
|
local area = width * height
|
||||||
|
|
||||||
|
-- Skip if area is below threshold
|
||||||
|
if area <= Cache.LARGE_BLUR_THRESHOLD then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Generate warning key (use elementId if available, otherwise use dimensions)
|
||||||
|
local warningKey = elementId or string.format("%dx%d:%s", width, height, blurType)
|
||||||
|
|
||||||
|
-- Skip if already warned for this element/area
|
||||||
|
if warningCache[warningKey] then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Mark as warned
|
||||||
|
warningCache[warningKey] = true
|
||||||
|
|
||||||
|
-- Issue warning
|
||||||
|
local message =
|
||||||
|
string.format("Large %s blur area detected (%dx%d = %d pixels) in immediate mode", blurType, width, height, area)
|
||||||
|
|
||||||
|
local suggestion =
|
||||||
|
"Consider using retained mode for this component to avoid recreating blur effects every frame. Large blur operations are expensive and can cause performance issues in immediate mode."
|
||||||
|
|
||||||
|
Blur._ErrorHandler:warn("Blur", "PERF_003", {
|
||||||
|
area = string.format("%.0fx%.0f", width or 0, height or 0),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Create a new blur effect instance
|
||||||
|
---@param props BlurProps? Blur configuration
|
||||||
|
---@return Blur blur The new blur instance
|
||||||
|
function Blur.new(props)
|
||||||
|
props = props or {}
|
||||||
|
|
||||||
|
local quality = props.quality or 5
|
||||||
|
quality = math.max(1, math.min(10, quality))
|
||||||
|
|
||||||
|
-- Get cached blur instance for this quality level
|
||||||
|
local blurData = Cache.getBlurInstance(quality)
|
||||||
|
|
||||||
|
local self = setmetatable({}, Blur)
|
||||||
|
self.shader = blurData.shader
|
||||||
|
self.quality = quality
|
||||||
|
self.taps = blurData.taps
|
||||||
|
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Apply blur to a region of the screen
|
||||||
|
---@param radius number Blur radius in pixels
|
||||||
|
---@param x number X position
|
||||||
|
---@param y number Y position
|
||||||
|
---@param width number Width of region
|
||||||
|
---@param height number Height of region
|
||||||
|
---@param drawFunc function Function to draw content to be blurred
|
||||||
|
function Blur:applyToRegion(radius, x, y, width, height, drawFunc)
|
||||||
|
if type(drawFunc) ~= "function" then
|
||||||
|
if Blur._ErrorHandler then
|
||||||
|
Blur._ErrorHandler:warn("Blur", "BLUR_001")
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if radius <= 0 or width <= 0 or height <= 0 then
|
||||||
|
drawFunc()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Early exit for very low radius (optimization)
|
||||||
|
if radius < Cache.RADIUS_THRESHOLD then
|
||||||
|
drawFunc()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for large blur area in immediate mode
|
||||||
|
checkLargeBlurWarning(nil, width, height, "content")
|
||||||
|
|
||||||
|
-- Calculate offset multiplier based on radius and quality
|
||||||
|
-- Higher quality = more samples = smaller steps for same radius
|
||||||
|
local offsetMultiplier = radius / self.quality
|
||||||
|
|
||||||
|
local canvas1 = Cache.getCanvas(width, height)
|
||||||
|
local canvas2 = Cache.getCanvas(width, height)
|
||||||
|
|
||||||
|
local prevCanvas = love.graphics.getCanvas()
|
||||||
|
local prevShader = love.graphics.getShader()
|
||||||
|
local prevColor = { love.graphics.getColor() }
|
||||||
|
local prevBlendMode = love.graphics.getBlendMode()
|
||||||
|
|
||||||
|
love.graphics.setCanvas(canvas1)
|
||||||
|
love.graphics.clear()
|
||||||
|
love.graphics.push()
|
||||||
|
love.graphics.origin()
|
||||||
|
love.graphics.translate(-x, -y)
|
||||||
|
drawFunc()
|
||||||
|
love.graphics.pop()
|
||||||
|
|
||||||
|
love.graphics.setShader(self.shader)
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.setBlendMode("alpha", "premultiplied")
|
||||||
|
|
||||||
|
-- Single pass with radius-controlled offset
|
||||||
|
love.graphics.setCanvas(canvas2)
|
||||||
|
love.graphics.clear()
|
||||||
|
self.shader:send("direction", { offsetMultiplier / width, 0 })
|
||||||
|
love.graphics.draw(canvas1, 0, 0)
|
||||||
|
|
||||||
|
love.graphics.setCanvas(canvas1)
|
||||||
|
love.graphics.clear()
|
||||||
|
self.shader:send("direction", { 0, offsetMultiplier / height })
|
||||||
|
love.graphics.draw(canvas2, 0, 0)
|
||||||
|
|
||||||
|
love.graphics.setCanvas(prevCanvas)
|
||||||
|
love.graphics.setShader()
|
||||||
|
love.graphics.setBlendMode(prevBlendMode)
|
||||||
|
love.graphics.draw(canvas1, x, y)
|
||||||
|
|
||||||
|
love.graphics.setShader(prevShader)
|
||||||
|
love.graphics.setColor(unpack(prevColor))
|
||||||
|
|
||||||
|
Cache.releaseCanvas(canvas1)
|
||||||
|
Cache.releaseCanvas(canvas2)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Apply backdrop blur effect (blur content behind a region)
|
||||||
|
---@param radius number Blur radius in pixels
|
||||||
|
---@param x number X position
|
||||||
|
---@param y number Y position
|
||||||
|
---@param width number Width of region
|
||||||
|
---@param height number Height of region
|
||||||
|
---@param backdropCanvas love.Canvas Canvas containing the backdrop content
|
||||||
|
function Blur:applyBackdrop(radius, x, y, width, height, backdropCanvas)
|
||||||
|
if not backdropCanvas then
|
||||||
|
if Blur._ErrorHandler then
|
||||||
|
Blur._ErrorHandler:warn("Blur", "BLUR_002")
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if radius <= 0 or width <= 0 or height <= 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Early exit for very low radius (optimization)
|
||||||
|
if radius < Cache.RADIUS_THRESHOLD then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Calculate offset multiplier based on radius and quality
|
||||||
|
local offsetMultiplier = radius / self.quality
|
||||||
|
|
||||||
|
local canvas1 = Cache.getCanvas(width, height)
|
||||||
|
local canvas2 = Cache.getCanvas(width, height)
|
||||||
|
|
||||||
|
local prevCanvas = love.graphics.getCanvas()
|
||||||
|
local prevShader = love.graphics.getShader()
|
||||||
|
local prevColor = { love.graphics.getColor() }
|
||||||
|
local prevBlendMode = love.graphics.getBlendMode()
|
||||||
|
|
||||||
|
love.graphics.setCanvas(canvas1)
|
||||||
|
love.graphics.clear()
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.setBlendMode("alpha", "premultiplied")
|
||||||
|
|
||||||
|
local backdropWidth, backdropHeight = backdropCanvas:getDimensions()
|
||||||
|
local quad = Cache.getQuad(x, y, width, height, backdropWidth, backdropHeight)
|
||||||
|
love.graphics.draw(backdropCanvas, quad, 0, 0)
|
||||||
|
|
||||||
|
love.graphics.setShader(self.shader)
|
||||||
|
|
||||||
|
-- Single pass with radius-controlled offset
|
||||||
|
love.graphics.setCanvas(canvas2)
|
||||||
|
love.graphics.clear()
|
||||||
|
self.shader:send("direction", { offsetMultiplier / width, 0 })
|
||||||
|
love.graphics.draw(canvas1, 0, 0)
|
||||||
|
|
||||||
|
love.graphics.setCanvas(canvas1)
|
||||||
|
love.graphics.clear()
|
||||||
|
self.shader:send("direction", { 0, offsetMultiplier / height })
|
||||||
|
love.graphics.draw(canvas2, 0, 0)
|
||||||
|
|
||||||
|
love.graphics.setCanvas(prevCanvas)
|
||||||
|
love.graphics.setShader()
|
||||||
|
love.graphics.setBlendMode(prevBlendMode)
|
||||||
|
love.graphics.draw(canvas1, x, y)
|
||||||
|
|
||||||
|
love.graphics.setShader(prevShader)
|
||||||
|
love.graphics.setColor(unpack(prevColor))
|
||||||
|
|
||||||
|
Cache.releaseCanvas(canvas1)
|
||||||
|
Cache.releaseCanvas(canvas2)
|
||||||
|
Cache.releaseQuad(quad)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get the current quality level
|
||||||
|
---@return number quality Quality level (1-10)
|
||||||
|
function Blur:getQuality()
|
||||||
|
return self.quality
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get the number of shader taps
|
||||||
|
---@return number taps Number of shader taps
|
||||||
|
function Blur:getTaps()
|
||||||
|
return self.taps
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Clear all caches (call on window resize or memory cleanup)
|
||||||
|
function Blur.clearCache()
|
||||||
|
Cache.clear()
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Apply backdrop blur with caching support
|
||||||
|
---@param radius number Blur radius in pixels
|
||||||
|
---@param x number X position
|
||||||
|
---@param y number Y position
|
||||||
|
---@param width number Width of region
|
||||||
|
---@param height number Height of region
|
||||||
|
---@param backdropCanvas love.Canvas Canvas containing the backdrop content
|
||||||
|
---@param elementId string|nil Element ID for caching (nil disables caching)
|
||||||
|
function Blur:applyBackdropCached(radius, x, y, width, height, backdropCanvas, elementId)
|
||||||
|
-- If caching is disabled or no element ID, fall back to regular apply
|
||||||
|
if not Blur._blurOptimizations or not elementId then
|
||||||
|
return self:applyBackdrop(radius, x, y, width, height, backdropCanvas)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Generate cache key
|
||||||
|
local cacheKey = Cache.generateBlurCacheKey(elementId, x, y, width, height, radius, self.quality, true)
|
||||||
|
|
||||||
|
-- Check cache
|
||||||
|
local cachedCanvas = Cache.getBlurredCanvas(cacheKey)
|
||||||
|
if cachedCanvas then
|
||||||
|
-- Draw cached blur
|
||||||
|
local prevCanvas = love.graphics.getCanvas()
|
||||||
|
local prevShader = love.graphics.getShader()
|
||||||
|
local prevColor = { love.graphics.getColor() }
|
||||||
|
local prevBlendMode = love.graphics.getBlendMode()
|
||||||
|
|
||||||
|
love.graphics.setCanvas(prevCanvas)
|
||||||
|
love.graphics.setShader()
|
||||||
|
love.graphics.setBlendMode(prevBlendMode)
|
||||||
|
love.graphics.draw(cachedCanvas, x, y)
|
||||||
|
|
||||||
|
love.graphics.setShader(prevShader)
|
||||||
|
love.graphics.setColor(unpack(prevColor))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Not cached, render and cache
|
||||||
|
if not backdropCanvas then
|
||||||
|
if Blur._ErrorHandler then
|
||||||
|
Blur._ErrorHandler:warn("Blur", "BLUR_002")
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if radius <= 0 or width <= 0 or height <= 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Early exit for very low radius (optimization)
|
||||||
|
if radius < Cache.RADIUS_THRESHOLD then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for large blur area in immediate mode
|
||||||
|
checkLargeBlurWarning(elementId, width, height, "backdrop")
|
||||||
|
|
||||||
|
-- Calculate offset multiplier based on radius and quality
|
||||||
|
local offsetMultiplier = radius / self.quality
|
||||||
|
|
||||||
|
local canvas1 = Cache.getCanvas(width, height)
|
||||||
|
local canvas2 = Cache.getCanvas(width, height)
|
||||||
|
|
||||||
|
local prevCanvas = love.graphics.getCanvas()
|
||||||
|
local prevShader = love.graphics.getShader()
|
||||||
|
local prevColor = { love.graphics.getColor() }
|
||||||
|
local prevBlendMode = love.graphics.getBlendMode()
|
||||||
|
|
||||||
|
love.graphics.setCanvas(canvas1)
|
||||||
|
love.graphics.clear()
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.setBlendMode("alpha", "premultiplied")
|
||||||
|
|
||||||
|
local backdropWidth, backdropHeight = backdropCanvas:getDimensions()
|
||||||
|
local quad = Cache.getQuad(x, y, width, height, backdropWidth, backdropHeight)
|
||||||
|
love.graphics.draw(backdropCanvas, quad, 0, 0)
|
||||||
|
|
||||||
|
love.graphics.setShader(self.shader)
|
||||||
|
|
||||||
|
-- Single pass with radius-controlled offset
|
||||||
|
love.graphics.setCanvas(canvas2)
|
||||||
|
love.graphics.clear()
|
||||||
|
self.shader:send("direction", { offsetMultiplier / width, 0 })
|
||||||
|
love.graphics.draw(canvas1, 0, 0)
|
||||||
|
|
||||||
|
love.graphics.setCanvas(canvas1)
|
||||||
|
love.graphics.clear()
|
||||||
|
self.shader:send("direction", { 0, offsetMultiplier / height })
|
||||||
|
love.graphics.draw(canvas2, 0, 0)
|
||||||
|
|
||||||
|
-- Cache the result
|
||||||
|
local cachedResult = love.graphics.newCanvas(width, height)
|
||||||
|
love.graphics.setCanvas(cachedResult)
|
||||||
|
love.graphics.clear()
|
||||||
|
love.graphics.setShader()
|
||||||
|
love.graphics.setBlendMode("alpha", "premultiplied")
|
||||||
|
love.graphics.draw(canvas1, 0, 0)
|
||||||
|
Cache.setBlurredCanvas(cacheKey, cachedResult)
|
||||||
|
|
||||||
|
love.graphics.setCanvas(prevCanvas)
|
||||||
|
love.graphics.setShader()
|
||||||
|
love.graphics.setBlendMode(prevBlendMode)
|
||||||
|
love.graphics.draw(canvas1, x, y)
|
||||||
|
|
||||||
|
love.graphics.setShader(prevShader)
|
||||||
|
love.graphics.setColor(unpack(prevColor))
|
||||||
|
|
||||||
|
Cache.releaseCanvas(canvas1)
|
||||||
|
Cache.releaseCanvas(canvas2)
|
||||||
|
Cache.releaseQuad(quad)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Clear blur cache for specific element
|
||||||
|
---@param elementId string Element ID
|
||||||
|
function Blur.clearElementCache(elementId)
|
||||||
|
Cache.clearBlurredCanvasesForElement(elementId)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Initialize Blur module with dependencies
|
||||||
|
---@param deps table Dependencies: { ErrorHandler = ErrorHandler?, immediateModeOptimizations = boolean? }
|
||||||
|
function Blur.init(deps)
|
||||||
|
if type(deps) == "table" then
|
||||||
|
Blur._ErrorHandler = deps.ErrorHandler
|
||||||
|
Blur._blurOptimizations = deps.immediateModeOptimizations or false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Blur.Cache = Cache
|
||||||
|
Blur.ShaderBuilder = ShaderBuilder
|
||||||
|
|
||||||
|
return Blur
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
--- Utility module for parsing and evaluating CSS-like calc() expressions
|
||||||
|
--- Supports arithmetic operations (+, -, *, /) with mixed units (px, %, vw, vh)
|
||||||
|
---@class Calc
|
||||||
|
local Calc = {}
|
||||||
|
|
||||||
|
--- Initialize Calc module with dependencies
|
||||||
|
---@param deps CalcDependencies Dependencies: { ErrorHandler = ErrorHandler? }
|
||||||
|
function Calc.init(deps)
|
||||||
|
Calc._ErrorHandler = deps.ErrorHandler
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Token types for lexical analysis
|
||||||
|
local TokenType = {
|
||||||
|
NUMBER = "NUMBER",
|
||||||
|
UNIT = "UNIT",
|
||||||
|
PLUS = "PLUS",
|
||||||
|
MINUS = "MINUS",
|
||||||
|
MULTIPLY = "MULTIPLY",
|
||||||
|
DIVIDE = "DIVIDE",
|
||||||
|
LPAREN = "LPAREN",
|
||||||
|
RPAREN = "RPAREN",
|
||||||
|
EOF = "EOF",
|
||||||
|
}
|
||||||
|
|
||||||
|
--- Tokenize a calc expression string into tokens
|
||||||
|
---@param expr string The expression to tokenize (e.g., "50% - 10vw")
|
||||||
|
---@return CalcToken[]? tokens Array of tokens with type, value, unit
|
||||||
|
---@return string? error Error message if tokenization fails
|
||||||
|
local function tokenize(expr)
|
||||||
|
local tokens = {}
|
||||||
|
local i = 1
|
||||||
|
local len = #expr
|
||||||
|
|
||||||
|
while i <= len do
|
||||||
|
local char = expr:sub(i, i)
|
||||||
|
|
||||||
|
-- Skip whitespace
|
||||||
|
if char:match("%s") then
|
||||||
|
i = i + 1
|
||||||
|
-- Number (including decimals, but NOT negative - handled separately below)
|
||||||
|
elseif char:match("%d") or (char == "." and expr:sub(i + 1, i + 1):match("%d")) then
|
||||||
|
local numStr = ""
|
||||||
|
|
||||||
|
-- Parse integer and decimal parts
|
||||||
|
while i <= len and (expr:sub(i, i):match("%d") or expr:sub(i, i) == ".") do
|
||||||
|
numStr = numStr .. expr:sub(i, i)
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local num = tonumber(numStr)
|
||||||
|
if not num then
|
||||||
|
return nil, "Invalid number: " .. numStr
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for unit following the number
|
||||||
|
local unitStr = ""
|
||||||
|
while i <= len and expr:sub(i, i):match("[%a%%]") do
|
||||||
|
unitStr = unitStr .. expr:sub(i, i)
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Default to px if no unit
|
||||||
|
if unitStr == "" then
|
||||||
|
unitStr = "px"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Validate unit
|
||||||
|
local validUnits = { px = true, ["%"] = true, vw = true, vh = true }
|
||||||
|
if not validUnits[unitStr] then
|
||||||
|
return nil, "Invalid unit: " .. unitStr
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(tokens, {
|
||||||
|
type = TokenType.NUMBER,
|
||||||
|
value = num,
|
||||||
|
unit = unitStr,
|
||||||
|
})
|
||||||
|
-- Operators
|
||||||
|
elseif char == "+" then
|
||||||
|
table.insert(tokens, { type = TokenType.PLUS })
|
||||||
|
i = i + 1
|
||||||
|
elseif char == "-" then
|
||||||
|
-- Check if this is a negative number or subtraction
|
||||||
|
-- It's a negative number if previous token is an operator or opening paren
|
||||||
|
local prevToken = tokens[#tokens]
|
||||||
|
if
|
||||||
|
not prevToken
|
||||||
|
or prevToken.type == TokenType.PLUS
|
||||||
|
or prevToken.type == TokenType.MINUS
|
||||||
|
or prevToken.type == TokenType.MULTIPLY
|
||||||
|
or prevToken.type == TokenType.DIVIDE
|
||||||
|
or prevToken.type == TokenType.LPAREN
|
||||||
|
then
|
||||||
|
-- This is a negative number, continue to number parsing
|
||||||
|
local numStr = "-"
|
||||||
|
i = i + 1
|
||||||
|
|
||||||
|
-- Parse integer and decimal parts
|
||||||
|
while i <= len and (expr:sub(i, i):match("%d") or expr:sub(i, i) == ".") do
|
||||||
|
numStr = numStr .. expr:sub(i, i)
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local num = tonumber(numStr)
|
||||||
|
if not num then
|
||||||
|
return nil, "Invalid number: " .. numStr
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for unit following the number
|
||||||
|
local unitStr = ""
|
||||||
|
while i <= len and expr:sub(i, i):match("[%a%%]") do
|
||||||
|
unitStr = unitStr .. expr:sub(i, i)
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Default to px if no unit
|
||||||
|
if unitStr == "" then
|
||||||
|
unitStr = "px"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Validate unit
|
||||||
|
local validUnits = { px = true, ["%"] = true, vw = true, vh = true }
|
||||||
|
if not validUnits[unitStr] then
|
||||||
|
return nil, "Invalid unit: " .. unitStr
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(tokens, {
|
||||||
|
type = TokenType.NUMBER,
|
||||||
|
value = num,
|
||||||
|
unit = unitStr,
|
||||||
|
})
|
||||||
|
else
|
||||||
|
-- This is subtraction operator
|
||||||
|
table.insert(tokens, { type = TokenType.MINUS })
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
elseif char == "*" then
|
||||||
|
table.insert(tokens, { type = TokenType.MULTIPLY })
|
||||||
|
i = i + 1
|
||||||
|
elseif char == "/" then
|
||||||
|
table.insert(tokens, { type = TokenType.DIVIDE })
|
||||||
|
i = i + 1
|
||||||
|
elseif char == "(" then
|
||||||
|
table.insert(tokens, { type = TokenType.LPAREN })
|
||||||
|
i = i + 1
|
||||||
|
elseif char == ")" then
|
||||||
|
table.insert(tokens, { type = TokenType.RPAREN })
|
||||||
|
i = i + 1
|
||||||
|
else
|
||||||
|
return nil, "Unexpected character: " .. char
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(tokens, { type = TokenType.EOF })
|
||||||
|
return tokens
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Parser for calc expressions using recursive descent
|
||||||
|
---@class Parser
|
||||||
|
---@field tokens CalcToken[] Array of tokens
|
||||||
|
---@field pos number Current token position
|
||||||
|
local Parser = {}
|
||||||
|
Parser.__index = Parser
|
||||||
|
|
||||||
|
--- Create a new parser
|
||||||
|
---@param tokens CalcToken[] Array of tokens
|
||||||
|
---@return Parser
|
||||||
|
function Parser.new(tokens)
|
||||||
|
local self = setmetatable({}, Parser)
|
||||||
|
self.tokens = tokens
|
||||||
|
self.pos = 1
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get current token
|
||||||
|
---@return CalcToken token Current token
|
||||||
|
function Parser:current()
|
||||||
|
return self.tokens[self.pos]
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Advance to next token
|
||||||
|
function Parser:advance()
|
||||||
|
self.pos = self.pos + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Parse expression (handles + and -)
|
||||||
|
---@return CalcASTNode ast Abstract syntax tree node
|
||||||
|
function Parser:parseExpression()
|
||||||
|
local left = self:parseTerm()
|
||||||
|
|
||||||
|
while self:current().type == TokenType.PLUS or self:current().type == TokenType.MINUS do
|
||||||
|
local op = self:current().type
|
||||||
|
self:advance()
|
||||||
|
local right = self:parseTerm()
|
||||||
|
left = {
|
||||||
|
type = op == TokenType.PLUS and "add" or "subtract",
|
||||||
|
left = left,
|
||||||
|
right = right,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return left
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Parse term (handles * and /)
|
||||||
|
---@return CalcASTNode ast Abstract syntax tree node
|
||||||
|
function Parser:parseTerm()
|
||||||
|
local left = self:parseFactor()
|
||||||
|
|
||||||
|
while self:current().type == TokenType.MULTIPLY or self:current().type == TokenType.DIVIDE do
|
||||||
|
local op = self:current().type
|
||||||
|
self:advance()
|
||||||
|
local right = self:parseFactor()
|
||||||
|
left = {
|
||||||
|
type = op == TokenType.MULTIPLY and "multiply" or "divide",
|
||||||
|
left = left,
|
||||||
|
right = right,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return left
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Parse factor (handles numbers and parentheses)
|
||||||
|
---@return CalcASTNode ast Abstract syntax tree node
|
||||||
|
function Parser:parseFactor()
|
||||||
|
local token = self:current()
|
||||||
|
|
||||||
|
if token.type == TokenType.NUMBER then
|
||||||
|
self:advance()
|
||||||
|
return {
|
||||||
|
type = "number",
|
||||||
|
value = token.value,
|
||||||
|
unit = token.unit,
|
||||||
|
}
|
||||||
|
elseif token.type == TokenType.LPAREN then
|
||||||
|
self:advance()
|
||||||
|
local expr = self:parseExpression()
|
||||||
|
if self:current().type ~= TokenType.RPAREN then
|
||||||
|
error("Expected closing parenthesis")
|
||||||
|
end
|
||||||
|
self:advance()
|
||||||
|
return expr
|
||||||
|
else
|
||||||
|
error("Unexpected token: " .. token.type)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Parse the tokens into an AST
|
||||||
|
---@return CalcASTNode ast Abstract syntax tree
|
||||||
|
function Parser:parse()
|
||||||
|
local ast = self:parseExpression()
|
||||||
|
if self:current().type ~= TokenType.EOF then
|
||||||
|
error("Unexpected tokens after expression")
|
||||||
|
end
|
||||||
|
return ast
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Create a calc expression object that can be resolved later
|
||||||
|
--- This is the main API function that users call
|
||||||
|
---@param expr string The calc expression (e.g., "50% - 10vw")
|
||||||
|
---@return CalcObject calcObject A calc expression object with AST
|
||||||
|
function Calc.new(expr)
|
||||||
|
-- Tokenize
|
||||||
|
local tokens, err = tokenize(expr)
|
||||||
|
if not tokens then
|
||||||
|
if Calc._ErrorHandler then
|
||||||
|
Calc._ErrorHandler:warn("Calc", "VAL_006", {
|
||||||
|
expression = expr,
|
||||||
|
error = err,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
-- Return a fallback calc object that resolves to 0
|
||||||
|
return {
|
||||||
|
_isCalc = true,
|
||||||
|
_expr = expr,
|
||||||
|
_ast = nil,
|
||||||
|
_error = err,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Parse
|
||||||
|
local parser = Parser.new(tokens)
|
||||||
|
local success, ast = pcall(function()
|
||||||
|
return parser:parse()
|
||||||
|
end)
|
||||||
|
|
||||||
|
if not success then
|
||||||
|
if Calc._ErrorHandler then
|
||||||
|
Calc._ErrorHandler:warn("Calc", "VAL_006", {
|
||||||
|
expression = expr,
|
||||||
|
error = ast, -- ast contains error message on failure
|
||||||
|
})
|
||||||
|
end
|
||||||
|
-- Return a fallback calc object that resolves to 0
|
||||||
|
return {
|
||||||
|
_isCalc = true,
|
||||||
|
_expr = expr,
|
||||||
|
_ast = nil,
|
||||||
|
_error = ast,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
_isCalc = true,
|
||||||
|
_expr = expr,
|
||||||
|
_ast = ast,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Check if a value is a calc expression
|
||||||
|
---@param value any The value to check
|
||||||
|
---@return boolean isCalc True if value is a calc expression
|
||||||
|
function Calc.isCalc(value)
|
||||||
|
return type(value) == "table" and value._isCalc == true
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Resolve a calc expression to pixel value
|
||||||
|
---@param calcObj CalcObject The calc expression object
|
||||||
|
---@param viewportWidth number Viewport width in pixels
|
||||||
|
---@param viewportHeight number Viewport height in pixels
|
||||||
|
---@param parentSize number? Parent dimension for percentage units
|
||||||
|
---@return number resolvedValue Resolved pixel value
|
||||||
|
function Calc.resolve(calcObj, viewportWidth, viewportHeight, parentSize)
|
||||||
|
if not calcObj._ast then
|
||||||
|
-- Error during parsing, return 0
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Evaluate AST node recursively
|
||||||
|
---@param node table AST node
|
||||||
|
---@return number value Evaluated value in pixels
|
||||||
|
local function evaluate(node)
|
||||||
|
if node.type == "number" then
|
||||||
|
-- Convert unit to pixels
|
||||||
|
local value = node.value
|
||||||
|
local unit = node.unit
|
||||||
|
|
||||||
|
if unit == "px" then
|
||||||
|
return value
|
||||||
|
elseif unit == "%" then
|
||||||
|
if not parentSize then
|
||||||
|
if Calc._ErrorHandler then
|
||||||
|
Calc._ErrorHandler:warn("Calc", "LAY_003", {
|
||||||
|
unit = "%",
|
||||||
|
issue = "parent dimension not available",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
return (value / 100) * parentSize
|
||||||
|
elseif unit == "vw" then
|
||||||
|
return (value / 100) * viewportWidth
|
||||||
|
elseif unit == "vh" then
|
||||||
|
return (value / 100) * viewportHeight
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
elseif node.type == "add" then
|
||||||
|
return evaluate(node.left) + evaluate(node.right)
|
||||||
|
elseif node.type == "subtract" then
|
||||||
|
return evaluate(node.left) - evaluate(node.right)
|
||||||
|
elseif node.type == "multiply" then
|
||||||
|
return evaluate(node.left) * evaluate(node.right)
|
||||||
|
elseif node.type == "divide" then
|
||||||
|
local divisor = evaluate(node.right)
|
||||||
|
if divisor == 0 then
|
||||||
|
if Calc._ErrorHandler then
|
||||||
|
Calc._ErrorHandler:warn("Calc", "VAL_006", {
|
||||||
|
expression = calcObj._expr,
|
||||||
|
error = "Division by zero",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
return evaluate(node.left) / divisor
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return evaluate(calcObj._ast)
|
||||||
|
end
|
||||||
|
|
||||||
|
return Calc
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
---@class Color
|
||||||
|
local Color = {}
|
||||||
|
Color.__index = Color
|
||||||
|
|
||||||
|
--- Initialize module with shared dependencies
|
||||||
|
---@param deps table Dependencies {ErrorHandler}
|
||||||
|
function Color.init(deps)
|
||||||
|
if type(deps) == "table" then
|
||||||
|
Color._ErrorHandler = deps.ErrorHandler
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Build type-safe color objects with automatic validation and clamping
|
||||||
|
--- Use this to avoid invalid color values and ensure consistent LÖVE-compatible colors (0-1 range)
|
||||||
|
---@param r number? Red component (0-1), defaults to 0
|
||||||
|
---@param g number? Green component (0-1), defaults to 0
|
||||||
|
---@param b number? Blue component (0-1), defaults to 0
|
||||||
|
---@param a number? Alpha component (0-1), defaults to 1
|
||||||
|
---@return Color color The new color instance
|
||||||
|
function Color.new(r, g, b, a)
|
||||||
|
-- Sanitize and clamp color components
|
||||||
|
local _, sanitizedR = Color.validateColorChannel(r or 0, 1)
|
||||||
|
local _, sanitizedG = Color.validateColorChannel(g or 0, 1)
|
||||||
|
local _, sanitizedB = Color.validateColorChannel(b or 0, 1)
|
||||||
|
local _, sanitizedA = Color.validateColorChannel(a or 1, 1)
|
||||||
|
|
||||||
|
-- FFI structs don't support metatables/methods without wrapping
|
||||||
|
-- The wrapping overhead negates the FFI benefits
|
||||||
|
local self = setmetatable({}, Color)
|
||||||
|
self.r = sanitizedR or 0
|
||||||
|
self.g = sanitizedG or 0
|
||||||
|
self.b = sanitizedB or 0
|
||||||
|
self.a = sanitizedA or 1
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Extract individual color channels for use with love.graphics.setColor()
|
||||||
|
--- Use this to pass colors to LÖVE's rendering functions
|
||||||
|
---@return number r Red component (0-1)
|
||||||
|
---@return number g Green component (0-1)
|
||||||
|
---@return number b Blue component (0-1)
|
||||||
|
---@return number a Alpha component (0-1)
|
||||||
|
function Color:toRGBA()
|
||||||
|
return self.r, self.g, self.b, self.a
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Parse CSS-style hex colors into Color objects for designer-friendly workflows
|
||||||
|
--- Use this to work with colors from design tools that export hex values
|
||||||
|
---@param hexWithTag string Hex color string (e.g. "#RRGGBB" or "#RRGGBBAA")
|
||||||
|
---@return Color color The parsed color (returns white on error with warning)
|
||||||
|
function Color.fromHex(hexWithTag)
|
||||||
|
-- Validate input type
|
||||||
|
if type(hexWithTag) ~= "string" then
|
||||||
|
Color._ErrorHandler:warn("Color", "VAL_004", {
|
||||||
|
input = tostring(hexWithTag),
|
||||||
|
issue = "not a string",
|
||||||
|
fallback = "white (#FFFFFF)",
|
||||||
|
})
|
||||||
|
return Color.new(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
local hex = hexWithTag:gsub("#", "")
|
||||||
|
if #hex == 6 then
|
||||||
|
local r = tonumber("0x" .. hex:sub(1, 2))
|
||||||
|
local g = tonumber("0x" .. hex:sub(3, 4))
|
||||||
|
local b = tonumber("0x" .. hex:sub(5, 6))
|
||||||
|
if not r or not g or not b then
|
||||||
|
Color._ErrorHandler:warn("Color", "VAL_004", {
|
||||||
|
input = hexWithTag,
|
||||||
|
issue = "invalid hex digits",
|
||||||
|
fallback = "white (#FFFFFF)",
|
||||||
|
})
|
||||||
|
return Color.new(1, 1, 1, 1) -- Return white as fallback
|
||||||
|
end
|
||||||
|
return Color.new(r / 255, g / 255, b / 255, 1)
|
||||||
|
elseif #hex == 8 then
|
||||||
|
local r = tonumber("0x" .. hex:sub(1, 2))
|
||||||
|
local g = tonumber("0x" .. hex:sub(3, 4))
|
||||||
|
local b = tonumber("0x" .. hex:sub(5, 6))
|
||||||
|
local a = tonumber("0x" .. hex:sub(7, 8))
|
||||||
|
if not r or not g or not b or not a then
|
||||||
|
Color._ErrorHandler:warn("Color", "VAL_004", {
|
||||||
|
input = hexWithTag,
|
||||||
|
issue = "invalid hex digits",
|
||||||
|
fallback = "white (#FFFFFFFF)",
|
||||||
|
})
|
||||||
|
return Color.new(1, 1, 1, 1) -- Return white as fallback
|
||||||
|
end
|
||||||
|
return Color.new(r / 255, g / 255, b / 255, a / 255)
|
||||||
|
else
|
||||||
|
Color._ErrorHandler:warn("Color", "VAL_004", {
|
||||||
|
input = hexWithTag,
|
||||||
|
expected = "#RRGGBB or #RRGGBBAA",
|
||||||
|
hexLength = #hex,
|
||||||
|
fallback = "white (#FFFFFF)",
|
||||||
|
})
|
||||||
|
return Color.new(1, 1, 1, 1) -- Return white as fallback
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Verify and sanitize individual color components to prevent rendering errors
|
||||||
|
--- Use this to safely process user input or external color data
|
||||||
|
---@param value any Value to validate
|
||||||
|
---@param max number? Maximum value (255 for 0-255 range, 1 for 0-1 range), defaults to 1
|
||||||
|
---@return boolean valid True if valid
|
||||||
|
---@return number? clamped Clamped value in 0-1 range, nil if invalid
|
||||||
|
function Color.validateColorChannel(value, max)
|
||||||
|
max = max or 1
|
||||||
|
|
||||||
|
if type(value) ~= "number" then
|
||||||
|
return false, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for NaN
|
||||||
|
if value ~= value then
|
||||||
|
return false, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for Infinity
|
||||||
|
if value == math.huge or value == -math.huge then
|
||||||
|
return false, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Normalize to 0-1 range
|
||||||
|
local normalized = value
|
||||||
|
if max == 255 then
|
||||||
|
normalized = value / 255
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Clamp to valid range
|
||||||
|
normalized = math.max(0, math.min(1, normalized))
|
||||||
|
|
||||||
|
return true, normalized
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Validate hex color format
|
||||||
|
---@param hex string Hex color string (with or without #)
|
||||||
|
---@return boolean valid True if valid format
|
||||||
|
---@return string? error Error message if invalid, nil if valid
|
||||||
|
function Color.validateHexColor(hex)
|
||||||
|
if type(hex) ~= "string" then
|
||||||
|
return false, "Hex color must be a string"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Remove # prefix
|
||||||
|
local cleanHex = hex:gsub("^#", "")
|
||||||
|
|
||||||
|
-- Check length (3, 6, or 8 characters)
|
||||||
|
if #cleanHex ~= 3 and #cleanHex ~= 6 and #cleanHex ~= 8 then
|
||||||
|
return false, string.format("Invalid hex length: %d. Expected 3, 6, or 8 characters", #cleanHex)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for valid hex characters
|
||||||
|
if not cleanHex:match("^[0-9A-Fa-f]+$") then
|
||||||
|
return false, "Invalid hex characters. Use only 0-9, A-F"
|
||||||
|
end
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Validate RGB/RGBA color values
|
||||||
|
---@param r number Red component
|
||||||
|
---@param g number Green component
|
||||||
|
---@param b number Blue component
|
||||||
|
---@param a number? Alpha component (optional, defaults to max)
|
||||||
|
---@param max number? Maximum value (255 or 1), defaults to 1
|
||||||
|
---@return boolean valid True if valid
|
||||||
|
---@return string? error Error message if invalid, nil if valid
|
||||||
|
function Color.validateRGBColor(r, g, b, a, max)
|
||||||
|
max = max or 1
|
||||||
|
a = a or max
|
||||||
|
|
||||||
|
local rValid = Color.validateColorChannel(r, max)
|
||||||
|
local gValid = Color.validateColorChannel(g, max)
|
||||||
|
local bValid = Color.validateColorChannel(b, max)
|
||||||
|
local aValid = Color.validateColorChannel(a, max)
|
||||||
|
|
||||||
|
if not rValid then
|
||||||
|
return false, string.format("Invalid red channel: %s", tostring(r))
|
||||||
|
end
|
||||||
|
if not gValid then
|
||||||
|
return false, string.format("Invalid green channel: %s", tostring(g))
|
||||||
|
end
|
||||||
|
if not bValid then
|
||||||
|
return false, string.format("Invalid blue channel: %s", tostring(b))
|
||||||
|
end
|
||||||
|
if not aValid then
|
||||||
|
return false, string.format("Invalid alpha channel: %s", tostring(a))
|
||||||
|
end
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Check if a value is a valid color format
|
||||||
|
---@param value any Value to check
|
||||||
|
---@return string? format Format type ("hex", "named", "table"), nil if invalid
|
||||||
|
function Color.isValidColorFormat(value)
|
||||||
|
local valueType = type(value)
|
||||||
|
|
||||||
|
-- Check for hex string
|
||||||
|
if valueType == "string" then
|
||||||
|
if value:match("^#?[0-9A-Fa-f]+$") then
|
||||||
|
local valid = Color.validateHexColor(value)
|
||||||
|
if valid then
|
||||||
|
return "hex"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for table format
|
||||||
|
if valueType == "table" then
|
||||||
|
-- Check for Color instance
|
||||||
|
if getmetatable(value) == Color then
|
||||||
|
return "table"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for array format {r, g, b, a}
|
||||||
|
if value[1] and value[2] and value[3] then
|
||||||
|
local valid = Color.validateRGBColor(value[1], value[2], value[3], value[4])
|
||||||
|
if valid then
|
||||||
|
return "table"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for named format {r=, g=, b=, a=}
|
||||||
|
if value.r and value.g and value.b then
|
||||||
|
local valid = Color.validateRGBColor(value.r, value.g, value.b, value.a)
|
||||||
|
if valid then
|
||||||
|
return "table"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Convert any color format to a valid Color object with graceful fallbacks
|
||||||
|
--- Use this to robustly handle colors from any source without crashes
|
||||||
|
---@param value any Color value to sanitize (hex, named, table, or Color instance)
|
||||||
|
---@param default Color? Default color if invalid (defaults to black)
|
||||||
|
---@return Color color Sanitized color instance (guaranteed non-nil)
|
||||||
|
function Color.sanitizeColor(value, default)
|
||||||
|
default = default or Color.new(0, 0, 0, 1)
|
||||||
|
|
||||||
|
local format = Color.isValidColorFormat(value)
|
||||||
|
|
||||||
|
if not format then
|
||||||
|
return default
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Handle hex format
|
||||||
|
if format == "hex" then
|
||||||
|
local cleanHex = value:gsub("^#", "")
|
||||||
|
|
||||||
|
-- Expand 3-digit hex to 6-digit
|
||||||
|
if #cleanHex == 3 then
|
||||||
|
cleanHex = cleanHex:gsub("(.)", "%1%1")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Try to parse
|
||||||
|
local success, result = pcall(Color.fromHex, "#" .. cleanHex)
|
||||||
|
if success then
|
||||||
|
return result
|
||||||
|
else
|
||||||
|
return default
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if format == "table" then
|
||||||
|
-- Color instance
|
||||||
|
if getmetatable(value) == Color then
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Array format
|
||||||
|
if value[1] then
|
||||||
|
local _, r = Color.validateColorChannel(value[1], 1)
|
||||||
|
local _, g = Color.validateColorChannel(value[2], 1)
|
||||||
|
local _, b = Color.validateColorChannel(value[3], 1)
|
||||||
|
local _, a = Color.validateColorChannel(value[4] or 1, 1)
|
||||||
|
|
||||||
|
if r and g and b and a then
|
||||||
|
return Color.new(r, g, b, a)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Named format
|
||||||
|
if value.r then
|
||||||
|
local _, r = Color.validateColorChannel(value.r, 1)
|
||||||
|
local _, g = Color.validateColorChannel(value.g, 1)
|
||||||
|
local _, b = Color.validateColorChannel(value.b, 1)
|
||||||
|
local _, a = Color.validateColorChannel(value.a or 1, 1)
|
||||||
|
|
||||||
|
if r and g and b and a then
|
||||||
|
return Color.new(r, g, b, a)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return default
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Universally convert any color format (hex, named, table) into a Color object
|
||||||
|
--- Use this as your main color input handler to accept flexible color specifications
|
||||||
|
---@param value any Color value (hex string, named color, table, or Color instance)
|
||||||
|
---@return Color color Parsed color instance (defaults to black on error)
|
||||||
|
function Color.parse(value)
|
||||||
|
return Color.sanitizeColor(value, Color.new(0, 0, 0, 1))
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Smoothly transition between two colors for animations and gradients
|
||||||
|
--- Use this to create color-based animations without manual channel calculations
|
||||||
|
---@param colorA Color Starting color
|
||||||
|
---@param colorB Color Ending color
|
||||||
|
---@param t number Interpolation factor (0-1)
|
||||||
|
---@return Color color Interpolated color
|
||||||
|
function Color.lerp(colorA, colorB, t)
|
||||||
|
-- Sanitize inputs
|
||||||
|
if type(colorA) ~= "table" or getmetatable(colorA) ~= Color then
|
||||||
|
colorA = Color.new(0, 0, 0, 1)
|
||||||
|
end
|
||||||
|
if type(colorB) ~= "table" or getmetatable(colorB) ~= Color then
|
||||||
|
colorB = Color.new(0, 0, 0, 1)
|
||||||
|
end
|
||||||
|
if type(t) ~= "number" or t ~= t or t == math.huge or t == -math.huge then
|
||||||
|
t = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Clamp t to 0-1 range
|
||||||
|
t = math.max(0, math.min(1, t))
|
||||||
|
|
||||||
|
-- Linear interpolation for each channel
|
||||||
|
local oneMinusT = 1 - t
|
||||||
|
local r = colorA.r * oneMinusT + colorB.r * t
|
||||||
|
local g = colorA.g * oneMinusT + colorB.g * t
|
||||||
|
local b = colorA.b * oneMinusT + colorB.b * t
|
||||||
|
local a = colorA.a * oneMinusT + colorB.a * t
|
||||||
|
|
||||||
|
return Color.new(r, g, b, a)
|
||||||
|
end
|
||||||
|
|
||||||
|
return Color
|
||||||
@@ -0,0 +1,596 @@
|
|||||||
|
---@class Context
|
||||||
|
local modulePath = (...):match("(.-)[^%.]+$")
|
||||||
|
local ZIndex = require(modulePath .. "ZIndex")
|
||||||
|
local Element = require(modulePath .. "Element")
|
||||||
|
local Context = {
|
||||||
|
topElements = {},
|
||||||
|
-- Base scale configuration
|
||||||
|
baseScale = nil, -- {width: number, height: number}
|
||||||
|
-- Current scale factors
|
||||||
|
scaleFactors = { x = 1.0, y = 1.0 },
|
||||||
|
defaultTheme = nil,
|
||||||
|
_focusedElement = nil,
|
||||||
|
_focusedElementId = nil, -- Stable id used to rehydrate focus across immediate-mode frames
|
||||||
|
_activeEventElement = nil,
|
||||||
|
_cachedViewport = { width = 0, height = 0 },
|
||||||
|
-- Immediate mode state
|
||||||
|
_immediateMode = false,
|
||||||
|
_frameNumber = 0,
|
||||||
|
_currentFrameElements = {},
|
||||||
|
_immediateModeState = nil, -- Will be initialized if immediate mode is enabled
|
||||||
|
_frameStarted = false,
|
||||||
|
_autoBeganFrame = false,
|
||||||
|
-- Z-index ordered element tracking for immediate mode
|
||||||
|
_zIndexOrderedElements = {}, -- Array of elements sorted by z-index (lowest to highest)
|
||||||
|
-- Focus management guard
|
||||||
|
_settingFocus = false,
|
||||||
|
-- Hook called whenever focus changes: function(element) or nil
|
||||||
|
_onFocusChanged = nil,
|
||||||
|
|
||||||
|
-- Navigation state
|
||||||
|
_navigationContext = {
|
||||||
|
lastFocusedElement = nil, -- For returning from modals
|
||||||
|
navigationMode = "sequential", -- "sequential" or "directional"
|
||||||
|
containerElement = nil, -- Current navigation container
|
||||||
|
},
|
||||||
|
|
||||||
|
initialized = false,
|
||||||
|
|
||||||
|
-- Expose internal hit-testing helpers for unit testing only.
|
||||||
|
-- These are populated below after their local definitions. They are NOT part
|
||||||
|
-- of the public API and must not be relied on by callers; they exist so the
|
||||||
|
-- shared hit-test core (the single place display:none guarding lives) can be
|
||||||
|
-- exercised directly by the test suite. Subsequent unified-event-routing
|
||||||
|
-- tasks consume these locals through the mode-agnostic query functions.
|
||||||
|
_test = {
|
||||||
|
pointHitsElement = nil,
|
||||||
|
elementHasScrollableOverflow = nil,
|
||||||
|
},
|
||||||
|
|
||||||
|
-- Debug draw overlay
|
||||||
|
_debugDraw = false,
|
||||||
|
_debugDrawKey = nil,
|
||||||
|
|
||||||
|
-- Initialization state tracking
|
||||||
|
---@type "uninitialized"|"initializing"|"ready"
|
||||||
|
_initState = "uninitialized",
|
||||||
|
---@type table[] Queue of {props: ElementProps, callback: function(element)|nil}
|
||||||
|
_initQueue = {},
|
||||||
|
|
||||||
|
-- Per-frame cache for findInteractiveAtPosition so Clickable.onUpdate's
|
||||||
|
-- per-element call (unified-event-routing task 05) doesn't re-walk the tree
|
||||||
|
-- + realloc + sort for every interactive element sharing the same cursor.
|
||||||
|
-- Invalidated explicitly by Context.clearInteractiveCache() at the start of
|
||||||
|
-- each flexlove.update (both modes) and in clearFrameElements (immediate
|
||||||
|
-- mid-frame rebuild). It also self-invalidates when the topElements table
|
||||||
|
-- reference changes (tests replace it per-case; immediate-mode beginFrame
|
||||||
|
-- reassigns it each frame), so direct callers that never go through
|
||||||
|
-- flexlove.update still see fresh results across tree swaps.
|
||||||
|
_interactiveLookupCache = {
|
||||||
|
valid = false,
|
||||||
|
x = nil,
|
||||||
|
y = nil,
|
||||||
|
result = nil,
|
||||||
|
topElementsRef = nil,
|
||||||
|
frameNumber = -1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
--- Check if a point hits an element, accounting for scroll offsets and display:none.
|
||||||
|
--- All mode-agnostic query functions use this as their single hit-test entry point,
|
||||||
|
--- ensuring fixes like display:none guarding apply everywhere.
|
||||||
|
---
|
||||||
|
--- This is the single canonical place where `element.display == false` short-
|
||||||
|
--- circuits hit testing. Parent-chain clipping/scroll-offset accumulation is
|
||||||
|
--- the caller's responsibility: callers walk the parent chain (using
|
||||||
|
--- `elementHasScrollableOverflow` to decide which ancestors clip) and pass the
|
||||||
|
--- accumulated scroll offset in here. Keeping the parent walk outside this core
|
||||||
|
--- lets retained-mode (recursive tree descent) and immediate-mode (flat
|
||||||
|
--- z-index list) callers share the exact same primitive bounds/display logic.
|
||||||
|
---@param element Element
|
||||||
|
---@param mx number Screen X coordinate
|
||||||
|
---@param my number Screen Y coordinate
|
||||||
|
---@param scrollOffsetX number? Accumulated scroll offset from parent chain
|
||||||
|
---@param scrollOffsetY number? Accumulated scroll offset from parent chain
|
||||||
|
---@return boolean hits
|
||||||
|
local function pointHitsElement(element, mx, my, scrollOffsetX, scrollOffsetY)
|
||||||
|
scrollOffsetX = scrollOffsetX or 0
|
||||||
|
scrollOffsetY = scrollOffsetY or 0
|
||||||
|
|
||||||
|
-- Skip display:none elements entirely
|
||||||
|
if element.display == false then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local bx = element.x
|
||||||
|
local by = element.y
|
||||||
|
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
|
||||||
|
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
|
||||||
|
|
||||||
|
local adjustedX = mx + scrollOffsetX
|
||||||
|
local adjustedY = my + scrollOffsetY
|
||||||
|
|
||||||
|
return adjustedX >= bx and adjustedX <= bx + bw and adjustedY >= by and adjustedY <= by + bh
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Check if an element has scrollable/clipped overflow (for scroll offset accumulation).
|
||||||
|
--- Returns true for `scroll`, `auto`, and `hidden` on either axis. These are the
|
||||||
|
--- overflow values that clip/translate descendant content and therefore require
|
||||||
|
--- scroll-offset compensation when hit testing descendants.
|
||||||
|
---@param element Element
|
||||||
|
---@return boolean
|
||||||
|
local function elementHasScrollableOverflow(element)
|
||||||
|
local overflowX = element.overflowX or element.overflow
|
||||||
|
local overflowY = element.overflowY or element.overflow
|
||||||
|
return overflowX == "scroll"
|
||||||
|
or overflowX == "auto"
|
||||||
|
or overflowY == "scroll"
|
||||||
|
or overflowY == "auto"
|
||||||
|
or overflowX == "hidden"
|
||||||
|
or overflowY == "hidden"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Expose the two core helpers for unit testing only (see Context._test above).
|
||||||
|
Context._test.pointHitsElement = pointHitsElement
|
||||||
|
Context._test.elementHasScrollableOverflow = elementHasScrollableOverflow
|
||||||
|
|
||||||
|
-- Public exposure of the canonical hit-test primitive so other modules
|
||||||
|
-- (e.g. FlexLove's `getElementAtPosition` / `_getTouchElementAtPosition`
|
||||||
|
-- tree walks) can share the single implementation of bounds + display:none
|
||||||
|
-- guarding instead of duplicating the `display == false` check inline.
|
||||||
|
-- This keeps "display == false" in exactly one place for hit-testing.
|
||||||
|
Context.pointHitsElement = pointHitsElement
|
||||||
|
Context.elementHasScrollableOverflow = elementHasScrollableOverflow
|
||||||
|
|
||||||
|
--- Find the first scrollable element at a screen position, regardless of mode.
|
||||||
|
--- This is the mode-agnostic successor to the two duplicated scrollable lookups
|
||||||
|
--- that previously lived inline in `flexlove.wheelmoved`:
|
||||||
|
--- * immediate mode — walked `Context._zIndexOrderedElements` in reverse and
|
||||||
|
--- re-implemented bounds + parent-chain clipping + scroll-offset math; and
|
||||||
|
--- * retained mode — recursed through `Context.topElements` with a private
|
||||||
|
--- `findScrollableAtPosition(elements, x, y)` helper.
|
||||||
|
--- Both paths now collapse into this single function, which routes every
|
||||||
|
--- hit test through `pointHitsElement` (the single place `display == false`
|
||||||
|
--- is guarded) and every scroll-offset decision through
|
||||||
|
--- `elementHasScrollableOverflow`. As a result display:none elements are never
|
||||||
|
--- returned in either mode, fixing the latent bug where the immediate-mode
|
||||||
|
--- path's `isPointInElement` did not skip display:none elements.
|
||||||
|
---
|
||||||
|
--- The retained-mode branch intentionally mirrors the original
|
||||||
|
--- `findScrollableAtPosition` helper's tree walk (deepest scrollable wins,
|
||||||
|
--- children checked before self) but is upgraded to thread accumulated scroll
|
||||||
|
--- offsets through `pointHitsElement` so nested scrolled containers are tested
|
||||||
|
--- against their visible position. The original helper is removed once
|
||||||
|
--- `flexlove.wheelmoved` is rerouted onto this function in task 04.
|
||||||
|
---@param x number Screen X coordinate
|
||||||
|
---@param y number Screen Y coordinate
|
||||||
|
---@return Element|nil The scrollable element, or nil
|
||||||
|
function Context.findScrollableAtPosition(x, y)
|
||||||
|
if Context.isImmediateMode() then
|
||||||
|
-- Immediate mode: iterate the z-index ordered list (reverse order =
|
||||||
|
-- topmost first). pointHitsElement supplies the bounds + display guard.
|
||||||
|
for i = #Context._zIndexOrderedElements, 1, -1 do
|
||||||
|
local element = Context._zIndexOrderedElements[i]
|
||||||
|
if pointHitsElement(element, x, y) then
|
||||||
|
local overflowX = element.overflowX or element.overflow
|
||||||
|
local overflowY = element.overflowY or element.overflow
|
||||||
|
if
|
||||||
|
(overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto")
|
||||||
|
and (element._overflowX or element._overflowY)
|
||||||
|
then
|
||||||
|
return element
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
else
|
||||||
|
-- Retained mode: recursive tree walk from topElements. Children are
|
||||||
|
-- checked before self (deepest scrollable wins); accumulated scroll
|
||||||
|
-- offsets are threaded through pointHitsElement so descendants of
|
||||||
|
-- scrolled containers are hit-tested against their translated position.
|
||||||
|
local function findInTree(elements, scrollOffsetX, scrollOffsetY)
|
||||||
|
scrollOffsetX = scrollOffsetX or 0
|
||||||
|
scrollOffsetY = scrollOffsetY or 0
|
||||||
|
for i = #elements, 1, -1 do
|
||||||
|
local element = elements[i]
|
||||||
|
if pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then
|
||||||
|
if #element.children > 0 then
|
||||||
|
local childScrollOffsetX = scrollOffsetX
|
||||||
|
local childScrollOffsetY = scrollOffsetY
|
||||||
|
if elementHasScrollableOverflow(element) then
|
||||||
|
childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0)
|
||||||
|
childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0)
|
||||||
|
end
|
||||||
|
local childResult = findInTree(element.children, childScrollOffsetX, childScrollOffsetY)
|
||||||
|
if childResult then
|
||||||
|
return childResult
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- No descendant was scrollable — check self.
|
||||||
|
local overflowX = element.overflowX or element.overflow
|
||||||
|
local overflowY = element.overflowY or element.overflow
|
||||||
|
if
|
||||||
|
(overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto")
|
||||||
|
and (element._overflowX or element._overflowY)
|
||||||
|
then
|
||||||
|
return element
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return findInTree(Context.topElements)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Check whether immediate mode is active.
|
||||||
|
--- This is the single canonical accessor for the mode flag consumed throughout
|
||||||
|
--- the framework. Mode-aware branches elsewhere call this instead of reading
|
||||||
|
--- `Context._immediateMode` directly, so the literal mode flag only appears
|
||||||
|
--- here (its definition) and in StateManager (its mirrored storage) — never
|
||||||
|
--- scattered across Element / behaviors / managers (behavior-mode-unification
|
||||||
|
--- task 11).
|
||||||
|
---@return boolean
|
||||||
|
function Context.isImmediateMode()
|
||||||
|
return Context._immediateMode
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return number, number -- scaleX, scaleY
|
||||||
|
function Context.getScaleFactors()
|
||||||
|
return Context.scaleFactors.x, Context.scaleFactors.y
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Register an element in the z-index ordered tree (for immediate mode)
|
||||||
|
---@param element Element The element to register
|
||||||
|
function Context.registerElement(element)
|
||||||
|
if not Context.isImmediateMode() then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(Context._zIndexOrderedElements, element)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Context.clearFrameElements()
|
||||||
|
Context._zIndexOrderedElements = {}
|
||||||
|
Context.clearInteractiveCache()
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Compute the composite z-index key for an element.
|
||||||
|
--- rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ
|
||||||
|
---
|
||||||
|
--- ROOT_WEIGHT (10^10) gives the top-level ancestor's z-index 10 digits of significance.
|
||||||
|
--- DEPTH_WEIGHT (10^3) gives nesting depth 3 digits, ensuring children always sort above
|
||||||
|
--- their ancestors. The element's own z (capped to ±999 by ZIndex.clamp) fits within the
|
||||||
|
--- remaining 3 digits without interfering with the depth component.
|
||||||
|
---
|
||||||
|
--- These weights assume |z| <= ZIndex.MAX_Z and practical tree depths (< 10^7), which
|
||||||
|
--- keeps the composite key well within Lua's exact integer range (2^53 ≈ 9 × 10^15).
|
||||||
|
---
|
||||||
|
--- This is the SINGLE canonical z-index ordering function, used by both
|
||||||
|
--- sortElementsByZIndex (the immediate-mode flat list sort) and
|
||||||
|
--- findInteractiveAtPosition (the mode-agnostic occlusion sort). Keeping them
|
||||||
|
--- on the same key ensures the interactive topmost element matches the visual
|
||||||
|
--- draw order — a button in a z=50 MainMenu window must occlude a button in a
|
||||||
|
--- z=0 BottomBar even when both buttons default to own z=0.
|
||||||
|
local function getEffectiveZIndex(elem)
|
||||||
|
local ownZ = elem.z or 0
|
||||||
|
local rootZ = ownZ
|
||||||
|
local depth = 0
|
||||||
|
local current = elem.parent
|
||||||
|
while current do
|
||||||
|
rootZ = current.z or 0
|
||||||
|
depth = depth + 1
|
||||||
|
current = current.parent
|
||||||
|
end
|
||||||
|
return rootZ * ZIndex.ROOT_WEIGHT + depth * ZIndex.DEPTH_WEIGHT + ownZ
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Public exposure so FlexLove.getElementAtPosition shares the single
|
||||||
|
-- implementation instead of duplicating the parent-chain walk as a closure.
|
||||||
|
Context.getEffectiveZIndex = getEffectiveZIndex
|
||||||
|
|
||||||
|
--- Sort elements by z-index (called after all elements are registered)
|
||||||
|
function Context.sortElementsByZIndex()
|
||||||
|
-- Precompute the composite key ONCE per element so the sort comparator is a
|
||||||
|
-- pure table lookup (O(1)) instead of re-walking the parent chain on every
|
||||||
|
-- O(N log N) comparison. This function runs every frame in immediate mode.
|
||||||
|
local elements = Context._zIndexOrderedElements
|
||||||
|
local zIndices = {}
|
||||||
|
for i = 1, #elements do
|
||||||
|
zIndices[elements[i]] = getEffectiveZIndex(elements[i])
|
||||||
|
end
|
||||||
|
table.sort(elements, function(a, b)
|
||||||
|
return zIndices[a] < zIndices[b]
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Find the topmost interactive element at a screen position, regardless of mode.
|
||||||
|
--- Replaces the former immediate-mode-only `Context.getTopElementAt()` (removed
|
||||||
|
--- in unified-event-routing task 05) and the retained-mode `_activeEventElement`
|
||||||
|
--- mechanism — both are now funneled through this single entry point.
|
||||||
|
---
|
||||||
|
--- In immediate mode this replaces Context.getTopElementAt() (which only worked
|
||||||
|
--- in immediate mode). In retained mode this provides the same role as the
|
||||||
|
--- _activeEventElement set by flexlove.getElementAtPosition().
|
||||||
|
---
|
||||||
|
--- An element is "interactive" if it has an onEvent handler, themeComponent, or is editable.
|
||||||
|
---@param x number Screen X coordinate
|
||||||
|
---@param y number Screen Y coordinate
|
||||||
|
---@return Element|nil The topmost interactive element, or nil
|
||||||
|
function Context.findInteractiveAtPosition(x, y)
|
||||||
|
-- Per-frame cache: Clickable.onUpdate runs this for every interactive
|
||||||
|
-- element under the same cursor, but the result for a given (x,y) is
|
||||||
|
-- identical across all of them within a single update pass. Returning a
|
||||||
|
-- cached element restores the old 1x/frame cost of the _activeEventElement
|
||||||
|
-- mechanism that task 05 replaced. Cache auto-invalidates when the
|
||||||
|
-- topElements table reference changes (so tests and mid-frame rebuilds get
|
||||||
|
-- fresh results) and is cleared explicitly per-frame in flexlove.update.
|
||||||
|
local cache = Context._interactiveLookupCache
|
||||||
|
if
|
||||||
|
cache.valid
|
||||||
|
and cache.x == x
|
||||||
|
and cache.y == y
|
||||||
|
and cache.topElementsRef == Context.topElements
|
||||||
|
and cache.frameNumber == Context._frameNumber
|
||||||
|
then
|
||||||
|
return cache.result
|
||||||
|
end
|
||||||
|
|
||||||
|
local interactiveCandidates = {}
|
||||||
|
|
||||||
|
local function collectInteractive(element, scrollOffsetX, scrollOffsetY)
|
||||||
|
scrollOffsetX = scrollOffsetX or 0
|
||||||
|
scrollOffsetY = scrollOffsetY or 0
|
||||||
|
|
||||||
|
if not pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if this element is interactive
|
||||||
|
if element.onEvent or element.themeComponent or element.editable then
|
||||||
|
table.insert(interactiveCandidates, element)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Recurse into children with accumulated scroll offset
|
||||||
|
local childScrollOffsetX = scrollOffsetX
|
||||||
|
local childScrollOffsetY = scrollOffsetY
|
||||||
|
if elementHasScrollableOverflow(element) then
|
||||||
|
childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0)
|
||||||
|
childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, child in ipairs(element.children) do
|
||||||
|
collectInteractive(child, childScrollOffsetX, childScrollOffsetY)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Always traverse the tree (works in both modes — topElements exists always)
|
||||||
|
for _, element in ipairs(Context.topElements) do
|
||||||
|
collectInteractive(element)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Sort by composite z-index descending — topmost wins. The composite key
|
||||||
|
-- (rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ) matches the ordering
|
||||||
|
-- used by sortElementsByZIndex / _zIndexOrderedElements, so the interactive
|
||||||
|
-- topmost element matches the visual draw order. This is critical for the
|
||||||
|
-- game's multi-window layout: a button inside a z=50 MainMenu window must
|
||||||
|
-- occlude a button inside a z=0 BottomBar even when both buttons default to
|
||||||
|
-- own z=0. Sorting by own-z alone (the original implementation) couldn't
|
||||||
|
-- distinguish them, so the wrong window's button could win, leaving the
|
||||||
|
-- visible button's isActiveElement=false and clicks/hover dead.
|
||||||
|
local zIndices = {}
|
||||||
|
for _, el in ipairs(interactiveCandidates) do
|
||||||
|
zIndices[el] = getEffectiveZIndex(el)
|
||||||
|
end
|
||||||
|
table.sort(interactiveCandidates, function(a, b)
|
||||||
|
return zIndices[a] > zIndices[b]
|
||||||
|
end)
|
||||||
|
|
||||||
|
local result = interactiveCandidates[1]
|
||||||
|
|
||||||
|
cache.x = x
|
||||||
|
cache.y = y
|
||||||
|
cache.result = result
|
||||||
|
cache.topElementsRef = Context.topElements
|
||||||
|
cache.frameNumber = Context._frameNumber
|
||||||
|
cache.valid = true
|
||||||
|
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Invalidate the per-frame `findInteractiveAtPosition` cache.
|
||||||
|
--- Called once at the top of `flexlove.update` (the natural per-frame boundary
|
||||||
|
--- in both modes) and from `clearFrameElements` (immediate-mode mid-frame
|
||||||
|
--- rebuild). After invalidation the next lookup recomputes fresh.
|
||||||
|
function Context.clearInteractiveCache()
|
||||||
|
local cache = Context._interactiveLookupCache
|
||||||
|
cache.valid = false
|
||||||
|
cache.x = nil
|
||||||
|
cache.y = nil
|
||||||
|
cache.result = nil
|
||||||
|
cache.topElementsRef = nil
|
||||||
|
cache.frameNumber = -1
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Set the focused element (centralizes focus management)
|
||||||
|
--- Automatically blurs the previously focused element if different
|
||||||
|
---@param element Element|nil The element to focus (nil to clear focus)
|
||||||
|
function Context.setFocused(element)
|
||||||
|
if Context._focusedElement == element then
|
||||||
|
return -- Already focused
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Prevent re-entry during focus change
|
||||||
|
if Context._settingFocus then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
Context._settingFocus = true
|
||||||
|
|
||||||
|
-- Save reference to previously focused element before updating
|
||||||
|
local oldFocusedElement = Context._focusedElement
|
||||||
|
|
||||||
|
-- Blur previously focused element
|
||||||
|
if oldFocusedElement and oldFocusedElement ~= element then
|
||||||
|
if oldFocusedElement._textEditor then
|
||||||
|
oldFocusedElement._textEditor:blur(oldFocusedElement)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Set new focused element and persist its id for immediate-mode rehydration
|
||||||
|
Context._focusedElement = element
|
||||||
|
Context._focusedElementId = element and (element.id ~= "" and element.id or nil) or nil
|
||||||
|
|
||||||
|
-- Notify any registered focus change hook (e.g. FocusIndicator)
|
||||||
|
if Context._onFocusChanged then
|
||||||
|
Context._onFocusChanged(element)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Focus the new element's text editor if it has one
|
||||||
|
if element and element._textEditor then
|
||||||
|
element._textEditor._focused = true
|
||||||
|
end
|
||||||
|
|
||||||
|
Context._settingFocus = false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Recursively search for an element by id in an element tree
|
||||||
|
---@param root Element The root element to start searching from
|
||||||
|
---@param targetId string The id to search for
|
||||||
|
---@return Element|nil The element with the matching id, or nil if not found
|
||||||
|
local function findElementById(root, targetId)
|
||||||
|
if root.id == targetId then
|
||||||
|
return root
|
||||||
|
end
|
||||||
|
for _, child in ipairs(root.children or {}) do
|
||||||
|
local found = findElementById(child, targetId)
|
||||||
|
if found then
|
||||||
|
return found
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Rehydrate _focusedElement from _focusedElementId by scanning live elements.
|
||||||
|
--- Called at the start of getFocused() in immediate mode so stale references
|
||||||
|
--- are always replaced with the current-frame object before use.
|
||||||
|
function Context._rehydrateFocus()
|
||||||
|
if not Context._focusedElementId then
|
||||||
|
Context._focusedElement = nil
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- First, try a fast linear search through all registered elements
|
||||||
|
for _, elem in ipairs(Context._zIndexOrderedElements) do
|
||||||
|
if elem.id == Context._focusedElementId then
|
||||||
|
Context._focusedElement = elem
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- If not found, recursively search from top-level elements
|
||||||
|
-- This handles cases where elements may not be in _zIndexOrderedElements
|
||||||
|
for _, topLevel in ipairs(Context.topElements or {}) do
|
||||||
|
local found = findElementById(topLevel, Context._focusedElementId)
|
||||||
|
if found then
|
||||||
|
Context._focusedElement = found
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Element with that id is not present this frame (e.g. screen changed)
|
||||||
|
Context._focusedElement = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get the currently focused element
|
||||||
|
---@return Element|nil The focused element, or nil if none
|
||||||
|
function Context.getFocused()
|
||||||
|
if Context.isImmediateMode() then
|
||||||
|
Context._rehydrateFocus()
|
||||||
|
end
|
||||||
|
return Context._focusedElement
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Clear focus from any element
|
||||||
|
function Context.clearFocus()
|
||||||
|
Context._focusedElementId = nil
|
||||||
|
Context.setFocused(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get all focusable elements in tab order, regardless of mode.
|
||||||
|
--- In immediate mode this extracts from _zIndexOrderedElements (flat, z-sorted).
|
||||||
|
--- In retained mode it walks the element tree (DOM order).
|
||||||
|
--- In both modes, display:none elements are excluded.
|
||||||
|
---@return table<Element> List of focusable elements in tab order
|
||||||
|
function Context.getFocusableElements()
|
||||||
|
local focusable = {}
|
||||||
|
|
||||||
|
local function isFocusable(elem)
|
||||||
|
if elem.display == false then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
-- Use Element:isFocusable() for consistent behavior
|
||||||
|
return Element.isFocusable(elem)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function collectFromTree(elements)
|
||||||
|
for _, elem in ipairs(elements) do
|
||||||
|
if isFocusable(elem) then
|
||||||
|
table.insert(focusable, elem)
|
||||||
|
end
|
||||||
|
if #elem.children > 0 then
|
||||||
|
collectFromTree(elem.children)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if Context._immediateMode then
|
||||||
|
-- Immediate mode: _zIndexOrderedElements is already in z-index order (lowest first),
|
||||||
|
-- which approximates tab order for most UIs.
|
||||||
|
for _, elem in ipairs(Context._zIndexOrderedElements) do
|
||||||
|
if isFocusable(elem) then
|
||||||
|
table.insert(focusable, elem)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
-- Retained mode: walk the top element trees in DOM order
|
||||||
|
collectFromTree(Context.topElements)
|
||||||
|
end
|
||||||
|
|
||||||
|
return focusable
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ====================
|
||||||
|
-- Navigation Context
|
||||||
|
-- ====================
|
||||||
|
|
||||||
|
--- Push current focus onto stack (for modals/dialogs)
|
||||||
|
---@param element Element?
|
||||||
|
function Context.pushFocusStack(element)
|
||||||
|
Context._navigationContext.lastFocusedElement = Context._focusedElement
|
||||||
|
if element then
|
||||||
|
Context.setFocused(element)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Pop focus from stack (return from modal)
|
||||||
|
---@return Element?
|
||||||
|
function Context.popFocusStack()
|
||||||
|
local previous = Context._navigationContext.lastFocusedElement
|
||||||
|
Context._navigationContext.lastFocusedElement = nil
|
||||||
|
Context.setFocused(previous)
|
||||||
|
return previous
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Set navigation container (scope for tab navigation)
|
||||||
|
---@param element Element?
|
||||||
|
function Context.setNavigationContainer(element)
|
||||||
|
Context._navigationContext.containerElement = element
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get navigation container
|
||||||
|
---@return Element?
|
||||||
|
function Context.getNavigationContainer()
|
||||||
|
return Context._navigationContext.containerElement
|
||||||
|
end
|
||||||
|
|
||||||
|
return Context
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
|||||||
|
-- Layout, flex, text, image, and ARIA enums used across FlexLove.
|
||||||
|
-- Extracted from utils so utils stays under its LOC budget; re-exported as
|
||||||
|
-- `utils.enums` for backward compatibility.
|
||||||
|
|
||||||
|
local enums = {
|
||||||
|
---@enum TextAlign
|
||||||
|
TextAlign = { START = "start", CENTER = "center", END = "end", JUSTIFY = "justify" },
|
||||||
|
---@enum TextAlignVertical
|
||||||
|
TextAlignVertical = { START = "start", CENTER = "center", END = "end" },
|
||||||
|
---@enum Positioning
|
||||||
|
Positioning = { ABSOLUTE = "absolute", RELATIVE = "relative", FLEX = "flex", GRID = "grid" },
|
||||||
|
---@enum FlexDirection
|
||||||
|
FlexDirection = {
|
||||||
|
HORIZONTAL = "horizontal",
|
||||||
|
VERTICAL = "vertical",
|
||||||
|
ROW = "row",
|
||||||
|
COLUMN = "column",
|
||||||
|
HORIZONTAL_REVERSE = "horizontal-reverse",
|
||||||
|
VERTICAL_REVERSE = "vertical-reverse",
|
||||||
|
ROW_REVERSE = "row-reverse",
|
||||||
|
COLUMN_REVERSE = "column-reverse",
|
||||||
|
},
|
||||||
|
---@enum JustifyContent
|
||||||
|
JustifyContent = {
|
||||||
|
FLEX_START = "flex-start",
|
||||||
|
CENTER = "center",
|
||||||
|
SPACE_AROUND = "space-around",
|
||||||
|
FLEX_END = "flex-end",
|
||||||
|
SPACE_EVENLY = "space-evenly",
|
||||||
|
SPACE_BETWEEN = "space-between",
|
||||||
|
},
|
||||||
|
---@enum JustifySelf
|
||||||
|
JustifySelf = {
|
||||||
|
AUTO = "auto",
|
||||||
|
FLEX_START = "flex-start",
|
||||||
|
CENTER = "center",
|
||||||
|
FLEX_END = "flex-end",
|
||||||
|
SPACE_AROUND = "space-around",
|
||||||
|
SPACE_EVENLY = "space-evenly",
|
||||||
|
SPACE_BETWEEN = "space-between",
|
||||||
|
},
|
||||||
|
---@enum AlignItems
|
||||||
|
AlignItems = {
|
||||||
|
STRETCH = "stretch",
|
||||||
|
FLEX_START = "flex-start",
|
||||||
|
FLEX_END = "flex-end",
|
||||||
|
CENTER = "center",
|
||||||
|
BASELINE = "baseline",
|
||||||
|
},
|
||||||
|
---@enum AlignSelf
|
||||||
|
AlignSelf = {
|
||||||
|
AUTO = "auto",
|
||||||
|
STRETCH = "stretch",
|
||||||
|
FLEX_START = "flex-start",
|
||||||
|
FLEX_END = "flex-end",
|
||||||
|
CENTER = "center",
|
||||||
|
BASELINE = "baseline",
|
||||||
|
},
|
||||||
|
---@enum AlignContent
|
||||||
|
AlignContent = {
|
||||||
|
STRETCH = "stretch",
|
||||||
|
FLEX_START = "flex-start",
|
||||||
|
FLEX_END = "flex-end",
|
||||||
|
CENTER = "center",
|
||||||
|
SPACE_BETWEEN = "space-between",
|
||||||
|
SPACE_AROUND = "space-around",
|
||||||
|
},
|
||||||
|
---@enum FlexWrap
|
||||||
|
FlexWrap = { NOWRAP = "nowrap", WRAP = "wrap", WRAP_REVERSE = "wrap-reverse" },
|
||||||
|
---@enum TextSize
|
||||||
|
TextSize = {
|
||||||
|
XXS = "xxs",
|
||||||
|
XS = "xs",
|
||||||
|
SM = "sm",
|
||||||
|
MD = "md",
|
||||||
|
LG = "lg",
|
||||||
|
XL = "xl",
|
||||||
|
XXL = "xxl",
|
||||||
|
XL3 = "3xl",
|
||||||
|
XL4 = "4xl",
|
||||||
|
},
|
||||||
|
---@enum ImageRepeat
|
||||||
|
ImageRepeat = {
|
||||||
|
NO_REPEAT = "no-repeat",
|
||||||
|
REPEAT = "repeat",
|
||||||
|
REPEAT_X = "repeat-x",
|
||||||
|
REPEAT_Y = "repeat-y",
|
||||||
|
SPACE = "space",
|
||||||
|
ROUND = "round",
|
||||||
|
},
|
||||||
|
|
||||||
|
---@enum ARIA Role (accessibility roles for screen readers)
|
||||||
|
ARIA = {
|
||||||
|
-- Widget roles
|
||||||
|
BUTTON = "button",
|
||||||
|
CHECKBOX = "checkbox",
|
||||||
|
LINK = "link",
|
||||||
|
MENUITEM = "menuitem",
|
||||||
|
MENUITEMCHECKBOX = "menuitemcheckbox",
|
||||||
|
MENUITEMRADIO = "menuitemradio",
|
||||||
|
PROGRESSBAR = "progressbar",
|
||||||
|
RADIO = "radio",
|
||||||
|
SCROLLBAR = "scrollbar",
|
||||||
|
SLIDER = "slider",
|
||||||
|
SPINBUTTON = "spinbutton",
|
||||||
|
SWITCH = "switch",
|
||||||
|
TAB = "tab",
|
||||||
|
TABLIST = "tablist",
|
||||||
|
TABPANEL = "tabpanel",
|
||||||
|
TEXTBOX = "textbox",
|
||||||
|
TOOLTIP = "tooltip",
|
||||||
|
TREEITEM = "treeitem",
|
||||||
|
COMBOBOX = "combobox",
|
||||||
|
GRID = "grid",
|
||||||
|
GRIDCELL = "gridcell",
|
||||||
|
LISTBOX = "listbox",
|
||||||
|
LISTITEM = "listitem",
|
||||||
|
MENU = "menu",
|
||||||
|
MENUBAR = "menubar",
|
||||||
|
TREE = "tree",
|
||||||
|
TREEGRID = "treegrid",
|
||||||
|
WINDOW = "window",
|
||||||
|
DIALOG = "dialog",
|
||||||
|
ALERTDIALOG = "alertdialog",
|
||||||
|
|
||||||
|
-- Landmark roles
|
||||||
|
BANNER = "banner",
|
||||||
|
COMPLEMENTARY = "complementary",
|
||||||
|
CONTENTINFO = "contentinfo",
|
||||||
|
FORM = "form",
|
||||||
|
MAIN = "main",
|
||||||
|
NAVIGATION = "navigation",
|
||||||
|
REGION = "region",
|
||||||
|
SEARCH = "search",
|
||||||
|
|
||||||
|
-- Live region roles
|
||||||
|
ALERT = "alert",
|
||||||
|
LOG = "log",
|
||||||
|
MARQUEE = "marquee",
|
||||||
|
STATUS = "status",
|
||||||
|
TIMERTIME = "timer",
|
||||||
|
|
||||||
|
-- Document structure roles
|
||||||
|
ARTICLE = "article",
|
||||||
|
BLOCKQUOTEBLOCKQUOTE = "blockquote",
|
||||||
|
CAPTION = "caption",
|
||||||
|
CODE = "code",
|
||||||
|
DEFINITION = "definition",
|
||||||
|
DELETED = "deletion",
|
||||||
|
DIRECTORY = "directory",
|
||||||
|
DIVISION = "division",
|
||||||
|
EMphasis = "emphasis",
|
||||||
|
HEADING = "heading",
|
||||||
|
INSERTED = "insertion",
|
||||||
|
LIST = "list",
|
||||||
|
MARK = "mark",
|
||||||
|
MATH = "math",
|
||||||
|
NONE = "none",
|
||||||
|
PARAGRAPH = "paragraph",
|
||||||
|
PRESENTATION = "presentation",
|
||||||
|
SEPARATOR = "separator",
|
||||||
|
STRONG = "strong",
|
||||||
|
SUBSCRIPT = "subscript",
|
||||||
|
SUPERSCRIPT = "superscript",
|
||||||
|
TERM = "term",
|
||||||
|
TIME = "time",
|
||||||
|
VARIABLE = "variable",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return { enums = enums }
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,843 @@
|
|||||||
|
---@class EventHandler
|
||||||
|
---@field onEvent fun(element:Element, event:InputEvent)?
|
||||||
|
---@field onEventDeferred boolean?
|
||||||
|
---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Touch-specific callback
|
||||||
|
---@field onTouchEventDeferred boolean? -- Whether onTouchEvent is deferred
|
||||||
|
---@field onGesture fun(element:Element, gesture:table)? -- Gesture callback
|
||||||
|
---@field onGestureDeferred boolean? -- Whether onGesture is deferred
|
||||||
|
---@field touchEnabled boolean -- Whether touch events are processed (default: true)
|
||||||
|
---@field multiTouchEnabled boolean -- Whether multi-touch is supported (default: false)
|
||||||
|
---@field _pressed table<number, boolean>
|
||||||
|
---@field _lastClickTime number?
|
||||||
|
---@field _lastClickButton number?
|
||||||
|
---@field _clickCount number
|
||||||
|
---@field _dragStartX table<number, number>
|
||||||
|
---@field _dragStartY table<number, number>
|
||||||
|
---@field _lastMouseX table<number, number>
|
||||||
|
---@field _lastMouseY table<number, number>
|
||||||
|
---@field _touches table<string, table> -- Multi-touch state per touch ID
|
||||||
|
---@field _touchStartPositions table<string, table> -- Touch start positions
|
||||||
|
---@field _lastTouchPositions table<string, table> -- Last touch positions for delta
|
||||||
|
---@field _touchHistory table<string, table> -- Touch position history for gestures (last 5)
|
||||||
|
---@field _hovered boolean
|
||||||
|
---@field _scrollbarPressHandled boolean
|
||||||
|
---@field _InputEvent table
|
||||||
|
---@field _utils table
|
||||||
|
---@field _Performance Performance? Performance module dependency
|
||||||
|
---@field _ErrorHandler ErrorHandler
|
||||||
|
local EventHandler = {}
|
||||||
|
EventHandler.__index = EventHandler
|
||||||
|
|
||||||
|
--- Initialize module with shared dependencies
|
||||||
|
---@param deps table Dependencies {Performance, ErrorHandler, InputEvent, Context, utils}
|
||||||
|
function EventHandler.init(deps)
|
||||||
|
EventHandler._Performance = deps.Performance
|
||||||
|
EventHandler._ErrorHandler = deps.ErrorHandler
|
||||||
|
EventHandler._InputEvent = deps.InputEvent
|
||||||
|
EventHandler._utils = deps.utils
|
||||||
|
EventHandler._Context = deps.Context
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param config table Configuration options
|
||||||
|
---@return EventHandler
|
||||||
|
function EventHandler.new(config)
|
||||||
|
config = config or {}
|
||||||
|
local self = setmetatable({}, EventHandler)
|
||||||
|
|
||||||
|
self.onEvent = config.onEvent
|
||||||
|
self.onEventDeferred = config.onEventDeferred
|
||||||
|
self.onTouchEvent = config.onTouchEvent
|
||||||
|
self.onTouchEventDeferred = config.onTouchEventDeferred or false
|
||||||
|
self.onGesture = config.onGesture
|
||||||
|
self.onGestureDeferred = config.onGestureDeferred or false
|
||||||
|
self.touchEnabled = config.touchEnabled ~= false -- Default true
|
||||||
|
self.multiTouchEnabled = config.multiTouchEnabled or false -- Default false
|
||||||
|
|
||||||
|
self._pressed = config._pressed or {}
|
||||||
|
|
||||||
|
self._lastClickTime = config._lastClickTime
|
||||||
|
self._lastClickButton = config._lastClickButton
|
||||||
|
self._clickCount = config._clickCount or 0
|
||||||
|
|
||||||
|
-- FocusIndicator reference (set after initialization)
|
||||||
|
self._FocusIndicator = nil
|
||||||
|
|
||||||
|
self._dragStartX = config._dragStartX or {}
|
||||||
|
self._dragStartY = config._dragStartY or {}
|
||||||
|
self._lastMouseX = config._lastMouseX or {}
|
||||||
|
self._lastMouseY = config._lastMouseY or {}
|
||||||
|
|
||||||
|
-- Multi-touch tracking
|
||||||
|
self._touches = config._touches or {}
|
||||||
|
self._touchStartPositions = config._touchStartPositions or {}
|
||||||
|
self._lastTouchPositions = config._lastTouchPositions or {}
|
||||||
|
self._touchHistory = config._touchHistory or {}
|
||||||
|
|
||||||
|
self._hovered = config._hovered or false
|
||||||
|
|
||||||
|
self._scrollbarPressHandled = false
|
||||||
|
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get state for persistence (for immediate mode)
|
||||||
|
---@return table State data
|
||||||
|
function EventHandler:getState()
|
||||||
|
return {
|
||||||
|
_pressed = self._pressed,
|
||||||
|
_lastClickTime = self._lastClickTime,
|
||||||
|
_lastClickButton = self._lastClickButton,
|
||||||
|
_clickCount = self._clickCount,
|
||||||
|
_dragStartX = self._dragStartX,
|
||||||
|
_dragStartY = self._dragStartY,
|
||||||
|
_lastMouseX = self._lastMouseX,
|
||||||
|
_lastMouseY = self._lastMouseY,
|
||||||
|
_touches = self._touches,
|
||||||
|
_touchStartPositions = self._touchStartPositions,
|
||||||
|
_lastTouchPositions = self._lastTouchPositions,
|
||||||
|
_touchHistory = self._touchHistory,
|
||||||
|
_hovered = self._hovered,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Restore state from persistence (for immediate mode)
|
||||||
|
---@param state table State data
|
||||||
|
function EventHandler:setState(state)
|
||||||
|
if not state then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
self._pressed = state._pressed or {}
|
||||||
|
self._lastClickTime = state._lastClickTime
|
||||||
|
self._lastClickButton = state._lastClickButton
|
||||||
|
self._clickCount = state._clickCount or 0
|
||||||
|
self._dragStartX = state._dragStartX or {}
|
||||||
|
self._dragStartY = state._dragStartY or {}
|
||||||
|
self._lastMouseX = state._lastMouseX or {}
|
||||||
|
self._lastMouseY = state._lastMouseY or {}
|
||||||
|
self._touches = state._touches or {}
|
||||||
|
self._touchStartPositions = state._touchStartPositions or {}
|
||||||
|
self._lastTouchPositions = state._lastTouchPositions or {}
|
||||||
|
self._touchHistory = state._touchHistory or {}
|
||||||
|
self._hovered = state._hovered or false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Process mouse button events in the update cycle
|
||||||
|
---@param element Element The parent element
|
||||||
|
---@param mx number Mouse X position
|
||||||
|
---@param my number Mouse Y position
|
||||||
|
---@param isHovering boolean Whether mouse is over element
|
||||||
|
---@param isActiveElement boolean Whether this is the top element at mouse position
|
||||||
|
function EventHandler:processMouseEvents(element, mx, my, isHovering, isActiveElement)
|
||||||
|
-- Start performance timing
|
||||||
|
-- Performance accessed via EventHandler._Performance
|
||||||
|
if EventHandler._Performance and EventHandler._Performance.enabled then
|
||||||
|
EventHandler._Performance:startTimer("event_mouse")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if currently dragging (allows drag continuation even if occluded)
|
||||||
|
local isDragging = false
|
||||||
|
for _, button in ipairs({ 1, 2, 3 }) do
|
||||||
|
if self._pressed[button] and love.mouse.isDown(button) then
|
||||||
|
isDragging = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if any button is currently pressed (tracked state)
|
||||||
|
local hasTrackedPress = false
|
||||||
|
for _, button in ipairs({ 1, 2, 3 }) do
|
||||||
|
if self._pressed[button] then
|
||||||
|
hasTrackedPress = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Can only process events if we have handler, element is enabled, and is active or dragging or has tracked press
|
||||||
|
-- Read onEvent from element (source of truth), fallback to handler cache for backwards compat
|
||||||
|
local canProcessEvents = (
|
||||||
|
element.onEvent
|
||||||
|
or self.onEvent
|
||||||
|
or element.editable
|
||||||
|
or element._selectState
|
||||||
|
or element.selectOption
|
||||||
|
)
|
||||||
|
and element.visibility ~= "hidden"
|
||||||
|
and not element.disabled
|
||||||
|
and (isActiveElement or isDragging or hasTrackedPress)
|
||||||
|
|
||||||
|
if not canProcessEvents then
|
||||||
|
-- If not hovering and no buttons are physically pressed, reset all pressed states
|
||||||
|
-- This ensures the pressed state is cleared when mouse leaves without button held
|
||||||
|
if not isHovering and not isDragging then
|
||||||
|
for _, button in ipairs({ 1, 2, 3 }) do
|
||||||
|
if self._pressed[button] and not love.mouse.isDown(button) then
|
||||||
|
self._pressed[button] = false
|
||||||
|
self._dragStartX[button] = nil
|
||||||
|
self._dragStartY[button] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Track hover state changes even when events can't be processed
|
||||||
|
-- Fire synthetic unhover when element becomes disabled while hovered
|
||||||
|
if element.disabled and self._hovered then
|
||||||
|
self._hovered = false
|
||||||
|
if element.onEvent or self.onEvent then
|
||||||
|
local modifiers = EventHandler._utils.getModifiers()
|
||||||
|
local unhoverEvent = EventHandler._InputEvent.new({
|
||||||
|
type = "unhover",
|
||||||
|
button = 0,
|
||||||
|
x = mx,
|
||||||
|
y = my,
|
||||||
|
modifiers = modifiers,
|
||||||
|
clickCount = 0,
|
||||||
|
})
|
||||||
|
self:_invokeCallback(element, unhoverEvent)
|
||||||
|
end
|
||||||
|
elseif self._hovered and not isHovering then
|
||||||
|
self._hovered = false
|
||||||
|
if element.onEvent or self.onEvent then
|
||||||
|
local modifiers = EventHandler._utils.getModifiers()
|
||||||
|
local unhoverEvent = EventHandler._InputEvent.new({
|
||||||
|
type = "unhover",
|
||||||
|
button = 0,
|
||||||
|
x = mx,
|
||||||
|
y = my,
|
||||||
|
modifiers = modifiers,
|
||||||
|
clickCount = 0,
|
||||||
|
})
|
||||||
|
self:_invokeCallback(element, unhoverEvent)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if EventHandler._Performance and EventHandler._Performance.enabled then
|
||||||
|
EventHandler._Performance:stopTimer("event_mouse")
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Track hover state changes and fire hover/unhover events BEFORE button processing
|
||||||
|
-- This ensures hover fires before press when mouse first enters element
|
||||||
|
local wasHovered = self._hovered
|
||||||
|
local isHoveringAndActive = isHovering and isActiveElement
|
||||||
|
|
||||||
|
if isHoveringAndActive and not wasHovered then
|
||||||
|
-- Just started hovering - fire hover event
|
||||||
|
self._hovered = true
|
||||||
|
local modifiers = EventHandler._utils.getModifiers()
|
||||||
|
local hoverEvent = EventHandler._InputEvent.new({
|
||||||
|
type = "hover",
|
||||||
|
button = 0,
|
||||||
|
x = mx,
|
||||||
|
y = my,
|
||||||
|
modifiers = modifiers,
|
||||||
|
clickCount = 0,
|
||||||
|
})
|
||||||
|
self:_invokeCallback(element, hoverEvent)
|
||||||
|
elseif not isHoveringAndActive and wasHovered then
|
||||||
|
-- Just stopped hovering - fire unhover event
|
||||||
|
self._hovered = false
|
||||||
|
local modifiers = EventHandler._utils.getModifiers()
|
||||||
|
local unhoverEvent = EventHandler._InputEvent.new({
|
||||||
|
type = "unhover",
|
||||||
|
button = 0,
|
||||||
|
x = mx,
|
||||||
|
y = my,
|
||||||
|
modifiers = modifiers,
|
||||||
|
clickCount = 0,
|
||||||
|
})
|
||||||
|
self:_invokeCallback(element, unhoverEvent)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Process all three mouse buttons
|
||||||
|
local buttons = { 1, 2, 3 } -- left, right, middle
|
||||||
|
|
||||||
|
for _, button in ipairs(buttons) do
|
||||||
|
-- Check if this button was tracked as pressed
|
||||||
|
local wasPressed = self._pressed[button]
|
||||||
|
local isPhysicallyPressed = love.mouse.isDown(button)
|
||||||
|
|
||||||
|
if isHovering or isDragging or wasPressed then
|
||||||
|
if isPhysicallyPressed then
|
||||||
|
-- Button is pressed down
|
||||||
|
if not wasPressed then
|
||||||
|
-- Just pressed - fire press event (only if hovering)
|
||||||
|
if isHovering then
|
||||||
|
self:_handleMousePress(element, mx, my, button)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
-- Button is still pressed - check for drag
|
||||||
|
self:_handleMouseDrag(element, mx, my, button, isHovering)
|
||||||
|
end
|
||||||
|
elseif wasPressed then
|
||||||
|
-- Button was just released
|
||||||
|
-- Only fire click and release events if mouse is still hovering AND element is active
|
||||||
|
-- (not occluded by another element)
|
||||||
|
if isHovering and isActiveElement then
|
||||||
|
self:_handleMouseRelease(element, mx, my, button)
|
||||||
|
else
|
||||||
|
-- Mouse left before release OR element is occluded - just clear the pressed state without firing events
|
||||||
|
self._pressed[button] = false
|
||||||
|
self._dragStartX[button] = nil
|
||||||
|
self._dragStartY[button] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- After processing events, reset pressed states for buttons that are no longer held
|
||||||
|
-- This handles the case where mouse leaves while button is held, then released
|
||||||
|
if not isHovering and not isDragging then
|
||||||
|
for _, button in ipairs({ 1, 2, 3 }) do
|
||||||
|
if self._pressed[button] and not love.mouse.isDown(button) then
|
||||||
|
self._pressed[button] = false
|
||||||
|
self._dragStartX[button] = nil
|
||||||
|
self._dragStartY[button] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Stop performance timing
|
||||||
|
if EventHandler._Performance and EventHandler._Performance.enabled then
|
||||||
|
EventHandler._Performance:stopTimer("event_mouse")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Handle mouse button press
|
||||||
|
---@param element Element The parent element
|
||||||
|
---@param mx number Mouse X position
|
||||||
|
---@param my number Mouse Y position
|
||||||
|
---@param button number Mouse button (1=left, 2=right, 3=middle)
|
||||||
|
function EventHandler:_handleMousePress(element, mx, my, button)
|
||||||
|
-- Check if press is on scrollbar first (skip if already handled)
|
||||||
|
if button == 1 and not self._scrollbarPressHandled and element._handleScrollbarPress then
|
||||||
|
if element:_handleScrollbarPress(mx, my, button) then
|
||||||
|
-- Scrollbar consumed the event, mark as pressed to prevent onEvent
|
||||||
|
self._pressed[button] = true
|
||||||
|
self._scrollbarPressHandled = true
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Fire press event
|
||||||
|
local modifiers = EventHandler._utils.getModifiers()
|
||||||
|
local pressEvent = EventHandler._InputEvent.new({
|
||||||
|
type = "press",
|
||||||
|
button = button,
|
||||||
|
x = mx,
|
||||||
|
y = my,
|
||||||
|
modifiers = modifiers,
|
||||||
|
clickCount = 1,
|
||||||
|
})
|
||||||
|
self:_invokeCallback(element, pressEvent)
|
||||||
|
|
||||||
|
self._pressed[button] = true
|
||||||
|
|
||||||
|
-- On left click, set keyboard focus to any focusable element (not just editable).
|
||||||
|
-- Clear the focus indicator since mouse navigation doesn't use it.
|
||||||
|
local isFocusable
|
||||||
|
if type(element.isFocusable) == "function" then
|
||||||
|
isFocusable = element:isFocusable()
|
||||||
|
else
|
||||||
|
isFocusable = (element.editable == true)
|
||||||
|
or (type(element.onEvent) == "function")
|
||||||
|
or element._selectState ~= nil
|
||||||
|
or element.selectOption ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
if button == 1 and EventHandler._Context and isFocusable then
|
||||||
|
EventHandler._Context.setFocused(element)
|
||||||
|
-- Hide focus indicator - it's only for keyboard navigation
|
||||||
|
if EventHandler._FocusIndicator then
|
||||||
|
EventHandler._FocusIndicator.setFocused(nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- Set mouse down position for text selection on left click
|
||||||
|
if button == 1 and element._textEditor then
|
||||||
|
element._mouseDownPosition = element._textEditor:mouseToTextPosition(element, mx, my)
|
||||||
|
element._textDragOccurred = false -- Reset drag flag on press
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Record drag start position per button
|
||||||
|
self._dragStartX[button] = mx
|
||||||
|
self._dragStartY[button] = my
|
||||||
|
self._lastMouseX[button] = mx
|
||||||
|
self._lastMouseY[button] = my
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Handle mouse drag (while button is pressed and mouse moves)
|
||||||
|
---@param element Element The parent element
|
||||||
|
---@param mx number Mouse X position
|
||||||
|
---@param my number Mouse Y position
|
||||||
|
---@param button number Mouse button
|
||||||
|
---@param isHovering boolean Whether mouse is over element
|
||||||
|
function EventHandler:_handleMouseDrag(element, mx, my, button, isHovering)
|
||||||
|
local lastX = self._lastMouseX[button] or mx
|
||||||
|
local lastY = self._lastMouseY[button] or my
|
||||||
|
|
||||||
|
if lastX ~= mx or lastY ~= my then
|
||||||
|
-- Handle scrollbar drag if scrollbar was pressed
|
||||||
|
if button == 1 and self._scrollbarPressHandled and element._handleScrollbarDrag then
|
||||||
|
element:_handleScrollbarDrag(mx, my)
|
||||||
|
self._lastMouseX[button] = mx
|
||||||
|
self._lastMouseY[button] = my
|
||||||
|
return -- Don't process other drag events while dragging scrollbar
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Mouse has moved - fire drag event only if still hovering
|
||||||
|
if isHovering then
|
||||||
|
local modifiers = EventHandler._utils.getModifiers()
|
||||||
|
local dx = mx - self._dragStartX[button]
|
||||||
|
local dy = my - self._dragStartY[button]
|
||||||
|
|
||||||
|
local dragEvent = EventHandler._InputEvent.new({
|
||||||
|
type = "drag",
|
||||||
|
button = button,
|
||||||
|
x = mx,
|
||||||
|
y = my,
|
||||||
|
dx = dx,
|
||||||
|
dy = dy,
|
||||||
|
modifiers = modifiers,
|
||||||
|
clickCount = 1,
|
||||||
|
})
|
||||||
|
self:_invokeCallback(element, dragEvent)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Handle text selection drag for editable elements
|
||||||
|
if button == 1 and element.editable and element._focused and element._handleTextDrag then
|
||||||
|
element:_handleTextDrag(mx, my)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Update last known position for this button
|
||||||
|
self._lastMouseX[button] = mx
|
||||||
|
self._lastMouseY[button] = my
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Handle mouse button release
|
||||||
|
---@param mx number Mouse X position
|
||||||
|
---@param my number Mouse Y position
|
||||||
|
---@param button number Mouse button
|
||||||
|
function EventHandler:_handleMouseRelease(element, mx, my, button)
|
||||||
|
local currentTime = love.timer.getTime()
|
||||||
|
local modifiers = EventHandler._utils.getModifiers()
|
||||||
|
|
||||||
|
-- Handle scrollbar release if scrollbar was pressed
|
||||||
|
if button == 1 and self._scrollbarPressHandled and element._handleScrollbarRelease then
|
||||||
|
element:_handleScrollbarRelease(button)
|
||||||
|
self._scrollbarPressHandled = false -- Reset flag
|
||||||
|
self._pressed[button] = false
|
||||||
|
self._dragStartX[button] = nil
|
||||||
|
self._dragStartY[button] = nil
|
||||||
|
return -- Don't process click events for scrollbar release
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Determine click count (double-click detection)
|
||||||
|
local clickCount
|
||||||
|
local doubleClickThreshold = 0.3 -- 300ms for double-click
|
||||||
|
|
||||||
|
if
|
||||||
|
self._lastClickTime
|
||||||
|
and self._lastClickButton == button
|
||||||
|
and (currentTime - self._lastClickTime) < doubleClickThreshold
|
||||||
|
then
|
||||||
|
clickCount = self._clickCount + 1
|
||||||
|
else
|
||||||
|
clickCount = 1
|
||||||
|
end
|
||||||
|
|
||||||
|
self._clickCount = clickCount
|
||||||
|
self._lastClickTime = currentTime
|
||||||
|
self._lastClickButton = button
|
||||||
|
|
||||||
|
-- Determine event type based on button
|
||||||
|
local eventType = "click"
|
||||||
|
if button == 2 then
|
||||||
|
eventType = "rightclick"
|
||||||
|
elseif button == 3 then
|
||||||
|
eventType = "middleclick"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Fire click event
|
||||||
|
local clickEvent = EventHandler._InputEvent.new({
|
||||||
|
type = eventType,
|
||||||
|
button = button,
|
||||||
|
x = mx,
|
||||||
|
y = my,
|
||||||
|
modifiers = modifiers,
|
||||||
|
clickCount = clickCount,
|
||||||
|
})
|
||||||
|
self:_invokeCallback(element, clickEvent)
|
||||||
|
|
||||||
|
self._pressed[button] = false
|
||||||
|
|
||||||
|
-- Clean up drag tracking
|
||||||
|
self._dragStartX[button] = nil
|
||||||
|
self._dragStartY[button] = nil
|
||||||
|
|
||||||
|
-- Clean up text selection drag tracking
|
||||||
|
if button == 1 then
|
||||||
|
element._mouseDownPosition = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Focus editable elements on left click
|
||||||
|
if button == 1 and element.editable then
|
||||||
|
-- Only focus if not already focused (to avoid moving cursor to end)
|
||||||
|
local wasFocused = element:isFocused()
|
||||||
|
if not wasFocused then
|
||||||
|
element:focus()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Handle text click for cursor positioning and word selection
|
||||||
|
-- Only process click if no text drag occurred (to preserve drag selection)
|
||||||
|
if element._handleTextClick and not element._textDragOccurred then
|
||||||
|
element:_handleTextClick(mx, my, clickCount)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Reset drag flag after release
|
||||||
|
element._textDragOccurred = false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Fire release event
|
||||||
|
local releaseEvent = EventHandler._InputEvent.new({
|
||||||
|
type = "release",
|
||||||
|
button = button,
|
||||||
|
x = mx,
|
||||||
|
y = my,
|
||||||
|
modifiers = modifiers,
|
||||||
|
clickCount = clickCount,
|
||||||
|
})
|
||||||
|
self:_invokeCallback(element, releaseEvent)
|
||||||
|
|
||||||
|
if button == 1 and element._handleSelectRelease then
|
||||||
|
element:_handleSelectRelease()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Process touch events in the update cycle
|
||||||
|
---@param element Element The parent element
|
||||||
|
function EventHandler:processTouchEvents(element)
|
||||||
|
-- Start performance timing
|
||||||
|
if EventHandler._Performance and EventHandler._Performance.enabled then
|
||||||
|
EventHandler._Performance:startTimer("event_touch")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if element can process events
|
||||||
|
local canProcessEvents = (
|
||||||
|
element.onEvent
|
||||||
|
or self.onEvent
|
||||||
|
or element.onTouchEvent
|
||||||
|
or self.onTouchEvent
|
||||||
|
or element.editable
|
||||||
|
)
|
||||||
|
and not element.disabled
|
||||||
|
and self.touchEnabled
|
||||||
|
|
||||||
|
if not canProcessEvents then
|
||||||
|
if EventHandler._Performance and EventHandler._Performance.enabled then
|
||||||
|
EventHandler._Performance:stopTimer("event_touch")
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local bx = element.x
|
||||||
|
local by = element.y
|
||||||
|
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
|
||||||
|
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
|
||||||
|
|
||||||
|
-- Get current active touches from LÖVE
|
||||||
|
local activeTouches = {}
|
||||||
|
local touches = love.touch.getTouches()
|
||||||
|
for _, id in ipairs(touches) do
|
||||||
|
activeTouches[tostring(id)] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Count active tracked touches for multi-touch filtering
|
||||||
|
local trackedTouchCount = 0
|
||||||
|
for _ in pairs(self._touches) do
|
||||||
|
trackedTouchCount = trackedTouchCount + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Process active touches
|
||||||
|
for _, id in ipairs(touches) do
|
||||||
|
local touchId = tostring(id)
|
||||||
|
local tx, ty = love.touch.getPosition(id)
|
||||||
|
local pressure = 1.0 -- LÖVE doesn't provide pressure by default
|
||||||
|
|
||||||
|
-- Check if touch is within element bounds
|
||||||
|
local isInside = tx >= bx and tx <= bx + bw and ty >= by and ty <= by + bh
|
||||||
|
|
||||||
|
if isInside then
|
||||||
|
if not self._touches[touchId] then
|
||||||
|
-- Multi-touch filtering: reject new touches when multiTouchEnabled=false
|
||||||
|
-- and we already have an active touch
|
||||||
|
if self.multiTouchEnabled or trackedTouchCount == 0 then
|
||||||
|
-- New touch began
|
||||||
|
self:_handleTouchBegan(element, touchId, tx, ty, pressure)
|
||||||
|
trackedTouchCount = trackedTouchCount + 1
|
||||||
|
end
|
||||||
|
else
|
||||||
|
-- Touch moved
|
||||||
|
self:_handleTouchMoved(element, touchId, tx, ty, pressure)
|
||||||
|
end
|
||||||
|
elseif self._touches[touchId] then
|
||||||
|
-- Touch moved outside or ended
|
||||||
|
if activeTouches[touchId] then
|
||||||
|
-- Still active but outside - fire moved event
|
||||||
|
self:_handleTouchMoved(element, touchId, tx, ty, pressure)
|
||||||
|
else
|
||||||
|
-- Touch ended
|
||||||
|
self:_handleTouchEnded(element, touchId, tx, ty, pressure)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for ended touches (touches that were tracked but are no longer active)
|
||||||
|
for touchId, _ in pairs(self._touches) do
|
||||||
|
if not activeTouches[touchId] then
|
||||||
|
-- Touch ended or cancelled
|
||||||
|
local lastPos = self._lastTouchPositions[touchId]
|
||||||
|
if lastPos then
|
||||||
|
self:_handleTouchEnded(element, touchId, lastPos.x, lastPos.y, 1.0)
|
||||||
|
else
|
||||||
|
-- Cleanup orphaned touch
|
||||||
|
self:_cleanupTouch(touchId)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Stop performance timing
|
||||||
|
if EventHandler._Performance and EventHandler._Performance.enabled then
|
||||||
|
EventHandler._Performance:stopTimer("event_touch")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Handle touch began event
|
||||||
|
---@param element Element The parent element
|
||||||
|
---@param touchId string Touch identifier
|
||||||
|
---@param x number Touch X position
|
||||||
|
---@param y number Touch Y position
|
||||||
|
---@param pressure number Touch pressure (0-1)
|
||||||
|
function EventHandler:_handleTouchBegan(element, touchId, x, y, pressure)
|
||||||
|
-- Create touch state
|
||||||
|
self._touches[touchId] = {
|
||||||
|
x = x,
|
||||||
|
y = y,
|
||||||
|
pressure = pressure,
|
||||||
|
timestamp = love.timer.getTime(),
|
||||||
|
phase = "began",
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Record start position
|
||||||
|
self._touchStartPositions[touchId] = { x = x, y = y }
|
||||||
|
self._lastTouchPositions[touchId] = { x = x, y = y }
|
||||||
|
|
||||||
|
-- Initialize touch history
|
||||||
|
self._touchHistory[touchId] = { { x = x, y = y, timestamp = love.timer.getTime() } }
|
||||||
|
|
||||||
|
-- Create and fire touch press event
|
||||||
|
local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "began", pressure)
|
||||||
|
touchEvent.type = "touchpress"
|
||||||
|
touchEvent.dx = 0
|
||||||
|
touchEvent.dy = 0
|
||||||
|
self:_invokeCallback(element, touchEvent)
|
||||||
|
self:_invokeTouchCallback(element, touchEvent)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Handle touch moved event
|
||||||
|
---@param element Element The parent element
|
||||||
|
---@param touchId string Touch identifier
|
||||||
|
---@param x number Touch X position
|
||||||
|
---@param y number Touch Y position
|
||||||
|
---@param pressure number Touch pressure (0-1)
|
||||||
|
function EventHandler:_handleTouchMoved(element, touchId, x, y, pressure)
|
||||||
|
local touchState = self._touches[touchId]
|
||||||
|
|
||||||
|
if not touchState then
|
||||||
|
-- Touch not tracked, ignore
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local lastPos = self._lastTouchPositions[touchId]
|
||||||
|
if not lastPos or lastPos.x ~= x or lastPos.y ~= y then
|
||||||
|
-- Touch position changed
|
||||||
|
local startPos = self._touchStartPositions[touchId]
|
||||||
|
local dx = x - startPos.x
|
||||||
|
local dy = y - startPos.y
|
||||||
|
|
||||||
|
-- Update touch state
|
||||||
|
touchState.x = x
|
||||||
|
touchState.y = y
|
||||||
|
touchState.pressure = pressure
|
||||||
|
touchState.phase = "moved"
|
||||||
|
|
||||||
|
-- Update last position
|
||||||
|
self._lastTouchPositions[touchId] = { x = x, y = y }
|
||||||
|
|
||||||
|
-- Add to touch history (keep last 5 positions)
|
||||||
|
local history = self._touchHistory[touchId] or {}
|
||||||
|
table.insert(history, { x = x, y = y, timestamp = love.timer.getTime() })
|
||||||
|
if #history > 5 then
|
||||||
|
table.remove(history, 1)
|
||||||
|
end
|
||||||
|
self._touchHistory[touchId] = history
|
||||||
|
|
||||||
|
-- Create and fire touch move event
|
||||||
|
local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "moved", pressure)
|
||||||
|
touchEvent.type = "touchmove"
|
||||||
|
touchEvent.dx = dx
|
||||||
|
touchEvent.dy = dy
|
||||||
|
self:_invokeCallback(element, touchEvent)
|
||||||
|
self:_invokeTouchCallback(element, touchEvent)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Handle touch ended event
|
||||||
|
---@param element Element The parent element
|
||||||
|
---@param touchId string Touch identifier
|
||||||
|
---@param x number Touch X position
|
||||||
|
---@param y number Touch Y position
|
||||||
|
---@param pressure number Touch pressure (0-1)
|
||||||
|
function EventHandler:_handleTouchEnded(element, touchId, x, y, pressure)
|
||||||
|
local touchState = self._touches[touchId]
|
||||||
|
|
||||||
|
if not touchState then
|
||||||
|
-- Touch not tracked, ignore
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local startPos = self._touchStartPositions[touchId]
|
||||||
|
local dx = x - startPos.x
|
||||||
|
local dy = y - startPos.y
|
||||||
|
|
||||||
|
-- Create and fire touch release event
|
||||||
|
local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "ended", pressure)
|
||||||
|
touchEvent.type = "touchrelease"
|
||||||
|
touchEvent.dx = dx
|
||||||
|
touchEvent.dy = dy
|
||||||
|
self:_invokeCallback(element, touchEvent)
|
||||||
|
self:_invokeTouchCallback(element, touchEvent)
|
||||||
|
|
||||||
|
-- Cleanup touch state
|
||||||
|
self:_cleanupTouch(touchId)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Cleanup touch state
|
||||||
|
---@param touchId string Touch ID
|
||||||
|
function EventHandler:_cleanupTouch(touchId)
|
||||||
|
self._touches[touchId] = nil
|
||||||
|
self._touchStartPositions[touchId] = nil
|
||||||
|
self._lastTouchPositions[touchId] = nil
|
||||||
|
self._touchHistory[touchId] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get active touches on this element
|
||||||
|
---@return table<string, table> Active touches
|
||||||
|
function EventHandler:getActiveTouches()
|
||||||
|
return self._touches
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Reset scrollbar press flag (called each frame)
|
||||||
|
function EventHandler:resetScrollbarPressFlag()
|
||||||
|
self._scrollbarPressHandled = false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Check if any mouse button is pressed
|
||||||
|
---@return boolean True if any button is pressed
|
||||||
|
function EventHandler:isAnyButtonPressed()
|
||||||
|
for _, pressed in pairs(self._pressed) do
|
||||||
|
if pressed then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Check if a specific button is pressed
|
||||||
|
---@param button number Mouse button (1=left, 2=right, 3=middle)
|
||||||
|
---@return boolean True if button is pressed
|
||||||
|
function EventHandler:isButtonPressed(button)
|
||||||
|
return self._pressed[button] == true
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Invoke the onEvent callback, optionally deferring it if onEventDeferred is true
|
||||||
|
---@param element Element The element that triggered the event
|
||||||
|
---@param event InputEvent The event data
|
||||||
|
function EventHandler:_invokeCallback(element, event)
|
||||||
|
-- Read onEvent from element (source of truth), fallback to handler cache for backwards compat
|
||||||
|
local callback = element.onEvent or self.onEvent
|
||||||
|
if not callback then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if self.onEventDeferred then
|
||||||
|
-- Get FlexLove module to defer the callback
|
||||||
|
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
|
||||||
|
if FlexLove and FlexLove.deferCallback then
|
||||||
|
FlexLove.deferCallback(function()
|
||||||
|
callback(element, event)
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
|
||||||
|
eventType = event.type,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
else
|
||||||
|
callback(element, event)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Invoke the onTouchEvent callback, optionally deferring it
|
||||||
|
---@param element Element The element that triggered the event
|
||||||
|
---@param event InputEvent The touch event data
|
||||||
|
function EventHandler:_invokeTouchCallback(element, event)
|
||||||
|
-- Read onTouchEvent from element (source of truth), fallback to handler cache for backwards compat
|
||||||
|
local callback = element.onTouchEvent or self.onTouchEvent
|
||||||
|
if not callback then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if self.onTouchEventDeferred then
|
||||||
|
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
|
||||||
|
if FlexLove and FlexLove.deferCallback then
|
||||||
|
FlexLove.deferCallback(function()
|
||||||
|
callback(element, event)
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
|
||||||
|
eventType = event.type,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
else
|
||||||
|
callback(element, event)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Invoke the onGesture callback, optionally deferring it
|
||||||
|
---@param element Element The element that triggered the event
|
||||||
|
---@param gesture table The gesture data from GestureRecognizer
|
||||||
|
function EventHandler:_invokeGestureCallback(element, gesture)
|
||||||
|
-- Read onGesture from element (source of truth), fallback to handler cache for backwards compat
|
||||||
|
local callback = element.onGesture or self.onGesture
|
||||||
|
if not callback then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if self.onGestureDeferred then
|
||||||
|
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
|
||||||
|
if FlexLove and FlexLove.deferCallback then
|
||||||
|
FlexLove.deferCallback(function()
|
||||||
|
callback(element, gesture)
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
|
||||||
|
gestureType = gesture.type,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
else
|
||||||
|
callback(element, gesture)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return EventHandler
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
local packageName = ... or "FocusIndicator"
|
||||||
|
local modulePath = packageName:match("(.-)[^%.]+$")
|
||||||
|
|
||||||
|
local function req(name)
|
||||||
|
return require(modulePath .. name)
|
||||||
|
end
|
||||||
|
|
||||||
|
local FocusIndicator = {}
|
||||||
|
|
||||||
|
--- Configuration
|
||||||
|
---@type KeyboardNavigationFocusIndicatorConfig
|
||||||
|
FocusIndicator.config = {
|
||||||
|
enabled = true,
|
||||||
|
|
||||||
|
--- Custom draw function to override default rendering
|
||||||
|
---@type function|nil
|
||||||
|
--- Called with: element, bounds, style - return true to skip default drawing
|
||||||
|
draw = nil,
|
||||||
|
|
||||||
|
-- Appearance
|
||||||
|
color = { 0.2, 0.6, 1.0, 0.8 }, -- Blue with 80% opacity
|
||||||
|
lineWidth = 2,
|
||||||
|
inset = -3, -- Negative value extends beyond element
|
||||||
|
borderRadius = 4,
|
||||||
|
|
||||||
|
-- Animation
|
||||||
|
animationDuration = 0.15, -- Seconds for focus animation
|
||||||
|
pulseEnabled = false, -- Enable pulsing animation
|
||||||
|
pulseDuration = 1.0, -- Seconds per pulse cycle
|
||||||
|
pulseScaleMin = 0.95, -- Minimum scale during pulse
|
||||||
|
pulseScaleMax = 1.05, -- Maximum scale during pulse
|
||||||
|
}
|
||||||
|
|
||||||
|
--- State
|
||||||
|
FocusIndicator._focusedElement = nil
|
||||||
|
FocusIndicator._animationProgress = 0
|
||||||
|
FocusIndicator._pulsePhase = 0
|
||||||
|
FocusIndicator._hidden = true
|
||||||
|
FocusIndicator._deps = nil
|
||||||
|
|
||||||
|
--- Initialize FocusIndicator module
|
||||||
|
---@param deps table Dependencies table containing Context and Color modules
|
||||||
|
---@field deps.Context table Context module for getting focused element
|
||||||
|
---@field deps.Color table Color module for color manipulation
|
||||||
|
function FocusIndicator.init(deps)
|
||||||
|
FocusIndicator._deps = deps
|
||||||
|
FocusIndicator._Context = deps.Context
|
||||||
|
FocusIndicator._Color = deps.Color
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Update animation state for entrance and pulse effects
|
||||||
|
---@param dt number Delta time in seconds since last frame
|
||||||
|
function FocusIndicator:update(dt)
|
||||||
|
if not FocusIndicator.config.enabled then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Update focus entrance animation
|
||||||
|
if FocusIndicator._animationProgress < 1 then
|
||||||
|
FocusIndicator._animationProgress =
|
||||||
|
math.min(1, FocusIndicator._animationProgress + (dt / FocusIndicator.config.animationDuration))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Update pulse animation
|
||||||
|
if FocusIndicator.config.pulseEnabled then
|
||||||
|
FocusIndicator._pulsePhase = (FocusIndicator._pulsePhase + dt) % FocusIndicator.config.pulseDuration
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Set the focused element to render indicator around
|
||||||
|
---@param element Element? The element to show focus indicator around, or nil to hide
|
||||||
|
function FocusIndicator.setFocused(element)
|
||||||
|
FocusIndicator._focusedElement = element
|
||||||
|
FocusIndicator._hidden = element == nil
|
||||||
|
-- Reset animation when focus changes
|
||||||
|
if element then
|
||||||
|
FocusIndicator._animationProgress = 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get the current scale factor for animations
|
||||||
|
--- Combines entrance scale (0.8 to 1.0) with optional pulse scale
|
||||||
|
---@return number Scale factor (typically 0.8-1.05 range)
|
||||||
|
function FocusIndicator:getScale()
|
||||||
|
local scale = 1
|
||||||
|
|
||||||
|
-- Apply entrance animation (scale up from 0.8)
|
||||||
|
local entranceScale = 0.8 + (0.2 * FocusIndicator._animationProgress)
|
||||||
|
scale = scale * entranceScale
|
||||||
|
|
||||||
|
-- Apply pulse animation
|
||||||
|
if FocusIndicator.config.pulseEnabled then
|
||||||
|
local pulseProgress = FocusIndicator._pulsePhase / FocusIndicator.config.pulseDuration
|
||||||
|
-- Smooth sine wave pulse
|
||||||
|
local pulseScale = FocusIndicator.config.pulseScaleMin
|
||||||
|
+ (FocusIndicator.config.pulseScaleMax - FocusIndicator.config.pulseScaleMin)
|
||||||
|
* (0.5 + 0.5 * math.sin(2 * math.pi * pulseProgress))
|
||||||
|
scale = scale * pulseScale
|
||||||
|
end
|
||||||
|
|
||||||
|
return scale
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get the current opacity for the indicator
|
||||||
|
--- Applies entrance animation fade-in to the configured alpha
|
||||||
|
---@return number Alpha value (0-1 range)
|
||||||
|
function FocusIndicator:getOpacity()
|
||||||
|
-- Fade in on focus
|
||||||
|
return FocusIndicator.config.color[4] * FocusIndicator._animationProgress
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Draw the focus indicator around the focused element
|
||||||
|
--- Renders a rounded rectangle border, or calls custom draw function if configured
|
||||||
|
--- Should be called from within love.draw() after all elements are drawn
|
||||||
|
function FocusIndicator:draw()
|
||||||
|
if not FocusIndicator.config.enabled then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if FocusIndicator._hidden then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- In immediate mode the stored element reference is stale (recreated every frame).
|
||||||
|
-- Always resolve through Context so we get the live object with up-to-date positions.
|
||||||
|
local element
|
||||||
|
if FocusIndicator._Context then
|
||||||
|
element = FocusIndicator._Context.getFocused()
|
||||||
|
else
|
||||||
|
element = FocusIndicator._focusedElement
|
||||||
|
end
|
||||||
|
|
||||||
|
if not element then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get element dimensions (use border-box size which includes padding)
|
||||||
|
local x = element.x or 0
|
||||||
|
local y = element.y or 0
|
||||||
|
local w = element._borderBoxWidth
|
||||||
|
or (element.width + (element.padding and (element.padding.left + element.padding.right) or 0))
|
||||||
|
local h = element._borderBoxHeight
|
||||||
|
or (element.height + (element.padding and (element.padding.top + element.padding.bottom) or 0))
|
||||||
|
|
||||||
|
if w == 0 or h == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Calculate indicator dimensions with inset and scale
|
||||||
|
local inset = FocusIndicator.config.inset
|
||||||
|
local scale = self:getScale()
|
||||||
|
|
||||||
|
local indicatorX = x + inset
|
||||||
|
local indicatorY = y + inset
|
||||||
|
local indicatorW = w - 2 * inset
|
||||||
|
local indicatorH = h - 2 * inset
|
||||||
|
|
||||||
|
-- Center the scale around the element
|
||||||
|
local offsetX = (indicatorW * (1 - scale)) / 2
|
||||||
|
local offsetY = (indicatorH * (1 - scale)) / 2
|
||||||
|
|
||||||
|
indicatorX = indicatorX + offsetX
|
||||||
|
indicatorY = indicatorY + offsetY
|
||||||
|
indicatorW = indicatorW * scale
|
||||||
|
indicatorH = indicatorH * scale
|
||||||
|
|
||||||
|
-- Get color with animated opacity
|
||||||
|
local r, g, b = FocusIndicator.config.color[1], FocusIndicator.config.color[2], FocusIndicator.config.color[3]
|
||||||
|
local a = self:getOpacity()
|
||||||
|
|
||||||
|
-- Build style table for custom draw callback
|
||||||
|
local bounds = {
|
||||||
|
x = indicatorX,
|
||||||
|
y = indicatorY,
|
||||||
|
width = indicatorW,
|
||||||
|
height = indicatorH,
|
||||||
|
}
|
||||||
|
|
||||||
|
local style = {
|
||||||
|
color = { r = r, g = g, b = b, a = a },
|
||||||
|
lineWidth = FocusIndicator.config.lineWidth,
|
||||||
|
borderRadius = FocusIndicator.config.borderRadius,
|
||||||
|
scale = scale,
|
||||||
|
opacity = a,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Check for custom draw callback
|
||||||
|
if FocusIndicator.config.draw then
|
||||||
|
local skipDefault = FocusIndicator.config.draw(element, bounds, style)
|
||||||
|
if skipDefault then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Save current love.graphics state
|
||||||
|
local prevBlend, prevAlphaMode = love.graphics.getBlendMode()
|
||||||
|
local prevR, prevG, prevB, prevA = love.graphics.getColor()
|
||||||
|
local prevLineWidth = love.graphics.getLineWidth()
|
||||||
|
|
||||||
|
-- Set blend mode for transparency
|
||||||
|
love.graphics.setBlendMode("alpha")
|
||||||
|
|
||||||
|
-- Draw rounded rectangle border
|
||||||
|
love.graphics.setColor(r, g, b, a)
|
||||||
|
love.graphics.setLineWidth(FocusIndicator.config.lineWidth)
|
||||||
|
|
||||||
|
-- Draw the rounded rectangle border
|
||||||
|
local borderRadius = FocusIndicator.config.borderRadius
|
||||||
|
love.graphics.rectangle("line", indicatorX, indicatorY, indicatorW, indicatorH, borderRadius)
|
||||||
|
|
||||||
|
-- Restore love.graphics state
|
||||||
|
love.graphics.setBlendMode(prevBlend, prevAlphaMode)
|
||||||
|
love.graphics.setColor(prevR, prevG, prevB, prevA)
|
||||||
|
love.graphics.setLineWidth(prevLineWidth)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Set the indicator color
|
||||||
|
---@param r number Red component (0-1 range)
|
||||||
|
---@param g number Green component (0-1 range)
|
||||||
|
---@param b number Blue component (0-1 range)
|
||||||
|
---@param a number|nil Alpha component (0-1 range), defaults to current alpha if omitted
|
||||||
|
function FocusIndicator.setColor(r, g, b, a)
|
||||||
|
FocusIndicator.config.color = { r, g, b, a or FocusIndicator.config.color[4] }
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Set the stroke width for the indicator border
|
||||||
|
---@param width number Line width in pixels
|
||||||
|
function FocusIndicator.setLineWidth(width)
|
||||||
|
FocusIndicator.config.lineWidth = width
|
||||||
|
end
|
||||||
|
|
||||||
|
return FocusIndicator
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
local modulePath = (...):match("(.-)[^%.]+$")
|
||||||
|
local function req(name)
|
||||||
|
return require(modulePath .. name)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Font cache with LRU eviction, font resolution, and cache management.
|
||||||
|
-- `ErrorHandler` and `resolveImagePath` are injected via init() to avoid
|
||||||
|
-- a cross-import into utils (utils re-exports the cache via aliases).
|
||||||
|
|
||||||
|
-- Font cache with LRU eviction
|
||||||
|
local FONT_CACHE = {}
|
||||||
|
local FONT_CACHE_MAX_SIZE = 50
|
||||||
|
local FONT_CACHE_STATS = {
|
||||||
|
hits = 0,
|
||||||
|
misses = 0,
|
||||||
|
evictions = 0,
|
||||||
|
size = 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
local ErrorHandler = nil
|
||||||
|
local resolveImagePath = nil
|
||||||
|
|
||||||
|
--- Initialize dependencies
|
||||||
|
---@param deps table Dependencies: { ErrorHandler = ErrorHandler, resolveImagePath = function }
|
||||||
|
local function init(deps)
|
||||||
|
if type(deps) == "table" then
|
||||||
|
ErrorHandler = deps.ErrorHandler
|
||||||
|
resolveImagePath = deps.resolveImagePath
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- LRU tracking: each entry has {font, lastUsed, accessCount}
|
||||||
|
local function updateCacheAccess(cacheKey)
|
||||||
|
local entry = FONT_CACHE[cacheKey]
|
||||||
|
if entry then
|
||||||
|
entry.lastUsed = love.timer.getTime()
|
||||||
|
entry.accessCount = entry.accessCount + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function evictLRU()
|
||||||
|
local oldestKey = nil
|
||||||
|
local oldestTime = math.huge
|
||||||
|
|
||||||
|
for key, entry in pairs(FONT_CACHE) do
|
||||||
|
-- Skip methods (get, getFont) - only evict cache entries (tables with lastUsed)
|
||||||
|
if type(entry) == "table" and entry.lastUsed then
|
||||||
|
if entry.lastUsed < oldestTime then
|
||||||
|
oldestTime = entry.lastUsed
|
||||||
|
oldestKey = key
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if oldestKey then
|
||||||
|
FONT_CACHE[oldestKey] = nil
|
||||||
|
FONT_CACHE_STATS.evictions = FONT_CACHE_STATS.evictions + 1
|
||||||
|
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size - 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Create or get a font from cache
|
||||||
|
---@param size number
|
||||||
|
---@param fontPath string?
|
||||||
|
---@return love.Font
|
||||||
|
function FONT_CACHE.get(size, fontPath)
|
||||||
|
-- Bucket font sizes for better cache reuse (reduces unique cache entries)
|
||||||
|
-- Small sizes (< 20): round to nearest 2
|
||||||
|
-- Medium sizes (20-40): round to nearest 4
|
||||||
|
-- Large sizes (> 40): round to nearest 8
|
||||||
|
if size < 20 then
|
||||||
|
size = math.floor((size + 1) / 2) * 2
|
||||||
|
elseif size < 40 then
|
||||||
|
size = math.floor((size + 2) / 4) * 4
|
||||||
|
else
|
||||||
|
size = math.floor((size + 4) / 8) * 8
|
||||||
|
end
|
||||||
|
|
||||||
|
local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size))
|
||||||
|
|
||||||
|
if FONT_CACHE[cacheKey] then
|
||||||
|
-- Cache hit
|
||||||
|
FONT_CACHE_STATS.hits = FONT_CACHE_STATS.hits + 1
|
||||||
|
updateCacheAccess(cacheKey)
|
||||||
|
return FONT_CACHE[cacheKey].font
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Cache miss
|
||||||
|
FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1
|
||||||
|
|
||||||
|
local font
|
||||||
|
if fontPath then
|
||||||
|
local resolvedPath = resolveImagePath(fontPath)
|
||||||
|
local success, result = pcall(love.graphics.newFont, resolvedPath, size)
|
||||||
|
if success then
|
||||||
|
font = result
|
||||||
|
else
|
||||||
|
if ErrorHandler then
|
||||||
|
ErrorHandler:warn("utils", "RES_004", {
|
||||||
|
resourceType = "font",
|
||||||
|
path = fontPath,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
font = love.graphics.newFont(size)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
font = love.graphics.newFont(size)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Add to cache with LRU metadata
|
||||||
|
FONT_CACHE[cacheKey] = {
|
||||||
|
font = font,
|
||||||
|
lastUsed = love.timer.getTime(),
|
||||||
|
accessCount = 1,
|
||||||
|
}
|
||||||
|
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size + 1
|
||||||
|
|
||||||
|
-- Evict if cache is full
|
||||||
|
if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then
|
||||||
|
evictLRU()
|
||||||
|
end
|
||||||
|
|
||||||
|
return font
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get font for text size (cached)
|
||||||
|
---@param textSize number?
|
||||||
|
---@param fontPath string?
|
||||||
|
---@return love.Font
|
||||||
|
function FONT_CACHE.getFont(textSize, fontPath)
|
||||||
|
if textSize then
|
||||||
|
return FONT_CACHE.get(textSize, fontPath)
|
||||||
|
else
|
||||||
|
return love.graphics.getFont()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Font resolution utilities
|
||||||
|
|
||||||
|
--- Resolve font path from fontFamily and theme
|
||||||
|
---@param fontFamily string? Font family name or direct path
|
||||||
|
---@param themeComponent string? Theme component name
|
||||||
|
---@param themeManager table? ThemeManager instance
|
||||||
|
---@return string? Resolved font path or nil
|
||||||
|
local function resolveFontPath(fontFamily, themeComponent, themeManager)
|
||||||
|
if fontFamily then
|
||||||
|
-- Check if fontFamily is a theme font name
|
||||||
|
local themeToUse = themeManager and themeManager:getTheme()
|
||||||
|
if themeToUse and themeToUse.fonts and themeToUse.fonts[fontFamily] then
|
||||||
|
return themeToUse.fonts[fontFamily]
|
||||||
|
else
|
||||||
|
-- Treat as direct path to font file
|
||||||
|
return fontFamily
|
||||||
|
end
|
||||||
|
elseif themeComponent and themeManager then
|
||||||
|
-- If using themeComponent but no fontFamily specified, check for default font in theme
|
||||||
|
return themeManager:getDefaultFontFamily()
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get font for element (resolves from theme or fontFamily)
|
||||||
|
---@param textSize number? Text size in pixels
|
||||||
|
---@param fontFamily string? Font family name or direct path
|
||||||
|
---@param themeComponent string? Theme component name
|
||||||
|
---@param themeManager table? ThemeManager instance
|
||||||
|
---@return love.Font
|
||||||
|
local function getFont(textSize, fontFamily, themeComponent, themeManager)
|
||||||
|
local fontPath = resolveFontPath(fontFamily, themeComponent, themeManager)
|
||||||
|
return FONT_CACHE.getFont(textSize, fontPath)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Font cache management
|
||||||
|
|
||||||
|
--- Get font cache statistics
|
||||||
|
---@return table stats {hits, misses, evictions, size, hitRate}
|
||||||
|
local function getFontCacheStats()
|
||||||
|
local total = FONT_CACHE_STATS.hits + FONT_CACHE_STATS.misses
|
||||||
|
local hitRate = total > 0 and (FONT_CACHE_STATS.hits / total) or 0
|
||||||
|
return {
|
||||||
|
hits = FONT_CACHE_STATS.hits,
|
||||||
|
misses = FONT_CACHE_STATS.misses,
|
||||||
|
evictions = FONT_CACHE_STATS.evictions,
|
||||||
|
size = FONT_CACHE_STATS.size,
|
||||||
|
hitRate = hitRate,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Set maximum font cache size
|
||||||
|
---@param maxSize number Maximum number of fonts to cache
|
||||||
|
local function setFontCacheSize(maxSize)
|
||||||
|
FONT_CACHE_MAX_SIZE = math.max(1, maxSize)
|
||||||
|
|
||||||
|
-- Evict entries if cache is now over limit
|
||||||
|
while FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE do
|
||||||
|
evictLRU()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Clear font cache
|
||||||
|
local function clearFontCache()
|
||||||
|
-- Clear cache entries but preserve methods (get, getFont)
|
||||||
|
for key, entry in pairs(FONT_CACHE) do
|
||||||
|
if type(entry) == "table" and entry.lastUsed then
|
||||||
|
FONT_CACHE[key] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
FONT_CACHE_STATS.size = 0
|
||||||
|
FONT_CACHE_STATS.evictions = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Preload font at multiple sizes
|
||||||
|
---@param fontPath string? Path to font file (nil for default font)
|
||||||
|
---@param sizes table Array of font sizes to preload
|
||||||
|
local function preloadFont(fontPath, sizes)
|
||||||
|
for _, size in ipairs(sizes) do
|
||||||
|
-- Round size to reduce cache entries
|
||||||
|
size = math.floor(size + 0.5)
|
||||||
|
|
||||||
|
local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size))
|
||||||
|
|
||||||
|
if not FONT_CACHE[cacheKey] then
|
||||||
|
local font
|
||||||
|
if fontPath then
|
||||||
|
local resolvedPath = resolveImagePath(fontPath)
|
||||||
|
local success, result = pcall(love.graphics.newFont, resolvedPath, size)
|
||||||
|
if success then
|
||||||
|
font = result
|
||||||
|
else
|
||||||
|
font = love.graphics.newFont(size)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
font = love.graphics.newFont(size)
|
||||||
|
end
|
||||||
|
|
||||||
|
FONT_CACHE[cacheKey] = {
|
||||||
|
font = font,
|
||||||
|
lastUsed = love.timer.getTime(),
|
||||||
|
accessCount = 1,
|
||||||
|
}
|
||||||
|
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size + 1
|
||||||
|
FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1
|
||||||
|
|
||||||
|
-- Evict if cache is full
|
||||||
|
if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then
|
||||||
|
evictLRU()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Reset font cache statistics
|
||||||
|
local function resetFontCacheStats()
|
||||||
|
FONT_CACHE_STATS.hits = 0
|
||||||
|
FONT_CACHE_STATS.misses = 0
|
||||||
|
FONT_CACHE_STATS.evictions = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
FONT_CACHE = FONT_CACHE,
|
||||||
|
init = init,
|
||||||
|
resolveFontPath = resolveFontPath,
|
||||||
|
getFont = getFont,
|
||||||
|
getFontCacheStats = getFontCacheStats,
|
||||||
|
setFontCacheSize = setFontCacheSize,
|
||||||
|
clearFontCache = clearFontCache,
|
||||||
|
preloadFont = preloadFont,
|
||||||
|
resetFontCacheStats = resetFontCacheStats,
|
||||||
|
}
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
---@class GestureRecognizer
|
||||||
|
---@field _touches table<string, table> -- Current touch states
|
||||||
|
---@field _gestureStates table -- Active gesture states
|
||||||
|
---@field _config table -- Gesture configuration (thresholds, etc.)
|
||||||
|
---@field _InputEvent table
|
||||||
|
---@field _utils table
|
||||||
|
local GestureRecognizer = {}
|
||||||
|
GestureRecognizer.__index = GestureRecognizer
|
||||||
|
|
||||||
|
-- Gesture types enum
|
||||||
|
local GestureType = {
|
||||||
|
TAP = "tap",
|
||||||
|
DOUBLE_TAP = "double_tap",
|
||||||
|
LONG_PRESS = "long_press",
|
||||||
|
SWIPE = "swipe",
|
||||||
|
PAN = "pan",
|
||||||
|
PINCH = "pinch",
|
||||||
|
ROTATE = "rotate",
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Gesture states
|
||||||
|
local GestureState = {
|
||||||
|
POSSIBLE = "possible",
|
||||||
|
BEGAN = "began",
|
||||||
|
CHANGED = "changed",
|
||||||
|
ENDED = "ended",
|
||||||
|
CANCELLED = "cancelled",
|
||||||
|
FAILED = "failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Default configuration
|
||||||
|
local defaultConfig = {
|
||||||
|
-- Tap gesture
|
||||||
|
tapMaxDuration = 0.3, -- seconds
|
||||||
|
tapMaxMovement = 10, -- pixels
|
||||||
|
|
||||||
|
-- Double-tap gesture
|
||||||
|
doubleTapInterval = 0.3, -- seconds between taps
|
||||||
|
|
||||||
|
-- Long-press gesture
|
||||||
|
longPressMinDuration = 0.5, -- seconds
|
||||||
|
longPressMaxMovement = 10, -- pixels
|
||||||
|
|
||||||
|
-- Swipe gesture
|
||||||
|
swipeMinDistance = 50, -- pixels
|
||||||
|
swipeMaxDuration = 0.2, -- seconds
|
||||||
|
swipeMinVelocity = 200, -- pixels per second
|
||||||
|
|
||||||
|
-- Pan gesture
|
||||||
|
panMinMovement = 5, -- pixels to start pan
|
||||||
|
|
||||||
|
-- Pinch gesture
|
||||||
|
pinchMinScaleChange = 0.1, -- 10% scale change
|
||||||
|
|
||||||
|
-- Rotate gesture
|
||||||
|
rotateMinAngleChange = 5, -- degrees
|
||||||
|
}
|
||||||
|
|
||||||
|
--- Create a new GestureRecognizer instance
|
||||||
|
---@param config table? Optional configuration options
|
||||||
|
---@param deps table Dependencies {InputEvent, utils}
|
||||||
|
---@return GestureRecognizer
|
||||||
|
function GestureRecognizer.new(config, deps)
|
||||||
|
config = config or {}
|
||||||
|
|
||||||
|
local self = setmetatable({}, GestureRecognizer)
|
||||||
|
|
||||||
|
self._InputEvent = deps.InputEvent
|
||||||
|
self._utils = deps.utils
|
||||||
|
|
||||||
|
-- Merge configuration with defaults
|
||||||
|
self._config = {}
|
||||||
|
for key, value in pairs(defaultConfig) do
|
||||||
|
self._config[key] = config[key] or value
|
||||||
|
end
|
||||||
|
|
||||||
|
self._touches = {}
|
||||||
|
self._gestureStates = {
|
||||||
|
tap = nil,
|
||||||
|
doubleTap = { lastTapTime = 0, tapCount = 0 },
|
||||||
|
longPress = {},
|
||||||
|
swipe = {},
|
||||||
|
pan = {},
|
||||||
|
pinch = {},
|
||||||
|
rotate = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Update gesture recognizer with touch event
|
||||||
|
---@param event InputEvent Touch event
|
||||||
|
function GestureRecognizer:processTouchEvent(event)
|
||||||
|
if not event.touchId then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local touchId = event.touchId
|
||||||
|
local gestures = {}
|
||||||
|
|
||||||
|
-- Update touch state
|
||||||
|
if event.type == "touchpress" then
|
||||||
|
self._touches[touchId] = {
|
||||||
|
startX = event.x,
|
||||||
|
startY = event.y,
|
||||||
|
x = event.x,
|
||||||
|
y = event.y,
|
||||||
|
startTime = event.timestamp,
|
||||||
|
lastTime = event.timestamp,
|
||||||
|
phase = "began",
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Initialize gesture detection
|
||||||
|
self:_detectTapBegan(touchId, event)
|
||||||
|
self:_detectLongPressBegan(touchId, event)
|
||||||
|
elseif event.type == "touchmove" then
|
||||||
|
local touch = self._touches[touchId]
|
||||||
|
if touch then
|
||||||
|
touch.x = event.x
|
||||||
|
touch.y = event.y
|
||||||
|
touch.lastTime = event.timestamp
|
||||||
|
touch.phase = "moved"
|
||||||
|
|
||||||
|
-- Update gesture detection
|
||||||
|
local panGesture = self:_detectPan(touchId, event)
|
||||||
|
if panGesture then
|
||||||
|
table.insert(gestures, panGesture)
|
||||||
|
end
|
||||||
|
local swipeGesture = self:_detectSwipe(touchId, event)
|
||||||
|
if swipeGesture then
|
||||||
|
table.insert(gestures, swipeGesture)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Multi-touch gestures
|
||||||
|
if self:_getTouchCount() >= 2 then
|
||||||
|
local pinchGesture = self:_detectPinch(event)
|
||||||
|
if pinchGesture then
|
||||||
|
table.insert(gestures, pinchGesture)
|
||||||
|
end
|
||||||
|
local rotateGesture = self:_detectRotate(event)
|
||||||
|
if rotateGesture then
|
||||||
|
table.insert(gestures, rotateGesture)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif event.type == "touchrelease" then
|
||||||
|
local touch = self._touches[touchId]
|
||||||
|
if touch then
|
||||||
|
touch.phase = "ended"
|
||||||
|
|
||||||
|
-- Finalize gesture detection
|
||||||
|
local tapGesture = self:_detectTapEnded(touchId, event)
|
||||||
|
if tapGesture then
|
||||||
|
table.insert(gestures, tapGesture)
|
||||||
|
end
|
||||||
|
local swipeGesture = self:_detectSwipeEnded(touchId, event)
|
||||||
|
if swipeGesture then
|
||||||
|
table.insert(gestures, swipeGesture)
|
||||||
|
end
|
||||||
|
local panGesture = self:_detectPanEnded(touchId, event)
|
||||||
|
if panGesture then
|
||||||
|
table.insert(gestures, panGesture)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Cleanup touch
|
||||||
|
self._touches[touchId] = nil
|
||||||
|
end
|
||||||
|
elseif event.type == "touchcancel" then
|
||||||
|
-- Cancel all active gestures for this touch
|
||||||
|
self._touches[touchId] = nil
|
||||||
|
self:_cancelAllGestures()
|
||||||
|
end
|
||||||
|
|
||||||
|
return #gestures > 0 and gestures or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get number of active touches
|
||||||
|
---@return number
|
||||||
|
function GestureRecognizer:_getTouchCount()
|
||||||
|
local count = 0
|
||||||
|
for _ in pairs(self._touches) do
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
return count
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Detect tap gesture began
|
||||||
|
---@param touchId string
|
||||||
|
---@param event InputEvent
|
||||||
|
function GestureRecognizer:_detectTapBegan(touchId, event)
|
||||||
|
-- Tap detection happens on touch end
|
||||||
|
-- Just record the touch for now
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Detect tap gesture ended
|
||||||
|
---@param touchId string
|
||||||
|
---@param event InputEvent
|
||||||
|
function GestureRecognizer:_detectTapEnded(touchId, event)
|
||||||
|
local touch = self._touches[touchId]
|
||||||
|
if not touch then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local duration = event.timestamp - touch.startTime
|
||||||
|
local dx = event.x - touch.startX
|
||||||
|
local dy = event.y - touch.startY
|
||||||
|
local distance = math.sqrt(dx * dx + dy * dy)
|
||||||
|
|
||||||
|
-- Check if it's a valid tap
|
||||||
|
if duration < self._config.tapMaxDuration and distance < self._config.tapMaxMovement then
|
||||||
|
local currentTime = event.timestamp
|
||||||
|
local doubleTapState = self._gestureStates.doubleTap
|
||||||
|
|
||||||
|
-- Check for double-tap
|
||||||
|
if currentTime - doubleTapState.lastTapTime < self._config.doubleTapInterval then
|
||||||
|
doubleTapState.tapCount = doubleTapState.tapCount + 1
|
||||||
|
|
||||||
|
if doubleTapState.tapCount >= 2 then
|
||||||
|
-- Fire double-tap gesture
|
||||||
|
return {
|
||||||
|
type = GestureType.DOUBLE_TAP,
|
||||||
|
state = GestureState.ENDED,
|
||||||
|
x = event.x,
|
||||||
|
y = event.y,
|
||||||
|
timestamp = event.timestamp,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
doubleTapState.tapCount = 1
|
||||||
|
end
|
||||||
|
|
||||||
|
doubleTapState.lastTapTime = currentTime
|
||||||
|
|
||||||
|
-- Fire tap gesture
|
||||||
|
return {
|
||||||
|
type = GestureType.TAP,
|
||||||
|
state = GestureState.ENDED,
|
||||||
|
x = event.x,
|
||||||
|
y = event.y,
|
||||||
|
timestamp = event.timestamp,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Detect long-press gesture began
|
||||||
|
---@param touchId string
|
||||||
|
---@param event InputEvent
|
||||||
|
function GestureRecognizer:_detectLongPressBegan(touchId, event)
|
||||||
|
-- Long-press detection happens continuously during touch
|
||||||
|
self._gestureStates.longPress[touchId] = {
|
||||||
|
startX = event.x,
|
||||||
|
startY = event.y,
|
||||||
|
startTime = event.timestamp,
|
||||||
|
triggered = false,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Detect pan gesture
|
||||||
|
---@param touchId string
|
||||||
|
---@param event InputEvent
|
||||||
|
---@return table? Gesture event
|
||||||
|
function GestureRecognizer:_detectPan(touchId, event)
|
||||||
|
local touch = self._touches[touchId]
|
||||||
|
if not touch then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local dx = event.x - touch.startX
|
||||||
|
local dy = event.y - touch.startY
|
||||||
|
local distance = math.sqrt(dx * dx + dy * dy)
|
||||||
|
|
||||||
|
local panState = self._gestureStates.pan[touchId]
|
||||||
|
|
||||||
|
if not panState then
|
||||||
|
-- Check if pan should begin
|
||||||
|
if distance >= self._config.panMinMovement then
|
||||||
|
self._gestureStates.pan[touchId] = {
|
||||||
|
active = true,
|
||||||
|
lastX = touch.startX,
|
||||||
|
lastY = touch.startY,
|
||||||
|
}
|
||||||
|
panState = self._gestureStates.pan[touchId]
|
||||||
|
|
||||||
|
return {
|
||||||
|
type = GestureType.PAN,
|
||||||
|
state = GestureState.BEGAN,
|
||||||
|
x = event.x,
|
||||||
|
y = event.y,
|
||||||
|
dx = dx,
|
||||||
|
dy = dy,
|
||||||
|
timestamp = event.timestamp,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
-- Pan is active, fire changed event
|
||||||
|
local panDx = event.x - panState.lastX
|
||||||
|
local panDy = event.y - panState.lastY
|
||||||
|
|
||||||
|
panState.lastX = event.x
|
||||||
|
panState.lastY = event.y
|
||||||
|
|
||||||
|
return {
|
||||||
|
type = GestureType.PAN,
|
||||||
|
state = GestureState.CHANGED,
|
||||||
|
x = event.x,
|
||||||
|
y = event.y,
|
||||||
|
dx = panDx,
|
||||||
|
dy = panDy,
|
||||||
|
totalDx = dx,
|
||||||
|
totalDy = dy,
|
||||||
|
timestamp = event.timestamp,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Detect pan ended
|
||||||
|
---@param touchId string
|
||||||
|
---@param event InputEvent
|
||||||
|
---@return table? Gesture event
|
||||||
|
function GestureRecognizer:_detectPanEnded(touchId, event)
|
||||||
|
local panState = self._gestureStates.pan[touchId]
|
||||||
|
if panState and panState.active then
|
||||||
|
self._gestureStates.pan[touchId] = nil
|
||||||
|
|
||||||
|
local touch = self._touches[touchId]
|
||||||
|
local dx = event.x - touch.startX
|
||||||
|
local dy = event.y - touch.startY
|
||||||
|
|
||||||
|
return {
|
||||||
|
type = GestureType.PAN,
|
||||||
|
state = GestureState.ENDED,
|
||||||
|
x = event.x,
|
||||||
|
y = event.y,
|
||||||
|
dx = dx,
|
||||||
|
dy = dy,
|
||||||
|
timestamp = event.timestamp,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Detect swipe gesture
|
||||||
|
---@param touchId string
|
||||||
|
---@param event InputEvent
|
||||||
|
function GestureRecognizer:_detectSwipe(touchId, event)
|
||||||
|
-- Swipe detection happens on touch end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Detect swipe ended
|
||||||
|
---@param touchId string
|
||||||
|
---@param event InputEvent
|
||||||
|
---@return table? Gesture event
|
||||||
|
function GestureRecognizer:_detectSwipeEnded(touchId, event)
|
||||||
|
local touch = self._touches[touchId]
|
||||||
|
if not touch then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local duration = event.timestamp - touch.startTime
|
||||||
|
local dx = event.x - touch.startX
|
||||||
|
local dy = event.y - touch.startY
|
||||||
|
local distance = math.sqrt(dx * dx + dy * dy)
|
||||||
|
|
||||||
|
-- Check if it's a valid swipe
|
||||||
|
if distance >= self._config.swipeMinDistance and duration <= self._config.swipeMaxDuration then
|
||||||
|
local velocity = distance / duration
|
||||||
|
|
||||||
|
if velocity >= self._config.swipeMinVelocity then
|
||||||
|
-- Determine swipe direction
|
||||||
|
local angle = math.atan2(dy, dx)
|
||||||
|
local direction = "right"
|
||||||
|
|
||||||
|
if angle >= -math.pi / 4 and angle < math.pi / 4 then
|
||||||
|
direction = "right"
|
||||||
|
elseif angle >= math.pi / 4 and angle < 3 * math.pi / 4 then
|
||||||
|
direction = "down"
|
||||||
|
elseif angle >= -3 * math.pi / 4 and angle < -math.pi / 4 then
|
||||||
|
direction = "up"
|
||||||
|
else
|
||||||
|
direction = "left"
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
type = GestureType.SWIPE,
|
||||||
|
state = GestureState.ENDED,
|
||||||
|
x = event.x,
|
||||||
|
y = event.y,
|
||||||
|
dx = dx,
|
||||||
|
dy = dy,
|
||||||
|
direction = direction,
|
||||||
|
velocity = velocity,
|
||||||
|
timestamp = event.timestamp,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Detect pinch gesture
|
||||||
|
---@param event InputEvent
|
||||||
|
---@return table? Gesture event
|
||||||
|
function GestureRecognizer:_detectPinch(event)
|
||||||
|
-- Get two touches for pinch
|
||||||
|
local touches = {}
|
||||||
|
for touchId, touch in pairs(self._touches) do
|
||||||
|
table.insert(touches, { id = touchId, touch = touch })
|
||||||
|
if #touches >= 2 then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #touches < 2 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local t1 = touches[1].touch
|
||||||
|
local t2 = touches[2].touch
|
||||||
|
|
||||||
|
-- Calculate current distance
|
||||||
|
local currentDx = t2.x - t1.x
|
||||||
|
local currentDy = t2.y - t1.y
|
||||||
|
local currentDistance = math.sqrt(currentDx * currentDx + currentDy * currentDy)
|
||||||
|
|
||||||
|
-- Calculate initial distance
|
||||||
|
local initialDx = t2.startX - t1.startX
|
||||||
|
local initialDy = t2.startY - t1.startY
|
||||||
|
local initialDistance = math.sqrt(initialDx * initialDx + initialDy * initialDy)
|
||||||
|
|
||||||
|
if initialDistance == 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Calculate scale
|
||||||
|
local scale = currentDistance / initialDistance
|
||||||
|
local pinchState = self._gestureStates.pinch
|
||||||
|
|
||||||
|
if not pinchState.active then
|
||||||
|
-- Check if pinch should begin
|
||||||
|
if math.abs(scale - 1.0) >= self._config.pinchMinScaleChange then
|
||||||
|
pinchState.active = true
|
||||||
|
pinchState.initialScale = scale
|
||||||
|
pinchState.lastScale = scale
|
||||||
|
|
||||||
|
-- Calculate center point
|
||||||
|
local centerX = (t1.x + t2.x) / 2
|
||||||
|
local centerY = (t1.y + t2.y) / 2
|
||||||
|
|
||||||
|
return {
|
||||||
|
type = GestureType.PINCH,
|
||||||
|
state = GestureState.BEGAN,
|
||||||
|
scale = scale,
|
||||||
|
centerX = centerX,
|
||||||
|
centerY = centerY,
|
||||||
|
timestamp = event.timestamp,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
-- Pinch is active, fire changed event
|
||||||
|
local centerX = (t1.x + t2.x) / 2
|
||||||
|
local centerY = (t1.y + t2.y) / 2
|
||||||
|
|
||||||
|
local scaleChange = scale - pinchState.lastScale
|
||||||
|
pinchState.lastScale = scale
|
||||||
|
|
||||||
|
return {
|
||||||
|
type = GestureType.PINCH,
|
||||||
|
state = GestureState.CHANGED,
|
||||||
|
scale = scale,
|
||||||
|
scaleChange = scaleChange,
|
||||||
|
centerX = centerX,
|
||||||
|
centerY = centerY,
|
||||||
|
timestamp = event.timestamp,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Detect rotate gesture
|
||||||
|
---@param event InputEvent
|
||||||
|
---@return table? Gesture event
|
||||||
|
function GestureRecognizer:_detectRotate(event)
|
||||||
|
-- Get two touches for rotation
|
||||||
|
local touches = {}
|
||||||
|
for touchId, touch in pairs(self._touches) do
|
||||||
|
table.insert(touches, { id = touchId, touch = touch })
|
||||||
|
if #touches >= 2 then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #touches < 2 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local t1 = touches[1].touch
|
||||||
|
local t2 = touches[2].touch
|
||||||
|
|
||||||
|
-- Calculate current angle
|
||||||
|
local currentAngle = math.atan2(t2.y - t1.y, t2.x - t1.x)
|
||||||
|
|
||||||
|
-- Calculate initial angle
|
||||||
|
local initialAngle = math.atan2(t2.startY - t1.startY, t2.startX - t1.startX)
|
||||||
|
|
||||||
|
-- Calculate rotation (in degrees)
|
||||||
|
local rotation = (currentAngle - initialAngle) * 180 / math.pi
|
||||||
|
|
||||||
|
local rotateState = self._gestureStates.rotate
|
||||||
|
|
||||||
|
if not rotateState.active then
|
||||||
|
-- Check if rotation should begin
|
||||||
|
if math.abs(rotation) >= self._config.rotateMinAngleChange then
|
||||||
|
rotateState.active = true
|
||||||
|
rotateState.initialRotation = rotation
|
||||||
|
rotateState.lastRotation = rotation
|
||||||
|
|
||||||
|
-- Calculate center point
|
||||||
|
local centerX = (t1.x + t2.x) / 2
|
||||||
|
local centerY = (t1.y + t2.y) / 2
|
||||||
|
|
||||||
|
return {
|
||||||
|
type = GestureType.ROTATE,
|
||||||
|
state = GestureState.BEGAN,
|
||||||
|
rotation = rotation,
|
||||||
|
centerX = centerX,
|
||||||
|
centerY = centerY,
|
||||||
|
timestamp = event.timestamp,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
-- Rotation is active, fire changed event
|
||||||
|
local centerX = (t1.x + t2.x) / 2
|
||||||
|
local centerY = (t1.y + t2.y) / 2
|
||||||
|
|
||||||
|
local rotationChange = rotation - rotateState.lastRotation
|
||||||
|
rotateState.lastRotation = rotation
|
||||||
|
|
||||||
|
return {
|
||||||
|
type = GestureType.ROTATE,
|
||||||
|
state = GestureState.CHANGED,
|
||||||
|
rotation = rotation,
|
||||||
|
rotationChange = rotationChange,
|
||||||
|
centerX = centerX,
|
||||||
|
centerY = centerY,
|
||||||
|
timestamp = event.timestamp,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Cancel all active gestures
|
||||||
|
function GestureRecognizer:_cancelAllGestures()
|
||||||
|
for gestureType, state in pairs(self._gestureStates) do
|
||||||
|
if type(state) == "table" and state.active then
|
||||||
|
state.active = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Reset gesture recognizer state
|
||||||
|
function GestureRecognizer:reset()
|
||||||
|
self._touches = {}
|
||||||
|
self._gestureStates = {
|
||||||
|
tap = nil,
|
||||||
|
doubleTap = { lastTapTime = 0, tapCount = 0 },
|
||||||
|
longPress = {},
|
||||||
|
swipe = {},
|
||||||
|
pan = {},
|
||||||
|
pinch = { active = false },
|
||||||
|
rotate = { active = false },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Export gesture types and states
|
||||||
|
GestureRecognizer.GestureType = GestureType
|
||||||
|
GestureRecognizer.GestureState = GestureState
|
||||||
|
|
||||||
|
return GestureRecognizer
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
local modulePath = (...):match("(.-)[^%.]+$")
|
||||||
|
local utils = require(modulePath .. "utils")
|
||||||
|
local enums = utils.enums
|
||||||
|
local Units = require(modulePath .. "Units")
|
||||||
|
|
||||||
|
local Positioning = enums.Positioning
|
||||||
|
local AlignItems = enums.AlignItems
|
||||||
|
|
||||||
|
--- Grid layout with variable column widths / row heights
|
||||||
|
--- Supports px, %, fr, auto, vw, vh, and calc track sizes
|
||||||
|
local Grid = {}
|
||||||
|
|
||||||
|
--- Parse a single track spec into {type, value}
|
||||||
|
--- Uses the Units pipeline for standard CSS units (px, %, vw, vh, calc).
|
||||||
|
--- Grid-specific types (fr, auto) are handled directly.
|
||||||
|
---@param spec number|string Track specification: number (px), string ("100px", "50%", "10vw", "1fr", "auto")
|
||||||
|
---@param availableSize number Container size for % resolution
|
||||||
|
---@param viewportWidth number Viewport width for vw resolution
|
||||||
|
---@param viewportHeight number Viewport height for vh resolution
|
||||||
|
---@return table {type: "px"|"fr"|"auto", value: number}
|
||||||
|
function Grid._parseTrack(spec, availableSize, viewportWidth, viewportHeight)
|
||||||
|
-- Handle calc objects (tables with _isCalc flag from FlexLove.calc())
|
||||||
|
if type(spec) == "table" then
|
||||||
|
local resolved = Units.resolve(spec, "calc", viewportWidth, viewportHeight, availableSize)
|
||||||
|
return { type = "px", value = resolved }
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(spec) == "number" then
|
||||||
|
return { type = "px", value = spec }
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(spec) == "string" then
|
||||||
|
if spec == "auto" then
|
||||||
|
return { type = "auto", value = 0 }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for fr unit (grid-specific, not in Units pipeline)
|
||||||
|
local numStr, unit = spec:match("^([%-]?[%d%.]+)(.*)$")
|
||||||
|
if numStr and unit == "fr" then
|
||||||
|
local num = tonumber(numStr)
|
||||||
|
if num then
|
||||||
|
return { type = "fr", value = num }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Delegate all other units to the Units pipeline (px, %, vw, vh, calc)
|
||||||
|
local parsedVal, parsedUnit = Units.parse(spec)
|
||||||
|
local resolved = Units.resolve(parsedVal, parsedUnit, viewportWidth, viewportHeight, availableSize)
|
||||||
|
return { type = "px", value = resolved }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Default: 1fr
|
||||||
|
return { type = "fr", value = 1 }
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Build track list from gridColumns/gridRows or fall back to equal 1fr tracks
|
||||||
|
---@param spec number|table? Track count (number = equal 1fr tracks) or array of track specs (e.g., {"1fr", "2fr", "100px"})
|
||||||
|
---@param availableSize number Container size for % resolution
|
||||||
|
---@param viewportWidth number Viewport width for vw resolution
|
||||||
|
---@param viewportHeight number Viewport height for vh resolution
|
||||||
|
---@return table Array of {type, value} track descriptors
|
||||||
|
function Grid._buildTracks(spec, availableSize, viewportWidth, viewportHeight)
|
||||||
|
if type(spec) == "table" and #spec > 0 then
|
||||||
|
local tracks = {}
|
||||||
|
for i, s in ipairs(spec) do
|
||||||
|
tracks[i] = Grid._parseTrack(s, availableSize, viewportWidth, viewportHeight)
|
||||||
|
end
|
||||||
|
return tracks
|
||||||
|
end
|
||||||
|
-- Fallback: equal 1fr tracks
|
||||||
|
local count = (type(spec) == "number" and spec > 0) and spec or 1
|
||||||
|
local tracks = {}
|
||||||
|
for i = 1, count do
|
||||||
|
tracks[i] = { type = "fr", value = 1 }
|
||||||
|
end
|
||||||
|
return tracks
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Measure intrinsic content sizes for auto tracks
|
||||||
|
--- Maps children to their tracks and computes each child's max-content contribution.
|
||||||
|
--- For children with explicit dimensions (units unit ~= "auto"), uses the original
|
||||||
|
--- explicit size. For auto-sized children, uses calculated content size.
|
||||||
|
--- Stores the max per auto track. Matches CSS Grid auto sizing where tracks size
|
||||||
|
--- to the max-content contribution of their grid items.
|
||||||
|
---@param tracks table Array of {type, value} track descriptors
|
||||||
|
---@param children table Array of grid child elements
|
||||||
|
---@param axis "width"|"height" Dimension axis to measure
|
||||||
|
function Grid._measureAutoTracks(tracks, children, axis)
|
||||||
|
local trackSizes = {}
|
||||||
|
local numTracks = #tracks
|
||||||
|
|
||||||
|
for i, child in ipairs(children) do
|
||||||
|
local index = i - 1
|
||||||
|
local trackIdx = (index % numTracks) + 1
|
||||||
|
|
||||||
|
local intrinsicSize
|
||||||
|
if axis == "width" then
|
||||||
|
local unit = child.units and child.units.width and child.units.width.unit
|
||||||
|
if unit and unit ~= "auto" then
|
||||||
|
-- Explicit width: use original value + padding (not stretched border-box)
|
||||||
|
intrinsicSize = (child.units.width.value or 0) + child.padding.left + child.padding.right
|
||||||
|
else
|
||||||
|
-- Auto-sized: use calculated content size
|
||||||
|
intrinsicSize = child:calculateAutoWidth()
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local unit = child.units and child.units.height and child.units.height.unit
|
||||||
|
if unit and unit ~= "auto" then
|
||||||
|
intrinsicSize = (child.units.height.value or 0) + child.padding.top + child.padding.bottom
|
||||||
|
else
|
||||||
|
intrinsicSize = child:calculateAutoHeight()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if intrinsicSize > 0 then
|
||||||
|
trackSizes[trackIdx] = math.max(trackSizes[trackIdx] or 0, intrinsicSize)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Apply measured sizes to auto tracks
|
||||||
|
for i, track in ipairs(tracks) do
|
||||||
|
if track.type == "auto" and trackSizes[i] then
|
||||||
|
track.value = trackSizes[i]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Resolve track sizes: auto (content) first, then px (fixed), then fr (remaining)
|
||||||
|
--- CSS Grid algorithm:
|
||||||
|
--- 1. auto tracks size to their content (max-content) — measured by _measureAutoTracks
|
||||||
|
--- 2. px tracks consume their fixed size
|
||||||
|
--- 3. fr tracks consume remaining free space proportionally
|
||||||
|
--- 4. If no fr tracks exist, auto tracks share remaining space equally
|
||||||
|
--- Mutates tracks in-place, converting all to {type="px", value=number}
|
||||||
|
---@param tracks table Array of {type, value} track descriptors
|
||||||
|
---@param availableSize number Total space available for tracks
|
||||||
|
---@param gap number Gap between tracks
|
||||||
|
function Grid._resolveTracks(tracks, availableSize, gap)
|
||||||
|
local count = #tracks
|
||||||
|
local totalGaps = (count > 1 and (count - 1) * gap) or 0
|
||||||
|
local remaining = math.max(0, availableSize - totalGaps)
|
||||||
|
|
||||||
|
-- Pass 1: Treat auto tracks as fixed (content-measured) and subtract
|
||||||
|
for _, track in ipairs(tracks) do
|
||||||
|
if track.type == "px" then
|
||||||
|
remaining = remaining - track.value
|
||||||
|
elseif track.type == "auto" then
|
||||||
|
remaining = remaining - math.max(0, track.value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
remaining = math.max(0, remaining)
|
||||||
|
|
||||||
|
-- Pass 2: Count fr shares
|
||||||
|
local totalFr = 0
|
||||||
|
local autoCount = 0
|
||||||
|
for _, track in ipairs(tracks) do
|
||||||
|
if track.type == "fr" then
|
||||||
|
totalFr = totalFr + track.value
|
||||||
|
elseif track.type == "auto" then
|
||||||
|
autoCount = autoCount + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pass 3: Distribute remaining space
|
||||||
|
if totalFr > 0 then
|
||||||
|
-- fr tracks consume all remaining free space
|
||||||
|
local frUnit = remaining / totalFr
|
||||||
|
for _, track in ipairs(tracks) do
|
||||||
|
if track.type == "fr" then
|
||||||
|
track.value = frUnit * track.value
|
||||||
|
track.type = "px"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif autoCount > 0 then
|
||||||
|
-- No fr tracks: auto tracks share remaining space equally (grow beyond content)
|
||||||
|
local extraPerAuto = math.max(0, remaining) / autoCount
|
||||||
|
for _, track in ipairs(tracks) do
|
||||||
|
if track.type == "auto" then
|
||||||
|
track.value = track.value + extraPerAuto
|
||||||
|
track.type = "px"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Layout grid items within a grid container
|
||||||
|
--- Supports variable column widths and row heights via gridColumns/gridRows (number or track specs)
|
||||||
|
--- Falls back to equal-sized 1fr tracks when nil
|
||||||
|
---@param element Element -- Grid container element
|
||||||
|
function Grid.layoutGridItems(element)
|
||||||
|
-- Calculate space reserved by absolutely positioned siblings
|
||||||
|
local reservedLeft = 0
|
||||||
|
local reservedRight = 0
|
||||||
|
local reservedTop = 0
|
||||||
|
local reservedBottom = 0
|
||||||
|
|
||||||
|
for _, child in ipairs(element.children) do
|
||||||
|
-- Only consider absolutely positioned children with explicit positioning and display != false
|
||||||
|
if child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute and child.display ~= false then
|
||||||
|
-- BORDER-BOX MODEL: Use border-box dimensions for space calculations
|
||||||
|
local childBorderBoxWidth = child:getBorderBoxWidth()
|
||||||
|
local childBorderBoxHeight = child:getBorderBoxHeight()
|
||||||
|
|
||||||
|
if child.left then
|
||||||
|
reservedLeft = math.max(reservedLeft, child.left + childBorderBoxWidth)
|
||||||
|
end
|
||||||
|
if child.right then
|
||||||
|
reservedRight = math.max(reservedRight, child.right + childBorderBoxWidth)
|
||||||
|
end
|
||||||
|
if child.top then
|
||||||
|
reservedTop = math.max(reservedTop, child.top + childBorderBoxHeight)
|
||||||
|
end
|
||||||
|
if child.bottom then
|
||||||
|
reservedBottom = math.max(reservedBottom, child.bottom + childBorderBoxHeight)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Calculate available space (accounting for padding and reserved space)
|
||||||
|
-- BORDER-BOX MODEL: element.width and element.height are already content dimensions
|
||||||
|
local availableWidth = math.max(0, element.width - reservedLeft - reservedRight)
|
||||||
|
local availableHeight = math.max(0, element.height - reservedTop - reservedBottom)
|
||||||
|
|
||||||
|
-- Get gaps
|
||||||
|
local columnGap = element.columnGap or 0
|
||||||
|
local rowGap = element.rowGap or 0
|
||||||
|
|
||||||
|
-- Collect grid children (exclude explicitly absolute and display=false)
|
||||||
|
local gridChildren = {}
|
||||||
|
for _, child in ipairs(element.children) do
|
||||||
|
if not (child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute) and child.display ~= false then
|
||||||
|
table.insert(gridChildren, child)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get viewport dimensions for unit resolution (vw, vh, %)
|
||||||
|
local vpw, vph = Units.getViewport()
|
||||||
|
|
||||||
|
-- Build tracks, measure auto tracks by content, then resolve sizes
|
||||||
|
local colTracks = Grid._buildTracks(element.gridColumns, availableWidth, vpw, vph)
|
||||||
|
local rowTracks = Grid._buildTracks(element.gridRows, availableHeight, vpw, vph)
|
||||||
|
|
||||||
|
Grid._measureAutoTracks(colTracks, gridChildren, "width")
|
||||||
|
Grid._measureAutoTracks(rowTracks, gridChildren, "height")
|
||||||
|
|
||||||
|
Grid._resolveTracks(colTracks, availableWidth, columnGap)
|
||||||
|
Grid._resolveTracks(rowTracks, availableHeight, rowGap)
|
||||||
|
|
||||||
|
-- Compute column start positions (for positioning)
|
||||||
|
local colStarts = {}
|
||||||
|
local currentX = element.x + element.padding.left + reservedLeft
|
||||||
|
for col = 1, #colTracks do
|
||||||
|
colStarts[col] = currentX
|
||||||
|
currentX = currentX + colTracks[col].value + columnGap
|
||||||
|
end
|
||||||
|
|
||||||
|
local rowStarts = {}
|
||||||
|
local currentY = element.y + element.padding.top + reservedTop
|
||||||
|
for row = 1, #rowTracks do
|
||||||
|
rowStarts[row] = currentY
|
||||||
|
currentY = currentY + rowTracks[row].value + rowGap
|
||||||
|
end
|
||||||
|
|
||||||
|
local effectiveAlignItems = element.alignItems or AlignItems.STRETCH
|
||||||
|
|
||||||
|
for i, child in ipairs(gridChildren) do
|
||||||
|
-- Calculate row and column (0-indexed for calculation)
|
||||||
|
local index = i - 1
|
||||||
|
local col = index % #colTracks
|
||||||
|
local row = math.floor(index / #colTracks)
|
||||||
|
|
||||||
|
if row >= #rowTracks then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get resolved cell position and size
|
||||||
|
local colIdx = col + 1
|
||||||
|
local rowIdx = row + 1
|
||||||
|
local cellX = colStarts[colIdx]
|
||||||
|
local cellY = rowStarts[rowIdx]
|
||||||
|
local cellWidth = colTracks[colIdx].value
|
||||||
|
local cellHeight = rowTracks[rowIdx].value
|
||||||
|
|
||||||
|
-- Apply alignment within grid cell (default to stretch)
|
||||||
|
-- BORDER-BOX MODEL: Set border-box dimensions, content area adjusts automatically
|
||||||
|
if effectiveAlignItems == AlignItems.STRETCH or effectiveAlignItems == "stretch" then
|
||||||
|
child.x = cellX
|
||||||
|
child.y = cellY
|
||||||
|
child._borderBoxWidth = cellWidth
|
||||||
|
child._borderBoxHeight = cellHeight
|
||||||
|
child.width = math.max(0, cellWidth - child.padding.left - child.padding.right)
|
||||||
|
child.height = math.max(0, cellHeight - child.padding.top - child.padding.bottom)
|
||||||
|
-- Disable auto-sizing when stretched by grid
|
||||||
|
child.autosizing.width = false
|
||||||
|
child.autosizing.height = false
|
||||||
|
elseif effectiveAlignItems == AlignItems.CENTER or effectiveAlignItems == "center" then
|
||||||
|
local childBorderBoxWidth = child:getBorderBoxWidth()
|
||||||
|
local childBorderBoxHeight = child:getBorderBoxHeight()
|
||||||
|
child.x = cellX + (cellWidth - childBorderBoxWidth) / 2
|
||||||
|
child.y = cellY + (cellHeight - childBorderBoxHeight) / 2
|
||||||
|
elseif
|
||||||
|
effectiveAlignItems == AlignItems.FLEX_START
|
||||||
|
or effectiveAlignItems == "flex-start"
|
||||||
|
or effectiveAlignItems == "start"
|
||||||
|
then
|
||||||
|
child.x = cellX
|
||||||
|
child.y = cellY
|
||||||
|
elseif
|
||||||
|
effectiveAlignItems == AlignItems.FLEX_END
|
||||||
|
or effectiveAlignItems == "flex-end"
|
||||||
|
or effectiveAlignItems == "end"
|
||||||
|
then
|
||||||
|
local childBorderBoxWidth = child:getBorderBoxWidth()
|
||||||
|
local childBorderBoxHeight = child:getBorderBoxHeight()
|
||||||
|
child.x = cellX + cellWidth - childBorderBoxWidth
|
||||||
|
child.y = cellY + cellHeight - childBorderBoxHeight
|
||||||
|
else
|
||||||
|
child.x = cellX
|
||||||
|
child.y = cellY
|
||||||
|
child._borderBoxWidth = cellWidth
|
||||||
|
child._borderBoxHeight = cellHeight
|
||||||
|
child.width = math.max(0, cellWidth - child.padding.left - child.padding.right)
|
||||||
|
child.height = math.max(0, cellHeight - child.padding.top - child.padding.bottom)
|
||||||
|
-- Disable auto-sizing when stretched by grid
|
||||||
|
child.autosizing.width = false
|
||||||
|
child.autosizing.height = false
|
||||||
|
end
|
||||||
|
|
||||||
|
if #child.children > 0 then
|
||||||
|
child:layoutChildren()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return Grid
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
local modulePath = (...):match("(.-)[^%.]+$")
|
||||||
|
local function req(name)
|
||||||
|
return require(modulePath .. name)
|
||||||
|
end
|
||||||
|
|
||||||
|
local utils = req("utils")
|
||||||
|
|
||||||
|
-- ErrorHandler will be injected via init
|
||||||
|
local ErrorHandler = nil
|
||||||
|
|
||||||
|
---@class ImageCache
|
||||||
|
---@field _cache table<string, {image: love.Image, imageData: love.ImageData?}>
|
||||||
|
local ImageCache = {}
|
||||||
|
ImageCache._cache = {}
|
||||||
|
|
||||||
|
--- Initialize ImageCache with dependencies
|
||||||
|
---@param deps table Dependencies table with ErrorHandler
|
||||||
|
function ImageCache.init(deps)
|
||||||
|
if deps and deps.ErrorHandler then
|
||||||
|
ErrorHandler = deps.ErrorHandler
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Load an image from file path with caching
|
||||||
|
--- Returns cached image if already loaded, otherwise loads and caches it
|
||||||
|
---@param imagePath string -- Path to image file
|
||||||
|
---@param loadImageData boolean? -- Optional: also load ImageData for pixel access (default: false)
|
||||||
|
---@return love.Image|nil -- Image object or nil on error
|
||||||
|
---@return string|nil -- Error message if loading failed
|
||||||
|
function ImageCache.load(imagePath, loadImageData)
|
||||||
|
if not imagePath or type(imagePath) ~= "string" or imagePath == "" then
|
||||||
|
return nil, "Invalid image path: path must be a non-empty string"
|
||||||
|
end
|
||||||
|
|
||||||
|
local normalizedPath = utils.normalizePath(imagePath)
|
||||||
|
|
||||||
|
if ImageCache._cache[normalizedPath] then
|
||||||
|
return ImageCache._cache[normalizedPath].image, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local success, imageOrError = pcall(love.graphics.newImage, normalizedPath)
|
||||||
|
if not success then
|
||||||
|
if ErrorHandler then
|
||||||
|
ErrorHandler:warn("ImageCache", "RES_004", {
|
||||||
|
resourceType = "image",
|
||||||
|
path = imagePath,
|
||||||
|
error = tostring(imageOrError),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
return nil, string.format("Failed to load image '%s': %s", imagePath, tostring(imageOrError))
|
||||||
|
end
|
||||||
|
|
||||||
|
local image = imageOrError
|
||||||
|
local imgData = nil
|
||||||
|
|
||||||
|
if loadImageData then
|
||||||
|
local dataSuccess, dataOrError = pcall(love.image.newImageData, normalizedPath)
|
||||||
|
if dataSuccess then
|
||||||
|
imgData = dataOrError
|
||||||
|
elseif ErrorHandler then
|
||||||
|
ErrorHandler:warn("ImageCache", "RES_004", {
|
||||||
|
resourceType = "image data",
|
||||||
|
path = imagePath,
|
||||||
|
error = tostring(dataOrError),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
ImageCache._cache[normalizedPath] = {
|
||||||
|
image = image,
|
||||||
|
imageData = imgData,
|
||||||
|
}
|
||||||
|
|
||||||
|
return image, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get a cached image without loading
|
||||||
|
---@param imagePath string -- Path to image file
|
||||||
|
---@return love.Image|nil -- Cached image or nil if not found
|
||||||
|
function ImageCache.get(imagePath)
|
||||||
|
if not imagePath or type(imagePath) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local normalizedPath = utils.normalizePath(imagePath)
|
||||||
|
local cached = ImageCache._cache[normalizedPath]
|
||||||
|
return cached and cached.image or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get cached ImageData for an image
|
||||||
|
---@param imagePath string -- Path to image file
|
||||||
|
---@return love.ImageData|nil -- Cached ImageData or nil if not found
|
||||||
|
function ImageCache.getImageData(imagePath)
|
||||||
|
if not imagePath or type(imagePath) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local normalizedPath = utils.normalizePath(imagePath)
|
||||||
|
local cached = ImageCache._cache[normalizedPath]
|
||||||
|
return cached and cached.imageData or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Remove a specific image from cache
|
||||||
|
---@param imagePath string -- Path to image file to remove
|
||||||
|
---@return boolean -- True if image was removed, false if not found
|
||||||
|
function ImageCache.remove(imagePath)
|
||||||
|
if not imagePath or type(imagePath) ~= "string" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local normalizedPath = utils.normalizePath(imagePath)
|
||||||
|
if ImageCache._cache[normalizedPath] then
|
||||||
|
local cached = ImageCache._cache[normalizedPath]
|
||||||
|
if cached.image then
|
||||||
|
cached.image:release()
|
||||||
|
end
|
||||||
|
if cached.imageData then
|
||||||
|
cached.imageData:release()
|
||||||
|
end
|
||||||
|
ImageCache._cache[normalizedPath] = nil
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Clear all cached images
|
||||||
|
function ImageCache.clear()
|
||||||
|
for path, cached in pairs(ImageCache._cache) do
|
||||||
|
if cached.image then
|
||||||
|
cached.image:release()
|
||||||
|
end
|
||||||
|
if cached.imageData then
|
||||||
|
cached.imageData:release()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
ImageCache._cache = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get cache statistics
|
||||||
|
---@return {count: number, memoryEstimate: number} -- Cache stats
|
||||||
|
function ImageCache.getStats()
|
||||||
|
local count = 0
|
||||||
|
local memoryEstimate = 0
|
||||||
|
|
||||||
|
for path, cached in pairs(ImageCache._cache) do
|
||||||
|
count = count + 1
|
||||||
|
if cached.image then
|
||||||
|
local w, h = cached.image:getDimensions()
|
||||||
|
-- Estimate: 4 bytes per pixel (RGBA)
|
||||||
|
memoryEstimate = memoryEstimate + (w * h * 4)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
count = count,
|
||||||
|
memoryEstimate = memoryEstimate,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return ImageCache
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
---@class ImageRenderer
|
||||||
|
local ImageRenderer = {}
|
||||||
|
|
||||||
|
-- ErrorHandler and utils will be injected via init
|
||||||
|
local ErrorHandler = nil
|
||||||
|
local utils = nil
|
||||||
|
|
||||||
|
--- Initialize ImageRenderer with dependencies
|
||||||
|
---@param deps table Dependencies table with ErrorHandler and utils
|
||||||
|
function ImageRenderer.init(deps)
|
||||||
|
if deps and deps.ErrorHandler then
|
||||||
|
ErrorHandler = deps.ErrorHandler
|
||||||
|
end
|
||||||
|
if deps and deps.utils then
|
||||||
|
utils = deps.utils
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Calculate rendering parameters for object-fit modes
|
||||||
|
--- Returns source and destination rectangles for rendering
|
||||||
|
---@param imageWidth number -- Natural width of the image
|
||||||
|
---@param imageHeight number -- Natural height of the image
|
||||||
|
---@param boundsWidth number -- Width of the bounds to fit within
|
||||||
|
---@param boundsHeight number -- Height of the bounds to fit within
|
||||||
|
---@param fitMode string? -- One of: "fill", "contain", "cover", "scale-down", "none" (default: "fill")
|
||||||
|
---@param objectPosition string? -- Position like "center center", "top left", "50% 50%" (default: "center center")
|
||||||
|
---@return {sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number, scaleX: number, scaleY: number}
|
||||||
|
function ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, fitMode, objectPosition)
|
||||||
|
fitMode = fitMode or "fill"
|
||||||
|
objectPosition = objectPosition or "center center"
|
||||||
|
|
||||||
|
if imageWidth <= 0 or imageHeight <= 0 or boundsWidth <= 0 or boundsHeight <= 0 then
|
||||||
|
ErrorHandler:error("ImageRenderer", "VAL_002", {
|
||||||
|
imageWidth = imageWidth,
|
||||||
|
imageHeight = imageHeight,
|
||||||
|
boundsWidth = boundsWidth,
|
||||||
|
boundsHeight = boundsHeight,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local result = {
|
||||||
|
sx = 0, -- Source X
|
||||||
|
sy = 0, -- Source Y
|
||||||
|
sw = imageWidth, -- Source width
|
||||||
|
sh = imageHeight, -- Source height
|
||||||
|
dx = 0, -- Destination X
|
||||||
|
dy = 0, -- Destination Y
|
||||||
|
dw = boundsWidth, -- Destination width
|
||||||
|
dh = boundsHeight, -- Destination height
|
||||||
|
scaleX = 1, -- Scale factor X
|
||||||
|
scaleY = 1, -- Scale factor Y
|
||||||
|
}
|
||||||
|
|
||||||
|
if fitMode == "fill" then
|
||||||
|
-- Stretch to fill bounds (may distort)
|
||||||
|
result.scaleX = boundsWidth / imageWidth
|
||||||
|
result.scaleY = boundsHeight / imageHeight
|
||||||
|
result.dw = boundsWidth
|
||||||
|
result.dh = boundsHeight
|
||||||
|
elseif fitMode == "contain" then
|
||||||
|
-- Scale to fit within bounds (preserves aspect ratio)
|
||||||
|
local scale = math.min(boundsWidth / imageWidth, boundsHeight / imageHeight)
|
||||||
|
result.scaleX = scale
|
||||||
|
result.scaleY = scale
|
||||||
|
result.dw = imageWidth * scale
|
||||||
|
result.dh = imageHeight * scale
|
||||||
|
|
||||||
|
-- Apply object-position for letterbox alignment
|
||||||
|
local posX, posY = ImageRenderer._parsePosition(objectPosition)
|
||||||
|
result.dx = (boundsWidth - result.dw) * posX
|
||||||
|
result.dy = (boundsHeight - result.dh) * posY
|
||||||
|
elseif fitMode == "cover" then
|
||||||
|
-- Scale to cover bounds (preserves aspect ratio, may crop)
|
||||||
|
local scale = math.max(boundsWidth / imageWidth, boundsHeight / imageHeight)
|
||||||
|
result.scaleX = scale
|
||||||
|
result.scaleY = scale
|
||||||
|
|
||||||
|
local scaledWidth = imageWidth * scale
|
||||||
|
local scaledHeight = imageHeight * scale
|
||||||
|
|
||||||
|
-- Apply object-position for crop alignment
|
||||||
|
local posX, posY = ImageRenderer._parsePosition(objectPosition)
|
||||||
|
|
||||||
|
-- Calculate which part of the scaled image to show
|
||||||
|
local cropX = (scaledWidth - boundsWidth) * posX
|
||||||
|
local cropY = (scaledHeight - boundsHeight) * posY
|
||||||
|
|
||||||
|
-- Convert back to source coordinates
|
||||||
|
result.sx = cropX / scale
|
||||||
|
result.sy = cropY / scale
|
||||||
|
result.sw = boundsWidth / scale
|
||||||
|
result.sh = boundsHeight / scale
|
||||||
|
|
||||||
|
result.dx = 0
|
||||||
|
result.dy = 0
|
||||||
|
result.dw = boundsWidth
|
||||||
|
result.dh = boundsHeight
|
||||||
|
elseif fitMode == "none" then
|
||||||
|
-- Use natural size (no scaling)
|
||||||
|
result.scaleX = 1
|
||||||
|
result.scaleY = 1
|
||||||
|
result.dw = imageWidth
|
||||||
|
result.dh = imageHeight
|
||||||
|
|
||||||
|
-- Apply object-position
|
||||||
|
local posX, posY = ImageRenderer._parsePosition(objectPosition)
|
||||||
|
result.dx = (boundsWidth - imageWidth) * posX
|
||||||
|
result.dy = (boundsHeight - imageHeight) * posY
|
||||||
|
elseif fitMode == "scale-down" then
|
||||||
|
-- Use none or contain, whichever is smaller
|
||||||
|
if imageWidth <= boundsWidth and imageHeight <= boundsHeight then
|
||||||
|
-- Image fits naturally, use "none"
|
||||||
|
return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "none", objectPosition)
|
||||||
|
else
|
||||||
|
-- Image too large, use "contain"
|
||||||
|
return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "contain", objectPosition)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
ErrorHandler:warn("ImageRenderer", "VAL_007", {
|
||||||
|
fitMode = fitMode,
|
||||||
|
fallback = "fill",
|
||||||
|
})
|
||||||
|
-- Use 'fill' as fallback
|
||||||
|
return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "fill", objectPosition)
|
||||||
|
end
|
||||||
|
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Parse object-position string into normalized coordinates (0-1)
|
||||||
|
--- Supports keywords (center, top, bottom, left, right) and percentages
|
||||||
|
---@param position string -- Position string like "center center", "top left", "50% 50%"
|
||||||
|
---@return number, number -- Normalized X and Y positions (0-1)
|
||||||
|
function ImageRenderer._parsePosition(position)
|
||||||
|
if not position or type(position) ~= "string" then
|
||||||
|
return 0.5, 0.5 -- Default to center
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Split into X and Y components
|
||||||
|
local parts = {}
|
||||||
|
for part in position:gmatch("%S+") do
|
||||||
|
table.insert(parts, part:lower())
|
||||||
|
end
|
||||||
|
|
||||||
|
-- If only one value, use it for both axes (with special handling)
|
||||||
|
if #parts == 1 then
|
||||||
|
local val = parts[1]
|
||||||
|
if val == "left" or val == "right" then
|
||||||
|
parts = { val, "center" }
|
||||||
|
elseif val == "top" or val == "bottom" then
|
||||||
|
parts = { "center", val }
|
||||||
|
else
|
||||||
|
parts = { val, val }
|
||||||
|
end
|
||||||
|
elseif #parts == 0 then
|
||||||
|
return 0.5, 0.5 -- Default to center
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parseValue(val)
|
||||||
|
-- Handle keywords
|
||||||
|
if val == "center" then
|
||||||
|
return 0.5
|
||||||
|
elseif val == "left" or val == "top" then
|
||||||
|
return 0
|
||||||
|
elseif val == "right" or val == "bottom" then
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Handle percentages
|
||||||
|
local percent = val:match("^([%d%.]+)%%$")
|
||||||
|
if percent then
|
||||||
|
return tonumber(percent) / 100
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Handle plain numbers (treat as percentage)
|
||||||
|
local num = tonumber(val)
|
||||||
|
if num then
|
||||||
|
return num / 100
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Invalid value, default to center
|
||||||
|
return 0.5
|
||||||
|
end
|
||||||
|
|
||||||
|
local x = parseValue(parts[1])
|
||||||
|
local y = parseValue(parts[2] or parts[1])
|
||||||
|
|
||||||
|
-- Clamp to 0-1 range
|
||||||
|
x = math.max(0, math.min(1, x))
|
||||||
|
y = math.max(0, math.min(1, y))
|
||||||
|
|
||||||
|
return x, y
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Draw an image with specified object-fit mode
|
||||||
|
---@param image love.Image -- Image to draw
|
||||||
|
---@param x number -- X position of bounds
|
||||||
|
---@param y number -- Y position of bounds
|
||||||
|
---@param width number -- Width of bounds
|
||||||
|
---@param height number -- Height of bounds
|
||||||
|
---@param fitMode string? -- Object-fit mode (default: "fill")
|
||||||
|
---@param objectPosition string? -- Object-position (default: "center center")
|
||||||
|
---@param opacity number? -- Opacity 0-1 (default: 1)
|
||||||
|
---@param tintColor Color? -- Color to tint the image (default: white/no tint)
|
||||||
|
function ImageRenderer.draw(image, x, y, width, height, fitMode, objectPosition, opacity, tintColor)
|
||||||
|
if not image then
|
||||||
|
return -- Nothing to draw
|
||||||
|
end
|
||||||
|
|
||||||
|
opacity = opacity or 1
|
||||||
|
fitMode = fitMode or "fill"
|
||||||
|
objectPosition = objectPosition or "center center"
|
||||||
|
|
||||||
|
local imgWidth, imgHeight = image:getDimensions()
|
||||||
|
local params = ImageRenderer.calculateFit(imgWidth, imgHeight, width, height, fitMode, objectPosition)
|
||||||
|
|
||||||
|
-- Save current color
|
||||||
|
local r, g, b, a = love.graphics.getColor()
|
||||||
|
|
||||||
|
-- Apply opacity and tint
|
||||||
|
if tintColor then
|
||||||
|
love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity)
|
||||||
|
else
|
||||||
|
love.graphics.setColor(1, 1, 1, opacity)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Draw image
|
||||||
|
if params.sx ~= 0 or params.sy ~= 0 or params.sw ~= imgWidth or params.sh ~= imgHeight then
|
||||||
|
-- Need to use a quad for cropping
|
||||||
|
local quad = love.graphics.newQuad(params.sx, params.sy, params.sw, params.sh, imgWidth, imgHeight)
|
||||||
|
love.graphics.draw(image, quad, x + params.dx, y + params.dy, 0, params.dw / params.sw, params.dh / params.sh)
|
||||||
|
else
|
||||||
|
-- Simple draw with scaling
|
||||||
|
love.graphics.draw(image, x + params.dx, y + params.dy, 0, params.scaleX, params.scaleY)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Restore color
|
||||||
|
love.graphics.setColor(r, g, b, a)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Draw an image with tiling/repeat mode
|
||||||
|
---@param image love.Image -- Image to draw
|
||||||
|
---@param x number -- X position of bounds
|
||||||
|
---@param y number -- Y position of bounds
|
||||||
|
---@param width number -- Width of bounds
|
||||||
|
---@param height number -- Height of bounds
|
||||||
|
---@param repeatMode string? -- Repeat mode: "repeat", "repeat-x", "repeat-y", "no-repeat", "space", "round" (default: "no-repeat")
|
||||||
|
---@param opacity number? -- Opacity 0-1 (default: 1)
|
||||||
|
---@param tintColor Color? -- Color to tint the image (default: white/no tint)
|
||||||
|
function ImageRenderer.drawTiled(image, x, y, width, height, repeatMode, opacity, tintColor)
|
||||||
|
if not image then
|
||||||
|
return -- Nothing to draw
|
||||||
|
end
|
||||||
|
|
||||||
|
opacity = opacity or 1
|
||||||
|
repeatMode = repeatMode or "no-repeat"
|
||||||
|
|
||||||
|
local imgWidth, imgHeight = image:getDimensions()
|
||||||
|
|
||||||
|
-- Save current color
|
||||||
|
local r, g, b, a = love.graphics.getColor()
|
||||||
|
|
||||||
|
-- Apply opacity and tint
|
||||||
|
if tintColor then
|
||||||
|
love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity)
|
||||||
|
else
|
||||||
|
love.graphics.setColor(1, 1, 1, opacity)
|
||||||
|
end
|
||||||
|
|
||||||
|
if repeatMode == "no-repeat" then
|
||||||
|
-- Just draw once, no tiling
|
||||||
|
love.graphics.draw(image, x, y)
|
||||||
|
elseif repeatMode == "repeat" then
|
||||||
|
-- Tile in both directions
|
||||||
|
local tilesX = math.ceil(width / imgWidth)
|
||||||
|
local tilesY = math.ceil(height / imgHeight)
|
||||||
|
|
||||||
|
for tileY = 0, tilesY - 1 do
|
||||||
|
for tileX = 0, tilesX - 1 do
|
||||||
|
local drawX = x + (tileX * imgWidth)
|
||||||
|
local drawY = y + (tileY * imgHeight)
|
||||||
|
|
||||||
|
-- Calculate how much of the tile to draw (for partial tiles at edges)
|
||||||
|
local drawWidth = math.min(imgWidth, width - (tileX * imgWidth))
|
||||||
|
local drawHeight = math.min(imgHeight, height - (tileY * imgHeight))
|
||||||
|
|
||||||
|
if drawWidth < imgWidth or drawHeight < imgHeight then
|
||||||
|
-- Use quad for partial tile
|
||||||
|
local quad = love.graphics.newQuad(0, 0, drawWidth, drawHeight, imgWidth, imgHeight)
|
||||||
|
love.graphics.draw(image, quad, drawX, drawY)
|
||||||
|
else
|
||||||
|
-- Draw full tile
|
||||||
|
love.graphics.draw(image, drawX, drawY)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif repeatMode == "repeat-x" then
|
||||||
|
-- Tile horizontally only
|
||||||
|
local tilesX = math.ceil(width / imgWidth)
|
||||||
|
|
||||||
|
for tileX = 0, tilesX - 1 do
|
||||||
|
local drawX = x + (tileX * imgWidth)
|
||||||
|
local drawWidth = math.min(imgWidth, width - (tileX * imgWidth))
|
||||||
|
|
||||||
|
if drawWidth < imgWidth then
|
||||||
|
-- Use quad for partial tile
|
||||||
|
local quad = love.graphics.newQuad(0, 0, drawWidth, imgHeight, imgWidth, imgHeight)
|
||||||
|
love.graphics.draw(image, quad, drawX, y)
|
||||||
|
else
|
||||||
|
-- Draw full tile
|
||||||
|
love.graphics.draw(image, drawX, y)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif repeatMode == "repeat-y" then
|
||||||
|
-- Tile vertically only
|
||||||
|
local tilesY = math.ceil(height / imgHeight)
|
||||||
|
|
||||||
|
for tileY = 0, tilesY - 1 do
|
||||||
|
local drawY = y + (tileY * imgHeight)
|
||||||
|
local drawHeight = math.min(imgHeight, height - (tileY * imgHeight))
|
||||||
|
|
||||||
|
if drawHeight < imgHeight then
|
||||||
|
-- Use quad for partial tile
|
||||||
|
local quad = love.graphics.newQuad(0, 0, imgWidth, drawHeight, imgWidth, imgHeight)
|
||||||
|
love.graphics.draw(image, quad, x, drawY)
|
||||||
|
else
|
||||||
|
-- Draw full tile
|
||||||
|
love.graphics.draw(image, x, drawY)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif repeatMode == "space" then
|
||||||
|
-- Distribute tiles with even spacing
|
||||||
|
local tilesX = math.floor(width / imgWidth)
|
||||||
|
local tilesY = math.floor(height / imgHeight)
|
||||||
|
|
||||||
|
if tilesX < 1 then
|
||||||
|
tilesX = 1
|
||||||
|
end
|
||||||
|
if tilesY < 1 then
|
||||||
|
tilesY = 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local spaceX = tilesX > 1 and (width - (tilesX * imgWidth)) / (tilesX - 1) or 0
|
||||||
|
local spaceY = tilesY > 1 and (height - (tilesY * imgHeight)) / (tilesY - 1) or 0
|
||||||
|
|
||||||
|
for tileY = 0, tilesY - 1 do
|
||||||
|
for tileX = 0, tilesX - 1 do
|
||||||
|
local drawX = x + (tileX * (imgWidth + spaceX))
|
||||||
|
local drawY = y + (tileY * (imgHeight + spaceY))
|
||||||
|
love.graphics.draw(image, drawX, drawY)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif repeatMode == "round" then
|
||||||
|
-- Scale tiles to fit bounds exactly
|
||||||
|
local tilesX = math.max(1, utils.round(width / imgWidth))
|
||||||
|
local tilesY = math.max(1, utils.round(height / imgHeight))
|
||||||
|
|
||||||
|
local scaleX = width / (tilesX * imgWidth)
|
||||||
|
local scaleY = height / (tilesY * imgHeight)
|
||||||
|
|
||||||
|
for tileY = 0, tilesY - 1 do
|
||||||
|
for tileX = 0, tilesX - 1 do
|
||||||
|
local drawX = x + (tileX * imgWidth * scaleX)
|
||||||
|
local drawY = y + (tileY * imgHeight * scaleY)
|
||||||
|
love.graphics.draw(image, drawX, drawY, 0, scaleX, scaleY)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
ErrorHandler:warn("ImageRenderer", "VAL_007", {
|
||||||
|
repeatMode = repeatMode,
|
||||||
|
fallback = "no-repeat",
|
||||||
|
})
|
||||||
|
love.graphics.draw(image, x, y)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Restore color
|
||||||
|
love.graphics.setColor(r, g, b, a)
|
||||||
|
end
|
||||||
|
|
||||||
|
return ImageRenderer
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
-- ====================
|
||||||
|
-- ImageScaler
|
||||||
|
-- ====================
|
||||||
|
|
||||||
|
local ImageScaler = {}
|
||||||
|
|
||||||
|
-- ErrorHandler will be injected via init
|
||||||
|
local ErrorHandler = nil
|
||||||
|
|
||||||
|
--- Initialize ImageScaler with dependencies
|
||||||
|
---@param deps table Dependencies table with ErrorHandler
|
||||||
|
function ImageScaler.init(deps)
|
||||||
|
if deps and deps.ErrorHandler then
|
||||||
|
ErrorHandler = deps.ErrorHandler
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Scale an ImageData region using nearest-neighbor sampling
|
||||||
|
--- Produces sharp, pixelated scaling - ideal for pixel art
|
||||||
|
---@param sourceImageData love.ImageData -- Source image data
|
||||||
|
---@param srcX number -- Source region X (0-based)
|
||||||
|
---@param srcY number -- Source region Y (0-based)
|
||||||
|
---@param srcW number -- Source region width
|
||||||
|
---@param srcH number -- Source region height
|
||||||
|
---@param destW number -- Destination width
|
||||||
|
---@param destH number -- Destination height
|
||||||
|
---@return love.ImageData -- Scaled image data
|
||||||
|
function ImageScaler.scaleNearest(sourceImageData, srcX, srcY, srcW, srcH, destW, destH)
|
||||||
|
if not sourceImageData then
|
||||||
|
ErrorHandler:error("ImageScaler", "VAL_001", {
|
||||||
|
parameter = "sourceImageData",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
if srcW <= 0 or srcH <= 0 or destW <= 0 or destH <= 0 then
|
||||||
|
ErrorHandler:warn("ImageScaler", "VAL_002", {
|
||||||
|
srcW = srcW,
|
||||||
|
srcH = srcH,
|
||||||
|
destW = destW,
|
||||||
|
destH = destH,
|
||||||
|
fallback = "1x1 transparent image",
|
||||||
|
})
|
||||||
|
-- Return a minimal 1x1 transparent image as fallback
|
||||||
|
local fallbackImageData = love.image.newImageData(1, 1)
|
||||||
|
fallbackImageData:setPixel(0, 0, 0, 0, 0, 0)
|
||||||
|
return fallbackImageData
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Create destination ImageData
|
||||||
|
local destImageData = love.image.newImageData(destW, destH)
|
||||||
|
|
||||||
|
-- Calculate scale ratios (cached outside loops for performance)
|
||||||
|
local scaleX = srcW / destW
|
||||||
|
local scaleY = srcH / destH
|
||||||
|
|
||||||
|
-- Nearest-neighbor sampling
|
||||||
|
for destY = 0, destH - 1 do
|
||||||
|
for destX = 0, destW - 1 do
|
||||||
|
-- Calculate source pixel coordinates using floor (nearest-neighbor)
|
||||||
|
local srcPixelX = math.floor(destX * scaleX) + srcX
|
||||||
|
local srcPixelY = math.floor(destY * scaleY) + srcY
|
||||||
|
|
||||||
|
-- Clamp to source bounds (safety check)
|
||||||
|
srcPixelX = math.min(srcPixelX, srcX + srcW - 1)
|
||||||
|
srcPixelY = math.min(srcPixelY, srcY + srcH - 1)
|
||||||
|
|
||||||
|
-- Sample source pixel
|
||||||
|
local r, g, b, a = sourceImageData:getPixel(srcPixelX, srcPixelY)
|
||||||
|
|
||||||
|
-- Write to destination
|
||||||
|
destImageData:setPixel(destX, destY, r, g, b, a)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return destImageData
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Linear interpolation helper
|
||||||
|
--- Blends between two values based on interpolation factor
|
||||||
|
---@param a number -- Start value
|
||||||
|
---@param b number -- End value
|
||||||
|
---@param t number -- Interpolation factor [0, 1]
|
||||||
|
---@return number -- Interpolated value
|
||||||
|
local function lerp(a, b, t)
|
||||||
|
return a + (b - a) * t
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Scale an ImageData region using bilinear interpolation
|
||||||
|
--- Produces smooth, filtered scaling - ideal for high-quality upscaling
|
||||||
|
---@param sourceImageData love.ImageData -- Source image data
|
||||||
|
---@param srcX number -- Source region X (0-based)
|
||||||
|
---@param srcY number -- Source region Y (0-based)
|
||||||
|
---@param srcW number -- Source region width
|
||||||
|
---@param srcH number -- Source region height
|
||||||
|
---@param destW number -- Destination width
|
||||||
|
---@param destH number -- Destination height
|
||||||
|
---@return love.ImageData -- Scaled image data
|
||||||
|
function ImageScaler.scaleBilinear(sourceImageData, srcX, srcY, srcW, srcH, destW, destH)
|
||||||
|
if not sourceImageData then
|
||||||
|
ErrorHandler:error("ImageScaler", "VAL_001", {
|
||||||
|
parameter = "sourceImageData",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
if srcW <= 0 or srcH <= 0 or destW <= 0 or destH <= 0 then
|
||||||
|
ErrorHandler:warn("ImageScaler", "VAL_002", {
|
||||||
|
srcW = srcW,
|
||||||
|
srcH = srcH,
|
||||||
|
destW = destW,
|
||||||
|
destH = destH,
|
||||||
|
fallback = "1x1 transparent image",
|
||||||
|
})
|
||||||
|
-- Return a minimal 1x1 transparent image as fallback
|
||||||
|
local fallbackImageData = love.image.newImageData(1, 1)
|
||||||
|
fallbackImageData:setPixel(0, 0, 0, 0, 0, 0)
|
||||||
|
return fallbackImageData
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Create destination ImageData
|
||||||
|
local destImageData = love.image.newImageData(destW, destH)
|
||||||
|
|
||||||
|
-- Calculate scale ratios
|
||||||
|
local scaleX = srcW / destW
|
||||||
|
local scaleY = srcH / destH
|
||||||
|
|
||||||
|
-- Bilinear interpolation
|
||||||
|
for destY = 0, destH - 1 do
|
||||||
|
for destX = 0, destW - 1 do
|
||||||
|
-- Calculate fractional source position
|
||||||
|
local srcXf = destX * scaleX
|
||||||
|
local srcYf = destY * scaleY
|
||||||
|
|
||||||
|
-- Get integer coordinates for 2x2 sampling grid
|
||||||
|
local x0 = math.floor(srcXf)
|
||||||
|
local y0 = math.floor(srcYf)
|
||||||
|
local x1 = math.min(x0 + 1, srcW - 1)
|
||||||
|
local y1 = math.min(y0 + 1, srcH - 1)
|
||||||
|
|
||||||
|
-- Get fractional parts for interpolation
|
||||||
|
local fx = srcXf - x0
|
||||||
|
local fy = srcYf - y0
|
||||||
|
|
||||||
|
-- Sample 4 neighboring pixels (with source offset)
|
||||||
|
local r00, g00, b00, a00 = sourceImageData:getPixel(srcX + x0, srcY + y0)
|
||||||
|
local r10, g10, b10, a10 = sourceImageData:getPixel(srcX + x1, srcY + y0)
|
||||||
|
local r01, g01, b01, a01 = sourceImageData:getPixel(srcX + x0, srcY + y1)
|
||||||
|
local r11, g11, b11, a11 = sourceImageData:getPixel(srcX + x1, srcY + y1)
|
||||||
|
|
||||||
|
-- Interpolate horizontally (top and bottom rows)
|
||||||
|
local rTop = lerp(r00, r10, fx)
|
||||||
|
local gTop = lerp(g00, g10, fx)
|
||||||
|
local bTop = lerp(b00, b10, fx)
|
||||||
|
local aTop = lerp(a00, a10, fx)
|
||||||
|
|
||||||
|
local rBottom = lerp(r01, r11, fx)
|
||||||
|
local gBottom = lerp(g01, g11, fx)
|
||||||
|
local bBottom = lerp(b01, b11, fx)
|
||||||
|
local aBottom = lerp(a01, a11, fx)
|
||||||
|
|
||||||
|
-- Interpolate vertically (final result)
|
||||||
|
local r = lerp(rTop, rBottom, fy)
|
||||||
|
local g = lerp(gTop, gBottom, fy)
|
||||||
|
local b = lerp(bTop, bBottom, fy)
|
||||||
|
local a = lerp(aTop, aBottom, fy)
|
||||||
|
|
||||||
|
-- Write to destination
|
||||||
|
destImageData:setPixel(destX, destY, r, g, b, a)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return destImageData
|
||||||
|
end
|
||||||
|
|
||||||
|
return ImageScaler
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
---@class InputEvent
|
||||||
|
---@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag"|"hover"|"unhover"|"touchpress"|"touchmove"|"touchrelease"|"touchcancel"
|
||||||
|
---@field button number -- Mouse button: 1 (left), 2 (right), 3 (middle)
|
||||||
|
---@field x number -- Mouse/Touch X position
|
||||||
|
---@field y number -- Mouse/Touch Y position
|
||||||
|
---@field dx number? -- Delta X from drag/touch start (only for drag/touch events)
|
||||||
|
---@field dy number? -- Delta Y from drag/touch start (only for drag/touch events)
|
||||||
|
---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean}
|
||||||
|
---@field clickCount number -- Number of clicks (for double/triple click detection)
|
||||||
|
---@field timestamp number -- Time when event occurred
|
||||||
|
---@field touchId string? -- Touch identifier (for multi-touch)
|
||||||
|
---@field pressure number? -- Touch pressure (0-1, defaults to 1.0)
|
||||||
|
---@field phase string? -- Touch phase: "began", "moved", "ended", "cancelled"
|
||||||
|
local InputEvent = {}
|
||||||
|
InputEvent.__index = InputEvent
|
||||||
|
|
||||||
|
---@class InputEventProps
|
||||||
|
---@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag"|"hover"|"unhover"|"touchpress"|"touchmove"|"touchrelease"|"touchcancel"
|
||||||
|
---@field button number
|
||||||
|
---@field x number
|
||||||
|
---@field y number
|
||||||
|
---@field dx number?
|
||||||
|
---@field dy number?
|
||||||
|
---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean}
|
||||||
|
---@field clickCount number?
|
||||||
|
---@field timestamp number?
|
||||||
|
---@field touchId string?
|
||||||
|
---@field pressure number?
|
||||||
|
---@field phase string?
|
||||||
|
|
||||||
|
--- Create a new input event
|
||||||
|
---@param props InputEventProps
|
||||||
|
---@return InputEvent
|
||||||
|
function InputEvent.new(props)
|
||||||
|
local self = setmetatable({}, InputEvent)
|
||||||
|
self.type = props.type
|
||||||
|
self.button = props.button
|
||||||
|
self.x = props.x
|
||||||
|
self.y = props.y
|
||||||
|
self.dx = props.dx
|
||||||
|
self.dy = props.dy
|
||||||
|
self.modifiers = props.modifiers
|
||||||
|
self.clickCount = props.clickCount or 1
|
||||||
|
self.timestamp = props.timestamp or love.timer.getTime()
|
||||||
|
|
||||||
|
-- Touch-specific properties
|
||||||
|
self.touchId = props.touchId
|
||||||
|
self.pressure = props.pressure or 1.0
|
||||||
|
self.phase = props.phase
|
||||||
|
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Create an InputEvent from LÖVE touch data
|
||||||
|
---@param id userdata Touch ID from LÖVE
|
||||||
|
---@param x number Touch X position
|
||||||
|
---@param y number Touch Y position
|
||||||
|
---@param phase string Touch phase: "began", "moved", "ended", "cancelled"
|
||||||
|
---@param pressure number? Touch pressure (0-1, defaults to 1.0)
|
||||||
|
---@return InputEvent
|
||||||
|
function InputEvent.fromTouch(id, x, y, phase, pressure)
|
||||||
|
local touchIdStr = tostring(id)
|
||||||
|
local eventType = "touchpress"
|
||||||
|
if phase == "moved" then
|
||||||
|
eventType = "touchmove"
|
||||||
|
elseif phase == "ended" then
|
||||||
|
eventType = "touchrelease"
|
||||||
|
elseif phase == "cancelled" then
|
||||||
|
eventType = "touchcancel"
|
||||||
|
end
|
||||||
|
|
||||||
|
return InputEvent.new({
|
||||||
|
type = eventType,
|
||||||
|
button = 1, -- Treat touch as left button
|
||||||
|
x = x,
|
||||||
|
y = y,
|
||||||
|
dx = 0,
|
||||||
|
dy = 0,
|
||||||
|
modifiers = { shift = false, ctrl = false, alt = false, super = false },
|
||||||
|
clickCount = 1,
|
||||||
|
timestamp = love.timer.getTime(),
|
||||||
|
touchId = touchIdStr,
|
||||||
|
pressure = pressure or 1.0,
|
||||||
|
phase = phase,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
return InputEvent
|
||||||
@@ -0,0 +1,748 @@
|
|||||||
|
local packageName = ... or "KeyboardNavigation"
|
||||||
|
local modulePath = packageName:match("(.-)[^%.]+$")
|
||||||
|
|
||||||
|
local function req(name)
|
||||||
|
return require(modulePath .. name)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@class KeyboardNavigation
|
||||||
|
---@field config KeyboardNavigationConfig
|
||||||
|
local KeyboardNavigation = {
|
||||||
|
|
||||||
|
config = {
|
||||||
|
-- Global settings
|
||||||
|
enabled = true,
|
||||||
|
debugMode = false,
|
||||||
|
|
||||||
|
-- Key bindings
|
||||||
|
keys = {
|
||||||
|
next = "tab",
|
||||||
|
previous = "shifttab",
|
||||||
|
up = "up",
|
||||||
|
down = "down",
|
||||||
|
left = "left",
|
||||||
|
right = "right",
|
||||||
|
activate = { "return", "space" },
|
||||||
|
dismiss = "escape",
|
||||||
|
toggleDebug = "f12",
|
||||||
|
inspect = "i",
|
||||||
|
},
|
||||||
|
|
||||||
|
-- Navigation behavior
|
||||||
|
wrapAround = true,
|
||||||
|
directionalNavigation = true,
|
||||||
|
focusVisible = true,
|
||||||
|
autofocusOnCreate = false,
|
||||||
|
|
||||||
|
--- Drop focus after pressing Enter/Space to activate an element
|
||||||
|
--- When false, focus remains on the element after activation
|
||||||
|
dropFocusOnSelection = true,
|
||||||
|
|
||||||
|
-- Developer tools
|
||||||
|
developerTools = {
|
||||||
|
enabled = true,
|
||||||
|
showProperties = true,
|
||||||
|
highlightColor = { 1, 0.8, 0, 0.5 },
|
||||||
|
},
|
||||||
|
|
||||||
|
-- Focus indicator style
|
||||||
|
focusIndicator = {
|
||||||
|
color = { 0.2, 0.6, 1.0, 0.8 },
|
||||||
|
lineWidth = 2,
|
||||||
|
inset = -3,
|
||||||
|
borderRadius = 4,
|
||||||
|
animationDuration = 0.15,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
-- State
|
||||||
|
_navigationStack = {},
|
||||||
|
_lastNavigationTime = 0,
|
||||||
|
_inspectMode = false,
|
||||||
|
_deps = nil,
|
||||||
|
|
||||||
|
-- Spatial index for directional navigation (performance optimization)
|
||||||
|
_spatialIndex = {
|
||||||
|
enabled = false,
|
||||||
|
cellSize = 100, -- Grid cell size in pixels
|
||||||
|
grid = {}, -- Grid storing element references
|
||||||
|
elementPositions = {}, -- Cache of element positions {element = {x, y, w, h}}
|
||||||
|
lastUpdateFrame = 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
--- Initialize KeyboardNavigation module
|
||||||
|
---@param deps table {Context, Element, ErrorHandler, utils, InputEvent}
|
||||||
|
function KeyboardNavigation.init(deps)
|
||||||
|
-- Validate required dependencies
|
||||||
|
local required = { Context = true, Element = true, ErrorHandler = true, utils = true, InputEvent = true }
|
||||||
|
for depName, _ in pairs(required) do
|
||||||
|
if not deps[depName] then
|
||||||
|
error(string.format("KeyboardNavigation.init: Missing required dependency: %s", depName))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
KeyboardNavigation._deps = deps
|
||||||
|
KeyboardNavigation._ErrorHandler = deps.ErrorHandler
|
||||||
|
KeyboardNavigation._InputEvent = deps.InputEvent
|
||||||
|
KeyboardNavigation._Context = deps.Context
|
||||||
|
KeyboardNavigation._Element = deps.Element
|
||||||
|
KeyboardNavigation._utils = deps.utils
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Handle keyboard press for navigation
|
||||||
|
---@param key string
|
||||||
|
---@param scancode string
|
||||||
|
---@param isrepeat boolean
|
||||||
|
---@return boolean handled
|
||||||
|
function KeyboardNavigation:handleKeyPress(key, scancode, isrepeat)
|
||||||
|
if not KeyboardNavigation._Context then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Debug logging
|
||||||
|
if KeyboardNavigation.config.debugMode then
|
||||||
|
print(
|
||||||
|
string.format(
|
||||||
|
"[KeyboardNavigation] Key pressed: %s (scancode: %s, repeat: %s)",
|
||||||
|
key,
|
||||||
|
scancode,
|
||||||
|
tostring(isrepeat)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print(string.format("[KeyboardNavigation] Enabled: %s", tostring(KeyboardNavigation.config.enabled)))
|
||||||
|
end
|
||||||
|
|
||||||
|
local config = KeyboardNavigation.config
|
||||||
|
local keys = config.keys
|
||||||
|
|
||||||
|
-- Check for activation keys
|
||||||
|
for _, activateKey in ipairs(keys.activate) do
|
||||||
|
if key == activateKey then
|
||||||
|
return self:activateElement()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for dismiss key
|
||||||
|
if key == keys.dismiss then
|
||||||
|
return self:dismissElement()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for next/previous navigation
|
||||||
|
-- Tab with shift held = previous; Tab without shift = next
|
||||||
|
if key == keys.next then
|
||||||
|
if love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift") then
|
||||||
|
return self:previousFocusable()
|
||||||
|
end
|
||||||
|
return self:nextFocusable()
|
||||||
|
end
|
||||||
|
|
||||||
|
if key == keys.previous then
|
||||||
|
return self:previousFocusable()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for directional navigation
|
||||||
|
if config.directionalNavigation then
|
||||||
|
if key == keys.up then
|
||||||
|
return self:navigateDirectional("up")
|
||||||
|
elseif key == keys.down then
|
||||||
|
return self:navigateDirectional("down")
|
||||||
|
elseif key == keys.left then
|
||||||
|
return self:navigateDirectional("left")
|
||||||
|
elseif key == keys.right then
|
||||||
|
return self:navigateDirectional("right")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Find next focusable element in the focusable list
|
||||||
|
---@param focusableList table<Element> List of focusable elements in tab order
|
||||||
|
---@param current Element? Currently focused element
|
||||||
|
---@return Element?
|
||||||
|
function KeyboardNavigation:_findNextInList(focusableList, current)
|
||||||
|
local currentIndex = 0
|
||||||
|
if current then
|
||||||
|
for i, elem in ipairs(focusableList) do
|
||||||
|
if elem.id == current.id then
|
||||||
|
currentIndex = i
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Search forward
|
||||||
|
if currentIndex < #focusableList then
|
||||||
|
return focusableList[currentIndex + 1]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Wrap around if enabled
|
||||||
|
if KeyboardNavigation.config.wrapAround and #focusableList > 0 then
|
||||||
|
return focusableList[1]
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get the focusable element list scoped to the navigation container
|
||||||
|
---@return Element[]
|
||||||
|
function KeyboardNavigation:_getScopedFocusableList()
|
||||||
|
local Context = KeyboardNavigation._Context
|
||||||
|
local container = Context.getNavigationContainer()
|
||||||
|
if container then
|
||||||
|
return container:getFocusableChildren()
|
||||||
|
end
|
||||||
|
return Context.getFocusableElements()
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Navigate to next focusable element (Tab)
|
||||||
|
---@return boolean success
|
||||||
|
function KeyboardNavigation:nextFocusable()
|
||||||
|
local Context = KeyboardNavigation._Context
|
||||||
|
|
||||||
|
local current = Context.getFocused()
|
||||||
|
if KeyboardNavigation.config.debugMode then
|
||||||
|
print(
|
||||||
|
string.format("[KeyboardNavigation] Tab pressed - Current focus: %s", tostring(current and current.id or "nil"))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
local focusableList = self:_getScopedFocusableList()
|
||||||
|
local nextElem = self:_findNextInList(focusableList, current)
|
||||||
|
|
||||||
|
if nextElem then
|
||||||
|
self:_focusElement(nextElem)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Find previous focusable element in the focusable list
|
||||||
|
---@param focusableList table<Element> List of focusable elements in tab order
|
||||||
|
---@param current Element? Currently focused element
|
||||||
|
---@return Element?
|
||||||
|
function KeyboardNavigation:_findPreviousInList(focusableList, current)
|
||||||
|
local currentIndex = #focusableList + 1
|
||||||
|
if current then
|
||||||
|
for i, elem in ipairs(focusableList) do
|
||||||
|
if elem.id == current.id then
|
||||||
|
currentIndex = i
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Search backward
|
||||||
|
if currentIndex - 1 >= 1 then
|
||||||
|
return focusableList[currentIndex - 1]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Wrap around if enabled
|
||||||
|
if KeyboardNavigation.config.wrapAround and #focusableList > 0 then
|
||||||
|
return focusableList[#focusableList]
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Navigate to previous focusable element (Shift+Tab)
|
||||||
|
---@return boolean success
|
||||||
|
function KeyboardNavigation:previousFocusable()
|
||||||
|
local Context = KeyboardNavigation._Context
|
||||||
|
|
||||||
|
local current = Context.getFocused()
|
||||||
|
|
||||||
|
local focusableList = self:_getScopedFocusableList()
|
||||||
|
local prevElem = self:_findPreviousInList(focusableList, current)
|
||||||
|
|
||||||
|
if prevElem then
|
||||||
|
self:_focusElement(prevElem)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Navigate using arrow keys
|
||||||
|
---@param direction "up"|"down"|"left"|"right"
|
||||||
|
---@return boolean success
|
||||||
|
function KeyboardNavigation:navigateDirectional(direction)
|
||||||
|
local Context = KeyboardNavigation._Context
|
||||||
|
local current = Context.getFocused()
|
||||||
|
|
||||||
|
if not current then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local nextElem = KeyboardNavigation:_findDirectionalNeighbor(current, direction)
|
||||||
|
|
||||||
|
if nextElem then
|
||||||
|
self:_focusElement(nextElem)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Find closest focusable element in the given direction
|
||||||
|
---@param current Element
|
||||||
|
---@param direction "up"|"down"|"left"|"right"
|
||||||
|
---@return Element?
|
||||||
|
function KeyboardNavigation:_findDirectionalNeighbor(current, direction)
|
||||||
|
-- Try spatial index first if enabled
|
||||||
|
if KeyboardNavigation._spatialIndex.enabled then
|
||||||
|
local spatialResult = self:_findDirectionalNeighborSpatial(current, direction)
|
||||||
|
if spatialResult then
|
||||||
|
return spatialResult
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Collect all focusable elements visible this frame
|
||||||
|
local Context = KeyboardNavigation._Context
|
||||||
|
local focusable = {}
|
||||||
|
|
||||||
|
local function collectFocusable(elem)
|
||||||
|
if elem:isFocusable() and elem ~= current then
|
||||||
|
table.insert(focusable, elem)
|
||||||
|
end
|
||||||
|
for _, child in ipairs(elem.children) do
|
||||||
|
collectFocusable(child)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Mode-agnostic: collect from Context's focusable list
|
||||||
|
local allFocusable = Context.getFocusableElements()
|
||||||
|
for _, elem in ipairs(allFocusable) do
|
||||||
|
if elem ~= current then
|
||||||
|
table.insert(focusable, elem)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #focusable == 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local currentRect = {
|
||||||
|
x = current.x,
|
||||||
|
y = current.y,
|
||||||
|
width = current.width or 0,
|
||||||
|
height = current.height or 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
local closest = nil
|
||||||
|
local closestDistance = math.huge
|
||||||
|
|
||||||
|
for _, elem in ipairs(focusable) do
|
||||||
|
local elemRect = {
|
||||||
|
x = elem.x,
|
||||||
|
y = elem.y,
|
||||||
|
width = elem.width or 0,
|
||||||
|
height = elem.height or 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
local distance, isInDirection = self:_calculateDirectionalDistance(currentRect, elemRect, direction)
|
||||||
|
|
||||||
|
if isInDirection and distance < closestDistance then
|
||||||
|
closest = elem
|
||||||
|
closestDistance = distance
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- If no element found in exact direction, try with looser criteria
|
||||||
|
if not closest then
|
||||||
|
closest = self:_findClosestInDirection(current, focusable, direction)
|
||||||
|
end
|
||||||
|
|
||||||
|
return closest
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Calculate distance and direction between elements
|
||||||
|
---@param from table {x, y, width, height}
|
||||||
|
---@param to table {x, y, width, height}
|
||||||
|
---@param direction string
|
||||||
|
---@return number distance, boolean isInDirection
|
||||||
|
function KeyboardNavigation:_calculateDirectionalDistance(from, to, direction)
|
||||||
|
-- Calculate bounding box edges
|
||||||
|
local fromLeft = from.x
|
||||||
|
local fromRight = from.x + from.width
|
||||||
|
local fromTop = from.y
|
||||||
|
local fromBottom = from.y + from.height
|
||||||
|
|
||||||
|
local toLeft = to.x
|
||||||
|
local toRight = to.x + to.width
|
||||||
|
local toTop = to.y
|
||||||
|
local toBottom = to.y + to.height
|
||||||
|
|
||||||
|
local distance = math.huge
|
||||||
|
local isInDirection = false
|
||||||
|
|
||||||
|
if direction == "up" then
|
||||||
|
if toBottom < fromTop then
|
||||||
|
isInDirection = true
|
||||||
|
distance = fromTop - toBottom
|
||||||
|
end
|
||||||
|
elseif direction == "down" then
|
||||||
|
if toTop > fromBottom then
|
||||||
|
isInDirection = true
|
||||||
|
distance = toTop - fromBottom
|
||||||
|
end
|
||||||
|
elseif direction == "left" then
|
||||||
|
if toRight < fromLeft then
|
||||||
|
isInDirection = true
|
||||||
|
distance = fromLeft - toRight
|
||||||
|
end
|
||||||
|
elseif direction == "right" then
|
||||||
|
if toLeft > fromRight then
|
||||||
|
isInDirection = true
|
||||||
|
distance = toLeft - fromRight
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return distance, isInDirection
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Find closest element in direction using center-to-center distance
|
||||||
|
---@param current Element
|
||||||
|
---@param focusable Element[]
|
||||||
|
---@param direction string
|
||||||
|
---@return Element?
|
||||||
|
function KeyboardNavigation:_findClosestInDirection(current, focusable, direction)
|
||||||
|
local currentCenterX = current.x + (current.width or 0) / 2
|
||||||
|
local currentCenterY = current.y + (current.height or 0) / 2
|
||||||
|
|
||||||
|
local closest = nil
|
||||||
|
local closestDistance = math.huge
|
||||||
|
|
||||||
|
for _, elem in ipairs(focusable) do
|
||||||
|
if elem ~= current then
|
||||||
|
local elemCenterX = elem.x + (elem.width or 0) / 2
|
||||||
|
local elemCenterY = elem.y + (elem.height or 0) / 2
|
||||||
|
|
||||||
|
local dx = elemCenterX - currentCenterX
|
||||||
|
local dy = elemCenterY - currentCenterY
|
||||||
|
|
||||||
|
-- Check if element is generally in the right direction
|
||||||
|
local isInDirection = false
|
||||||
|
|
||||||
|
if direction == "up" and dy < 0 then
|
||||||
|
isInDirection = true
|
||||||
|
elseif direction == "down" and dy > 0 then
|
||||||
|
isInDirection = true
|
||||||
|
elseif direction == "left" and dx < 0 then
|
||||||
|
isInDirection = true
|
||||||
|
elseif direction == "right" and dx > 0 then
|
||||||
|
isInDirection = true
|
||||||
|
end
|
||||||
|
|
||||||
|
if isInDirection then
|
||||||
|
local distance = math.sqrt(dx * dx + dy * dy)
|
||||||
|
if distance < closestDistance then
|
||||||
|
closest = elem
|
||||||
|
closestDistance = distance
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return closest
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Focus an element
|
||||||
|
---@param element Element
|
||||||
|
function KeyboardNavigation:_focusElement(element)
|
||||||
|
local Context = KeyboardNavigation._Context
|
||||||
|
|
||||||
|
if element and element:isFocusable() then
|
||||||
|
if KeyboardNavigation.config.debugMode then
|
||||||
|
print(
|
||||||
|
string.format(
|
||||||
|
"[KeyboardNavigation] Focusing element: %s (id: %s)",
|
||||||
|
element.themeComponent or "unknown",
|
||||||
|
tostring(element.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
Context.setFocused(element)
|
||||||
|
|
||||||
|
-- Update focus indicator
|
||||||
|
if KeyboardNavigation.FocusIndicator then
|
||||||
|
KeyboardNavigation.FocusIndicator.setFocused(element)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Call onFocus callback if it exists
|
||||||
|
if element.onFocus then
|
||||||
|
local success, err = pcall(function()
|
||||||
|
if element.onFocusDeferred then
|
||||||
|
table.insert(Context._deferredCallbacks or {}, function()
|
||||||
|
element:onFocus(element)
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
element:onFocus(element)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
if not success then
|
||||||
|
KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_001", {
|
||||||
|
elementId = element.id or "unknown",
|
||||||
|
error = tostring(err),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
---@return boolean
|
||||||
|
function KeyboardNavigation:_shouldDropFocusOnSelection(element)
|
||||||
|
if element and element.dropFocusOnSelection ~= nil then
|
||||||
|
return element.dropFocusOnSelection == true
|
||||||
|
end
|
||||||
|
|
||||||
|
return KeyboardNavigation.config.dropFocusOnSelection == true
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Activate currently focused element
|
||||||
|
---@return boolean success
|
||||||
|
function KeyboardNavigation:activateElement()
|
||||||
|
local Context = KeyboardNavigation._Context
|
||||||
|
local focused = Context.getFocused()
|
||||||
|
|
||||||
|
if not focused then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
if focused.disabled then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Fire press and release events
|
||||||
|
if focused.onEvent then
|
||||||
|
local modifiers = KeyboardNavigation._utils.getModifiers()
|
||||||
|
local pressEvent = KeyboardNavigation._InputEvent.new({
|
||||||
|
type = "press",
|
||||||
|
button = 1,
|
||||||
|
x = focused.x,
|
||||||
|
y = focused.y,
|
||||||
|
modifiers = modifiers,
|
||||||
|
clickCount = 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
local releaseEvent = KeyboardNavigation._InputEvent.new({
|
||||||
|
type = "release",
|
||||||
|
button = 1,
|
||||||
|
x = focused.x,
|
||||||
|
y = focused.y,
|
||||||
|
modifiers = modifiers,
|
||||||
|
clickCount = 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
local success, err = pcall(function()
|
||||||
|
focused.onEvent(focused, pressEvent)
|
||||||
|
focused.onEvent(focused, releaseEvent)
|
||||||
|
end)
|
||||||
|
|
||||||
|
if not success then
|
||||||
|
KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_002", {
|
||||||
|
elementId = focused.id or "unknown",
|
||||||
|
error = tostring(err),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Drop focus after selection based on per-element override or global config.
|
||||||
|
if KeyboardNavigation:_shouldDropFocusOnSelection(focused) then
|
||||||
|
Context.clearFocus()
|
||||||
|
if KeyboardNavigation.FocusIndicator then
|
||||||
|
KeyboardNavigation.FocusIndicator.setFocused(nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Dismiss currently focused element
|
||||||
|
---@return boolean success
|
||||||
|
function KeyboardNavigation:dismissElement()
|
||||||
|
local Context = KeyboardNavigation._Context
|
||||||
|
local focused = Context.getFocused()
|
||||||
|
|
||||||
|
if not focused then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if element has a dismiss handler
|
||||||
|
if focused.onDismiss then
|
||||||
|
local success, err = pcall(function()
|
||||||
|
if focused.onDismissDeferred then
|
||||||
|
table.insert(Context._deferredCallbacks or {}, function()
|
||||||
|
focused:onDismiss(focused)
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
focused:onDismiss(focused)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
if not success then
|
||||||
|
KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_003", {
|
||||||
|
elementId = focused.id or "unknown",
|
||||||
|
error = tostring(err),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
return true -- Handler took care of dismissal
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Default behavior: blur the element (only if no onDismiss handler)
|
||||||
|
Context.clearFocus()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Update keyboard navigation (for animations, etc.)
|
||||||
|
---@param dt number
|
||||||
|
function KeyboardNavigation:update(dt)
|
||||||
|
-- Update focus indicator if it exists
|
||||||
|
if KeyboardNavigation.FocusIndicator then
|
||||||
|
KeyboardNavigation.FocusIndicator:update(dt)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Push current focus onto stack (for modals/dialogs)
|
||||||
|
--- Saves current focus and sets new focus to the given element
|
||||||
|
---@param element Element? The element to focus (e.g., modal dialog)
|
||||||
|
function KeyboardNavigation:pushFocus(element)
|
||||||
|
local Context = KeyboardNavigation._Context
|
||||||
|
|
||||||
|
table.insert(KeyboardNavigation._navigationStack, Context.getFocused())
|
||||||
|
Context.pushFocusStack(element)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Pop focus from stack (return from modal)
|
||||||
|
--- Restores previously focused element from the stack
|
||||||
|
---@return Element? The previously focused element, or nil if stack was empty
|
||||||
|
function KeyboardNavigation:popFocus()
|
||||||
|
local Context = KeyboardNavigation._Context
|
||||||
|
|
||||||
|
local previous = Context.popFocusStack()
|
||||||
|
if #KeyboardNavigation._navigationStack > 0 then
|
||||||
|
previous = table.remove(KeyboardNavigation._navigationStack)
|
||||||
|
end
|
||||||
|
|
||||||
|
return previous
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ====================
|
||||||
|
-- Spatial Index (Performance Optimization)
|
||||||
|
-- ====================
|
||||||
|
|
||||||
|
--- Enable spatial index for faster directional navigation
|
||||||
|
---@param enabled boolean
|
||||||
|
function KeyboardNavigation.enableSpatialIndex(enabled)
|
||||||
|
KeyboardNavigation._spatialIndex.enabled = enabled
|
||||||
|
if not enabled then
|
||||||
|
KeyboardNavigation:_clearSpatialIndex()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Clear spatial index
|
||||||
|
function KeyboardNavigation:_clearSpatialIndex()
|
||||||
|
KeyboardNavigation._spatialIndex.grid = {}
|
||||||
|
KeyboardNavigation._spatialIndex.elementPositions = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Find directional neighbor using spatial index
|
||||||
|
---@param current Element
|
||||||
|
---@param direction "up"|"down"|"left"|"right"
|
||||||
|
---@return Element?
|
||||||
|
function KeyboardNavigation:_findDirectionalNeighborSpatial(current, direction)
|
||||||
|
local index = KeyboardNavigation._spatialIndex
|
||||||
|
local cellSize = index.cellSize
|
||||||
|
|
||||||
|
-- Get current element's grid position
|
||||||
|
local currentPos = index.elementPositions[current]
|
||||||
|
if not currentPos then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local centerX = currentPos.x + currentPos.w / 2
|
||||||
|
local centerY = currentPos.y + currentPos.h / 2
|
||||||
|
local currentCellX = math.floor(centerX / cellSize)
|
||||||
|
local currentCellY = math.floor(centerY / cellSize)
|
||||||
|
|
||||||
|
-- Search in direction, expanding outward
|
||||||
|
local maxSearchRadius = 20 -- Maximum cells to search
|
||||||
|
local visited = {}
|
||||||
|
|
||||||
|
for radius = 1, maxSearchRadius do
|
||||||
|
local candidates = {}
|
||||||
|
|
||||||
|
-- Get cells in the search ring
|
||||||
|
if direction == "up" then
|
||||||
|
table.insert(candidates, { currentCellX, currentCellY - radius })
|
||||||
|
if radius > 1 then
|
||||||
|
table.insert(candidates, { currentCellX - 1, currentCellY - radius })
|
||||||
|
table.insert(candidates, { currentCellX + 1, currentCellY - radius })
|
||||||
|
end
|
||||||
|
elseif direction == "down" then
|
||||||
|
table.insert(candidates, { currentCellX, currentCellY + radius })
|
||||||
|
if radius > 1 then
|
||||||
|
table.insert(candidates, { currentCellX - 1, currentCellY + radius })
|
||||||
|
table.insert(candidates, { currentCellX + 1, currentCellY + radius })
|
||||||
|
end
|
||||||
|
elseif direction == "left" then
|
||||||
|
table.insert(candidates, { currentCellX - radius, currentCellY })
|
||||||
|
if radius > 1 then
|
||||||
|
table.insert(candidates, { currentCellX - radius, currentCellY - 1 })
|
||||||
|
table.insert(candidates, { currentCellX - radius, currentCellY + 1 })
|
||||||
|
end
|
||||||
|
elseif direction == "right" then
|
||||||
|
table.insert(candidates, { currentCellX + radius, currentCellY })
|
||||||
|
if radius > 1 then
|
||||||
|
table.insert(candidates, { currentCellX + radius, currentCellY - 1 })
|
||||||
|
table.insert(candidates, { currentCellX + radius, currentCellY + 1 })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check each candidate cell
|
||||||
|
for _, cell in ipairs(candidates) do
|
||||||
|
local cellKey = string.format("%d,%d", cell[1], cell[2])
|
||||||
|
local cellElements = index.grid[cellKey]
|
||||||
|
|
||||||
|
if cellElements then
|
||||||
|
for _, elem in ipairs(cellElements) do
|
||||||
|
if elem ~= current and not visited[elem] then
|
||||||
|
visited[elem] = true
|
||||||
|
local elemPos = index.elementPositions[elem]
|
||||||
|
if elemPos then
|
||||||
|
local elemCenterX = elemPos.x + elemPos.w / 2
|
||||||
|
local elemCenterY = elemPos.y + elemPos.h / 2
|
||||||
|
|
||||||
|
-- Check if element is in the correct direction
|
||||||
|
local isInDirection = false
|
||||||
|
if direction == "up" and elemCenterY < centerY then
|
||||||
|
isInDirection = true
|
||||||
|
elseif direction == "down" and elemCenterY > centerY then
|
||||||
|
isInDirection = true
|
||||||
|
elseif direction == "left" and elemCenterX < centerX then
|
||||||
|
isInDirection = true
|
||||||
|
elseif direction == "right" and elemCenterX > centerX then
|
||||||
|
isInDirection = true
|
||||||
|
end
|
||||||
|
|
||||||
|
if isInDirection then
|
||||||
|
return elem
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return KeyboardNavigation
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,697 @@
|
|||||||
|
---@class MemoryScanner
|
||||||
|
---@field _StateManager table
|
||||||
|
---@field _Context table
|
||||||
|
---@field _ImageCache table
|
||||||
|
---@field _ErrorHandler table
|
||||||
|
local MemoryScanner = {}
|
||||||
|
|
||||||
|
---Initialize MemoryScanner with dependencies
|
||||||
|
---@param deps {StateManager: table, Context: table, ImageCache: table, ErrorHandler: table}
|
||||||
|
function MemoryScanner.init(deps)
|
||||||
|
MemoryScanner._StateManager = deps.StateManager
|
||||||
|
MemoryScanner._Context = deps.Context
|
||||||
|
MemoryScanner._ImageCache = deps.ImageCache
|
||||||
|
MemoryScanner._ErrorHandler = deps.ErrorHandler
|
||||||
|
end
|
||||||
|
|
||||||
|
---Count items in a table
|
||||||
|
---@param tbl table
|
||||||
|
---@return number
|
||||||
|
local function countTable(tbl)
|
||||||
|
local count = 0
|
||||||
|
for _ in pairs(tbl) do
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
return count
|
||||||
|
end
|
||||||
|
|
||||||
|
---Calculate memory size estimate for a table (recursive)
|
||||||
|
---@param tbl table
|
||||||
|
---@param visited table? Tracking table to prevent circular references
|
||||||
|
---@param depth number? Current recursion depth
|
||||||
|
---@return number bytes Estimated memory usage in bytes
|
||||||
|
local function estimateTableSize(tbl, visited, depth)
|
||||||
|
if type(tbl) ~= "table" then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
visited = visited or {}
|
||||||
|
depth = depth or 0
|
||||||
|
|
||||||
|
-- Limit recursion depth to prevent stack overflow
|
||||||
|
if depth > 10 then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for circular references
|
||||||
|
if visited[tbl] then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
visited[tbl] = true
|
||||||
|
|
||||||
|
local size = 40 -- Base table overhead (approximate)
|
||||||
|
|
||||||
|
for k, v in pairs(tbl) do
|
||||||
|
-- Key size
|
||||||
|
if type(k) == "string" then
|
||||||
|
size = size + #k + 24 -- String overhead
|
||||||
|
elseif type(k) == "number" then
|
||||||
|
size = size + 8
|
||||||
|
else
|
||||||
|
size = size + 8 -- Reference
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Value size
|
||||||
|
if type(v) == "string" then
|
||||||
|
size = size + #v + 24
|
||||||
|
elseif type(v) == "number" then
|
||||||
|
size = size + 8
|
||||||
|
elseif type(v) == "boolean" then
|
||||||
|
size = size + 4
|
||||||
|
elseif type(v) == "table" then
|
||||||
|
size = size + estimateTableSize(v, visited, depth + 1)
|
||||||
|
elseif type(v) == "function" then
|
||||||
|
size = size + 16 -- Function reference
|
||||||
|
else
|
||||||
|
size = size + 8 -- Other references
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return size
|
||||||
|
end
|
||||||
|
|
||||||
|
---Scan StateManager for memory issues
|
||||||
|
---@return table report Detailed report of StateManager memory usage
|
||||||
|
function MemoryScanner.scanStateManager()
|
||||||
|
local report = {
|
||||||
|
stateCount = 0,
|
||||||
|
stateStoreSize = 0,
|
||||||
|
metadataSize = 0,
|
||||||
|
callSiteCounterSize = 0,
|
||||||
|
orphanedStates = {},
|
||||||
|
staleStates = {},
|
||||||
|
largeStates = {},
|
||||||
|
issues = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if not MemoryScanner._StateManager then
|
||||||
|
table.insert(report.issues, {
|
||||||
|
severity = "error",
|
||||||
|
message = "StateManager not initialized",
|
||||||
|
})
|
||||||
|
return report
|
||||||
|
end
|
||||||
|
|
||||||
|
local internal = MemoryScanner._StateManager._getInternalState()
|
||||||
|
local stateStore = internal.stateStore
|
||||||
|
local stateMetadata = internal.stateMetadata
|
||||||
|
local callSiteCounters = internal.callSiteCounters
|
||||||
|
local currentFrame = MemoryScanner._StateManager.getFrameNumber()
|
||||||
|
|
||||||
|
-- Count states
|
||||||
|
report.stateCount = countTable(stateStore)
|
||||||
|
|
||||||
|
-- Estimate sizes
|
||||||
|
report.stateStoreSize = estimateTableSize(stateStore)
|
||||||
|
report.metadataSize = estimateTableSize(stateMetadata)
|
||||||
|
report.callSiteCounterSize = estimateTableSize(callSiteCounters)
|
||||||
|
|
||||||
|
-- Check for orphaned states (metadata without state)
|
||||||
|
for id, _ in pairs(stateMetadata) do
|
||||||
|
if not stateStore[id] then
|
||||||
|
table.insert(report.orphanedStates, id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for stale states (not accessed in many frames)
|
||||||
|
local staleThreshold = 120 -- 2 seconds at 60fps
|
||||||
|
for id, meta in pairs(stateMetadata) do
|
||||||
|
local framesSinceAccess = currentFrame - meta.lastFrame
|
||||||
|
if framesSinceAccess > staleThreshold then
|
||||||
|
table.insert(report.staleStates, {
|
||||||
|
id = id,
|
||||||
|
framesSinceAccess = framesSinceAccess,
|
||||||
|
createdFrame = meta.createdFrame,
|
||||||
|
accessCount = meta.accessCount,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for large states (may indicate memory bloat)
|
||||||
|
for id, state in pairs(stateStore) do
|
||||||
|
local stateSize = estimateTableSize(state)
|
||||||
|
if stateSize > 1024 then -- More than 1KB
|
||||||
|
table.insert(report.largeStates, {
|
||||||
|
id = id,
|
||||||
|
size = stateSize,
|
||||||
|
keyCount = countTable(state),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check callSiteCounters (should be near 0 after frame cleanup)
|
||||||
|
local callSiteCount = countTable(callSiteCounters)
|
||||||
|
if callSiteCount > 100 then
|
||||||
|
table.insert(report.issues, {
|
||||||
|
severity = "warning",
|
||||||
|
message = string.format("callSiteCounters has %d entries (expected near 0)", callSiteCount),
|
||||||
|
suggestion = "incrementFrame() may not be called properly, or counters aren't being reset",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for excessive state count
|
||||||
|
if report.stateCount > 500 then
|
||||||
|
table.insert(report.issues, {
|
||||||
|
severity = "warning",
|
||||||
|
message = string.format("High state count: %d states", report.stateCount),
|
||||||
|
suggestion = "Consider reducing element count or implementing more aggressive cleanup",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for orphaned states
|
||||||
|
if #report.orphanedStates > 0 then
|
||||||
|
table.insert(report.issues, {
|
||||||
|
severity = "error",
|
||||||
|
message = string.format("Found %d orphaned states (metadata without state)", #report.orphanedStates),
|
||||||
|
suggestion = "This indicates a bug in state management - metadata should be cleaned up with state",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for stale states
|
||||||
|
if #report.staleStates > 10 then
|
||||||
|
table.insert(report.issues, {
|
||||||
|
severity = "warning",
|
||||||
|
message = string.format("Found %d stale states (not accessed in 2+ seconds)", #report.staleStates),
|
||||||
|
suggestion = "Cleanup may not be aggressive enough - consider reducing stateRetentionFrames",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
return report
|
||||||
|
end
|
||||||
|
|
||||||
|
---Scan Context for memory issues
|
||||||
|
---@return table report Detailed report of Context memory usage
|
||||||
|
function MemoryScanner.scanContext()
|
||||||
|
local report = {
|
||||||
|
topElementCount = 0,
|
||||||
|
zIndexElementCount = 0,
|
||||||
|
frameElementCount = 0,
|
||||||
|
issues = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if not MemoryScanner._Context then
|
||||||
|
table.insert(report.issues, {
|
||||||
|
severity = "error",
|
||||||
|
message = "Context not initialized",
|
||||||
|
})
|
||||||
|
return report
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Count elements
|
||||||
|
report.topElementCount = #MemoryScanner._Context.topElements
|
||||||
|
report.zIndexElementCount = #MemoryScanner._Context._zIndexOrderedElements
|
||||||
|
report.frameElementCount = #MemoryScanner._Context._currentFrameElements
|
||||||
|
|
||||||
|
-- Check for stale z-index elements (should be cleared each frame)
|
||||||
|
if MemoryScanner._Context.isImmediateMode() then
|
||||||
|
-- In immediate mode, _zIndexOrderedElements should be cleared at frame start
|
||||||
|
-- If it has elements outside of frame rendering, that's a leak
|
||||||
|
if not MemoryScanner._Context._frameStarted and report.zIndexElementCount > 0 then
|
||||||
|
table.insert(report.issues, {
|
||||||
|
severity = "warning",
|
||||||
|
message = string.format("Z-index array has %d elements outside of frame", report.zIndexElementCount),
|
||||||
|
suggestion = "clearFrameElements() may not be called properly in beginFrame()",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for excessive element count
|
||||||
|
if report.topElementCount > 100 then
|
||||||
|
table.insert(report.issues, {
|
||||||
|
severity = "info",
|
||||||
|
message = string.format("High top-level element count: %d", report.topElementCount),
|
||||||
|
suggestion = "Consider consolidating elements or using fewer top-level containers",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
return report
|
||||||
|
end
|
||||||
|
|
||||||
|
---Scan ImageCache for memory issues
|
||||||
|
---@return table report Detailed report of ImageCache memory usage
|
||||||
|
function MemoryScanner.scanImageCache()
|
||||||
|
local report = {
|
||||||
|
imageCount = 0,
|
||||||
|
estimatedMemory = 0,
|
||||||
|
issues = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if not MemoryScanner._ImageCache then
|
||||||
|
table.insert(report.issues, {
|
||||||
|
severity = "error",
|
||||||
|
message = "ImageCache not initialized",
|
||||||
|
})
|
||||||
|
return report
|
||||||
|
end
|
||||||
|
|
||||||
|
local stats = MemoryScanner._ImageCache.getStats()
|
||||||
|
report.imageCount = stats.count
|
||||||
|
report.estimatedMemory = stats.memoryEstimate
|
||||||
|
|
||||||
|
-- Check for excessive memory usage (>100MB)
|
||||||
|
if report.estimatedMemory > 100 * 1024 * 1024 then
|
||||||
|
table.insert(report.issues, {
|
||||||
|
severity = "warning",
|
||||||
|
message = string.format("ImageCache using ~%.2f MB", report.estimatedMemory / 1024 / 1024),
|
||||||
|
suggestion = "Consider implementing cache eviction or clearing unused images",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for excessive image count
|
||||||
|
if report.imageCount > 50 then
|
||||||
|
table.insert(report.issues, {
|
||||||
|
severity = "info",
|
||||||
|
message = string.format("ImageCache has %d images", report.imageCount),
|
||||||
|
suggestion = "Review if all cached images are necessary",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
return report
|
||||||
|
end
|
||||||
|
|
||||||
|
---Check if a circular reference is intentional (parent-child, module, or metatable)
|
||||||
|
---@param path string The current path where circular ref was detected
|
||||||
|
---@param originalPath string The original path where the table was first seen
|
||||||
|
---@return boolean True if this is an intentional circular reference
|
||||||
|
local function isIntentionalCircularReference(path, originalPath)
|
||||||
|
-- Pattern 1: child.parent points back to parent
|
||||||
|
-- Example: "topElements.1.children.1.parent" -> "topElements.1"
|
||||||
|
if path:match("%.parent$") then
|
||||||
|
local parentPath = path:match("^(.+)%.children%.[^.]+%.parent$")
|
||||||
|
if parentPath == originalPath then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pattern 2: parent.children[n] points to child, child points back somewhere in parent tree
|
||||||
|
-- Example: "topElements.1" -> "topElements.1.children.1.parent"
|
||||||
|
if originalPath:match("%.parent$") then
|
||||||
|
local childParentPath = originalPath:match("^(.+)%.children%.[^.]+%.parent$")
|
||||||
|
if childParentPath == path then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pattern 3: Check for nested parent-child cycles
|
||||||
|
-- child.children[n].parent -> child
|
||||||
|
local segments = {}
|
||||||
|
for segment in path:gmatch("[^.]+") do
|
||||||
|
table.insert(segments, segment)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Look for .children.N.parent pattern
|
||||||
|
for i = 1, #segments - 2 do
|
||||||
|
if segments[i] == "children" and segments[i + 2] == "parent" then
|
||||||
|
-- Reconstruct path without the .children.N.parent suffix
|
||||||
|
local reconstructedPath = table.concat(segments, ".", 1, i - 1)
|
||||||
|
if reconstructedPath == originalPath then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pattern 4: Metatable __index self-references (modules)
|
||||||
|
-- Example: "element._renderer._Theme.__index" -> "element._renderer._Theme"
|
||||||
|
if path:match("%.__index$") then
|
||||||
|
local basePath = path:match("^(.+)%.__index$")
|
||||||
|
if basePath == originalPath then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pattern 5: Shared module references (elements sharing same module instances)
|
||||||
|
-- Example: Multiple elements referencing _utils, _Theme, _Blur, etc.
|
||||||
|
-- These start with _ and are typically modules
|
||||||
|
local pathModuleName = path:match("%.(_[%w]+)%.")
|
||||||
|
local originalModuleName = originalPath:match("%.(_[%w]+)%.")
|
||||||
|
|
||||||
|
if pathModuleName and originalModuleName then
|
||||||
|
-- If both paths reference the same internal module (starting with _), it's intentional
|
||||||
|
if pathModuleName == originalModuleName then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pattern 6: Shared Color/Transform objects between elements
|
||||||
|
-- These are value objects that can be safely shared
|
||||||
|
if path:match("Color") and originalPath:match("Color") then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if path:match("Transform") and originalPath:match("Transform") then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pattern 7: LayoutEngine holding reference to its element
|
||||||
|
-- Example: "element._layoutEngine.element" -> "element"
|
||||||
|
if path:match("%._layoutEngine%.element$") then
|
||||||
|
local elementPath = path:match("^(.+)%._layoutEngine%.element$")
|
||||||
|
if elementPath == originalPath then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pattern 8: Renderer holding references to element properties
|
||||||
|
-- Example: "element._renderer.cornerRadius" -> "element.cornerRadius"
|
||||||
|
if path:match("%._renderer%.") then
|
||||||
|
local rendererBasePath = path:match("^(.+)%._renderer%.")
|
||||||
|
local originalBasePath = originalPath:match("^(.+)%.")
|
||||||
|
if rendererBasePath == originalBasePath then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pattern 9: Context reference from layout engine (shared singleton)
|
||||||
|
-- Example: "element._layoutEngine._Context.topElements" -> "topElements"
|
||||||
|
if path:match("%._layoutEngine%._Context%.") and originalPath == "topElements" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
---Detect circular references in a table
|
||||||
|
---@param tbl table Table to check
|
||||||
|
---@param path string? Current path (for reporting)
|
||||||
|
---@param visited table? Tracking table
|
||||||
|
---@return table[] circularRefs Array of circular reference paths
|
||||||
|
---@return table[] intentionalRefs Array of intentional parent-child refs
|
||||||
|
local function detectCircularReferences(tbl, path, visited)
|
||||||
|
if type(tbl) ~= "table" then
|
||||||
|
return {}, {}
|
||||||
|
end
|
||||||
|
|
||||||
|
path = path or "root"
|
||||||
|
visited = visited or {}
|
||||||
|
local circularRefs = {}
|
||||||
|
local intentionalRefs = {}
|
||||||
|
|
||||||
|
-- Check if we've seen this table before
|
||||||
|
if visited[tbl] then
|
||||||
|
local ref = {
|
||||||
|
path = path,
|
||||||
|
originalPath = visited[tbl],
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Determine if this is an intentional circular reference
|
||||||
|
if isIntentionalCircularReference(path, visited[tbl]) then
|
||||||
|
table.insert(intentionalRefs, ref)
|
||||||
|
else
|
||||||
|
table.insert(circularRefs, ref)
|
||||||
|
end
|
||||||
|
|
||||||
|
return circularRefs, intentionalRefs
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Mark as visited
|
||||||
|
visited[tbl] = path
|
||||||
|
|
||||||
|
-- Recursively check children
|
||||||
|
for k, v in pairs(tbl) do
|
||||||
|
if type(v) == "table" then
|
||||||
|
local childPath = path .. "." .. tostring(k)
|
||||||
|
local childRefs, childIntentionalRefs = detectCircularReferences(v, childPath, visited)
|
||||||
|
for _, ref in ipairs(childRefs) do
|
||||||
|
table.insert(circularRefs, ref)
|
||||||
|
end
|
||||||
|
for _, ref in ipairs(childIntentionalRefs) do
|
||||||
|
table.insert(intentionalRefs, ref)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return circularRefs, intentionalRefs
|
||||||
|
end
|
||||||
|
|
||||||
|
---Scan for circular references in immediate mode
|
||||||
|
---@return table report Detailed report of circular references
|
||||||
|
function MemoryScanner.scanCircularReferences()
|
||||||
|
local report = {
|
||||||
|
stateStoreCircularRefs = {},
|
||||||
|
stateStoreIntentionalRefs = {},
|
||||||
|
contextCircularRefs = {},
|
||||||
|
contextIntentionalRefs = {},
|
||||||
|
issues = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if MemoryScanner._StateManager then
|
||||||
|
local internal = MemoryScanner._StateManager._getInternalState()
|
||||||
|
report.stateStoreCircularRefs, report.stateStoreIntentionalRefs =
|
||||||
|
detectCircularReferences(internal.stateStore, "stateStore")
|
||||||
|
end
|
||||||
|
|
||||||
|
if MemoryScanner._Context then
|
||||||
|
report.contextCircularRefs, report.contextIntentionalRefs =
|
||||||
|
detectCircularReferences(MemoryScanner._Context.topElements, "topElements")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Report issues only for cross-module circular references
|
||||||
|
if #report.stateStoreCircularRefs > 0 then
|
||||||
|
table.insert(report.issues, {
|
||||||
|
severity = "info",
|
||||||
|
message = string.format(
|
||||||
|
"Found %d cross-module circular references in StateManager",
|
||||||
|
#report.stateStoreCircularRefs
|
||||||
|
),
|
||||||
|
suggestion = "These are typically architectural dependencies between modules, not memory leaks",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
if #report.contextCircularRefs > 0 then
|
||||||
|
table.insert(report.issues, {
|
||||||
|
severity = "info",
|
||||||
|
message = string.format("Found %d cross-module circular references in Context", #report.contextCircularRefs),
|
||||||
|
suggestion = "These are typically architectural dependencies (e.g., layout engine ↔ renderer), not memory leaks",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
return report
|
||||||
|
end
|
||||||
|
|
||||||
|
---Run comprehensive memory scan
|
||||||
|
---@return table report Complete memory analysis report
|
||||||
|
function MemoryScanner.scan()
|
||||||
|
local startMemory = collectgarbage("count")
|
||||||
|
|
||||||
|
local report = {
|
||||||
|
timestamp = os.time(),
|
||||||
|
startMemory = startMemory / 1024, -- MB
|
||||||
|
stateManager = MemoryScanner.scanStateManager(),
|
||||||
|
context = MemoryScanner.scanContext(),
|
||||||
|
imageCache = MemoryScanner.scanImageCache(),
|
||||||
|
circularRefs = MemoryScanner.scanCircularReferences(),
|
||||||
|
summary = {
|
||||||
|
totalIssues = 0,
|
||||||
|
criticalIssues = 0,
|
||||||
|
warnings = 0,
|
||||||
|
info = 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Count issues by severity
|
||||||
|
local function countIssues(subReport)
|
||||||
|
for _, issue in ipairs(subReport.issues or {}) do
|
||||||
|
report.summary.totalIssues = report.summary.totalIssues + 1
|
||||||
|
if issue.severity == "error" then
|
||||||
|
report.summary.criticalIssues = report.summary.criticalIssues + 1
|
||||||
|
elseif issue.severity == "warning" then
|
||||||
|
report.summary.warnings = report.summary.warnings + 1
|
||||||
|
elseif issue.severity == "info" then
|
||||||
|
report.summary.info = report.summary.info + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
countIssues(report.stateManager)
|
||||||
|
countIssues(report.context)
|
||||||
|
countIssues(report.imageCache)
|
||||||
|
countIssues(report.circularRefs)
|
||||||
|
|
||||||
|
-- Force GC and measure freed memory
|
||||||
|
local beforeGC = collectgarbage("count")
|
||||||
|
collectgarbage("collect")
|
||||||
|
collectgarbage("collect")
|
||||||
|
local afterGC = collectgarbage("count")
|
||||||
|
|
||||||
|
report.gcAnalysis = {
|
||||||
|
beforeGC = beforeGC / 1024, -- MB
|
||||||
|
afterGC = afterGC / 1024, -- MB
|
||||||
|
freed = (beforeGC - afterGC) / 1024, -- MB
|
||||||
|
freedPercent = ((beforeGC - afterGC) / beforeGC) * 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Analyze GC effectiveness
|
||||||
|
if report.gcAnalysis.freedPercent < 5 then
|
||||||
|
table.insert(report.stateManager.issues, {
|
||||||
|
severity = "info",
|
||||||
|
message = string.format("GC freed only %.1f%% of memory", report.gcAnalysis.freedPercent),
|
||||||
|
suggestion = "Most memory is still referenced - this is normal if UI is active",
|
||||||
|
})
|
||||||
|
elseif report.gcAnalysis.freedPercent > 30 then
|
||||||
|
table.insert(report.stateManager.issues, {
|
||||||
|
severity = "warning",
|
||||||
|
message = string.format("GC freed %.1f%% of memory", report.gcAnalysis.freedPercent),
|
||||||
|
suggestion = "Significant memory was unreferenced - may indicate cleanup issues",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
return report
|
||||||
|
end
|
||||||
|
|
||||||
|
---Format report as human-readable string
|
||||||
|
---@param report table Memory scan report
|
||||||
|
---@return string formatted Formatted report
|
||||||
|
function MemoryScanner.formatReport(report)
|
||||||
|
local lines = {}
|
||||||
|
|
||||||
|
table.insert(lines, "=== FlexLöve Memory Scanner Report ===")
|
||||||
|
table.insert(lines, string.format("Timestamp: %s", os.date("%Y-%m-%d %H:%M:%S", report.timestamp)))
|
||||||
|
table.insert(lines, string.format("Memory: %.2f MB", report.startMemory))
|
||||||
|
table.insert(lines, "")
|
||||||
|
|
||||||
|
-- Summary
|
||||||
|
table.insert(lines, "--- Summary ---")
|
||||||
|
table.insert(lines, string.format("Total Issues: %d", report.summary.totalIssues))
|
||||||
|
table.insert(lines, string.format(" Critical: %d", report.summary.criticalIssues))
|
||||||
|
table.insert(lines, string.format(" Warnings: %d", report.summary.warnings))
|
||||||
|
table.insert(lines, string.format(" Info: %d", report.summary.info))
|
||||||
|
table.insert(lines, "")
|
||||||
|
|
||||||
|
-- StateManager
|
||||||
|
table.insert(lines, "--- StateManager ---")
|
||||||
|
table.insert(lines, string.format("State Count: %d", report.stateManager.stateCount))
|
||||||
|
table.insert(lines, string.format("State Store Size: %.2f KB", report.stateManager.stateStoreSize / 1024))
|
||||||
|
table.insert(lines, string.format("Metadata Size: %.2f KB", report.stateManager.metadataSize / 1024))
|
||||||
|
table.insert(lines, string.format("CallSite Counters: %.2f KB", report.stateManager.callSiteCounterSize / 1024))
|
||||||
|
table.insert(lines, string.format("Orphaned States: %d", #report.stateManager.orphanedStates))
|
||||||
|
table.insert(lines, string.format("Stale States: %d", #report.stateManager.staleStates))
|
||||||
|
table.insert(lines, string.format("Large States: %d", #report.stateManager.largeStates))
|
||||||
|
|
||||||
|
if #report.stateManager.issues > 0 then
|
||||||
|
table.insert(lines, "Issues:")
|
||||||
|
for _, issue in ipairs(report.stateManager.issues) do
|
||||||
|
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
|
||||||
|
if issue.suggestion then
|
||||||
|
table.insert(lines, string.format(" → %s", issue.suggestion))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.insert(lines, "")
|
||||||
|
|
||||||
|
-- Context
|
||||||
|
table.insert(lines, "--- Context ---")
|
||||||
|
table.insert(lines, string.format("Top Elements: %d", report.context.topElementCount))
|
||||||
|
table.insert(lines, string.format("Z-Index Elements: %d", report.context.zIndexElementCount))
|
||||||
|
table.insert(lines, string.format("Frame Elements: %d", report.context.frameElementCount))
|
||||||
|
|
||||||
|
if #report.context.issues > 0 then
|
||||||
|
table.insert(lines, "Issues:")
|
||||||
|
for _, issue in ipairs(report.context.issues) do
|
||||||
|
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
|
||||||
|
if issue.suggestion then
|
||||||
|
table.insert(lines, string.format(" → %s", issue.suggestion))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.insert(lines, "")
|
||||||
|
|
||||||
|
-- ImageCache
|
||||||
|
table.insert(lines, "--- ImageCache ---")
|
||||||
|
table.insert(lines, string.format("Image Count: %d", report.imageCache.imageCount))
|
||||||
|
table.insert(lines, string.format("Estimated Memory: %.2f MB", report.imageCache.estimatedMemory / 1024 / 1024))
|
||||||
|
|
||||||
|
if #report.imageCache.issues > 0 then
|
||||||
|
table.insert(lines, "Issues:")
|
||||||
|
for _, issue in ipairs(report.imageCache.issues) do
|
||||||
|
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
|
||||||
|
if issue.suggestion then
|
||||||
|
table.insert(lines, string.format(" → %s", issue.suggestion))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.insert(lines, "")
|
||||||
|
|
||||||
|
-- Circular References
|
||||||
|
table.insert(lines, "--- Circular References ---")
|
||||||
|
table.insert(lines, string.format("StateStore (Cross-module refs): %d", #report.circularRefs.stateStoreCircularRefs))
|
||||||
|
table.insert(
|
||||||
|
lines,
|
||||||
|
string.format(
|
||||||
|
"StateStore (Intentional - parent-child, modules, metatables): %d",
|
||||||
|
#report.circularRefs.stateStoreIntentionalRefs
|
||||||
|
)
|
||||||
|
)
|
||||||
|
table.insert(lines, string.format("Context (Cross-module refs): %d", #report.circularRefs.contextCircularRefs))
|
||||||
|
table.insert(
|
||||||
|
lines,
|
||||||
|
string.format(
|
||||||
|
"Context (Intentional - parent-child, modules, metatables): %d",
|
||||||
|
#report.circularRefs.contextIntentionalRefs
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if #report.circularRefs.issues > 0 then
|
||||||
|
table.insert(lines, "Issues:")
|
||||||
|
for _, issue in ipairs(report.circularRefs.issues) do
|
||||||
|
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
|
||||||
|
if issue.suggestion then
|
||||||
|
table.insert(lines, string.format(" → %s", issue.suggestion))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
table.insert(lines, " ✓ No unexpected circular references detected")
|
||||||
|
end
|
||||||
|
table.insert(lines, " Note: Cross-module refs are typically architectural dependencies, not memory leaks")
|
||||||
|
table.insert(lines, "")
|
||||||
|
|
||||||
|
-- GC Analysis
|
||||||
|
table.insert(lines, "--- Garbage Collection Analysis ---")
|
||||||
|
table.insert(lines, string.format("Before GC: %.2f MB", report.gcAnalysis.beforeGC))
|
||||||
|
table.insert(lines, string.format("After GC: %.2f MB", report.gcAnalysis.afterGC))
|
||||||
|
table.insert(lines, string.format("Freed: %.2f MB (%.1f%%)", report.gcAnalysis.freed, report.gcAnalysis.freedPercent))
|
||||||
|
table.insert(lines, "")
|
||||||
|
|
||||||
|
table.insert(lines, "=== End Report ===")
|
||||||
|
|
||||||
|
return table.concat(lines, "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
---Save report to file
|
||||||
|
---@param report table Memory scan report
|
||||||
|
---@param filename string? Output filename (default: memory_report.txt)
|
||||||
|
function MemoryScanner.saveReport(report, filename)
|
||||||
|
filename = filename or "memory_report.txt"
|
||||||
|
local formatted = MemoryScanner.formatReport(report)
|
||||||
|
|
||||||
|
local file = io.open(filename, "w")
|
||||||
|
if file then
|
||||||
|
file:write(formatted)
|
||||||
|
file:close()
|
||||||
|
if MemoryScanner._ErrorHandler then
|
||||||
|
MemoryScanner._ErrorHandler:warn("MemoryScanner", "RES_004", {
|
||||||
|
resourceType = "report",
|
||||||
|
path = filename,
|
||||||
|
status = "saved",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
else
|
||||||
|
if MemoryScanner._ErrorHandler then
|
||||||
|
MemoryScanner._ErrorHandler:warn("MemoryScanner", "RES_004", {
|
||||||
|
resourceType = "report",
|
||||||
|
path = filename,
|
||||||
|
status = "failed to save",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return MemoryScanner
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
---@class ModuleLoader
|
||||||
|
local ModuleLoader = {}
|
||||||
|
|
||||||
|
-- Module registry to track loaded vs. stub modules
|
||||||
|
ModuleLoader._registry = {}
|
||||||
|
ModuleLoader._ErrorHandler = nil
|
||||||
|
|
||||||
|
--- Initialize ModuleLoader with dependencies
|
||||||
|
---@param deps table
|
||||||
|
function ModuleLoader.init(deps)
|
||||||
|
ModuleLoader._ErrorHandler = deps.ErrorHandler
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Create a null-object stub for a missing optional module
|
||||||
|
--- Provides safe defaults that won't cause runtime errors
|
||||||
|
---@param moduleName string
|
||||||
|
---@return table
|
||||||
|
local function createNullObject(moduleName)
|
||||||
|
local stub = {
|
||||||
|
_isStub = true,
|
||||||
|
_moduleName = moduleName,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Common method stubs that return safe defaults
|
||||||
|
local metatable = {
|
||||||
|
__index = function(_, key)
|
||||||
|
-- Common initialization method
|
||||||
|
if key == "init" then
|
||||||
|
return function()
|
||||||
|
return stub
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Common constructor method
|
||||||
|
if key == "new" then
|
||||||
|
return function()
|
||||||
|
return stub
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Common draw method
|
||||||
|
if key == "draw" then
|
||||||
|
return function() end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Common update method
|
||||||
|
if key == "update" then
|
||||||
|
return function() end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Common render method
|
||||||
|
if key == "render" then
|
||||||
|
return function() end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Common cleanup method
|
||||||
|
if key == "destroy" then
|
||||||
|
return function() end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Common cleanup method
|
||||||
|
if key == "cleanup" then
|
||||||
|
return function() end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Common clear method
|
||||||
|
if key == "clear" then
|
||||||
|
return function() end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Common reset method
|
||||||
|
if key == "reset" then
|
||||||
|
return function() end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Common get method
|
||||||
|
if key == "get" then
|
||||||
|
return function()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Common set method
|
||||||
|
if key == "set" then
|
||||||
|
return function() end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Common load method
|
||||||
|
if key == "load" then
|
||||||
|
return function()
|
||||||
|
return stub
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Common cache-related methods
|
||||||
|
if key == "cache" or key == "getCache" or key == "clearCache" then
|
||||||
|
return function()
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- For any unknown method, return a no-op function that accepts any arguments
|
||||||
|
-- This allows safe method calls on stub objects (e.g., Performance:startFrame())
|
||||||
|
return function()
|
||||||
|
return stub
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
|
||||||
|
-- Make function calls safe (in case the stub itself is called)
|
||||||
|
__call = function()
|
||||||
|
return stub
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
setmetatable(stub, metatable)
|
||||||
|
return stub
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Safely require a module with graceful fallback for optional modules
|
||||||
|
--- Returns the module if it exists, or a null-object stub if it's optional and missing
|
||||||
|
--- Throws an error if a required module is missing
|
||||||
|
---@param modulePath string Full path to the module (e.g., "modules.Performance")
|
||||||
|
---@param isOptional boolean If true, returns null-object on failure; if false, throws error
|
||||||
|
---@return table module The loaded module or a null-object stub
|
||||||
|
function ModuleLoader.safeRequire(modulePath, isOptional)
|
||||||
|
-- Check if already loaded
|
||||||
|
if ModuleLoader._registry[modulePath] then
|
||||||
|
return ModuleLoader._registry[modulePath]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Attempt to load the module
|
||||||
|
local success, result = pcall(require, modulePath)
|
||||||
|
|
||||||
|
if success then
|
||||||
|
-- Module loaded successfully
|
||||||
|
ModuleLoader._registry[modulePath] = result
|
||||||
|
return result
|
||||||
|
else
|
||||||
|
-- Module failed to load
|
||||||
|
if isOptional then
|
||||||
|
-- Create null-object stub for optional module
|
||||||
|
local stub = createNullObject(modulePath)
|
||||||
|
ModuleLoader._registry[modulePath] = stub
|
||||||
|
|
||||||
|
-- Log warning about missing optional module
|
||||||
|
if ModuleLoader._ErrorHandler then
|
||||||
|
ModuleLoader._ErrorHandler:warn("ModuleLoader", "MOD_001", {
|
||||||
|
modulePath = modulePath,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
return stub
|
||||||
|
else
|
||||||
|
-- Required module is missing - throw error
|
||||||
|
error(string.format("Required module '%s' not found: %s", modulePath, tostring(result)))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Check if a module is actually loaded (not a stub)
|
||||||
|
---@param modulePath string Full path to the module
|
||||||
|
---@return boolean isLoaded True if module is loaded, false if it's a stub or not loaded
|
||||||
|
function ModuleLoader.isModuleLoaded(modulePath)
|
||||||
|
local module = ModuleLoader._registry[modulePath]
|
||||||
|
if not module then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if it's a stub
|
||||||
|
return not module._isStub
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get list of all loaded modules
|
||||||
|
---@return table modules List of module paths that are actually loaded (not stubs)
|
||||||
|
function ModuleLoader.getLoadedModules()
|
||||||
|
local loaded = {}
|
||||||
|
for path, module in pairs(ModuleLoader._registry) do
|
||||||
|
if not module._isStub then
|
||||||
|
table.insert(loaded, path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return loaded
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get list of all stub modules
|
||||||
|
---@return table stubs List of module paths that are stubs
|
||||||
|
function ModuleLoader.getStubModules()
|
||||||
|
local stubs = {}
|
||||||
|
for path, module in pairs(ModuleLoader._registry) do
|
||||||
|
if module._isStub then
|
||||||
|
table.insert(stubs, path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return stubs
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Clear the module registry (useful for testing)
|
||||||
|
function ModuleLoader._clearRegistry()
|
||||||
|
ModuleLoader._registry = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
return ModuleLoader
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
local modulePath = (...):match("(.-)[^%.]+$")
|
||||||
|
local ImageScaler = require(modulePath .. "ImageScaler")
|
||||||
|
|
||||||
|
local NinePatch = {}
|
||||||
|
|
||||||
|
-- ErrorHandler will be injected via init
|
||||||
|
local ErrorHandler = nil
|
||||||
|
|
||||||
|
--- Initialize NinePatch with dependencies
|
||||||
|
---@param deps table Dependencies table with ErrorHandler
|
||||||
|
function NinePatch.init(deps)
|
||||||
|
if deps and deps.ErrorHandler then
|
||||||
|
ErrorHandler = deps.ErrorHandler
|
||||||
|
end
|
||||||
|
-- Also initialize ImageScaler since it's a dependency
|
||||||
|
if ImageScaler.init then
|
||||||
|
ImageScaler.init(deps)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Draw a 9-patch component using Android-style rendering
|
||||||
|
--- Corners are scaled by scaleCorners multiplier, edges stretch in one dimension only
|
||||||
|
---@param component ThemeComponent
|
||||||
|
---@param atlas love.Image
|
||||||
|
---@param x number -- X position (top-left corner)
|
||||||
|
---@param y number -- Y position (top-left corner)
|
||||||
|
---@param width number -- Total width (border-box)
|
||||||
|
---@param height number -- Total height (border-box)
|
||||||
|
---@param opacity number?
|
||||||
|
---@param elementScaleCorners number? -- Element-level override for scaleCorners (scale multiplier)
|
||||||
|
---@param elementScalingAlgorithm "nearest"|"bilinear"? -- Element-level override for scalingAlgorithm
|
||||||
|
function NinePatch.draw(component, atlas, x, y, width, height, opacity, elementScaleCorners, elementScalingAlgorithm)
|
||||||
|
if not component or not atlas then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
opacity = opacity or 1
|
||||||
|
love.graphics.setColor(1, 1, 1, opacity)
|
||||||
|
|
||||||
|
local regions = component.regions
|
||||||
|
|
||||||
|
-- Extract border dimensions from regions (in pixels)
|
||||||
|
local left = regions.topLeft.w
|
||||||
|
local right = regions.topRight.w
|
||||||
|
local top = regions.topLeft.h
|
||||||
|
local bottom = regions.bottomLeft.h
|
||||||
|
local centerW = regions.middleCenter.w
|
||||||
|
local centerH = regions.middleCenter.h
|
||||||
|
|
||||||
|
-- Calculate content area (space remaining after borders)
|
||||||
|
local contentWidth = width - left - right
|
||||||
|
local contentHeight = height - top - bottom
|
||||||
|
|
||||||
|
-- Clamp to prevent negative dimensions
|
||||||
|
contentWidth = math.max(0, contentWidth)
|
||||||
|
contentHeight = math.max(0, contentHeight)
|
||||||
|
|
||||||
|
-- Calculate stretch scales for edges and center
|
||||||
|
local scaleX = contentWidth / centerW
|
||||||
|
local scaleY = contentHeight / centerH
|
||||||
|
|
||||||
|
-- Create quads for each region
|
||||||
|
local atlasWidth, atlasHeight = atlas:getDimensions()
|
||||||
|
|
||||||
|
local function makeQuad(region)
|
||||||
|
return love.graphics.newQuad(region.x, region.y, region.w, region.h, atlasWidth, atlasHeight)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get corner scale multiplier
|
||||||
|
-- Priority: element-level override > component setting > default (nil = no scaling)
|
||||||
|
local scaleCorners = elementScaleCorners
|
||||||
|
if scaleCorners == nil then
|
||||||
|
scaleCorners = component.scaleCorners
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Priority: element-level override > component setting > default ("bilinear")
|
||||||
|
local scalingAlgorithm = elementScalingAlgorithm
|
||||||
|
if scalingAlgorithm == nil then
|
||||||
|
scalingAlgorithm = component.scalingAlgorithm or "bilinear"
|
||||||
|
end
|
||||||
|
|
||||||
|
if scaleCorners and type(scaleCorners) == "number" and scaleCorners > 0 then
|
||||||
|
-- Initialize cache if needed
|
||||||
|
if not component._scaledRegionCache then
|
||||||
|
component._scaledRegionCache = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Use the numeric scale multiplier directly
|
||||||
|
local scaleFactor = scaleCorners
|
||||||
|
|
||||||
|
-- Helper to get or create scaled region
|
||||||
|
local function getScaledRegion(regionName, region, targetWidth, targetHeight)
|
||||||
|
local cacheKey = string.format("%s_%.2f_%s", regionName, scaleFactor, scalingAlgorithm)
|
||||||
|
|
||||||
|
if component._scaledRegionCache[cacheKey] then
|
||||||
|
return component._scaledRegionCache[cacheKey]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get ImageData from component (stored during theme loading)
|
||||||
|
local atlasData = component._loadedAtlasData
|
||||||
|
if not atlasData then
|
||||||
|
ErrorHandler.error(
|
||||||
|
"NinePatch",
|
||||||
|
"REN_007",
|
||||||
|
"No ImageData available for atlas. Image must be loaded with safeLoadImage.",
|
||||||
|
{
|
||||||
|
componentType = component.type,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
local scaledData
|
||||||
|
|
||||||
|
if scalingAlgorithm == "nearest" then
|
||||||
|
scaledData =
|
||||||
|
ImageScaler.scaleNearest(atlasData, region.x, region.y, region.w, region.h, targetWidth, targetHeight)
|
||||||
|
else
|
||||||
|
scaledData =
|
||||||
|
ImageScaler.scaleBilinear(atlasData, region.x, region.y, region.w, region.h, targetWidth, targetHeight)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Convert to image and cache
|
||||||
|
local scaledImage = love.graphics.newImage(scaledData)
|
||||||
|
component._scaledRegionCache[cacheKey] = scaledImage
|
||||||
|
|
||||||
|
return scaledImage
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Calculate scaled dimensions for corners
|
||||||
|
local scaledLeft = math.floor(left * scaleFactor + 0.5)
|
||||||
|
local scaledRight = math.floor(right * scaleFactor + 0.5)
|
||||||
|
local scaledTop = math.floor(top * scaleFactor + 0.5)
|
||||||
|
local scaledBottom = math.floor(bottom * scaleFactor + 0.5)
|
||||||
|
|
||||||
|
-- CORNERS (scaled using algorithm)
|
||||||
|
local topLeftScaled = getScaledRegion("topLeft", regions.topLeft, scaledLeft, scaledTop)
|
||||||
|
local topRightScaled = getScaledRegion("topRight", regions.topRight, scaledRight, scaledTop)
|
||||||
|
local bottomLeftScaled = getScaledRegion("bottomLeft", regions.bottomLeft, scaledLeft, scaledBottom)
|
||||||
|
local bottomRightScaled = getScaledRegion("bottomRight", regions.bottomRight, scaledRight, scaledBottom)
|
||||||
|
|
||||||
|
love.graphics.draw(topLeftScaled, x, y)
|
||||||
|
love.graphics.draw(topRightScaled, x + width - scaledRight, y)
|
||||||
|
love.graphics.draw(bottomLeftScaled, x, y + height - scaledBottom)
|
||||||
|
love.graphics.draw(bottomRightScaled, x + width - scaledRight, y + height - scaledBottom)
|
||||||
|
|
||||||
|
-- Update content dimensions to account for scaled borders
|
||||||
|
local adjustedContentWidth = width - scaledLeft - scaledRight
|
||||||
|
local adjustedContentHeight = height - scaledTop - scaledBottom
|
||||||
|
adjustedContentWidth = math.max(0, adjustedContentWidth)
|
||||||
|
adjustedContentHeight = math.max(0, adjustedContentHeight)
|
||||||
|
|
||||||
|
-- Recalculate stretch scales
|
||||||
|
local adjustedScaleX = adjustedContentWidth / centerW
|
||||||
|
local adjustedScaleY = adjustedContentHeight / centerH
|
||||||
|
|
||||||
|
-- TOP/BOTTOM EDGES (stretch horizontally, scale vertically)
|
||||||
|
if adjustedContentWidth > 0 then
|
||||||
|
local topCenterScaled = getScaledRegion("topCenter", regions.topCenter, regions.topCenter.w, scaledTop)
|
||||||
|
local bottomCenterScaled =
|
||||||
|
getScaledRegion("bottomCenter", regions.bottomCenter, regions.bottomCenter.w, scaledBottom)
|
||||||
|
|
||||||
|
love.graphics.draw(topCenterScaled, x + scaledLeft, y, 0, adjustedScaleX, 1)
|
||||||
|
love.graphics.draw(bottomCenterScaled, x + scaledLeft, y + height - scaledBottom, 0, adjustedScaleX, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- LEFT/RIGHT EDGES (stretch vertically, scale horizontally)
|
||||||
|
if adjustedContentHeight > 0 then
|
||||||
|
local middleLeftScaled = getScaledRegion("middleLeft", regions.middleLeft, scaledLeft, regions.middleLeft.h)
|
||||||
|
local middleRightScaled = getScaledRegion("middleRight", regions.middleRight, scaledRight, regions.middleRight.h)
|
||||||
|
|
||||||
|
love.graphics.draw(middleLeftScaled, x, y + scaledTop, 0, 1, adjustedScaleY)
|
||||||
|
love.graphics.draw(middleRightScaled, x + width - scaledRight, y + scaledTop, 0, 1, adjustedScaleY)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- CENTER (stretch both dimensions, no scaling)
|
||||||
|
if adjustedContentWidth > 0 and adjustedContentHeight > 0 then
|
||||||
|
love.graphics.draw(
|
||||||
|
atlas,
|
||||||
|
makeQuad(regions.middleCenter),
|
||||||
|
x + scaledLeft,
|
||||||
|
y + scaledTop,
|
||||||
|
0,
|
||||||
|
adjustedScaleX,
|
||||||
|
adjustedScaleY
|
||||||
|
)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
-- Original rendering logic (no scaling)
|
||||||
|
-- CORNERS (no scaling - 1:1 pixel perfect)
|
||||||
|
love.graphics.draw(atlas, makeQuad(regions.topLeft), x, y)
|
||||||
|
love.graphics.draw(atlas, makeQuad(regions.topRight), x + left + contentWidth, y)
|
||||||
|
love.graphics.draw(atlas, makeQuad(regions.bottomLeft), x, y + top + contentHeight)
|
||||||
|
love.graphics.draw(atlas, makeQuad(regions.bottomRight), x + left + contentWidth, y + top + contentHeight)
|
||||||
|
|
||||||
|
-- TOP/BOTTOM EDGES (stretch horizontally only)
|
||||||
|
if contentWidth > 0 then
|
||||||
|
love.graphics.draw(atlas, makeQuad(regions.topCenter), x + left, y, 0, scaleX, 1)
|
||||||
|
love.graphics.draw(atlas, makeQuad(regions.bottomCenter), x + left, y + top + contentHeight, 0, scaleX, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- LEFT/RIGHT EDGES (stretch vertically only)
|
||||||
|
if contentHeight > 0 then
|
||||||
|
love.graphics.draw(atlas, makeQuad(regions.middleLeft), x, y + top, 0, 1, scaleY)
|
||||||
|
love.graphics.draw(atlas, makeQuad(regions.middleRight), x + left + contentWidth, y + top, 0, 1, scaleY)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- CENTER (stretch both dimensions)
|
||||||
|
if contentWidth > 0 and contentHeight > 0 then
|
||||||
|
love.graphics.draw(atlas, makeQuad(regions.middleCenter), x + left, y + top, 0, scaleX, scaleY)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Reset color
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
return NinePatch
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
local modulePath = (...):match("(.-)[^%.]+$")
|
||||||
|
local function req(name)
|
||||||
|
return require(modulePath .. name)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- All numeric, range, type, and enum validation lives here.
|
||||||
|
-- `clamp` is injected via init() to avoid a cross-import into utils.
|
||||||
|
-- `ErrorHandler` is injected via init() so error reporting routes through
|
||||||
|
-- the shared handler (matching the pre-split behavior of utils.validate*).
|
||||||
|
|
||||||
|
local ErrorHandler = nil
|
||||||
|
local clamp = nil
|
||||||
|
|
||||||
|
--- Initialize dependencies
|
||||||
|
---@param deps table Dependencies: { ErrorHandler = table, clamp = function }
|
||||||
|
local function init(deps)
|
||||||
|
if type(deps) == "table" then
|
||||||
|
ErrorHandler = deps.ErrorHandler or ErrorHandler
|
||||||
|
clamp = deps.clamp or clamp
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Numeric validation utilities
|
||||||
|
|
||||||
|
--- Check if a value is NaN (not-a-number)
|
||||||
|
--- @param value any Value to check
|
||||||
|
--- @return boolean
|
||||||
|
local function isNaN(value)
|
||||||
|
return type(value) == "number" and value ~= value
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Check if a value is Infinity
|
||||||
|
--- @param value any Value to check
|
||||||
|
--- @return boolean
|
||||||
|
local function isInfinity(value)
|
||||||
|
return type(value) == "number" and (value == math.huge or value == -math.huge)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Validate a numeric value with comprehensive checks
|
||||||
|
--- @param value any Value to validate
|
||||||
|
--- @param options table? Validation options
|
||||||
|
--- @return boolean, string?, number? Returns valid, errorMessage, sanitizedValue
|
||||||
|
local function validateNumber(value, options)
|
||||||
|
options = options or {}
|
||||||
|
|
||||||
|
-- Check if value is a number type
|
||||||
|
if type(value) ~= "number" then
|
||||||
|
if options.default ~= nil then
|
||||||
|
return true, nil, options.default
|
||||||
|
end
|
||||||
|
return false, string.format("Value must be a number, got %s", type(value)), nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for NaN
|
||||||
|
if isNaN(value) then
|
||||||
|
if not options.allowNaN then
|
||||||
|
if options.default ~= nil then
|
||||||
|
return true, nil, options.default
|
||||||
|
end
|
||||||
|
return false, "Value is NaN (not-a-number)", nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for Infinity
|
||||||
|
if isInfinity(value) then
|
||||||
|
if not options.allowInfinity then
|
||||||
|
if options.default ~= nil then
|
||||||
|
return true, nil, options.default
|
||||||
|
end
|
||||||
|
return false, "Value is Infinity", nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for integer requirement
|
||||||
|
if options.integer and math.floor(value) ~= value then
|
||||||
|
return false, string.format("Value must be an integer, got %s", value), nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for positive requirement
|
||||||
|
if options.positive and value <= 0 then
|
||||||
|
return false, string.format("Value must be positive, got %s", value), nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check bounds
|
||||||
|
if options.min and value < options.min then
|
||||||
|
return false, string.format("Value %s is below minimum %s", value, options.min), nil
|
||||||
|
end
|
||||||
|
|
||||||
|
if options.max and value > options.max then
|
||||||
|
return false, string.format("Value %s is above maximum %s", value, options.max), nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return true, nil, value
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Sanitize a numeric value (never errors, always returns valid number)
|
||||||
|
--- @param value any Value to sanitize
|
||||||
|
--- @param min number? Minimum value
|
||||||
|
--- @param max number? Maximum value
|
||||||
|
--- @param default number? Default value for invalid inputs
|
||||||
|
--- @return number Sanitized value
|
||||||
|
local function sanitizeNumber(value, min, max, default)
|
||||||
|
default = default or 0
|
||||||
|
min = min or -math.huge
|
||||||
|
max = max or math.huge
|
||||||
|
|
||||||
|
-- Convert to number if possible
|
||||||
|
if type(value) == "string" then
|
||||||
|
value = tonumber(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Handle non-numeric
|
||||||
|
if type(value) ~= "number" then
|
||||||
|
return default
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Handle NaN
|
||||||
|
if isNaN(value) then
|
||||||
|
return default
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Handle Infinity
|
||||||
|
if value == math.huge then
|
||||||
|
return max
|
||||||
|
end
|
||||||
|
if value == -math.huge then
|
||||||
|
return min
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Clamp to range
|
||||||
|
return clamp(value, min, max)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Validate and convert to integer
|
||||||
|
--- @param value any Value to validate
|
||||||
|
--- @param min number? Minimum value
|
||||||
|
--- @param max number? Maximum value
|
||||||
|
--- @return boolean, string?, number? Returns valid, errorMessage, integerValue
|
||||||
|
local function validateInteger(value, min, max)
|
||||||
|
local valid, err, sanitized = validateNumber(value, {
|
||||||
|
min = min,
|
||||||
|
max = max,
|
||||||
|
integer = true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not valid then
|
||||||
|
return false, err, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return true, nil, math.floor(sanitized or value)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Validate and normalize percentage value
|
||||||
|
--- @param value any Value to validate (can be "50%", 0.5, or 50)
|
||||||
|
--- @return boolean, string?, number? Returns valid, errorMessage, normalizedValue (0-1)
|
||||||
|
local function validatePercentage(value)
|
||||||
|
-- Handle string percentage
|
||||||
|
if type(value) == "string" then
|
||||||
|
local num = value:match("^(%d+%.?%d*)%%$")
|
||||||
|
if num then
|
||||||
|
value = tonumber(num)
|
||||||
|
if value then
|
||||||
|
value = value / 100
|
||||||
|
end
|
||||||
|
else
|
||||||
|
value = tonumber(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(value) ~= "number" then
|
||||||
|
return false, "Percentage must be a number", nil
|
||||||
|
end
|
||||||
|
|
||||||
|
if isNaN(value) or isInfinity(value) then
|
||||||
|
return false, "Percentage cannot be NaN or Infinity", nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- If value is > 1, assume it's 0-100 range
|
||||||
|
if value > 1 then
|
||||||
|
value = value / 100
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Clamp to 0-1
|
||||||
|
value = clamp(value, 0, 1)
|
||||||
|
|
||||||
|
return true, nil, value
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Validate opacity value (0-1)
|
||||||
|
--- @param value any Value to validate
|
||||||
|
--- @return boolean, string?, number? Returns valid, errorMessage, opacityValue
|
||||||
|
local function validateOpacity(value)
|
||||||
|
return validateNumber(value, { min = 0, max = 1, default = 1 })
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Validate degree value (0-360)
|
||||||
|
--- @param value any Value to validate
|
||||||
|
--- @return boolean, string?, number? Returns valid, errorMessage, degreeValue
|
||||||
|
local function validateDegrees(value)
|
||||||
|
local valid, err, sanitized = validateNumber(value)
|
||||||
|
if not valid then
|
||||||
|
return false, err, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Normalize to 0-360 range
|
||||||
|
local degrees = sanitized or value
|
||||||
|
degrees = degrees % 360
|
||||||
|
if degrees < 0 then
|
||||||
|
degrees = degrees + 360
|
||||||
|
end
|
||||||
|
|
||||||
|
return true, nil, degrees
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Validate coordinate value (pixel position)
|
||||||
|
--- @param value any Value to validate
|
||||||
|
--- @return boolean, string?, number? Returns valid, errorMessage, coordinateValue
|
||||||
|
local function validateCoordinate(value)
|
||||||
|
return validateNumber(value, {
|
||||||
|
allowNaN = false,
|
||||||
|
allowInfinity = false,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Validate dimension value (width/height, must be non-negative)
|
||||||
|
--- @param value any Value to validate
|
||||||
|
--- @return boolean, string?, number? Returns valid, errorMessage, dimensionValue
|
||||||
|
local function validateDimension(value)
|
||||||
|
return validateNumber(value, {
|
||||||
|
min = 0,
|
||||||
|
allowNaN = false,
|
||||||
|
allowInfinity = false,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Validate that a value is in an enum table
|
||||||
|
---@param value any Value to validate
|
||||||
|
---@param enumTable table Enum table with valid values
|
||||||
|
---@param propName string Property name for error messages
|
||||||
|
---@param moduleName string? Module name for error messages (default: "Element")
|
||||||
|
---@return boolean True if valid
|
||||||
|
local function validateEnum(value, enumTable, propName, moduleName)
|
||||||
|
if value == nil then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, validValue in pairs(enumTable) do
|
||||||
|
if value == validValue then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Build list of valid options
|
||||||
|
local validOptions = {}
|
||||||
|
for _, v in pairs(enumTable) do
|
||||||
|
table.insert(validOptions, "'" .. v .. "'")
|
||||||
|
end
|
||||||
|
table.sort(validOptions)
|
||||||
|
|
||||||
|
if ErrorHandler then
|
||||||
|
ErrorHandler:error(moduleName or "Element", "VAL_007", {
|
||||||
|
property = propName,
|
||||||
|
expected = table.concat(validOptions, ", "),
|
||||||
|
got = tostring(value),
|
||||||
|
})
|
||||||
|
else
|
||||||
|
error(
|
||||||
|
string.format("%s must be one of: %s. Got: '%s'", propName, table.concat(validOptions, ", "), tostring(value))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Validate that a numeric value is within a range
|
||||||
|
---@param value any Value to validate
|
||||||
|
---@param min number Minimum allowed value
|
||||||
|
---@param max number Maximum allowed value
|
||||||
|
---@param propName string Property name for error messages
|
||||||
|
---@param moduleName string? Module name for error messages (default: "Element")
|
||||||
|
---@return boolean True if valid
|
||||||
|
local function validateRange(value, min, max, propName, moduleName)
|
||||||
|
if value == nil then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if type(value) ~= "number" then
|
||||||
|
if ErrorHandler then
|
||||||
|
ErrorHandler:error(moduleName or "Element", "VAL_001", {
|
||||||
|
property = propName,
|
||||||
|
expected = "number",
|
||||||
|
got = type(value),
|
||||||
|
})
|
||||||
|
else
|
||||||
|
error(string.format("%s must be a number, got %s", propName, type(value)))
|
||||||
|
end
|
||||||
|
elseif value < min or value > max then
|
||||||
|
if ErrorHandler then
|
||||||
|
ErrorHandler:error(moduleName or "Element", "VAL_002", {
|
||||||
|
property = propName,
|
||||||
|
min = tostring(min),
|
||||||
|
max = tostring(max),
|
||||||
|
value = tostring(value),
|
||||||
|
})
|
||||||
|
else
|
||||||
|
error(
|
||||||
|
string.format("%s must be between %s and %s, got %s", propName, tostring(min), tostring(max), tostring(value))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Validate that a value is of the expected type
|
||||||
|
---@param value any Value to validate
|
||||||
|
---@param expectedType string Expected type name
|
||||||
|
---@param propName string Property name for error messages
|
||||||
|
---@param moduleName string? Module name for error messages (default: "Element")
|
||||||
|
---@return boolean True if valid
|
||||||
|
local function validateType(value, expectedType, propName, moduleName)
|
||||||
|
if value == nil then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local actualType = type(value)
|
||||||
|
if actualType ~= expectedType then
|
||||||
|
if ErrorHandler then
|
||||||
|
ErrorHandler:error(moduleName or "Element", "VAL_001", {
|
||||||
|
property = propName,
|
||||||
|
expected = expectedType,
|
||||||
|
got = actualType,
|
||||||
|
})
|
||||||
|
else
|
||||||
|
error(string.format("%s must be %s, got %s", propName, expectedType, actualType))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
init = init,
|
||||||
|
isNaN = isNaN,
|
||||||
|
isInfinity = isInfinity,
|
||||||
|
validateNumber = validateNumber,
|
||||||
|
sanitizeNumber = sanitizeNumber,
|
||||||
|
validateInteger = validateInteger,
|
||||||
|
validatePercentage = validatePercentage,
|
||||||
|
validateOpacity = validateOpacity,
|
||||||
|
validateDegrees = validateDegrees,
|
||||||
|
validateCoordinate = validateCoordinate,
|
||||||
|
validateDimension = validateDimension,
|
||||||
|
validateEnum = validateEnum,
|
||||||
|
validateRange = validateRange,
|
||||||
|
validateType = validateType,
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
local modulePath = (...):match("(.-)[^%.]+$")
|
||||||
|
local function req(name)
|
||||||
|
return require(modulePath .. name)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Path sanitization, validation, and file-extension helpers.
|
||||||
|
-- Uses love.filesystem when available (optional) for existence checks.
|
||||||
|
|
||||||
|
--- Normalize a file path for consistent cache keys
|
||||||
|
---@param path string File path to normalize
|
||||||
|
---@return string Normalized path
|
||||||
|
local function normalizePath(path)
|
||||||
|
path = path:match("^%s*(.-)%s*$")
|
||||||
|
path = path:gsub("\\", "/")
|
||||||
|
path = path:gsub("/+", "/")
|
||||||
|
return path
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Sanitize a file path
|
||||||
|
--- @param path string Path to sanitize
|
||||||
|
--- @return string Sanitized path
|
||||||
|
local function sanitizePath(path)
|
||||||
|
if path == nil then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
path = tostring(path)
|
||||||
|
|
||||||
|
-- Trim whitespace
|
||||||
|
path = path:match("^%s*(.-)%s*$") or ""
|
||||||
|
|
||||||
|
-- Normalize separators to forward slash
|
||||||
|
path = path:gsub("\\", "/")
|
||||||
|
|
||||||
|
-- Remove duplicate slashes
|
||||||
|
path = path:gsub("/+", "/")
|
||||||
|
|
||||||
|
-- Remove trailing slash (except for root)
|
||||||
|
if #path > 1 and path:sub(-1) == "/" then
|
||||||
|
path = path:sub(1, -2)
|
||||||
|
end
|
||||||
|
|
||||||
|
return path
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Check if a path is safe (no traversal attacks)
|
||||||
|
--- @param path string Path to check
|
||||||
|
--- @param baseDir string? Base directory to check against (optional)
|
||||||
|
--- @return boolean, string? Returns true if safe, or false with reason
|
||||||
|
local function isPathSafe(path, baseDir)
|
||||||
|
if path == nil or path == "" then
|
||||||
|
return false, "Path is empty"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Sanitize the path
|
||||||
|
path = sanitizePath(path)
|
||||||
|
|
||||||
|
-- Check for suspicious patterns
|
||||||
|
if path:match("%.%.") then
|
||||||
|
return false, "Path contains '..' (parent directory reference)"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for null bytes
|
||||||
|
if path:match("%z") then
|
||||||
|
return false, "Path contains null bytes"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for encoded traversal attempts (including double-encoding)
|
||||||
|
local lowerPath = path:lower()
|
||||||
|
if
|
||||||
|
lowerPath:match("%%2e")
|
||||||
|
or lowerPath:match("%%2f")
|
||||||
|
or lowerPath:match("%%5c")
|
||||||
|
or lowerPath:match("%%252e")
|
||||||
|
or lowerPath:match("%%252f")
|
||||||
|
or lowerPath:match("%%255c")
|
||||||
|
then
|
||||||
|
return false, "Path contains URL-encoded directory separators"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- If baseDir is provided, ensure path is within it
|
||||||
|
if baseDir then
|
||||||
|
baseDir = sanitizePath(baseDir)
|
||||||
|
|
||||||
|
-- For relative paths, prepend baseDir
|
||||||
|
local fullPath = path
|
||||||
|
if not path:match("^/") and not path:match("^%a:") then
|
||||||
|
fullPath = baseDir .. "/" .. path
|
||||||
|
end
|
||||||
|
fullPath = sanitizePath(fullPath)
|
||||||
|
|
||||||
|
-- Check if fullPath starts with baseDir
|
||||||
|
if not fullPath:match("^" .. baseDir:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%1")) then
|
||||||
|
return false, "Path is outside allowed directory"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Validate a file path with comprehensive checks
|
||||||
|
--- @param path string Path to validate
|
||||||
|
--- @param options table? Validation options
|
||||||
|
--- @return boolean, string? Returns true if valid, or false with error message
|
||||||
|
local function validatePath(path, options)
|
||||||
|
options = options or {}
|
||||||
|
|
||||||
|
-- Check path is not nil/empty
|
||||||
|
if path == nil or path == "" then
|
||||||
|
return false, "Path is empty"
|
||||||
|
end
|
||||||
|
|
||||||
|
path = tostring(path)
|
||||||
|
|
||||||
|
-- Check maximum length
|
||||||
|
local maxLength = options.maxLength or 4096
|
||||||
|
if #path > maxLength then
|
||||||
|
return false, string.format("Path exceeds maximum length of %d characters", maxLength)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Sanitize path
|
||||||
|
path = sanitizePath(path)
|
||||||
|
|
||||||
|
-- Check for safety (traversal attacks)
|
||||||
|
local safe, reason = isPathSafe(path, options.baseDir)
|
||||||
|
if not safe then
|
||||||
|
return false, reason
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check allowed extensions
|
||||||
|
if options.allowedExtensions then
|
||||||
|
local ext = path:match("%.([^%.]+)$")
|
||||||
|
if not ext then
|
||||||
|
return false, "Path has no file extension"
|
||||||
|
end
|
||||||
|
|
||||||
|
ext = ext:lower()
|
||||||
|
local allowed = false
|
||||||
|
for _, allowedExt in ipairs(options.allowedExtensions) do
|
||||||
|
if ext == allowedExt:lower() then
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not allowed then
|
||||||
|
return false, string.format("File extension '%s' is not allowed", ext)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if file must exist
|
||||||
|
if options.mustExist and love and love.filesystem then
|
||||||
|
local info = love.filesystem.getInfo(path)
|
||||||
|
if not info then
|
||||||
|
return false, "File does not exist"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get file extension from path
|
||||||
|
--- @param path string File path
|
||||||
|
--- @return string? extension File extension (lowercase) or nil
|
||||||
|
local function getFileExtension(path)
|
||||||
|
if not path then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local ext = path:match("%.([^%.]+)$")
|
||||||
|
return ext and ext:lower() or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Check if path has allowed extension
|
||||||
|
--- @param path string File path
|
||||||
|
--- @param allowedExtensions table Array of allowed extensions
|
||||||
|
--- @return boolean
|
||||||
|
local function hasAllowedExtension(path, allowedExtensions)
|
||||||
|
local ext = getFileExtension(path)
|
||||||
|
if not ext then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, allowedExt in ipairs(allowedExtensions) do
|
||||||
|
if ext == allowedExt:lower() then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
normalizePath = normalizePath,
|
||||||
|
sanitizePath = sanitizePath,
|
||||||
|
isPathSafe = isPathSafe,
|
||||||
|
validatePath = validatePath,
|
||||||
|
getFileExtension = getFileExtension,
|
||||||
|
hasAllowedExtension = hasAllowedExtension,
|
||||||
|
}
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
---@class Performance
|
||||||
|
---@field enabled boolean
|
||||||
|
---@field hudEnabled boolean
|
||||||
|
---@field hudToggleKey string
|
||||||
|
---@field hudPosition {x: number, y: number}
|
||||||
|
---@field warningThresholdMs number
|
||||||
|
---@field criticalThresholdMs number
|
||||||
|
---@field logToConsole boolean
|
||||||
|
---@field logWarnings boolean
|
||||||
|
---@field warningsEnabled boolean
|
||||||
|
---@field _ErrorHandler table?
|
||||||
|
---@field _timers table
|
||||||
|
---@field _metrics table
|
||||||
|
---@field _lastMetricsCleanup number
|
||||||
|
---@field _frameMetrics table
|
||||||
|
---@field _memoryMetrics table
|
||||||
|
---@field _warnings table
|
||||||
|
---@field _lastFrameStart number?
|
||||||
|
---@field _shownWarnings table
|
||||||
|
---@field _memoryProfiler table
|
||||||
|
local Performance = {}
|
||||||
|
Performance.__index = Performance
|
||||||
|
|
||||||
|
---@type Performance|nil
|
||||||
|
local instance = nil
|
||||||
|
|
||||||
|
local METRICS_CLEANUP_INTERVAL = 30
|
||||||
|
local METRICS_RETENTION_TIME = 10
|
||||||
|
local MAX_METRICS_COUNT = 500
|
||||||
|
local CORE_METRICS = { frame = true, layout = true, render = true }
|
||||||
|
|
||||||
|
---@param config {enabled?: boolean, hudEnabled?: boolean, hudToggleKey?: string, hudPosition?: {x: number, y: number}, warningThresholdMs?: number, criticalThresholdMs?: number, logToConsole?: boolean, logWarnings?: boolean, warningsEnabled?: boolean, memoryProfiling?: boolean}?
|
||||||
|
---@param deps {ErrorHandler: ErrorHandler}
|
||||||
|
---@return Performance
|
||||||
|
function Performance.init(config, deps)
|
||||||
|
if instance == nil then
|
||||||
|
local self = setmetatable({}, Performance)
|
||||||
|
|
||||||
|
-- Configuration
|
||||||
|
self.enabled = config and config.enabled or false
|
||||||
|
self.hudEnabled = config and config.hudEnabled or false
|
||||||
|
self.hudToggleKey = config and config.hudToggleKey or "f3"
|
||||||
|
self.hudPosition = config and config.hudPosition or { x = 10, y = 10 }
|
||||||
|
self.warningThresholdMs = config and config.warningThresholdMs or 13.0
|
||||||
|
self.criticalThresholdMs = config and config.criticalThresholdMs or 16.67
|
||||||
|
self.logToConsole = config and config.logToConsole or false
|
||||||
|
self.logWarnings = config and config.logWarnings or true
|
||||||
|
self.warningsEnabled = config and config.warningsEnabled or true
|
||||||
|
|
||||||
|
self._timers = {}
|
||||||
|
self._metrics = {}
|
||||||
|
self._lastMetricsCleanup = 0
|
||||||
|
self._frameMetrics = {
|
||||||
|
frameCount = 0,
|
||||||
|
totalTime = 0,
|
||||||
|
lastFrameTime = 0,
|
||||||
|
minFrameTime = math.huge,
|
||||||
|
maxFrameTime = 0,
|
||||||
|
fps = 0,
|
||||||
|
lastFpsUpdate = 0,
|
||||||
|
fpsUpdateInterval = 0.5,
|
||||||
|
}
|
||||||
|
self._memoryMetrics = {
|
||||||
|
current = 0,
|
||||||
|
peak = 0,
|
||||||
|
gcCount = 0,
|
||||||
|
lastGcCheck = 0,
|
||||||
|
}
|
||||||
|
self._warnings = {}
|
||||||
|
self._lastFrameStart = nil
|
||||||
|
self._shownWarnings = {}
|
||||||
|
self._memoryProfiler = {
|
||||||
|
enabled = config and config.memoryProfiling or false,
|
||||||
|
sampleInterval = 60,
|
||||||
|
framesSinceLastSample = 0,
|
||||||
|
samples = {},
|
||||||
|
maxSamples = 20,
|
||||||
|
monitoredTables = {},
|
||||||
|
}
|
||||||
|
self._ErrorHandler = deps and deps.ErrorHandler
|
||||||
|
instance = self
|
||||||
|
end
|
||||||
|
return instance
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Toggle HUD visibility
|
||||||
|
function Performance:toggleHUD()
|
||||||
|
self.hudEnabled = not self.hudEnabled
|
||||||
|
end
|
||||||
|
|
||||||
|
function Performance:startTimer(name)
|
||||||
|
if not self.enabled then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self._timers[name] = love.timer.getTime()
|
||||||
|
end
|
||||||
|
|
||||||
|
function Performance:stopTimer(name)
|
||||||
|
if not self.enabled then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local startTime = self._timers[name]
|
||||||
|
if not startTime then
|
||||||
|
-- Silently return nil if timer wasn't started
|
||||||
|
-- This can happen legitimately when Performance is toggled mid-frame
|
||||||
|
-- or when layout functions have early returns
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local elapsed = (love.timer.getTime() - startTime) * 1000
|
||||||
|
self._timers[name] = nil
|
||||||
|
|
||||||
|
-- Update metrics
|
||||||
|
if not self._metrics[name] then
|
||||||
|
self._metrics[name] = {
|
||||||
|
total = 0,
|
||||||
|
count = 0,
|
||||||
|
min = math.huge,
|
||||||
|
max = 0,
|
||||||
|
average = 0,
|
||||||
|
lastUsed = love.timer.getTime(),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local m = self._metrics[name]
|
||||||
|
m.total = m.total + elapsed
|
||||||
|
m.count = m.count + 1
|
||||||
|
m.min = math.min(m.min, elapsed)
|
||||||
|
m.max = math.max(m.max, elapsed)
|
||||||
|
m.average = m.total / m.count
|
||||||
|
m.lastUsed = love.timer.getTime()
|
||||||
|
|
||||||
|
-- Check for warnings
|
||||||
|
if elapsed > self.criticalThresholdMs then
|
||||||
|
self:_addWarning(name, elapsed, "critical")
|
||||||
|
elseif elapsed > self.warningThresholdMs then
|
||||||
|
self:_addWarning(name, elapsed, "warning")
|
||||||
|
end
|
||||||
|
|
||||||
|
if self.logToConsole then
|
||||||
|
-- Use ErrorHandler if available, otherwise fall back to print
|
||||||
|
if self._ErrorHandler and self._ErrorHandler.warn then
|
||||||
|
self._ErrorHandler:warn("Performance", "PERF_001", {
|
||||||
|
metric = name,
|
||||||
|
elapsed = string.format("%.3fms", elapsed),
|
||||||
|
})
|
||||||
|
else
|
||||||
|
print(string.format("[Performance] %s: %.3fms", name, elapsed))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return elapsed
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Update with actual delta time from LÖVE (call from love.update)
|
||||||
|
---@param dt number Delta time in seconds
|
||||||
|
function Performance:updateDeltaTime(dt)
|
||||||
|
if not self.enabled then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local now = love.timer.getTime()
|
||||||
|
if now - self._frameMetrics.lastFpsUpdate >= self._frameMetrics.fpsUpdateInterval then
|
||||||
|
if dt > 0 then
|
||||||
|
self._frameMetrics.fps = math.floor(1 / dt + 0.5)
|
||||||
|
end
|
||||||
|
self._frameMetrics.lastFpsUpdate = now
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Start frame timing (call at beginning of frame)
|
||||||
|
function Performance:startFrame()
|
||||||
|
if not self.enabled then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self._lastFrameStart = love.timer.getTime()
|
||||||
|
self:_updateMemory()
|
||||||
|
end
|
||||||
|
|
||||||
|
function Performance:endFrame()
|
||||||
|
if not self.enabled or not self._lastFrameStart then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local now = love.timer.getTime()
|
||||||
|
local frameTime = (now - self._lastFrameStart) * 1000
|
||||||
|
|
||||||
|
self._frameMetrics.lastFrameTime = frameTime
|
||||||
|
self._frameMetrics.totalTime = self._frameMetrics.totalTime + frameTime
|
||||||
|
self._frameMetrics.frameCount = self._frameMetrics.frameCount + 1
|
||||||
|
self._frameMetrics.minFrameTime = math.min(self._frameMetrics.minFrameTime, frameTime)
|
||||||
|
self._frameMetrics.maxFrameTime = math.max(self._frameMetrics.maxFrameTime, frameTime)
|
||||||
|
|
||||||
|
if frameTime > self.criticalThresholdMs then
|
||||||
|
self:_addWarning("frame", frameTime, "critical")
|
||||||
|
end
|
||||||
|
|
||||||
|
self:updateMemoryProfiling()
|
||||||
|
|
||||||
|
-- Periodic metrics cleanup
|
||||||
|
if now - self._lastMetricsCleanup >= METRICS_CLEANUP_INTERVAL then
|
||||||
|
local cleanupTime = now - METRICS_RETENTION_TIME
|
||||||
|
for name, data in pairs(self._metrics) do
|
||||||
|
if not CORE_METRICS[name] and data.lastUsed and data.lastUsed < cleanupTime then
|
||||||
|
self._metrics[name] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
self._lastMetricsCleanup = now
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Enforce max metrics limit
|
||||||
|
local metricsCount = 0
|
||||||
|
for _ in pairs(self._metrics) do
|
||||||
|
metricsCount = metricsCount + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
if metricsCount > MAX_METRICS_COUNT then
|
||||||
|
local sortedMetrics = {}
|
||||||
|
for name, data in pairs(self._metrics) do
|
||||||
|
if not CORE_METRICS[name] then
|
||||||
|
table.insert(sortedMetrics, { name = name, lastUsed = data.lastUsed or 0 })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
table.sort(sortedMetrics, function(a, b)
|
||||||
|
return a.lastUsed < b.lastUsed
|
||||||
|
end)
|
||||||
|
|
||||||
|
local toRemove = metricsCount - MAX_METRICS_COUNT
|
||||||
|
for i = 1, math.min(toRemove, #sortedMetrics) do
|
||||||
|
self._metrics[sortedMetrics[i].name] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Update memory metrics
|
||||||
|
function Performance:_updateMemory()
|
||||||
|
if not self.enabled then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local memKb = collectgarbage("count")
|
||||||
|
self._memoryMetrics.current = memKb
|
||||||
|
self._memoryMetrics.peak = math.max(self._memoryMetrics.peak, memKb)
|
||||||
|
|
||||||
|
local now = love.timer.getTime()
|
||||||
|
if now - self._memoryMetrics.lastGcCheck >= 1.0 then
|
||||||
|
self._memoryMetrics.gcCount = self._memoryMetrics.gcCount + 1
|
||||||
|
self._memoryMetrics.lastGcCheck = now
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Add a performance warning (private)
|
||||||
|
--- @param name string Metric name
|
||||||
|
--- @param value number Metric value
|
||||||
|
--- @param level "warning"|"critical" Warning level
|
||||||
|
function Performance:_addWarning(name, value, level)
|
||||||
|
if not self.logWarnings then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local warning = {
|
||||||
|
name = name,
|
||||||
|
value = value,
|
||||||
|
level = level,
|
||||||
|
time = love.timer.getTime(),
|
||||||
|
}
|
||||||
|
|
||||||
|
table.insert(self._warnings, warning)
|
||||||
|
|
||||||
|
if #self._warnings > 100 then
|
||||||
|
table.remove(self._warnings, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
if self.logToConsole or self.warningsEnabled then
|
||||||
|
local warningKey = name .. "_" .. level
|
||||||
|
local lastWarningTime = self._shownWarnings[warningKey] or 0
|
||||||
|
local now = love.timer.getTime()
|
||||||
|
|
||||||
|
if now - lastWarningTime >= 60 then
|
||||||
|
if self._ErrorHandler and self._ErrorHandler.warn then
|
||||||
|
local code = level == "critical" and "PERF_002" or "PERF_001"
|
||||||
|
|
||||||
|
self._ErrorHandler:warn("Performance", code, {
|
||||||
|
metric = name,
|
||||||
|
value = string.format("%.2fms", value),
|
||||||
|
threshold = level == "critical" and self.criticalThresholdMs or self.warningThresholdMs,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
self._shownWarnings[warningKey] = now
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Render performance HUD
|
||||||
|
--- @param x number? X position (default: 10)
|
||||||
|
--- @param y number? Y position (default: 10)
|
||||||
|
function Performance:renderHUD(x, y)
|
||||||
|
if not self.hudEnabled then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
x = x or self.hudPosition.x
|
||||||
|
y = y or self.hudPosition.y
|
||||||
|
|
||||||
|
self:_updateMemory()
|
||||||
|
|
||||||
|
local fm = self._frameMetrics
|
||||||
|
local mm = self._memoryMetrics
|
||||||
|
|
||||||
|
love.graphics.setColor(0, 0, 0, 0.8)
|
||||||
|
love.graphics.rectangle("fill", x, y, 300, 220)
|
||||||
|
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
local lineHeight = 18
|
||||||
|
local currentY = y + 10
|
||||||
|
|
||||||
|
-- FPS
|
||||||
|
local fpsColor = { 1, 1, 1 }
|
||||||
|
if fm.lastFrameTime > self.criticalThresholdMs then
|
||||||
|
fpsColor = { 1, 0, 0 }
|
||||||
|
elseif fm.lastFrameTime > self.warningThresholdMs then
|
||||||
|
fpsColor = { 1, 1, 0 }
|
||||||
|
end
|
||||||
|
love.graphics.setColor(fpsColor)
|
||||||
|
love.graphics.print(string.format("FPS: %d (%.2fms)", fm.fps, fm.lastFrameTime), x + 10, currentY)
|
||||||
|
currentY = currentY + lineHeight
|
||||||
|
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
local avgFrame = fm.frameCount > 0 and fm.totalTime / fm.frameCount or 0
|
||||||
|
love.graphics.print(string.format("Avg Frame: %.2fms", avgFrame), x + 10, currentY)
|
||||||
|
currentY = currentY + lineHeight
|
||||||
|
love.graphics.print(string.format("Min/Max: %.2f/%.2fms", fm.minFrameTime, fm.maxFrameTime), x + 10, currentY)
|
||||||
|
currentY = currentY + lineHeight
|
||||||
|
|
||||||
|
local currentMb = mm.current / 1024
|
||||||
|
local peakMb = mm.peak / 1024
|
||||||
|
love.graphics.print(string.format("Memory: %.2f MB (peak: %.2f MB)", currentMb, peakMb), x + 10, currentY)
|
||||||
|
currentY = currentY + lineHeight
|
||||||
|
|
||||||
|
local metricsCount = 0
|
||||||
|
for _ in pairs(self._metrics) do
|
||||||
|
metricsCount = metricsCount + 1
|
||||||
|
end
|
||||||
|
local metricsColor = metricsCount > MAX_METRICS_COUNT * 0.8 and { 1, 0.5, 0 } or { 1, 1, 1 }
|
||||||
|
love.graphics.setColor(metricsColor)
|
||||||
|
love.graphics.print(string.format("Metrics: %d/%d", metricsCount, MAX_METRICS_COUNT), x + 10, currentY)
|
||||||
|
currentY = currentY + lineHeight + 5
|
||||||
|
|
||||||
|
-- Top timings
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
local sortedMetrics = {}
|
||||||
|
for name, data in pairs(self._metrics) do
|
||||||
|
table.insert(sortedMetrics, { name = name, average = data.average })
|
||||||
|
end
|
||||||
|
table.sort(sortedMetrics, function(a, b)
|
||||||
|
return a.average > b.average
|
||||||
|
end)
|
||||||
|
|
||||||
|
love.graphics.print("Top Timings:", x + 10, currentY)
|
||||||
|
currentY = currentY + lineHeight
|
||||||
|
|
||||||
|
for i = 1, math.min(5, #sortedMetrics) do
|
||||||
|
local m = sortedMetrics[i]
|
||||||
|
love.graphics.print(string.format(" %s: %.3fms", m.name, m.average), x + 10, currentY)
|
||||||
|
currentY = currentY + lineHeight
|
||||||
|
end
|
||||||
|
|
||||||
|
if #self._warnings > 0 then
|
||||||
|
love.graphics.setColor(1, 0.5, 0, 1)
|
||||||
|
love.graphics.print(string.format("Warnings: %d", #self._warnings), x + 10, currentY)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Handle keyboard input for HUD toggle
|
||||||
|
--- @param key string Key pressed
|
||||||
|
function Performance:keypressed(key)
|
||||||
|
if key == self.hudToggleKey then
|
||||||
|
self:toggleHUD()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Log a performance warning (only once per warning key)
|
||||||
|
--- @param warningKey string Unique key for this warning type
|
||||||
|
--- @param module string Module name (e.g., "LayoutEngine", "Element")
|
||||||
|
--- @param message string Warning message
|
||||||
|
--- @param details table? Additional details
|
||||||
|
--- @param suggestion string? Optimization suggestion
|
||||||
|
function Performance:logWarning(warningKey, module, message, details, suggestion)
|
||||||
|
if not self.warningsEnabled then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if self._shownWarnings[warningKey] then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
self._shownWarnings[warningKey] = true
|
||||||
|
|
||||||
|
local count = 0
|
||||||
|
for _ in pairs(self._shownWarnings) do
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
if count > 1000 then
|
||||||
|
self._shownWarnings = { [warningKey] = true }
|
||||||
|
end
|
||||||
|
|
||||||
|
if self._ErrorHandler and self._ErrorHandler.warn then
|
||||||
|
self._ErrorHandler:warn(module, "PERF_001", details or {})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Track a counter metric (increments per frame)
|
||||||
|
--- @param name string Counter name
|
||||||
|
--- @param value number? Value to add (default: 1)
|
||||||
|
function Performance:incrementCounter(name, value)
|
||||||
|
if not self.enabled then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
value = value or 1
|
||||||
|
|
||||||
|
if not self._metrics[name] then
|
||||||
|
self._metrics[name] = {
|
||||||
|
total = 0,
|
||||||
|
count = 0,
|
||||||
|
min = math.huge,
|
||||||
|
max = 0,
|
||||||
|
average = 0,
|
||||||
|
frameValue = 0,
|
||||||
|
lastUsed = love.timer.getTime(),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local m = self._metrics[name]
|
||||||
|
m.frameValue = (m.frameValue or 0) + value
|
||||||
|
m.lastUsed = love.timer.getTime()
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Reset frame counters (call at end of frame)
|
||||||
|
function Performance:resetFrameCounters()
|
||||||
|
if not self.enabled then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local now = love.timer.getTime()
|
||||||
|
local toRemove = {}
|
||||||
|
|
||||||
|
for name, data in pairs(self._metrics) do
|
||||||
|
if data.frameValue then
|
||||||
|
if data.frameValue > 0 then
|
||||||
|
data.total = data.total + data.frameValue
|
||||||
|
data.count = data.count + 1
|
||||||
|
data.min = math.min(data.min, data.frameValue)
|
||||||
|
data.max = math.max(data.max, data.frameValue)
|
||||||
|
data.average = data.total / data.count
|
||||||
|
data.lastUsed = now
|
||||||
|
end
|
||||||
|
|
||||||
|
data.frameValue = 0
|
||||||
|
|
||||||
|
if data.count == 0 and not CORE_METRICS[name] then
|
||||||
|
table.insert(toRemove, name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, name in ipairs(toRemove) do
|
||||||
|
self._metrics[name] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Register a table for memory leak monitoring
|
||||||
|
--- @param name string Friendly name for the table
|
||||||
|
--- @param tableRef table Reference to the table to monitor
|
||||||
|
function Performance:registerTableForMonitoring(name, tableRef)
|
||||||
|
self._memoryProfiler.monitoredTables[name] = tableRef
|
||||||
|
end
|
||||||
|
|
||||||
|
function Performance:_sampleMemory()
|
||||||
|
local sample = {
|
||||||
|
time = love.timer.getTime(),
|
||||||
|
memory = collectgarbage("count") / 1024, -- MB
|
||||||
|
tableSizes = {},
|
||||||
|
}
|
||||||
|
local function getTableSize(tbl)
|
||||||
|
local count = 0
|
||||||
|
for _ in pairs(tbl) do
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
return count
|
||||||
|
end
|
||||||
|
|
||||||
|
for name, tableRef in pairs(self._memoryProfiler.monitoredTables) do
|
||||||
|
sample.tableSizes[name] = getTableSize(tableRef)
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(self._memoryProfiler.samples, sample)
|
||||||
|
|
||||||
|
-- Keep only maxSamples
|
||||||
|
if #self._memoryProfiler.samples > self._memoryProfiler.maxSamples then
|
||||||
|
table.remove(self._memoryProfiler.samples, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for memory leaks (consistent growth)
|
||||||
|
if #self._memoryProfiler.samples >= 5 then
|
||||||
|
for name, _ in pairs(self._memoryProfiler.monitoredTables) do
|
||||||
|
local sizes = {}
|
||||||
|
for i = math.max(1, #self._memoryProfiler.samples - 4), #self._memoryProfiler.samples do
|
||||||
|
table.insert(sizes, self._memoryProfiler.samples[i].tableSizes[name])
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if table is consistently growing
|
||||||
|
local growing = true
|
||||||
|
for i = 2, #sizes do
|
||||||
|
if sizes[i] <= sizes[i - 1] then
|
||||||
|
growing = false
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if growing and sizes[#sizes] > sizes[1] * 1.5 then
|
||||||
|
self:_addWarning("memory_leak", sizes[#sizes], "warning")
|
||||||
|
|
||||||
|
if not self._shownWarnings[name] then
|
||||||
|
local message = string.format("Table '%s' growing consistently", name)
|
||||||
|
if self._ErrorHandler and self._ErrorHandler.warn then
|
||||||
|
self._ErrorHandler:warn("Performance", "MEM_001", {
|
||||||
|
table = name,
|
||||||
|
initialSize = sizes[1],
|
||||||
|
currentSize = sizes[#sizes],
|
||||||
|
growthPercent = math.floor(((sizes[#sizes] / sizes[1]) - 1) * 100),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
self._shownWarnings[name] = true
|
||||||
|
end
|
||||||
|
elseif not growing then
|
||||||
|
self._shownWarnings[name] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Update memory profiling (call from endFrame)
|
||||||
|
function Performance:updateMemoryProfiling()
|
||||||
|
if not self._memoryProfiler.enabled then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
self._memoryProfiler.framesSinceLastSample = self._memoryProfiler.framesSinceLastSample + 1
|
||||||
|
|
||||||
|
if self._memoryProfiler.framesSinceLastSample >= self._memoryProfiler.sampleInterval then
|
||||||
|
self:_sampleMemory()
|
||||||
|
self._memoryProfiler.framesSinceLastSample = 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return Performance
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
-- modules/PropertySchema.lua
|
||||||
|
--
|
||||||
|
-- Declarative source of truth for every Element prop.
|
||||||
|
--
|
||||||
|
-- Each entry describes one prop that Element.new / Element:setProperty currently
|
||||||
|
-- handles inline. Downstream tasks (03 data-driven prop binding, 05 registry-driven
|
||||||
|
-- setProperty dispatch) read this metadata instead of hardcoding property names.
|
||||||
|
--
|
||||||
|
-- Design constraints (locked — tasks 03/05 depend on this API):
|
||||||
|
-- * Pure Lua — NO `love` import, NO dependency on utils/Color/Units/ErrorHandler.
|
||||||
|
-- Normalizers/validators are small, dependency-free closures so the module is
|
||||||
|
-- unit-testable standalone. Color/^/unit/enum *defaults* that require those
|
||||||
|
-- modules are left as `nil` here and applied by construction-time special
|
||||||
|
-- handlers in Task 03; only defaults expressible as literals are stored.
|
||||||
|
-- * O(1) lookup — `get(name)` is a single table index into a pre-built registry;
|
||||||
|
-- no per-call construction.
|
||||||
|
-- * Additive — `define(specs)` merges entries by name so build profiles can
|
||||||
|
-- extend/override without rebuilding the whole table.
|
||||||
|
--
|
||||||
|
-- Metadata shape per prop (all fields present, false/nil when not applicable):
|
||||||
|
-- type string — type tag for tooling ("number"|"string"|"boolean"|
|
||||||
|
-- "table"|"function"|"color"|"any")
|
||||||
|
-- default any|nil — literal default value applied when prop is absent
|
||||||
|
-- normalizer fn|nil — pure fn(value) -> value; transforms input before
|
||||||
|
-- storage (e.g. single-value padding -> 4-side table)
|
||||||
|
-- validator fn|nil — pure fn(value) -> bool; returns false for invalid
|
||||||
|
-- input (Task 03 warns + falls back on false)
|
||||||
|
-- isDimension boolean — true for width/height: setProperty routes these
|
||||||
|
-- through _resolveDimensionProperty (unit-string
|
||||||
|
-- resolution + border-box sync). Other unit-accepting
|
||||||
|
-- props (x/y/gap/padding/etc.) are resolved at
|
||||||
|
-- construction via special handlers, NOT via this flag.
|
||||||
|
-- affectsLayout boolean — true for props in the legacy setProperty
|
||||||
|
-- `layoutProperties` table; setting one invalidates
|
||||||
|
-- layout (matches baseline behavior exactly).
|
||||||
|
-- syncsTheme boolean — true for props whose setProperty path must reach
|
||||||
|
-- ThemeManager/Renderer (disabled/active/themeComponent)
|
||||||
|
-- hasDeferred boolean — true for callbacks that have an `on<Name>Deferred`
|
||||||
|
-- boolean companion prop (auto-wired by Task 03)
|
||||||
|
-- storageKey string|nil— when set, the prop is stored on the element under
|
||||||
|
-- this key instead of its own name (prop aliases, e.g.
|
||||||
|
-- isDisabled -> stored as `disabled`)
|
||||||
|
|
||||||
|
local PropertySchema = {}
|
||||||
|
|
||||||
|
---@type table<string, table>
|
||||||
|
local registry = {}
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Pure normalizers (small + dependency-free; hot-pathed during construction)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
--- Expand a single value to a 4-side table. Leaves tables unchanged. nil passthrough.
|
||||||
|
--- Used by padding/margin: `padding = 5` -> `{top=5,right=5,bottom=5,left=5}`.
|
||||||
|
local function expandSides(value)
|
||||||
|
if value == nil then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if type(value) == "table" then
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
return { top = value, right = value, bottom = value, left = value }
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Normalize flex direction aliases to internal enum names.
|
||||||
|
--- "row" -> "horizontal", "column" -> "vertical",
|
||||||
|
--- "row-reverse" -> "horizontal-reverse", "column-reverse" -> "vertical-reverse";
|
||||||
|
--- everything else passes through.
|
||||||
|
local function normalizeFlexDirection(value)
|
||||||
|
if value == "row" then
|
||||||
|
return "horizontal"
|
||||||
|
elseif value == "column" then
|
||||||
|
return "vertical"
|
||||||
|
elseif value == "row-reverse" then
|
||||||
|
return "horizontal-reverse"
|
||||||
|
elseif value == "column-reverse" then
|
||||||
|
return "vertical-reverse"
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Replicate Element.new's border-shape normalization (pure).
|
||||||
|
--- * table with sides: true -> 1, number -> value, false/nil -> false; nil if no
|
||||||
|
--- truthy side remains.
|
||||||
|
--- * number / other truthy scalar: kept as-is.
|
||||||
|
--- * nil / false: nil.
|
||||||
|
local function normalizeBorder(value)
|
||||||
|
if value == nil or value == false then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if type(value) == "table" then
|
||||||
|
local function side(v)
|
||||||
|
if v == true then
|
||||||
|
return 1
|
||||||
|
elseif type(v) == "number" then
|
||||||
|
return v
|
||||||
|
else
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local t = side(value.top)
|
||||||
|
local r = side(value.right)
|
||||||
|
local b = side(value.bottom)
|
||||||
|
local l = side(value.left)
|
||||||
|
if not (t or r or b or l) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return { top = t, right = r, bottom = b, left = l }
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Replicate Element.new's cornerRadius-shape normalization (pure).
|
||||||
|
--- * number: 0 -> nil, else the number.
|
||||||
|
--- * table: nil if all four sides are zero/absent, else fill zeros for absent sides.
|
||||||
|
--- * nil -> nil.
|
||||||
|
local function normalizeCornerRadius(value)
|
||||||
|
if value == nil then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if type(value) == "number" then
|
||||||
|
if value == 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
if type(value) == "table" then
|
||||||
|
-- Mirrors Element.new: `or` truthiness (0 is truthy in Lua). Only an all-
|
||||||
|
-- nil/false table collapses to nil; any present side — including 0 — yields
|
||||||
|
-- the 4-side table with zero-filled absent sides.
|
||||||
|
local hasAny = value.topLeft or value.topRight or value.bottomLeft or value.bottomRight
|
||||||
|
if not hasAny then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
topLeft = value.topLeft or 0,
|
||||||
|
topRight = value.topRight or 0,
|
||||||
|
bottomLeft = value.bottomLeft or 0,
|
||||||
|
bottomRight = value.bottomRight or 0,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Pure validators (dependency-free; return boolean)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
--- Range validator factory: returns fn(v) -> bool. nil is treated as valid
|
||||||
|
--- (absence handling is the default mechanism's job).
|
||||||
|
local function rangeValidator(min, max)
|
||||||
|
return function(v)
|
||||||
|
if v == nil then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return type(v) == "number" and v >= min and v <= max
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Enum validator factory: returns fn(v) -> bool for membership in `set` (set may
|
||||||
|
--- be an array or a map of value->truthy).
|
||||||
|
local function enumValidator(set)
|
||||||
|
local lookup = {}
|
||||||
|
if type(set) == "table" then
|
||||||
|
for k, v in pairs(set) do
|
||||||
|
if type(k) == "number" then
|
||||||
|
lookup[v] = true
|
||||||
|
else
|
||||||
|
lookup[k] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return function(v)
|
||||||
|
if v == nil then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return lookup[v] == true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Boolean validator: nil is valid (absence); otherwise must be a boolean.
|
||||||
|
local function booleanValidator(v)
|
||||||
|
return v == nil or type(v) == "boolean"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Registry construction
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
--- Build a fully-populated metadata entry, filling omitted fields with defaults.
|
||||||
|
local function entry(spec)
|
||||||
|
return {
|
||||||
|
type = spec.type or "any",
|
||||||
|
default = spec.default,
|
||||||
|
normalizer = spec.normalizer,
|
||||||
|
validator = spec.validator,
|
||||||
|
isDimension = spec.isDimension == true,
|
||||||
|
affectsLayout = spec.affectsLayout == true,
|
||||||
|
syncsTheme = spec.syncsTheme == true,
|
||||||
|
hasDeferred = spec.hasDeferred == true,
|
||||||
|
storageKey = spec.storageKey,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Merge prop specs into the registry (additive; later entries override earlier).
|
||||||
|
---@param specs table<string, table> map of prop-name -> spec
|
||||||
|
---@return table registry the live registry table (for chaining/inspection)
|
||||||
|
function PropertySchema.define(specs)
|
||||||
|
for name, spec in pairs(specs) do
|
||||||
|
registry[name] = entry(spec)
|
||||||
|
end
|
||||||
|
return registry
|
||||||
|
end
|
||||||
|
|
||||||
|
--- O(1) metadata lookup.
|
||||||
|
---@param name string prop name
|
||||||
|
---@return table|nil metadata nil for unknown props (no error)
|
||||||
|
function PropertySchema.get(name)
|
||||||
|
return registry[name]
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Return the live registry (for inspection / coverage assertions only — not for
|
||||||
|
--- per-call construction).
|
||||||
|
---@return table
|
||||||
|
function PropertySchema.all()
|
||||||
|
return registry
|
||||||
|
end
|
||||||
|
|
||||||
|
--- True if a prop is registered.
|
||||||
|
---@param name string
|
||||||
|
---@return boolean
|
||||||
|
function PropertySchema.has(name)
|
||||||
|
return registry[name] ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- True if setting this prop invalidates layout (legacy `layoutProperties` set).
|
||||||
|
--- O(1) registry lookup — no per-call table construction. Unknown props return false,
|
||||||
|
--- matching the legacy `layoutProperties[name]` nil-lookup behavior exactly.
|
||||||
|
---@param name string prop name
|
||||||
|
---@return boolean
|
||||||
|
function PropertySchema.affectsLayout(name)
|
||||||
|
local meta = registry[name]
|
||||||
|
return meta ~= nil and meta.affectsLayout == true
|
||||||
|
end
|
||||||
|
|
||||||
|
--- True for dimension props (width/height) that `setProperty` routes through
|
||||||
|
--- `_resolveDimensionProperty` (unit-string resolution + border-box sync).
|
||||||
|
--- O(1) registry lookup — no per-call table construction. Unknown props return false,
|
||||||
|
--- matching the legacy `dimensionProperties[name]` nil-lookup behavior exactly.
|
||||||
|
---@param name string prop name
|
||||||
|
---@return boolean
|
||||||
|
function PropertySchema.isDimension(name)
|
||||||
|
local meta = registry[name]
|
||||||
|
return meta ~= nil and meta.isDimension == true
|
||||||
|
end
|
||||||
|
|
||||||
|
--- True for props whose setProperty path must reach ThemeManager/Renderer
|
||||||
|
--- (disabled/active/themeComponent). O(1) registry lookup — no per-call table
|
||||||
|
--- construction. Unknown props return false, matching a legacy nil-lookup exactly.
|
||||||
|
---@param name string prop name
|
||||||
|
---@return boolean
|
||||||
|
function PropertySchema.syncsTheme(name)
|
||||||
|
local meta = registry[name]
|
||||||
|
return meta ~= nil and meta.syncsTheme == true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Default schema (covers every prop handled in Element.new lines 259-1909 and
|
||||||
|
-- Element:setProperty lines 4291-4417 of the Task-01 baseline).
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
local function defineDefaults()
|
||||||
|
PropertySchema.define({
|
||||||
|
-- ------------------------------------------------------------------ identity
|
||||||
|
id = { type = "string" },
|
||||||
|
userdata = { type = "any" },
|
||||||
|
parent = { type = "table", affectsLayout = true },
|
||||||
|
children = { type = "table" },
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------ callbacks
|
||||||
|
onEvent = { type = "function", hasDeferred = true },
|
||||||
|
onFocus = { type = "function", hasDeferred = true },
|
||||||
|
onBlur = { type = "function", hasDeferred = true },
|
||||||
|
onTextInput = { type = "function", hasDeferred = true },
|
||||||
|
onTextChange = { type = "function", hasDeferred = true },
|
||||||
|
onEnter = { type = "function", hasDeferred = true },
|
||||||
|
onCreate = { type = "function", hasDeferred = true },
|
||||||
|
onTouchEvent = { type = "function", hasDeferred = true },
|
||||||
|
onGesture = { type = "function", hasDeferred = true },
|
||||||
|
onImageLoad = { type = "function", hasDeferred = true },
|
||||||
|
onImageError = { type = "function", hasDeferred = true },
|
||||||
|
|
||||||
|
-- Deferred companion flags (stored directly; no further Deferred companion)
|
||||||
|
onEventDeferred = { type = "boolean", default = false },
|
||||||
|
onFocusDeferred = { type = "boolean", default = false },
|
||||||
|
onBlurDeferred = { type = "boolean", default = false },
|
||||||
|
onTextInputDeferred = { type = "boolean", default = false },
|
||||||
|
onTextChangeDeferred = { type = "boolean", default = false },
|
||||||
|
onEnterDeferred = { type = "boolean", default = false },
|
||||||
|
onCreateDeferred = { type = "boolean", default = false },
|
||||||
|
onTouchEventDeferred = { type = "boolean", default = false },
|
||||||
|
onGestureDeferred = { type = "boolean", default = false },
|
||||||
|
onImageLoadDeferred = { type = "boolean", default = false },
|
||||||
|
onImageErrorDeferred = { type = "boolean", default = false },
|
||||||
|
|
||||||
|
-- focus / touch behavior
|
||||||
|
dropFocusOnSelection = { type = "boolean" },
|
||||||
|
customDraw = { type = "function" },
|
||||||
|
touchEnabled = { type = "boolean", default = true },
|
||||||
|
multiTouchEnabled = { type = "boolean", default = false },
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------ theme
|
||||||
|
theme = { type = "table" },
|
||||||
|
themeComponent = { type = "string", syncsTheme = true },
|
||||||
|
disabled = { type = "boolean", default = false, syncsTheme = true },
|
||||||
|
isDisabled = {
|
||||||
|
type = "boolean",
|
||||||
|
default = false,
|
||||||
|
syncsTheme = true,
|
||||||
|
storageKey = "disabled",
|
||||||
|
},
|
||||||
|
active = { type = "boolean", default = false, syncsTheme = true },
|
||||||
|
disableHighlight = { type = "boolean" },
|
||||||
|
themeStateLock = { type = "boolean" },
|
||||||
|
themeComponentDisabledStates = { type = "table" },
|
||||||
|
scaleCorners = { type = "boolean" },
|
||||||
|
scalingAlgorithm = { type = "string" },
|
||||||
|
contentAutoSizingMultiplier = { type = "table" },
|
||||||
|
contentBlur = { type = "table" },
|
||||||
|
backdropBlur = { type = "table" },
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------ text editing
|
||||||
|
editable = { type = "boolean", default = false },
|
||||||
|
multiline = { type = "boolean", default = false },
|
||||||
|
passwordMode = { type = "boolean", default = false },
|
||||||
|
textWrap = { type = "string" }, -- default computed from multiline
|
||||||
|
maxLines = { type = "number" },
|
||||||
|
maxLength = { type = "number" },
|
||||||
|
placeholder = { type = "string" },
|
||||||
|
inputType = { type = "string", default = "text" },
|
||||||
|
textOverflow = { type = "string", default = "clip" },
|
||||||
|
scrollable = { type = "boolean" }, -- default = multiline
|
||||||
|
autoGrow = { type = "boolean" }, -- default = multiline
|
||||||
|
selectOnFocus = { type = "boolean", default = false },
|
||||||
|
cursorColor = { type = "color" },
|
||||||
|
selectionColor = { type = "color" },
|
||||||
|
cursorBlinkRate = { type = "number", default = 0.5 },
|
||||||
|
text = { type = "string" },
|
||||||
|
textAlign = {
|
||||||
|
type = "string",
|
||||||
|
default = "start",
|
||||||
|
validator = enumValidator({ "start", "center", "end", "justify" }),
|
||||||
|
},
|
||||||
|
-- textAlignVertical is a derived storage field split out from textAlign
|
||||||
|
-- (bindVisualState resolves table/compound-string input into H + V). Its
|
||||||
|
-- validator is exposed for bindVisualState to validate the V component; the
|
||||||
|
-- prop itself stays in SPECIAL_PROPS because compound parsing needs
|
||||||
|
-- ErrorHandler warnings (schema is pure-Lua, cannot warn).
|
||||||
|
textAlignVertical = {
|
||||||
|
type = "string",
|
||||||
|
default = "start",
|
||||||
|
validator = enumValidator({ "start", "center", "end" }),
|
||||||
|
},
|
||||||
|
textColor = { type = "color" },
|
||||||
|
fontFamily = { type = "string" },
|
||||||
|
textSize = { type = "any" }, -- number | preset string; resolved by special handler
|
||||||
|
minTextSize = { type = "number" },
|
||||||
|
maxTextSize = { type = "number" },
|
||||||
|
autoScaleText = { type = "boolean", default = true },
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------ dimensions / box model
|
||||||
|
width = { type = "any", isDimension = true, affectsLayout = true },
|
||||||
|
height = { type = "any", isDimension = true, affectsLayout = true },
|
||||||
|
x = { type = "any", affectsLayout = false },
|
||||||
|
y = { type = "any", affectsLayout = false },
|
||||||
|
minWidth = { type = "any" },
|
||||||
|
maxWidth = { type = "any" },
|
||||||
|
minHeight = { type = "any" },
|
||||||
|
maxHeight = { type = "any" },
|
||||||
|
gap = { type = "any", affectsLayout = true },
|
||||||
|
padding = {
|
||||||
|
type = "any",
|
||||||
|
affectsLayout = true,
|
||||||
|
normalizer = expandSides,
|
||||||
|
},
|
||||||
|
margin = {
|
||||||
|
type = "any",
|
||||||
|
affectsLayout = true,
|
||||||
|
normalizer = expandSides,
|
||||||
|
},
|
||||||
|
flexDirection = {
|
||||||
|
type = "string",
|
||||||
|
default = "horizontal",
|
||||||
|
affectsLayout = true,
|
||||||
|
normalizer = normalizeFlexDirection,
|
||||||
|
},
|
||||||
|
flexWrap = { type = "string", default = "nowrap", affectsLayout = true },
|
||||||
|
justifyContent = { type = "string", default = "flex-start", affectsLayout = true },
|
||||||
|
alignItems = { type = "string", default = "stretch", affectsLayout = true },
|
||||||
|
alignContent = { type = "string", default = "stretch", affectsLayout = true },
|
||||||
|
positioning = { type = "string", default = "relative", affectsLayout = true },
|
||||||
|
gridRows = { type = "number", affectsLayout = true },
|
||||||
|
gridColumns = { type = "number", affectsLayout = true },
|
||||||
|
top = { type = "any", affectsLayout = true },
|
||||||
|
right = { type = "any", affectsLayout = true },
|
||||||
|
bottom = { type = "any", affectsLayout = true },
|
||||||
|
left = { type = "any", affectsLayout = true },
|
||||||
|
columnGap = { type = "any" },
|
||||||
|
rowGap = { type = "any" },
|
||||||
|
flex = { type = "any" }, -- shorthand: expands to flexGrow/flexShrink/flexBasis
|
||||||
|
flexGrow = { type = "number", default = 0, validator = rangeValidator(0, math.huge) },
|
||||||
|
flexShrink = { type = "number", default = 1, validator = rangeValidator(0, math.huge) },
|
||||||
|
flexBasis = { type = "any", default = "auto" },
|
||||||
|
alignSelf = { type = "string", default = "auto" },
|
||||||
|
justifySelf = { type = "string" },
|
||||||
|
z = { type = "number", default = 0 },
|
||||||
|
tabIndex = { type = "number" },
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------ border / background / visual
|
||||||
|
border = { type = "any", normalizer = normalizeBorder },
|
||||||
|
borderColor = { type = "color" }, -- default Color.new(0,0,0,1) via special handler
|
||||||
|
backgroundColor = { type = "color" }, -- default transparent via special handler
|
||||||
|
opacity = {
|
||||||
|
type = "number",
|
||||||
|
default = 1,
|
||||||
|
validator = rangeValidator(0, 1),
|
||||||
|
},
|
||||||
|
visibility = { type = "string", default = "visible" },
|
||||||
|
display = {
|
||||||
|
type = "boolean",
|
||||||
|
default = true,
|
||||||
|
validator = booleanValidator,
|
||||||
|
},
|
||||||
|
transform = { type = "table" },
|
||||||
|
cornerRadius = { type = "any", normalizer = normalizeCornerRadius },
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------ image
|
||||||
|
imagePath = { type = "string" },
|
||||||
|
image = { type = "table" },
|
||||||
|
objectFit = {
|
||||||
|
type = "string",
|
||||||
|
default = "fill",
|
||||||
|
validator = enumValidator({ "fill", "contain", "cover", "scale-down", "none" }),
|
||||||
|
},
|
||||||
|
objectPosition = { type = "string", default = "center center" },
|
||||||
|
imageOpacity = {
|
||||||
|
type = "number",
|
||||||
|
default = 1,
|
||||||
|
validator = rangeValidator(0, 1),
|
||||||
|
},
|
||||||
|
imageRepeat = {
|
||||||
|
type = "string",
|
||||||
|
default = "no-repeat",
|
||||||
|
validator = enumValidator({
|
||||||
|
"no-repeat",
|
||||||
|
"repeat",
|
||||||
|
"repeat-x",
|
||||||
|
"repeat-y",
|
||||||
|
"space",
|
||||||
|
"round",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
imageTint = { type = "color" },
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------ scroll / scrollbar
|
||||||
|
overflow = { type = "string" },
|
||||||
|
overflowX = { type = "string" },
|
||||||
|
overflowY = { type = "string" },
|
||||||
|
scrollbarWidth = { type = "number" },
|
||||||
|
scrollbarColor = { type = "color" },
|
||||||
|
scrollbarTrackColor = { type = "color" },
|
||||||
|
scrollbarRadius = { type = "number" },
|
||||||
|
scrollbarPadding = { type = "number" },
|
||||||
|
scrollSpeed = { type = "number" },
|
||||||
|
invertScroll = { type = "boolean" },
|
||||||
|
smoothScrollEnabled = { type = "boolean" },
|
||||||
|
scrollBarStyle = { type = "string" },
|
||||||
|
scrollbarKnobOffset = { type = "number" },
|
||||||
|
hideScrollbars = { type = "boolean" },
|
||||||
|
scrollbarPlacement = { type = "string" },
|
||||||
|
scrollbarBalance = { type = "number" },
|
||||||
|
_scrollX = { type = "number", storageKey = "_scrollX" },
|
||||||
|
_scrollY = { type = "number", storageKey = "_scrollY" },
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------ select
|
||||||
|
selectParent = { type = "table" },
|
||||||
|
selectOption = { type = "table" },
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------ transition
|
||||||
|
transition = { type = "table", default = {} },
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
--- (Re)populate the default schema. Idempotent: safe to call from Element.init
|
||||||
|
--- for build profiles that re-require the module. Returns the live registry.
|
||||||
|
---@return table registry
|
||||||
|
function PropertySchema.populate()
|
||||||
|
defineDefaults()
|
||||||
|
return registry
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Auto-populate on require so the registry is ready without an explicit init call
|
||||||
|
-- (pure module, no external deps — safe at load time).
|
||||||
|
PropertySchema.populate()
|
||||||
|
|
||||||
|
return PropertySchema
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
|||||||
|
local RoundedRect = {}
|
||||||
|
|
||||||
|
--- Generate points for a rounded rectangle
|
||||||
|
---@param x number
|
||||||
|
---@param y number
|
||||||
|
---@param width number
|
||||||
|
---@param height number
|
||||||
|
---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number
|
||||||
|
---@param segments number? -- Number of segments per corner arc (default: 10)
|
||||||
|
---@return table -- Array of vertices for love.graphics.polygon
|
||||||
|
function RoundedRect.getPoints(x, y, width, height, cornerRadius, segments)
|
||||||
|
segments = segments or 10
|
||||||
|
local points = {}
|
||||||
|
|
||||||
|
-- Helper to add arc points
|
||||||
|
local function addArc(cx, cy, radius, startAngle, endAngle)
|
||||||
|
if radius <= 0 then
|
||||||
|
table.insert(points, cx)
|
||||||
|
table.insert(points, cy)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
for i = 0, segments do
|
||||||
|
local angle = startAngle + (endAngle - startAngle) * (i / segments)
|
||||||
|
table.insert(points, cx + math.cos(angle) * radius)
|
||||||
|
table.insert(points, cy + math.sin(angle) * radius)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Handle uniform corner radius (number)
|
||||||
|
if type(cornerRadius) == "number" then
|
||||||
|
cornerRadius = {
|
||||||
|
topLeft = cornerRadius,
|
||||||
|
topRight = cornerRadius,
|
||||||
|
bottomLeft = cornerRadius,
|
||||||
|
bottomRight = cornerRadius,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local r1 = math.min(cornerRadius.topLeft, width / 2, height / 2)
|
||||||
|
local r2 = math.min(cornerRadius.topRight, width / 2, height / 2)
|
||||||
|
local r3 = math.min(cornerRadius.bottomRight, width / 2, height / 2)
|
||||||
|
local r4 = math.min(cornerRadius.bottomLeft, width / 2, height / 2)
|
||||||
|
|
||||||
|
-- Top-right corner
|
||||||
|
addArc(x + width - r2, y + r2, r2, -math.pi / 2, 0)
|
||||||
|
|
||||||
|
-- Bottom-right corner
|
||||||
|
addArc(x + width - r3, y + height - r3, r3, 0, math.pi / 2)
|
||||||
|
|
||||||
|
-- Bottom-left corner
|
||||||
|
addArc(x + r4, y + height - r4, r4, math.pi / 2, math.pi)
|
||||||
|
|
||||||
|
-- Top-left corner
|
||||||
|
addArc(x + r1, y + r1, r1, math.pi, math.pi * 1.5)
|
||||||
|
|
||||||
|
return points
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Draw a filled rounded rectangle
|
||||||
|
---@param mode string -- "fill" or "line"
|
||||||
|
---@param x number
|
||||||
|
---@param y number
|
||||||
|
---@param width number
|
||||||
|
---@param height number
|
||||||
|
---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number|nil
|
||||||
|
function RoundedRect.draw(mode, x, y, width, height, cornerRadius)
|
||||||
|
-- OPTIMIZATION: Handle nil cornerRadius (no rounding)
|
||||||
|
if not cornerRadius then
|
||||||
|
love.graphics.rectangle(mode, x, y, width, height)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Handle uniform corner radius (number)
|
||||||
|
if type(cornerRadius) == "number" then
|
||||||
|
if cornerRadius <= 0 then
|
||||||
|
love.graphics.rectangle(mode, x, y, width, height)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- Convert to table format for processing
|
||||||
|
cornerRadius = {
|
||||||
|
topLeft = cornerRadius,
|
||||||
|
topRight = cornerRadius,
|
||||||
|
bottomLeft = cornerRadius,
|
||||||
|
bottomRight = cornerRadius,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if any corners are rounded
|
||||||
|
local hasRoundedCorners = cornerRadius.topLeft > 0
|
||||||
|
or cornerRadius.topRight > 0
|
||||||
|
or cornerRadius.bottomLeft > 0
|
||||||
|
or cornerRadius.bottomRight > 0
|
||||||
|
|
||||||
|
if not hasRoundedCorners then
|
||||||
|
-- No rounded corners, use regular rectangle
|
||||||
|
love.graphics.rectangle(mode, x, y, width, height)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local points = RoundedRect.getPoints(x, y, width, height, cornerRadius)
|
||||||
|
|
||||||
|
if mode == "fill" then
|
||||||
|
love.graphics.polygon("fill", points)
|
||||||
|
else
|
||||||
|
-- For line mode, draw the outline
|
||||||
|
love.graphics.polygon("line", points)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Create a stencil function for rounded rectangle clipping
|
||||||
|
---@param x number
|
||||||
|
---@param y number
|
||||||
|
---@param width number
|
||||||
|
---@param height number
|
||||||
|
---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number|nil
|
||||||
|
---@return function
|
||||||
|
function RoundedRect.stencilFunction(x, y, width, height, cornerRadius)
|
||||||
|
return function()
|
||||||
|
RoundedRect.draw("fill", x, y, width, height, cornerRadius)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return RoundedRect
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,719 @@
|
|||||||
|
---@class Select
|
||||||
|
local Select = {}
|
||||||
|
|
||||||
|
---Initialize Select module with required dependencies
|
||||||
|
---@param deps table
|
||||||
|
function Select.init(deps)
|
||||||
|
Select._ErrorHandler = deps.ErrorHandler
|
||||||
|
Select._Context = deps.Context
|
||||||
|
Select._StateManager = deps.StateManager
|
||||||
|
Select._utils = deps.utils
|
||||||
|
Select._Element = deps.Element
|
||||||
|
end
|
||||||
|
|
||||||
|
---Initialize selectParent state on an element
|
||||||
|
---@param element Element
|
||||||
|
---@param selectParentConfig table
|
||||||
|
function Select.initSelectParent(element, selectParentConfig)
|
||||||
|
element._selectState = {
|
||||||
|
value = selectParentConfig.value,
|
||||||
|
open = selectParentConfig.open or false,
|
||||||
|
placeholder = selectParentConfig.placeholder,
|
||||||
|
selectFrame = nil,
|
||||||
|
selectAnchor = nil,
|
||||||
|
onChange = selectParentConfig.onChange,
|
||||||
|
options = {},
|
||||||
|
optionLookup = {},
|
||||||
|
expectedFrameParent = nil,
|
||||||
|
frameAdopted = false,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Restore select state from StateManager. Mode-aware via
|
||||||
|
-- Context.isImmediateMode (behavior-mode-unification task 11).
|
||||||
|
if Select._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then
|
||||||
|
local state = Select._StateManager.getState(element._stateId)
|
||||||
|
if state and state._selectOpen ~= nil then
|
||||||
|
element._selectState.open = state._selectOpen
|
||||||
|
end
|
||||||
|
if state and state._selectValue ~= nil then
|
||||||
|
element._selectState.value = state._selectValue
|
||||||
|
if element.selectParent then
|
||||||
|
element.selectParent.value = state._selectValue
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if state and state._selectSelectedLabel ~= nil then
|
||||||
|
element._selectState.selectedLabel = state._selectSelectedLabel
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---Initialize selectOption on an element
|
||||||
|
---@param element Element
|
||||||
|
---@param selectOptionConfig table
|
||||||
|
function Select.initSelectOption(element, selectOptionConfig)
|
||||||
|
element.selectOption = {
|
||||||
|
value = selectOptionConfig.value,
|
||||||
|
label = selectOptionConfig.label or element.text,
|
||||||
|
disabled = selectOptionConfig.disabled or false,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param selectParent Element
|
||||||
|
function Select.rebuildOptionLookup(selectParent)
|
||||||
|
if not selectParent or not selectParent._selectState then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
selectParent._selectState.optionLookup = {}
|
||||||
|
for _, optionElement in ipairs(selectParent._selectState.options) do
|
||||||
|
if optionElement and optionElement.selectOption then
|
||||||
|
selectParent._selectState.optionLookup[optionElement.selectOption.value] = optionElement
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param selectParent Element
|
||||||
|
function Select.syncOptionStates(selectParent)
|
||||||
|
if not selectParent or not selectParent._selectState then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local selectedOption = nil
|
||||||
|
local selectedLabel = selectParent._selectState.selectedLabel
|
||||||
|
|
||||||
|
for _, optionElement in ipairs(selectParent._selectState.options) do
|
||||||
|
local isSelected = optionElement.selectOption
|
||||||
|
and optionElement.selectOption.value == selectParent._selectState.value
|
||||||
|
optionElement._selectSelected = isSelected
|
||||||
|
optionElement.ariaChecked = isSelected
|
||||||
|
|
||||||
|
if isSelected then
|
||||||
|
selectedOption = optionElement
|
||||||
|
selectedLabel = optionElement.selectOption.label or optionElement.text
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
selectParent._selectState.selectedOption = selectedOption
|
||||||
|
selectParent._selectState.selectedLabel = selectedLabel
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
function Select.resetOptions(element)
|
||||||
|
if not element._selectState then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
element._selectState.options = {}
|
||||||
|
element._selectState.optionLookup = {}
|
||||||
|
element._selectState.selectedOption = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param frame any
|
||||||
|
---@return boolean
|
||||||
|
function Select.isValidSelectFrame(frame)
|
||||||
|
local Element = Select._Element
|
||||||
|
return type(frame) == "table" and getmetatable(frame) == Element
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
---@param code string
|
||||||
|
---@param details table?
|
||||||
|
function Select.warnSelectFrame(element, code, details)
|
||||||
|
Select._ErrorHandler:warn("Element", code, details or { element = element.id })
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
---@param frame Element
|
||||||
|
function Select.trackManagedFrame(element, frame)
|
||||||
|
element._selectState.selectFrame = frame
|
||||||
|
local expectedParent = element._selectState.selectAnchor or element
|
||||||
|
element._selectState.expectedFrameParent = expectedParent
|
||||||
|
element._selectState.frameAdopted = frame.parent == expectedParent
|
||||||
|
if frame._managedSelectBaseOpacity == nil then
|
||||||
|
frame._managedSelectBaseOpacity = frame.opacity
|
||||||
|
end
|
||||||
|
if frame._managedSelectBaseVisibility == nil then
|
||||||
|
frame._managedSelectBaseVisibility = frame.visibility or "visible"
|
||||||
|
end
|
||||||
|
if frame._managedSelectBaseDisabled == nil then
|
||||||
|
frame._managedSelectBaseDisabled = frame.disabled or false
|
||||||
|
end
|
||||||
|
frame._managedSelectOwner = element
|
||||||
|
frame._managedSelectFrame = true
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
---@return Element
|
||||||
|
function Select.getOrCreateManagedAnchor(element)
|
||||||
|
if element._selectState.selectAnchor then
|
||||||
|
return element._selectState.selectAnchor
|
||||||
|
end
|
||||||
|
|
||||||
|
local Element = Select._Element
|
||||||
|
local anchor = Element.new({
|
||||||
|
id = string.format("%s__select_anchor", element.id or "select"),
|
||||||
|
parent = element,
|
||||||
|
positioning = Select._utils.enums.Positioning.ABSOLUTE,
|
||||||
|
left = 0,
|
||||||
|
top = element:getBorderBoxHeight(),
|
||||||
|
width = element:getBorderBoxWidth(),
|
||||||
|
opacity = 1,
|
||||||
|
visibility = "hidden",
|
||||||
|
disabled = true,
|
||||||
|
})
|
||||||
|
|
||||||
|
anchor._managedSelectAnchor = true
|
||||||
|
anchor._managedSelectOwner = element
|
||||||
|
element._selectState.selectAnchor = anchor
|
||||||
|
return anchor
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
---@param frame Element
|
||||||
|
function Select.applyManagedFrameLayout(element, frame)
|
||||||
|
local anchor = Select.getOrCreateManagedAnchor(element)
|
||||||
|
local triggerBorderBoxWidth = element:getBorderBoxWidth()
|
||||||
|
anchor.left = 0
|
||||||
|
anchor.top = element:getBorderBoxHeight()
|
||||||
|
anchor.width = triggerBorderBoxWidth
|
||||||
|
anchor.units.left = { value = 0, unit = "px" }
|
||||||
|
anchor.units.top = { value = element:getBorderBoxHeight(), unit = "px" }
|
||||||
|
anchor.units.width = { value = triggerBorderBoxWidth, unit = "px" }
|
||||||
|
frame._managedSelectMinimumBorderBoxWidth = triggerBorderBoxWidth
|
||||||
|
|
||||||
|
frame.positioning = frame.positioning or Select._utils.enums.Positioning.RELATIVE
|
||||||
|
frame._explicitlyAbsolute = false
|
||||||
|
frame.left = nil
|
||||||
|
frame.top = nil
|
||||||
|
frame.right = nil
|
||||||
|
frame.bottom = nil
|
||||||
|
|
||||||
|
if frame.parent ~= anchor then
|
||||||
|
frame:setParent(anchor)
|
||||||
|
end
|
||||||
|
|
||||||
|
if frame.autosizing and frame.autosizing.width then
|
||||||
|
local contentWidth = frame:calculateAutoWidth()
|
||||||
|
frame._borderBoxWidth = contentWidth + frame.padding.left + frame.padding.right
|
||||||
|
frame.width = contentWidth
|
||||||
|
end
|
||||||
|
|
||||||
|
if frame.parent == anchor then
|
||||||
|
anchor.width = math.max(triggerBorderBoxWidth, frame:getBorderBoxWidth())
|
||||||
|
anchor.units.width = { value = anchor.width, unit = "px" }
|
||||||
|
end
|
||||||
|
|
||||||
|
element._selectState.expectedFrameParent = anchor
|
||||||
|
element._selectState.frameAdopted = frame.parent == anchor
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
---@param frame Element
|
||||||
|
function Select.adoptSelectFrame(element, frame)
|
||||||
|
if not element._selectState then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if not Select.isValidSelectFrame(frame) then
|
||||||
|
Select.warnSelectFrame(element, "ELEM_007", {
|
||||||
|
element = element.id,
|
||||||
|
property = "selectParent.selectFrame",
|
||||||
|
got = type(frame),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if frame == element then
|
||||||
|
Select.warnSelectFrame(element, "ELEM_007", {
|
||||||
|
element = element.id,
|
||||||
|
property = "selectParent.selectFrame",
|
||||||
|
reason = "select cannot use itself as its managed frame",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local anchor = Select.getOrCreateManagedAnchor(element)
|
||||||
|
|
||||||
|
if frame.parent and frame.parent ~= element and frame.parent ~= anchor then
|
||||||
|
Select.warnSelectFrame(element, "ELEM_008", {
|
||||||
|
element = element.id,
|
||||||
|
frame = frame.id,
|
||||||
|
parent = frame.parent.id,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
Select.trackManagedFrame(element, frame)
|
||||||
|
Select.applyManagedFrameLayout(element, frame)
|
||||||
|
Select.syncManagedFrameVisibility(element)
|
||||||
|
|
||||||
|
-- Layout is deferred to endFrame in immediate mode. shouldLayout()
|
||||||
|
-- encapsulates the mode check (behavior-mode-unification task 11).
|
||||||
|
if Select._StateManager.shouldLayout() then
|
||||||
|
anchor:layoutChildren()
|
||||||
|
element:layoutChildren()
|
||||||
|
end
|
||||||
|
|
||||||
|
local pendingOptions = {}
|
||||||
|
for _, child in ipairs(element.children) do
|
||||||
|
if child ~= frame and child.selectOption then
|
||||||
|
table.insert(pendingOptions, child)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, option in ipairs(pendingOptions) do
|
||||||
|
Select.attachOptionToManagedFrame(option)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
function Select.ensureFrameState(element)
|
||||||
|
if not element._selectState or not element._selectState.selectFrame then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local frame = element._selectState.selectFrame
|
||||||
|
local anchor = element._selectState.selectAnchor
|
||||||
|
if anchor then
|
||||||
|
local triggerBorderBoxWidth = element:getBorderBoxWidth()
|
||||||
|
anchor.left = 0
|
||||||
|
anchor.top = element:getBorderBoxHeight()
|
||||||
|
anchor.width = triggerBorderBoxWidth
|
||||||
|
anchor.units.left = { value = 0, unit = "px" }
|
||||||
|
anchor.units.top = { value = element:getBorderBoxHeight(), unit = "px" }
|
||||||
|
anchor.units.width = { value = triggerBorderBoxWidth, unit = "px" }
|
||||||
|
frame._managedSelectMinimumBorderBoxWidth = triggerBorderBoxWidth
|
||||||
|
if frame.autosizing and frame.autosizing.width then
|
||||||
|
local contentWidth = frame:calculateAutoWidth()
|
||||||
|
frame._borderBoxWidth = contentWidth + frame.padding.left + frame.padding.right
|
||||||
|
frame.width = contentWidth
|
||||||
|
end
|
||||||
|
if frame.parent == anchor then
|
||||||
|
anchor.width = math.max(triggerBorderBoxWidth, frame:getBorderBoxWidth())
|
||||||
|
anchor.units.width = { value = anchor.width, unit = "px" }
|
||||||
|
end
|
||||||
|
if frame.parent == anchor then
|
||||||
|
anchor:layoutChildren()
|
||||||
|
end
|
||||||
|
elseif frame.parent == element then
|
||||||
|
Select.applyManagedFrameLayout(element, frame)
|
||||||
|
end
|
||||||
|
|
||||||
|
local expectedParent = anchor or element._selectState.expectedFrameParent
|
||||||
|
if frame.parent ~= expectedParent then
|
||||||
|
Select.warnSelectFrame(element, "ELEM_009", {
|
||||||
|
element = element.id,
|
||||||
|
frame = frame.id,
|
||||||
|
expectedParent = expectedParent and expectedParent.id or nil,
|
||||||
|
actualParent = frame.parent and frame.parent.id or nil,
|
||||||
|
})
|
||||||
|
element._selectState.expectedFrameParent = frame.parent
|
||||||
|
element._selectState.frameAdopted = frame.parent == expectedParent
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
function Select.syncManagedFrameVisibility(element)
|
||||||
|
if not element._selectState or not element._selectState.selectFrame then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local frame = element._selectState.selectFrame
|
||||||
|
local anchor = element._selectState.selectAnchor
|
||||||
|
local isOpen = element._selectState.open == true
|
||||||
|
frame.visibility = isOpen and (frame._managedSelectBaseVisibility or "visible") or "hidden"
|
||||||
|
frame.opacity = frame._managedSelectBaseOpacity or 1
|
||||||
|
if isOpen then
|
||||||
|
frame.disabled = frame._managedSelectBaseDisabled == true
|
||||||
|
else
|
||||||
|
frame.disabled = true
|
||||||
|
end
|
||||||
|
if anchor then
|
||||||
|
anchor.visibility = isOpen and "visible" or "hidden"
|
||||||
|
anchor.opacity = 1
|
||||||
|
anchor.disabled = not isOpen
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
---@return Element?
|
||||||
|
function Select.findOwningSelectParent(element)
|
||||||
|
if element._selectParentHint and element._selectParentHint._selectState then
|
||||||
|
return element._selectParentHint
|
||||||
|
end
|
||||||
|
|
||||||
|
local current = element.parent
|
||||||
|
while current do
|
||||||
|
if current._selectState then
|
||||||
|
return current
|
||||||
|
end
|
||||||
|
current = current.parent
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
function Select.registerWithSelectParent(element)
|
||||||
|
if not element.selectOption then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local selectParent = Select.findOwningSelectParent(element)
|
||||||
|
if not selectParent then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
element._selectParentElement = selectParent
|
||||||
|
|
||||||
|
for _, optionElement in ipairs(selectParent._selectState.options) do
|
||||||
|
if optionElement == element then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(selectParent._selectState.options, element)
|
||||||
|
Select.rebuildOptionLookup(selectParent)
|
||||||
|
Select.syncOptionStates(selectParent)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
function Select.attachOptionToManagedFrame(element)
|
||||||
|
if not element.selectOption then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local selectParent = Select.findOwningSelectParent(element)
|
||||||
|
if not selectParent or not selectParent._selectState or not selectParent._selectState.selectFrame then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local selectFrame = selectParent._selectState.selectFrame
|
||||||
|
if element.parent ~= selectFrame then
|
||||||
|
element._selectParentHint = selectParent
|
||||||
|
|
||||||
|
if
|
||||||
|
element._originalPositioning == Select._utils.enums.Positioning.ABSOLUTE
|
||||||
|
and element._managedSelectOptionUsesFrameLayout == nil
|
||||||
|
then
|
||||||
|
element._managedSelectOptionUsesFrameLayout = true
|
||||||
|
element.positioning = Select._utils.enums.Positioning.RELATIVE
|
||||||
|
element._originalPositioning = nil
|
||||||
|
element._explicitlyAbsolute = false
|
||||||
|
element.left = nil
|
||||||
|
element.top = nil
|
||||||
|
element.right = nil
|
||||||
|
element.bottom = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
element:setParent(selectFrame)
|
||||||
|
-- Ensure frame geometry eagerly only in retained mode; deferred to the
|
||||||
|
-- per-frame update in immediate mode (behavior-mode-unification task 11).
|
||||||
|
if Select._StateManager.shouldLayout() then
|
||||||
|
Select.ensureFrameState(selectParent)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
function Select.unregisterFromSelectParent(element)
|
||||||
|
if not element.selectOption or not element._selectParentElement or not element._selectParentElement._selectState then
|
||||||
|
element._selectParentElement = nil
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local selectParent = element._selectParentElement
|
||||||
|
for index, optionElement in ipairs(selectParent._selectState.options) do
|
||||||
|
if optionElement == element then
|
||||||
|
table.remove(selectParent._selectState.options, index)
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Select.rebuildOptionLookup(selectParent)
|
||||||
|
Select.syncOptionStates(selectParent)
|
||||||
|
element._selectParentElement = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
function Select.saveStateToStateManager(element)
|
||||||
|
if not element._selectState then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if element._stateId and Select._Context.isImmediateMode() and element._stateId ~= "" then
|
||||||
|
Select._StateManager.updateState(element._stateId, {
|
||||||
|
_selectOpen = element._selectState.open,
|
||||||
|
_selectValue = element._selectState.value,
|
||||||
|
_selectSelectedLabel = element._selectState.selectedLabel,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
function Select.openSelect(element)
|
||||||
|
if not element._selectState then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
Select.ensureFrameState(element)
|
||||||
|
element._selectState.open = true
|
||||||
|
element.ariaExpanded = true
|
||||||
|
if element.selectParent then
|
||||||
|
element.selectParent.open = true
|
||||||
|
end
|
||||||
|
Select.syncManagedFrameVisibility(element)
|
||||||
|
Select.saveStateToStateManager(element)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
function Select.closeSelect(element)
|
||||||
|
if not element._selectState then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
Select.ensureFrameState(element)
|
||||||
|
element._selectState.open = false
|
||||||
|
element.ariaExpanded = false
|
||||||
|
if element.selectParent then
|
||||||
|
element.selectParent.open = false
|
||||||
|
end
|
||||||
|
Select.syncManagedFrameVisibility(element)
|
||||||
|
Select.saveStateToStateManager(element)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
function Select.toggleSelect(element)
|
||||||
|
if not element._selectState then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if element.disabled then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if element._selectState.open then
|
||||||
|
Select.closeSelect(element)
|
||||||
|
else
|
||||||
|
Select.openSelect(element)
|
||||||
|
end
|
||||||
|
|
||||||
|
if element.onEvent then
|
||||||
|
element.onEvent(element, { type = "selecttoggle", open = element._selectState.open })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
---@return boolean
|
||||||
|
function Select.isSelectOpen(element)
|
||||||
|
return element._selectState ~= nil and element._selectState.open == true
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
---@return any
|
||||||
|
function Select.getSelectValue(element)
|
||||||
|
if not element._selectState then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return element._selectState.value
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
---@return string?
|
||||||
|
function Select.getSelectLabel(element)
|
||||||
|
if not element._selectState then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local selectedOption = element._selectState.selectedOption
|
||||||
|
or element._selectState.optionLookup[element._selectState.value]
|
||||||
|
if selectedOption and selectedOption.selectOption then
|
||||||
|
return selectedOption.selectOption.label or selectedOption.text
|
||||||
|
end
|
||||||
|
|
||||||
|
return element._selectState.selectedLabel or element._selectState.placeholder
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
---@return boolean
|
||||||
|
function Select.isSelectedOption(element)
|
||||||
|
if not element.selectOption or not element._selectParentElement or not element._selectParentElement._selectState then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return element._selectParentElement._selectState.value == element.selectOption.value
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
---@param value any
|
||||||
|
---@param optionElement Element?
|
||||||
|
function Select.setSelectValue(element, value, optionElement)
|
||||||
|
if not element._selectState then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if element.disabled then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local didChange = element._selectState.value ~= value
|
||||||
|
element._selectState.value = value
|
||||||
|
if element.selectParent then
|
||||||
|
element.selectParent.value = value
|
||||||
|
end
|
||||||
|
|
||||||
|
if optionElement and optionElement.selectOption then
|
||||||
|
element._selectState.selectedLabel = optionElement.selectOption.label or optionElement.text
|
||||||
|
end
|
||||||
|
|
||||||
|
Select.syncOptionStates(element)
|
||||||
|
Select.closeSelect(element)
|
||||||
|
Select.saveStateToStateManager(element)
|
||||||
|
|
||||||
|
if element.onEvent then
|
||||||
|
element.onEvent(element, { type = "selectchange", value = value, option = optionElement })
|
||||||
|
end
|
||||||
|
|
||||||
|
if didChange and element._selectState.onChange then
|
||||||
|
element._selectState.onChange(element, value, optionElement and optionElement.selectOption or nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param element Element
|
||||||
|
function Select.handleRelease(element)
|
||||||
|
if element.disabled then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if element.selectOption then
|
||||||
|
local selectParent = element._selectParentElement or Select.findOwningSelectParent(element)
|
||||||
|
if not selectParent then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if element.selectOption.disabled then
|
||||||
|
Select.closeSelect(selectParent)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
Select.setSelectValue(selectParent, element.selectOption.value, element)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if element._selectState then
|
||||||
|
Select.toggleSelect(element)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---Save select state for state persistence (called from Element:saveState)
|
||||||
|
---@param element Element
|
||||||
|
---@return table?
|
||||||
|
function Select.saveState(element)
|
||||||
|
if not element._selectState then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
value = element._selectState.value,
|
||||||
|
open = element._selectState.open,
|
||||||
|
selectedLabel = element._selectState.selectedLabel,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
---Restore select state (called from Element:restoreState)
|
||||||
|
---@param element Element
|
||||||
|
---@param state table
|
||||||
|
function Select.restoreState(element, state)
|
||||||
|
if not element._selectState or not state then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
element._selectState.value = state.value
|
||||||
|
element._selectState.open = state.open or false
|
||||||
|
element._selectState.selectedLabel = state.selectedLabel
|
||||||
|
if element.selectParent then
|
||||||
|
element.selectParent.value = state.value
|
||||||
|
element.selectParent.open = state.open or false
|
||||||
|
end
|
||||||
|
element.ariaExpanded = element._selectState.open
|
||||||
|
Select.syncOptionStates(element)
|
||||||
|
end
|
||||||
|
|
||||||
|
---Clean up select-related resources (called from Element:destroy)
|
||||||
|
---@param element Element
|
||||||
|
function Select.cleanupDestroy(element)
|
||||||
|
if element._selectState then
|
||||||
|
local frame = element._selectState.selectFrame
|
||||||
|
local anchor = element._selectState.selectAnchor
|
||||||
|
if frame then
|
||||||
|
frame._managedSelectOwner = nil
|
||||||
|
frame._managedSelectFrame = nil
|
||||||
|
frame._managedSelectBaseOpacity = nil
|
||||||
|
frame._managedSelectBaseVisibility = nil
|
||||||
|
frame._managedSelectBaseDisabled = nil
|
||||||
|
end
|
||||||
|
if anchor then
|
||||||
|
anchor._managedSelectOwner = nil
|
||||||
|
anchor._managedSelectAnchor = nil
|
||||||
|
end
|
||||||
|
element._selectState = nil
|
||||||
|
end
|
||||||
|
if element._managedSelectFrame and element._managedSelectOwner then
|
||||||
|
if element._managedSelectOwner._selectState then
|
||||||
|
element._managedSelectOwner._selectState.selectFrame = nil
|
||||||
|
element._managedSelectOwner._selectState.expectedFrameParent = nil
|
||||||
|
element._managedSelectOwner._selectState.frameAdopted = false
|
||||||
|
end
|
||||||
|
element._managedSelectOwner = nil
|
||||||
|
element._managedSelectFrame = nil
|
||||||
|
element._managedSelectBaseOpacity = nil
|
||||||
|
element._managedSelectBaseVisibility = nil
|
||||||
|
element._managedSelectBaseDisabled = nil
|
||||||
|
end
|
||||||
|
if element._managedSelectAnchor and element._managedSelectOwner then
|
||||||
|
if element._managedSelectOwner._selectState then
|
||||||
|
element._managedSelectOwner._selectState.selectAnchor = nil
|
||||||
|
end
|
||||||
|
element._managedSelectOwner = nil
|
||||||
|
element._managedSelectAnchor = nil
|
||||||
|
end
|
||||||
|
if element.selectParent then
|
||||||
|
element.selectParent.onChange = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Called when a select parent removes a child: clears frame/anchor refs if the removed child was the
|
||||||
|
--- select-managed frame or anchor. Keeps select state-mutation logic owned by the Select module.
|
||||||
|
---@param element Element The select parent whose child was removed.
|
||||||
|
---@param child Element The removed child.
|
||||||
|
function Select.handleChildRemoved(element, child)
|
||||||
|
if not element._selectState then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if element._selectState.selectFrame == child then
|
||||||
|
element._selectState.selectFrame = nil
|
||||||
|
element._selectState.expectedFrameParent = nil
|
||||||
|
element._selectState.frameAdopted = false
|
||||||
|
end
|
||||||
|
if element._selectState.selectAnchor == child then
|
||||||
|
element._selectState.selectAnchor = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Layout-path hook: adjust an auto-width child's border-box width for a managed-select frame.
|
||||||
|
--- Invoked from LayoutEngine (via the Element delegate) during vertical-flex auto-width calculation.
|
||||||
|
---@param element Element The managed-select frame (the dropdown container).
|
||||||
|
---@param child Element The flex child being measured.
|
||||||
|
---@param childBorderBoxWidth number Current computed border-box width of `child`.
|
||||||
|
---@return number Possibly-adjusted border-box width.
|
||||||
|
function Select.adjustAutoWidthChild(element, child, childBorderBoxWidth)
|
||||||
|
if
|
||||||
|
element._managedSelectFrame
|
||||||
|
and element.autosizing
|
||||||
|
and element.autosizing.width
|
||||||
|
and child.units
|
||||||
|
and child.units.width
|
||||||
|
and child.units.width.unit == "%"
|
||||||
|
then
|
||||||
|
local intrinsicBorderBoxWidth = child:calculateAutoWidth() + child.padding.left + child.padding.right
|
||||||
|
return math.max(childBorderBoxWidth, intrinsicBorderBoxWidth)
|
||||||
|
end
|
||||||
|
return childBorderBoxWidth
|
||||||
|
end
|
||||||
|
|
||||||
|
return Select
|
||||||
@@ -0,0 +1,790 @@
|
|||||||
|
---@class StateManager
|
||||||
|
local StateManager = {}
|
||||||
|
|
||||||
|
-- ErrorHandler will be injected via init
|
||||||
|
local ErrorHandler
|
||||||
|
|
||||||
|
-- State storage: ID -> state table
|
||||||
|
local stateStore = {}
|
||||||
|
|
||||||
|
-- Frame tracking metadata: ID -> {lastFrame, createdFrame, accessCount}
|
||||||
|
local stateMetadata = {}
|
||||||
|
|
||||||
|
-- Frame counter
|
||||||
|
local frameNumber = 0
|
||||||
|
|
||||||
|
-- Counter to track multiple elements created at the same source location (e.g., in loops)
|
||||||
|
local callSiteCounters = {}
|
||||||
|
|
||||||
|
-- Stateful element mapping: stateId -> element instance
|
||||||
|
-- Used in retained mode for cache-through: StateManager resolves id -> element -> field
|
||||||
|
local statefulElements = {}
|
||||||
|
|
||||||
|
-- Dirty state tracking for flushFrame: set of {id, key} pairs modified this frame
|
||||||
|
local dirtyState = {}
|
||||||
|
|
||||||
|
-- Immediate mode flag
|
||||||
|
local _immediateMode = false
|
||||||
|
|
||||||
|
-- Configuration
|
||||||
|
local config = {
|
||||||
|
stateRetentionFrames = 2, -- Keep unused state for 2 frames
|
||||||
|
maxStateEntries = 1000, -- Maximum state entries before forced GC
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Default state values (sparse storage - don't store these)
|
||||||
|
local stateDefaults = {
|
||||||
|
-- Interaction states
|
||||||
|
hover = false,
|
||||||
|
pressed = false,
|
||||||
|
focused = false,
|
||||||
|
disabled = false,
|
||||||
|
active = false,
|
||||||
|
|
||||||
|
-- Scrollbar states
|
||||||
|
scrollbarHoveredVertical = false,
|
||||||
|
scrollbarHoveredHorizontal = false,
|
||||||
|
scrollbarDragging = false,
|
||||||
|
hoveredScrollbar = nil,
|
||||||
|
scrollbarDragOffset = 0,
|
||||||
|
dragStartMouseX = 0,
|
||||||
|
dragStartMouseY = 0,
|
||||||
|
dragStartScrollX = 0,
|
||||||
|
dragStartScrollY = 0,
|
||||||
|
|
||||||
|
-- Scroll position
|
||||||
|
scrollX = 0,
|
||||||
|
scrollY = 0,
|
||||||
|
_scrollX = 0,
|
||||||
|
_scrollY = 0,
|
||||||
|
|
||||||
|
-- Click tracking
|
||||||
|
_clickCount = 0,
|
||||||
|
_lastClickTime = nil,
|
||||||
|
_lastClickButton = nil,
|
||||||
|
|
||||||
|
-- Internal states
|
||||||
|
_hovered = nil,
|
||||||
|
_focused = nil,
|
||||||
|
_cursorPosition = nil,
|
||||||
|
_selectionStart = nil,
|
||||||
|
_selectionEnd = nil,
|
||||||
|
_textBuffer = "",
|
||||||
|
_cursorBlinkTimer = 0,
|
||||||
|
_cursorVisible = true,
|
||||||
|
_cursorBlinkPaused = false,
|
||||||
|
_cursorBlinkPauseTimer = 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
--- Check if a value equals the default for a key
|
||||||
|
---@param key string State key
|
||||||
|
---@param value any Value to check
|
||||||
|
---@return boolean isDefault True if value equals default
|
||||||
|
local function isDefaultValue(key, value)
|
||||||
|
local defaultVal = stateDefaults[key]
|
||||||
|
|
||||||
|
-- If no default defined, check for common defaults
|
||||||
|
if defaultVal == nil then
|
||||||
|
-- Empty tables are default
|
||||||
|
if type(value) == "table" and next(value) == nil then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
-- nil values are default
|
||||||
|
if value == nil then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
-- Otherwise, not a default value
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Compare values
|
||||||
|
if type(value) == "table" then
|
||||||
|
-- Empty tables are considered default
|
||||||
|
if next(value) == nil then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
-- For other tables, compare contents (shallow)
|
||||||
|
if type(defaultVal) ~= "table" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
for k, v in pairs(value) do
|
||||||
|
if defaultVal[k] ~= v then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
else
|
||||||
|
return value == defaultVal
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ====================
|
||||||
|
-- ID Generation
|
||||||
|
-- ====================
|
||||||
|
|
||||||
|
--- Generate a hash from a table of properties
|
||||||
|
---@param props table
|
||||||
|
---@param visited table|nil Tracking table to prevent circular references
|
||||||
|
---@param depth number|nil Current recursion depth
|
||||||
|
---@return string
|
||||||
|
local function hashProps(props, visited, depth)
|
||||||
|
if not props then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Initialize visited table on first call
|
||||||
|
visited = visited or {}
|
||||||
|
depth = depth or 0
|
||||||
|
|
||||||
|
-- Limit recursion depth to prevent deep nesting issues
|
||||||
|
if depth > 3 then
|
||||||
|
return "[deep]"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if we've already visited this table (circular reference)
|
||||||
|
if visited[props] then
|
||||||
|
return "[circular]"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Mark this table as visited
|
||||||
|
visited[props] = true
|
||||||
|
|
||||||
|
local parts = {}
|
||||||
|
local keys = {}
|
||||||
|
|
||||||
|
-- Properties to skip (they cause issues or aren't relevant for ID generation)
|
||||||
|
local skipKeys = {
|
||||||
|
onEvent = true,
|
||||||
|
parent = true,
|
||||||
|
children = true,
|
||||||
|
onFocus = true,
|
||||||
|
onBlur = true,
|
||||||
|
onTextInput = true,
|
||||||
|
onTextChange = true,
|
||||||
|
onEnter = true,
|
||||||
|
userdata = true,
|
||||||
|
-- Dynamic input/state properties that should not affect ID stability
|
||||||
|
text = true, -- Text content changes as user types
|
||||||
|
placeholder = true, -- Placeholder text is presentational
|
||||||
|
editable = true, -- Editable state can be toggled dynamically
|
||||||
|
selectOnFocus = true, -- Input behavior flag
|
||||||
|
autoGrow = true, -- Auto-grow behavior flag
|
||||||
|
passwordMode = true, -- Password mode can be toggled
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Collect and sort keys for consistent ordering
|
||||||
|
for k in pairs(props) do
|
||||||
|
if not skipKeys[k] then
|
||||||
|
table.insert(keys, k)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(keys)
|
||||||
|
|
||||||
|
-- Build hash string from sorted key-value pairs
|
||||||
|
for _, k in ipairs(keys) do
|
||||||
|
local v = props[k]
|
||||||
|
local vtype = type(v)
|
||||||
|
|
||||||
|
if vtype == "string" or vtype == "number" or vtype == "boolean" then
|
||||||
|
table.insert(parts, k .. "=" .. tostring(v))
|
||||||
|
elseif vtype == "table" then
|
||||||
|
table.insert(parts, k .. "={" .. hashProps(v, visited, depth + 1) .. "}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(parts, ";")
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Generate a unique ID from call site and properties
|
||||||
|
---@param props table|nil Optional properties to include in ID generation
|
||||||
|
---@param parent table|nil Optional parent element for tree-based ID generation
|
||||||
|
---@return string
|
||||||
|
function StateManager.generateID(props, parent)
|
||||||
|
-- Get call stack information
|
||||||
|
local info = debug.getinfo(3, "Sl") -- Level 3: caller of Element.new -> caller of generateID
|
||||||
|
|
||||||
|
if not info then
|
||||||
|
-- Fallback to random ID if debug info unavailable
|
||||||
|
return "auto_" .. tostring(math.random(1000000, 9999999))
|
||||||
|
end
|
||||||
|
|
||||||
|
local source = info.source or "unknown"
|
||||||
|
local line = info.currentline or 0
|
||||||
|
|
||||||
|
-- Create base location key from source file and line number
|
||||||
|
local filename = source:match("([^/\\]+)$") or source -- Get filename
|
||||||
|
filename = filename:gsub("%.lua$", "") -- Remove .lua extension
|
||||||
|
local locationKey = filename .. "_L" .. line
|
||||||
|
|
||||||
|
-- If we have a parent, use tree-based ID generation for stability
|
||||||
|
if parent and parent.id and parent.id ~= "" then
|
||||||
|
-- For child elements, use call-site (file + line) like top-level elements
|
||||||
|
-- This ensures the same call site always generates the same ID, even when
|
||||||
|
-- retained children persist in parent.children array
|
||||||
|
local baseID = parent.id .. "_" .. locationKey
|
||||||
|
|
||||||
|
-- Count how many children have been created at THIS call site
|
||||||
|
local callSiteKey = parent.id .. "_" .. locationKey
|
||||||
|
callSiteCounters[callSiteKey] = (callSiteCounters[callSiteKey] or 0) + 1
|
||||||
|
local instanceNum = callSiteCounters[callSiteKey]
|
||||||
|
|
||||||
|
if instanceNum > 1 then
|
||||||
|
baseID = baseID .. "_" .. instanceNum
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Add property hash if provided (for additional differentiation)
|
||||||
|
if props then
|
||||||
|
local propHash = hashProps(props)
|
||||||
|
if propHash ~= "" then
|
||||||
|
-- Use first 8 chars of a simple hash
|
||||||
|
local hash = 0
|
||||||
|
for i = 1, #propHash do
|
||||||
|
hash = (hash * 31 + string.byte(propHash, i)) % 1000000
|
||||||
|
end
|
||||||
|
baseID = baseID .. "_" .. hash
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return baseID
|
||||||
|
end
|
||||||
|
|
||||||
|
-- No parent (top-level element): use call-site counter approach
|
||||||
|
-- Track how many elements have been created at this location
|
||||||
|
callSiteCounters[locationKey] = (callSiteCounters[locationKey] or 0) + 1
|
||||||
|
local instanceNum = callSiteCounters[locationKey]
|
||||||
|
|
||||||
|
local baseID = locationKey
|
||||||
|
|
||||||
|
-- Add instance number if multiple elements created at same location (e.g., in loops)
|
||||||
|
if instanceNum > 1 then
|
||||||
|
baseID = baseID .. "_" .. instanceNum
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Add property hash if provided (for additional differentiation)
|
||||||
|
if props then
|
||||||
|
local propHash = hashProps(props)
|
||||||
|
if propHash ~= "" then
|
||||||
|
-- Use first 8 chars of a simple hash
|
||||||
|
local hash = 0
|
||||||
|
for i = 1, #propHash do
|
||||||
|
hash = (hash * 31 + string.byte(propHash, i)) % 1000000
|
||||||
|
end
|
||||||
|
baseID = baseID .. "_" .. hash
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return baseID
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ====================
|
||||||
|
-- State Management
|
||||||
|
-- ====================
|
||||||
|
|
||||||
|
--- Initialize StateManager with dependencies
|
||||||
|
---@param deps table Dependencies: { ErrorHandler = ErrorHandler }
|
||||||
|
function StateManager.init(deps)
|
||||||
|
if type(deps) == "table" then
|
||||||
|
ErrorHandler = deps.ErrorHandler
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get state for an element ID, creating if it doesn't exist
|
||||||
|
---@param id string Element ID
|
||||||
|
---@param defaultState table|nil Default state if creating new
|
||||||
|
---@return table state State table for the element
|
||||||
|
function StateManager.getState(id, defaultState)
|
||||||
|
if not id then
|
||||||
|
ErrorHandler:error("StateManager", "SYS_001", {
|
||||||
|
parameter = "id",
|
||||||
|
value = "nil",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Create state if it doesn't exist
|
||||||
|
if not stateStore[id] then
|
||||||
|
-- Start with empty state (sparse storage)
|
||||||
|
stateStore[id] = defaultState or {}
|
||||||
|
|
||||||
|
-- Create metadata
|
||||||
|
stateMetadata[id] = {
|
||||||
|
lastFrame = frameNumber,
|
||||||
|
createdFrame = frameNumber,
|
||||||
|
accessCount = 0,
|
||||||
|
}
|
||||||
|
else
|
||||||
|
-- Update metadata
|
||||||
|
local meta = stateMetadata[id]
|
||||||
|
meta.lastFrame = frameNumber
|
||||||
|
meta.accessCount = meta.accessCount + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
return stateStore[id]
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Set state for an element ID (replaces entire state)
|
||||||
|
---@param id string Element ID
|
||||||
|
---@param state table State to store
|
||||||
|
function StateManager.setState(id, state)
|
||||||
|
if not id then
|
||||||
|
ErrorHandler:error("StateManager", "SYS_001", {
|
||||||
|
parameter = "id",
|
||||||
|
value = "nil",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Create sparse state (remove default values)
|
||||||
|
local sparseState = {}
|
||||||
|
for key, value in pairs(state) do
|
||||||
|
if not isDefaultValue(key, value) then
|
||||||
|
sparseState[key] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
stateStore[id] = sparseState
|
||||||
|
|
||||||
|
-- Update or create metadata
|
||||||
|
if not stateMetadata[id] then
|
||||||
|
stateMetadata[id] = {
|
||||||
|
lastFrame = frameNumber,
|
||||||
|
createdFrame = frameNumber,
|
||||||
|
accessCount = 1,
|
||||||
|
}
|
||||||
|
else
|
||||||
|
stateMetadata[id].lastFrame = frameNumber
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Update state for an element ID (merges with existing state)
|
||||||
|
---@param id string Element ID
|
||||||
|
---@param newState table New state values to merge
|
||||||
|
function StateManager.updateState(id, newState)
|
||||||
|
local state = StateManager.getState(id)
|
||||||
|
|
||||||
|
-- Merge new state into existing state (with diffing optimization)
|
||||||
|
local changed = false
|
||||||
|
for key, value in pairs(newState) do
|
||||||
|
if state[key] ~= value then
|
||||||
|
state[key] = value
|
||||||
|
changed = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Only update metadata if something actually changed
|
||||||
|
if changed then
|
||||||
|
stateMetadata[id].lastFrame = frameNumber
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Update state only if values have changed (optimized for immediate mode)
|
||||||
|
---@param id string Element ID
|
||||||
|
---@param newState table New state values to merge
|
||||||
|
---@return boolean changed True if any values changed
|
||||||
|
function StateManager.updateStateIfChanged(id, newState)
|
||||||
|
local state = StateManager.getState(id)
|
||||||
|
local changed = false
|
||||||
|
|
||||||
|
for key, value in pairs(newState) do
|
||||||
|
-- Skip if value hasn't changed (optimization)
|
||||||
|
if state[key] ~= value then
|
||||||
|
state[key] = value
|
||||||
|
changed = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if changed then
|
||||||
|
stateMetadata[id].lastFrame = frameNumber
|
||||||
|
end
|
||||||
|
|
||||||
|
return changed
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Clear state for a specific element ID
|
||||||
|
---@param id string Element ID
|
||||||
|
function StateManager.clearState(id)
|
||||||
|
stateStore[id] = nil
|
||||||
|
stateMetadata[id] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Mark state as used this frame (updates last accessed frame)
|
||||||
|
---@param id string Element ID
|
||||||
|
function StateManager.markStateUsed(id)
|
||||||
|
if stateMetadata[id] then
|
||||||
|
stateMetadata[id].lastFrame = frameNumber
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ====================
|
||||||
|
-- Frame Management
|
||||||
|
-- ====================
|
||||||
|
|
||||||
|
--- Increment frame counter (called at frame start)
|
||||||
|
function StateManager.incrementFrame()
|
||||||
|
frameNumber = frameNumber + 1
|
||||||
|
-- Reset call site counters for new frame
|
||||||
|
callSiteCounters = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get current frame number
|
||||||
|
---@return number
|
||||||
|
function StateManager.getFrameNumber()
|
||||||
|
return frameNumber
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ====================
|
||||||
|
-- Granular State Access (Unified API for both modes)
|
||||||
|
-- ====================
|
||||||
|
|
||||||
|
--- Get a single state value by key for a given element ID.
|
||||||
|
--- Works identically in both modes — the caller does not need to know the mode.
|
||||||
|
---
|
||||||
|
--- Immediate mode: reads from persistent state store.
|
||||||
|
--- Retained mode: resolves through registered element field (cache-through).
|
||||||
|
---
|
||||||
|
---@param id string Element state ID
|
||||||
|
---@param key string State key
|
||||||
|
---@return any value The stored value, or nil if not found
|
||||||
|
function StateManager.getStateValue(id, key)
|
||||||
|
if not id or not key then
|
||||||
|
ErrorHandler:error("StateManager", "SYS_001", {
|
||||||
|
parameter = "id and key",
|
||||||
|
value = "missing",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Update metadata for access tracking
|
||||||
|
if stateMetadata[id] then
|
||||||
|
stateMetadata[id].lastFrame = frameNumber
|
||||||
|
stateMetadata[id].accessCount = stateMetadata[id].accessCount + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
if _immediateMode then
|
||||||
|
-- Immediate mode: read from persistent state store
|
||||||
|
local state = stateStore[id]
|
||||||
|
if state then
|
||||||
|
return state[key]
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
else
|
||||||
|
-- Retained mode: resolve through element field
|
||||||
|
local element = statefulElements[id]
|
||||||
|
if element then
|
||||||
|
return element[key]
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Set a single state value by key for a given element ID.
|
||||||
|
--- Works identically in both modes — the caller does not need to know the mode.
|
||||||
|
---
|
||||||
|
--- Immediate mode: marks dirty for flushFrame() persistence.
|
||||||
|
--- Retained mode: writes directly to element field (cache-through).
|
||||||
|
---
|
||||||
|
---@param id string Element state ID
|
||||||
|
---@param key string State key
|
||||||
|
---@param value any Value to store
|
||||||
|
function StateManager.setStateValue(id, key, value)
|
||||||
|
if not id or not key then
|
||||||
|
ErrorHandler:error("StateManager", "SYS_001", {
|
||||||
|
parameter = "id and key",
|
||||||
|
value = "missing",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Update metadata
|
||||||
|
if not stateMetadata[id] then
|
||||||
|
stateMetadata[id] = {
|
||||||
|
lastFrame = frameNumber,
|
||||||
|
createdFrame = frameNumber,
|
||||||
|
accessCount = 1,
|
||||||
|
}
|
||||||
|
else
|
||||||
|
stateMetadata[id].lastFrame = frameNumber
|
||||||
|
end
|
||||||
|
|
||||||
|
if _immediateMode then
|
||||||
|
-- Immediate mode: mark dirty for flushFrame persistence
|
||||||
|
local state = StateManager.getState(id)
|
||||||
|
state[key] = value
|
||||||
|
dirtyState[id] = dirtyState[id] or {}
|
||||||
|
dirtyState[id][key] = true
|
||||||
|
else
|
||||||
|
-- Retained mode: write directly to element field
|
||||||
|
local element = statefulElements[id]
|
||||||
|
if element then
|
||||||
|
element[key] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ====================
|
||||||
|
-- Stateful Element Registration (Retained Mode Cache-Through)
|
||||||
|
-- ====================
|
||||||
|
|
||||||
|
--- Register an element instance for retained-mode cache-through.
|
||||||
|
--- After registration, getStateValue/setStateValue will resolve through the element's fields.
|
||||||
|
---
|
||||||
|
--- Called by Element in _construct phase.
|
||||||
|
---
|
||||||
|
---@param id string State ID (typically element.id)
|
||||||
|
---@param element table Element instance to link
|
||||||
|
function StateManager.registerStateful(id, element)
|
||||||
|
if not id or not element then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
statefulElements[id] = element
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Unregister an element instance.
|
||||||
|
--- After unregistration, retained-mode access will fall back to nil.
|
||||||
|
---
|
||||||
|
--- Called by Element in _cleanup phase.
|
||||||
|
---
|
||||||
|
---@param id string State ID to unregister
|
||||||
|
function StateManager.unregisterStateful(id)
|
||||||
|
if id then
|
||||||
|
statefulElements[id] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ====================
|
||||||
|
-- Frame Flush (Immediate Mode Dirty State Persistence)
|
||||||
|
-- ====================
|
||||||
|
|
||||||
|
--- Flush dirty state to persistent store at end of frame.
|
||||||
|
--- Called automatically at frame end in immediate mode.
|
||||||
|
--- Behaviors call setStateValue during update without knowing the mode.
|
||||||
|
---
|
||||||
|
--- In retained mode, this is a no-op (state is written directly to elements).
|
||||||
|
function StateManager.flushFrame()
|
||||||
|
if not _immediateMode then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- All dirty writes were already applied to stateStore during setStateValue
|
||||||
|
-- This method exists for future extensions (e.g., batching, analytics)
|
||||||
|
-- Reset dirty tracking for next frame
|
||||||
|
dirtyState = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ====================
|
||||||
|
-- Mode Configuration
|
||||||
|
-- ====================
|
||||||
|
|
||||||
|
--- Configure immediate mode state.
|
||||||
|
--- Called by Context when immediate mode is enabled/disabled.
|
||||||
|
---
|
||||||
|
---@param enabled boolean Whether immediate mode is active
|
||||||
|
function StateManager.setImmediateMode(enabled)
|
||||||
|
_immediateMode = enabled
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Check if immediate mode is active.
|
||||||
|
---@return boolean
|
||||||
|
function StateManager.isImmediateMode()
|
||||||
|
return _immediateMode
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Whether at-construction layout / eager initialization should run now.
|
||||||
|
--- Returns true in retained mode (layout eagerly), false in immediate mode
|
||||||
|
--- (layout is deferred to `FlexLove.endFrame` / FlexLove so it runs once all
|
||||||
|
--- elements for the frame have been created). This replaces the scattered
|
||||||
|
--- `if not _immediateMode then layoutChildren()` mode checks with a single
|
||||||
|
--- mode-aware query (behavior-mode-unification task 11).
|
||||||
|
---@return boolean
|
||||||
|
function StateManager.shouldLayout()
|
||||||
|
return not _immediateMode
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ====================
|
||||||
|
-- Cleanup & Maintenance
|
||||||
|
-- ====================
|
||||||
|
|
||||||
|
--- Clean up stale states (not accessed recently)
|
||||||
|
---@return number count Number of states cleaned up
|
||||||
|
function StateManager.cleanup()
|
||||||
|
local cleanedCount = 0
|
||||||
|
local retentionFrames = config.stateRetentionFrames
|
||||||
|
|
||||||
|
for id, meta in pairs(stateMetadata) do
|
||||||
|
local framesSinceAccess = frameNumber - meta.lastFrame
|
||||||
|
|
||||||
|
if framesSinceAccess > retentionFrames then
|
||||||
|
stateStore[id] = nil
|
||||||
|
stateMetadata[id] = nil
|
||||||
|
cleanedCount = cleanedCount + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Clean up empty states (sparse storage optimization)
|
||||||
|
for id, state in pairs(stateStore) do
|
||||||
|
if next(state) == nil then
|
||||||
|
stateStore[id] = nil
|
||||||
|
stateMetadata[id] = nil
|
||||||
|
cleanedCount = cleanedCount + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return cleanedCount
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Force cleanup if state count exceeds maximum
|
||||||
|
---@return number count Number of states cleaned up
|
||||||
|
function StateManager.forceCleanupIfNeeded()
|
||||||
|
local stateCount = StateManager.getStateCount()
|
||||||
|
|
||||||
|
if stateCount > config.maxStateEntries then
|
||||||
|
-- Clean up states not accessed in last 10 frames (aggressive)
|
||||||
|
local cleanedCount = 0
|
||||||
|
|
||||||
|
for id, meta in pairs(stateMetadata) do
|
||||||
|
local framesSinceAccess = frameNumber - meta.lastFrame
|
||||||
|
|
||||||
|
if framesSinceAccess > 10 then
|
||||||
|
stateStore[id] = nil
|
||||||
|
stateMetadata[id] = nil
|
||||||
|
cleanedCount = cleanedCount + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return cleanedCount
|
||||||
|
end
|
||||||
|
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get total number of stored states
|
||||||
|
---@return number
|
||||||
|
function StateManager.getStateCount()
|
||||||
|
local count = 0
|
||||||
|
for _ in pairs(stateStore) do
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
return count
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Clear all states
|
||||||
|
function StateManager.clearAllStates()
|
||||||
|
stateStore = {}
|
||||||
|
stateMetadata = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Configure state management
|
||||||
|
---@param newConfig {stateRetentionFrames?: number, maxStateEntries?: number}
|
||||||
|
function StateManager.configure(newConfig)
|
||||||
|
if newConfig.stateRetentionFrames then
|
||||||
|
config.stateRetentionFrames = newConfig.stateRetentionFrames
|
||||||
|
end
|
||||||
|
if newConfig.maxStateEntries then
|
||||||
|
config.maxStateEntries = newConfig.maxStateEntries
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get state statistics for debugging
|
||||||
|
---@return table stats State usage statistics
|
||||||
|
function StateManager.getStats()
|
||||||
|
local stateCount = StateManager.getStateCount()
|
||||||
|
local oldest = nil
|
||||||
|
local newest = nil
|
||||||
|
|
||||||
|
for _, meta in pairs(stateMetadata) do
|
||||||
|
if not oldest or meta.createdFrame < oldest then
|
||||||
|
oldest = meta.createdFrame
|
||||||
|
end
|
||||||
|
if not newest or meta.createdFrame > newest then
|
||||||
|
newest = meta.createdFrame
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Count callSiteCounters
|
||||||
|
local callSiteCount = 0
|
||||||
|
for _ in pairs(callSiteCounters) do
|
||||||
|
callSiteCount = callSiteCount + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Warn if callSiteCounters is unexpectedly large
|
||||||
|
if callSiteCount > 1000 then
|
||||||
|
if ErrorHandler then
|
||||||
|
ErrorHandler.warn("StateManager", "STATE_001", {
|
||||||
|
count = callSiteCount,
|
||||||
|
expected = "near 0",
|
||||||
|
frameNumber = frameNumber,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
stateCount = stateCount,
|
||||||
|
frameNumber = frameNumber,
|
||||||
|
oldestState = oldest,
|
||||||
|
newestState = newest,
|
||||||
|
callSiteCounterCount = callSiteCount,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get internal state (for debugging/profiling only)
|
||||||
|
---@return table internal {stateStore, stateMetadata, callSiteCounters}
|
||||||
|
function StateManager._getInternalState()
|
||||||
|
return {
|
||||||
|
stateStore = stateStore,
|
||||||
|
stateMetadata = stateMetadata,
|
||||||
|
callSiteCounters = callSiteCounters,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Reset the entire state system (for testing)
|
||||||
|
function StateManager.reset()
|
||||||
|
stateStore = {}
|
||||||
|
stateMetadata = {}
|
||||||
|
frameNumber = 0
|
||||||
|
callSiteCounters = {}
|
||||||
|
statefulElements = {}
|
||||||
|
dirtyState = {}
|
||||||
|
_immediateMode = false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ====================
|
||||||
|
-- Convenience Functions (for backward compatibility)
|
||||||
|
-- ====================
|
||||||
|
|
||||||
|
--- Check if an element is currently hovered
|
||||||
|
---@param id string Element ID
|
||||||
|
---@return boolean
|
||||||
|
function StateManager.isHovered(id)
|
||||||
|
local state = StateManager.getState(id)
|
||||||
|
return state.hover or false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Check if an element is currently pressed
|
||||||
|
---@param id string Element ID
|
||||||
|
---@return boolean
|
||||||
|
function StateManager.isPressed(id)
|
||||||
|
local state = StateManager.getState(id)
|
||||||
|
return state.pressed or false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Check if an element is currently focused
|
||||||
|
---@param id string Element ID
|
||||||
|
---@return boolean
|
||||||
|
function StateManager.isFocused(id)
|
||||||
|
local state = StateManager.getState(id)
|
||||||
|
return state.focused or false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Check if an element is disabled
|
||||||
|
---@param id string Element ID
|
||||||
|
---@return boolean
|
||||||
|
function StateManager.isDisabled(id)
|
||||||
|
local state = StateManager.getState(id)
|
||||||
|
return state.disabled or false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Check if an element is active (e.g., input focused)
|
||||||
|
---@param id string Element ID
|
||||||
|
---@return boolean
|
||||||
|
function StateManager.isActive(id)
|
||||||
|
local state = StateManager.getState(id)
|
||||||
|
return state.active or false
|
||||||
|
end
|
||||||
|
|
||||||
|
return StateManager
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
|||||||
|
local modulePath = (...):match("(.-)[^%.]+$")
|
||||||
|
local function req(name)
|
||||||
|
return require(modulePath .. name)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Text sanitization, escaping, and input validation utilities.
|
||||||
|
|
||||||
|
-- ErrorHandler is injected via init() for truncation warnings.
|
||||||
|
local ErrorHandler = nil
|
||||||
|
|
||||||
|
--- Initialize dependencies
|
||||||
|
---@param deps table Dependencies: { ErrorHandler = ErrorHandler }
|
||||||
|
local function init(deps)
|
||||||
|
if type(deps) == "table" then
|
||||||
|
ErrorHandler = deps.ErrorHandler
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Sanitize text to prevent security vulnerabilities
|
||||||
|
--- @param text string? Text to sanitize
|
||||||
|
--- @param options table? Sanitization options
|
||||||
|
--- @return string Sanitized text
|
||||||
|
local function sanitizeText(text, options)
|
||||||
|
local utf8 = require("utf8")
|
||||||
|
-- Handle nil or non-string inputs
|
||||||
|
if text == nil then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
if type(text) ~= "string" then
|
||||||
|
text = tostring(text)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Default options
|
||||||
|
options = options or {}
|
||||||
|
local maxLength = options.maxLength or 10000
|
||||||
|
local allowNewlines = options.allowNewlines ~= false -- default true
|
||||||
|
local allowTabs = options.allowTabs ~= false -- default true
|
||||||
|
local stripControls = options.stripControls ~= false -- default true
|
||||||
|
local trimWhitespace = options.trimWhitespace ~= false -- default true
|
||||||
|
|
||||||
|
-- Remove null bytes (critical security risk)
|
||||||
|
text = text:gsub("%z", "")
|
||||||
|
|
||||||
|
-- Strip control characters except allowed ones
|
||||||
|
if stripControls then
|
||||||
|
local pattern = "[\1-\31\127]" -- All control characters
|
||||||
|
if allowNewlines and allowTabs then
|
||||||
|
pattern = "[\1-\8\11\12\14-\31\127]" -- Exclude \t (9), \n (10), \r (13)
|
||||||
|
elseif allowNewlines then
|
||||||
|
pattern = "[\1-\9\11\12\14-\31\127]" -- Exclude \n (10), \r (13)
|
||||||
|
elseif allowTabs then
|
||||||
|
pattern = "[\1-\8\10\12-\31\127]" -- Exclude \t (9)
|
||||||
|
end
|
||||||
|
text = text:gsub(pattern, "")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Trim leading/trailing whitespace
|
||||||
|
if trimWhitespace then
|
||||||
|
text = text:match("^%s*(.-)%s*$") or ""
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Limit string length (use UTF-8 character count, not byte count)
|
||||||
|
local charCount = utf8.len(text)
|
||||||
|
if charCount and charCount > maxLength then
|
||||||
|
if ErrorHandler then
|
||||||
|
ErrorHandler:warn("utils", "UTIL_001", {
|
||||||
|
original = charCount,
|
||||||
|
truncated = maxLength,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
-- Truncate to maxLength UTF-8 characters
|
||||||
|
local bytePos = utf8.offset(text, maxLength + 1)
|
||||||
|
if bytePos then
|
||||||
|
text = text:sub(1, bytePos - 1)
|
||||||
|
end
|
||||||
|
if ErrorHandler then
|
||||||
|
ErrorHandler:warn("utils", string.format("Text truncated from %d to %d characters", charCount, maxLength))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return text
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Validate text input against rules
|
||||||
|
--- @param text string Text to validate
|
||||||
|
--- @param rules table Validation rules
|
||||||
|
--- @return boolean, string? Returns true if valid, or false with error message
|
||||||
|
local function validateTextInput(text, rules)
|
||||||
|
rules = rules or {}
|
||||||
|
|
||||||
|
-- Check minimum length
|
||||||
|
if rules.minLength and #text < rules.minLength then
|
||||||
|
return false, string.format("Text must be at least %d characters", rules.minLength)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check maximum length
|
||||||
|
if rules.maxLength and #text > rules.maxLength then
|
||||||
|
return false, string.format("Text must be at most %d characters", rules.maxLength)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check pattern match
|
||||||
|
if rules.pattern and not text:match(rules.pattern) then
|
||||||
|
return false, rules.patternError or "Text does not match required pattern"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check character whitelist
|
||||||
|
if rules.allowedChars then
|
||||||
|
local pattern = "[^" .. rules.allowedChars .. "]"
|
||||||
|
if text:match(pattern) then
|
||||||
|
return false, "Text contains invalid characters"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check character blacklist
|
||||||
|
if rules.forbiddenChars then
|
||||||
|
local pattern = "[" .. rules.forbiddenChars .. "]"
|
||||||
|
if text:match(pattern) then
|
||||||
|
return false, "Text contains forbidden characters"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Validate text against range/length rules (alias of validateTextInput)
|
||||||
|
--- @param text string Text to validate
|
||||||
|
--- @param rules table Validation rules (minLength, maxLength, pattern, etc.)
|
||||||
|
--- @return boolean, string? Returns true if valid, or false with error message
|
||||||
|
local function validateTextRange(text, rules)
|
||||||
|
return validateTextInput(text, rules)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Escape HTML special characters
|
||||||
|
--- @param text string Text to escape
|
||||||
|
--- @return string Escaped text
|
||||||
|
local function escapeHtml(text)
|
||||||
|
if text == nil then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
text = tostring(text)
|
||||||
|
text = text:gsub("&", "&")
|
||||||
|
text = text:gsub("<", "<")
|
||||||
|
text = text:gsub(">", ">")
|
||||||
|
text = text:gsub('"', """)
|
||||||
|
text = text:gsub("'", "'")
|
||||||
|
return text
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Escape Lua pattern special characters
|
||||||
|
--- @param text string Text to escape
|
||||||
|
--- @return string Escaped text
|
||||||
|
local function escapeLuaPattern(text)
|
||||||
|
if text == nil then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
text = tostring(text)
|
||||||
|
-- Escape all Lua pattern special characters
|
||||||
|
text = text:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1")
|
||||||
|
return text
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Strip all non-printable characters from text
|
||||||
|
--- @param text string Text to clean
|
||||||
|
--- @return string Cleaned text
|
||||||
|
local function stripNonPrintable(text)
|
||||||
|
if text == nil then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
text = tostring(text)
|
||||||
|
-- Keep printable ASCII (32-126), newline (10), tab (9), and carriage return (13)
|
||||||
|
text = text:gsub("[^\9\10\13\32-\126]", "")
|
||||||
|
return text
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
init = init,
|
||||||
|
sanitizeText = sanitizeText,
|
||||||
|
validateTextInput = validateTextInput,
|
||||||
|
validateTextRange = validateTextRange,
|
||||||
|
escapeHtml = escapeHtml,
|
||||||
|
escapeLuaPattern = escapeLuaPattern,
|
||||||
|
stripNonPrintable = stripNonPrintable,
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
|||||||
|
---@class UTF8
|
||||||
|
---Compatibility layer for UTF-8 support across Lua versions
|
||||||
|
---Handles utf8 (Lua 5.3+), lua-utf8 (LuaRocks), and basic fallbacks
|
||||||
|
|
||||||
|
local UTF8 = {}
|
||||||
|
|
||||||
|
-- Try to load UTF-8 library in order of preference:
|
||||||
|
-- 1. Built-in utf8 (Lua 5.3+, LÖVE2D)
|
||||||
|
-- 2. lua-utf8 from LuaRocks (Lua 5.1, 5.2)
|
||||||
|
-- 3. Error if neither available
|
||||||
|
local function loadUTF8()
|
||||||
|
-- Try built-in utf8 first (Lua 5.3+ and LÖVE2D)
|
||||||
|
if utf8 and type(utf8) == "table" and utf8.len then
|
||||||
|
return utf8
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Try lua-utf8 from LuaRocks
|
||||||
|
local ok, luautf8 = pcall(require, "lua-utf8")
|
||||||
|
if ok then
|
||||||
|
return luautf8
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Try standard utf8 module name as fallback
|
||||||
|
ok, luautf8 = pcall(require, "utf8")
|
||||||
|
if ok then
|
||||||
|
return luautf8
|
||||||
|
end
|
||||||
|
|
||||||
|
-- No UTF-8 library available
|
||||||
|
error("No UTF-8 library available. Please install 'luautf8' via LuaRocks: luarocks install luautf8")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Load the UTF-8 implementation
|
||||||
|
local utf8lib = loadUTF8()
|
||||||
|
|
||||||
|
-- Export all utf8 functions
|
||||||
|
UTF8.char = utf8lib.char
|
||||||
|
UTF8.charpattern = utf8lib.charpattern
|
||||||
|
UTF8.codes = utf8lib.codes
|
||||||
|
UTF8.codepoint = utf8lib.codepoint
|
||||||
|
UTF8.len = utf8lib.len
|
||||||
|
UTF8.offset = utf8lib.offset
|
||||||
|
|
||||||
|
return UTF8
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
--- Utility module for parsing and resolving CSS-like units (px, %, vw, vh)
|
||||||
|
--- Provides unit parsing, validation, and conversion to pixel values
|
||||||
|
---@class Units
|
||||||
|
---@field _Context table? Context module dependency
|
||||||
|
---@field _ErrorHandler table? ErrorHandler module dependency
|
||||||
|
---@field _Calc table? Calc module dependency
|
||||||
|
local Units = {}
|
||||||
|
|
||||||
|
--- Initialize Units module with dependencies
|
||||||
|
---@param deps table Dependencies: { Context = table?, ErrorHandler = table?, Calc = table? }
|
||||||
|
function Units.init(deps)
|
||||||
|
Units._Context = deps.Context
|
||||||
|
Units._ErrorHandler = deps.ErrorHandler
|
||||||
|
Units._Calc = deps.Calc
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Parse a unit value into numeric value and unit type
|
||||||
|
--- Supports: px (pixels), % (percentage), vw/vh (viewport), and calc() expressions
|
||||||
|
---@param value string|number|table The value to parse (e.g., "50px", "10%", "2vw", 100, or calc object)
|
||||||
|
---@return number|table numericValue The numeric portion of the value or calc object
|
||||||
|
---@return string unitType The unit type ("px", "%", "vw", "vh", "calc")
|
||||||
|
function Units.parse(value)
|
||||||
|
-- Check if value is a calc expression
|
||||||
|
if Units._Calc and Units._Calc.isCalc(value) then
|
||||||
|
return value, "calc"
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(value) == "number" then
|
||||||
|
return value, "px"
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(value) ~= "string" and type(value) ~= "table" then
|
||||||
|
Units._ErrorHandler:warn("Units", "VAL_001", {
|
||||||
|
property = "unit value",
|
||||||
|
expected = "string, number, or calc object",
|
||||||
|
got = type(value),
|
||||||
|
})
|
||||||
|
return 0, "px"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for unit-only input (e.g., "px", "%", "vw" without a number)
|
||||||
|
local validUnits = { px = true, ["%"] = true, vw = true, vh = true }
|
||||||
|
if validUnits[value] then
|
||||||
|
Units._ErrorHandler:warn("Units", "VAL_005", {
|
||||||
|
input = value,
|
||||||
|
expected = "number + unit (e.g., '50" .. value .. "')",
|
||||||
|
})
|
||||||
|
return 0, "px"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for invalid format (space between number and unit)
|
||||||
|
if value:match("%d%s+%a") then
|
||||||
|
Units._ErrorHandler:warn("Units", "VAL_005", {
|
||||||
|
input = value,
|
||||||
|
issue = "contains space between number and unit",
|
||||||
|
})
|
||||||
|
return 0, "px"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Match number followed by optional unit
|
||||||
|
local numStr, unit = value:match("^([%-]?[%d%.]+)(.*)$")
|
||||||
|
if not numStr then
|
||||||
|
Units._ErrorHandler:warn("Units", "VAL_005", {
|
||||||
|
input = value,
|
||||||
|
})
|
||||||
|
return 0, "px"
|
||||||
|
end
|
||||||
|
|
||||||
|
local num = tonumber(numStr)
|
||||||
|
if not num then
|
||||||
|
Units._ErrorHandler:warn("Units", "VAL_005", {
|
||||||
|
input = value,
|
||||||
|
issue = "numeric value cannot be parsed",
|
||||||
|
})
|
||||||
|
return 0, "px"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Default to pixels if no unit specified
|
||||||
|
if unit == "" then
|
||||||
|
unit = "px"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- validUnits is already defined at the top of the function
|
||||||
|
if not validUnits[unit] then
|
||||||
|
Units._ErrorHandler:warn("Units", "VAL_005", {
|
||||||
|
input = value,
|
||||||
|
unit = unit,
|
||||||
|
validUnits = "px, %, vw, vh",
|
||||||
|
})
|
||||||
|
return num, "px"
|
||||||
|
end
|
||||||
|
|
||||||
|
return num, unit
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Convert relative units to absolute pixel values
|
||||||
|
--- Resolves %, vw, vh units based on viewport and parent dimensions, and evaluates calc() expressions
|
||||||
|
---@param value number|table Numeric value to convert or calc object
|
||||||
|
---@param unit string Unit type ("px", "%", "vw", "vh", "calc")
|
||||||
|
---@param viewportWidth number Current viewport width in pixels
|
||||||
|
---@param viewportHeight number Current viewport height in pixels
|
||||||
|
---@param parentSize number? Required for percentage units (parent dimension in pixels)
|
||||||
|
---@return number resolvedValue Resolved pixel value
|
||||||
|
function Units.resolve(value, unit, viewportWidth, viewportHeight, parentSize)
|
||||||
|
if unit == "calc" then
|
||||||
|
-- Resolve calc expression
|
||||||
|
if Units._Calc then
|
||||||
|
return Units._Calc.resolve(value, viewportWidth, viewportHeight, parentSize)
|
||||||
|
else
|
||||||
|
Units._ErrorHandler:warn("Units", "VAL_006", {
|
||||||
|
unit = "calc",
|
||||||
|
issue = "Calc module not available",
|
||||||
|
})
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
elseif unit == "px" then
|
||||||
|
return value
|
||||||
|
elseif unit == "%" then
|
||||||
|
if not parentSize then
|
||||||
|
Units._ErrorHandler:warn("Units", "LAY_003", {
|
||||||
|
unit = "%",
|
||||||
|
issue = "parent dimension not available",
|
||||||
|
})
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
return (value / 100) * parentSize
|
||||||
|
elseif unit == "vw" then
|
||||||
|
return (value / 100) * viewportWidth
|
||||||
|
elseif unit == "vh" then
|
||||||
|
return (value / 100) * viewportHeight
|
||||||
|
else
|
||||||
|
Units._ErrorHandler:warn("Units", "VAL_005", {
|
||||||
|
unit = unit,
|
||||||
|
validUnits = "px, %, vw, vh, calc",
|
||||||
|
})
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get current viewport dimensions
|
||||||
|
--- Uses cached viewport during resize operations, otherwise queries LÖVE graphics
|
||||||
|
---@return number width Viewport width in pixels
|
||||||
|
---@return number height Viewport height in pixels
|
||||||
|
function Units.getViewport()
|
||||||
|
-- Return cached viewport if available (only during resize operations)
|
||||||
|
if Units._Context._cachedViewport and Units._Context._cachedViewport.width > 0 then
|
||||||
|
return Units._Context._cachedViewport.width, Units._Context._cachedViewport.height
|
||||||
|
end
|
||||||
|
|
||||||
|
if love.graphics and love.graphics.getDimensions then
|
||||||
|
return love.graphics.getDimensions()
|
||||||
|
else
|
||||||
|
local w, h = love.window.getMode()
|
||||||
|
return w, h
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Apply base scale factor to a value based on axis
|
||||||
|
--- Used for responsive scaling of UI elements
|
||||||
|
---@param value number The value to scale
|
||||||
|
---@param axis "x"|"y" The axis to scale on
|
||||||
|
---@param scaleFactors {x:number, y:number} Scale factors for each axis
|
||||||
|
---@return number scaledValue The scaled value
|
||||||
|
function Units.applyBaseScale(value, axis, scaleFactors)
|
||||||
|
if axis == "x" then
|
||||||
|
return value * scaleFactors.x
|
||||||
|
else
|
||||||
|
return value * scaleFactors.y
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Resolve spacing properties (margin, padding) to pixel values
|
||||||
|
--- Supports individual sides (top, right, bottom, left) and shortcuts (vertical, horizontal)
|
||||||
|
---@param spacingProps table? Spacing properties with top/right/bottom/left/vertical/horizontal
|
||||||
|
---@param parentWidth number Parent element width in pixels
|
||||||
|
---@param parentHeight number Parent element height in pixels
|
||||||
|
---@return table resolvedSpacing Table with top, right, bottom, left in pixels
|
||||||
|
function Units.resolveSpacing(spacingProps, parentWidth, parentHeight)
|
||||||
|
if not spacingProps then
|
||||||
|
return { top = 0, right = 0, bottom = 0, left = 0 }
|
||||||
|
end
|
||||||
|
|
||||||
|
local viewportWidth, viewportHeight = Units.getViewport()
|
||||||
|
local result = {}
|
||||||
|
|
||||||
|
local vertical = spacingProps.vertical
|
||||||
|
local horizontal = spacingProps.horizontal
|
||||||
|
|
||||||
|
if vertical then
|
||||||
|
if type(vertical) == "string" or (Units._Calc and Units._Calc.isCalc(vertical)) then
|
||||||
|
local value, unit = Units.parse(vertical)
|
||||||
|
vertical = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if horizontal then
|
||||||
|
if type(horizontal) == "string" or (Units._Calc and Units._Calc.isCalc(horizontal)) then
|
||||||
|
local value, unit = Units.parse(horizontal)
|
||||||
|
horizontal = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, side in ipairs({ "top", "right", "bottom", "left" }) do
|
||||||
|
local value = spacingProps[side]
|
||||||
|
if value then
|
||||||
|
if type(value) == "string" or (Units._Calc and Units._Calc.isCalc(value)) then
|
||||||
|
local numValue, unit = Units.parse(value)
|
||||||
|
local parentSize = (side == "top" or side == "bottom") and parentHeight or parentWidth
|
||||||
|
result[side] = Units.resolve(numValue, unit, viewportWidth, viewportHeight, parentSize)
|
||||||
|
else
|
||||||
|
result[side] = value
|
||||||
|
end
|
||||||
|
else
|
||||||
|
if side == "top" or side == "bottom" then
|
||||||
|
result[side] = vertical or 0
|
||||||
|
else
|
||||||
|
result[side] = horizontal or 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Validate a unit string format
|
||||||
|
--- Checks if the string can be successfully parsed as a valid unit or calc expression
|
||||||
|
---@param unitStr string|table The unit string to validate (e.g., "50px", "10%") or calc object
|
||||||
|
---@return boolean isValid True if the unit string is valid, false otherwise
|
||||||
|
function Units.isValid(unitStr)
|
||||||
|
-- Check if it's a calc expression
|
||||||
|
if Units._Calc and Units._Calc.isCalc(unitStr) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(unitStr) ~= "string" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check for invalid format (space between number and unit)
|
||||||
|
if unitStr:match("%d%s+%a") then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Match number followed by optional unit
|
||||||
|
local numStr, unit = unitStr:match("^([%-]?[%d%.]+)(.*)$")
|
||||||
|
if not numStr then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if numeric part is valid
|
||||||
|
local num = tonumber(numStr)
|
||||||
|
if not num then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Default to pixels if no unit specified
|
||||||
|
if unit == "" then
|
||||||
|
unit = "px"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if unit is valid
|
||||||
|
local validUnits = { px = true, ["%"] = true, vw = true, vh = true }
|
||||||
|
return validUnits[unit] == true
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Parse CSS flex shorthand into flexGrow, flexShrink, flexBasis
|
||||||
|
--- Supports: number, "auto", "none", "grow shrink basis"
|
||||||
|
---@param flexValue number|string The flex shorthand value
|
||||||
|
---@return number flexGrow
|
||||||
|
---@return number flexShrink
|
||||||
|
---@return string|number flexBasis
|
||||||
|
function Units.parseFlexShorthand(flexValue)
|
||||||
|
-- Single number: flex-grow
|
||||||
|
if type(flexValue) == "number" then
|
||||||
|
return flexValue, 1, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- String values
|
||||||
|
if type(flexValue) == "string" then
|
||||||
|
-- "auto" = 1 1 auto
|
||||||
|
if flexValue == "auto" then
|
||||||
|
return 1, 1, "auto"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- "none" = 0 0 auto
|
||||||
|
if flexValue == "none" then
|
||||||
|
return 0, 0, "auto"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Parse "grow shrink basis" format
|
||||||
|
local parts = {}
|
||||||
|
for part in flexValue:gmatch("%S+") do
|
||||||
|
table.insert(parts, part)
|
||||||
|
end
|
||||||
|
|
||||||
|
local grow = 0
|
||||||
|
local shrink = 1
|
||||||
|
local basis = "auto"
|
||||||
|
|
||||||
|
if #parts == 1 then
|
||||||
|
-- Single value: could be grow (number) or basis (with unit)
|
||||||
|
local num = tonumber(parts[1])
|
||||||
|
if num then
|
||||||
|
grow = num
|
||||||
|
basis = 0
|
||||||
|
else
|
||||||
|
basis = parts[1]
|
||||||
|
end
|
||||||
|
elseif #parts == 2 then
|
||||||
|
-- Two values: grow shrink (both numbers) or grow basis
|
||||||
|
local num1 = tonumber(parts[1])
|
||||||
|
local num2 = tonumber(parts[2])
|
||||||
|
if num1 and num2 then
|
||||||
|
grow = num1
|
||||||
|
shrink = num2
|
||||||
|
basis = 0
|
||||||
|
elseif num1 then
|
||||||
|
grow = num1
|
||||||
|
basis = parts[2]
|
||||||
|
end
|
||||||
|
elseif #parts >= 3 then
|
||||||
|
-- Three values: grow shrink basis
|
||||||
|
grow = tonumber(parts[1]) or 0
|
||||||
|
shrink = tonumber(parts[2]) or 1
|
||||||
|
basis = parts[3]
|
||||||
|
end
|
||||||
|
|
||||||
|
return grow, shrink, basis
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Default fallback
|
||||||
|
return 0, 1, "auto"
|
||||||
|
end
|
||||||
|
|
||||||
|
return Units
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---@class ZIndex
|
||||||
|
local ZIndex = {}
|
||||||
|
|
||||||
|
-- The effective z-index formula used for sorting is:
|
||||||
|
-- rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ
|
||||||
|
-- where rootZ is the z-index of the top-level ancestor, depth is the
|
||||||
|
-- nesting level, and ownZ is the element's own z property.
|
||||||
|
--
|
||||||
|
-- Constraints enforced by these weights:
|
||||||
|
-- |ownZ| <= MAX_Z (must fit within DEPTH_WEIGHT digits)
|
||||||
|
-- DEPTH_WEIGHT has enough room for depths well beyond any practical tree
|
||||||
|
-- ROOT_WEIGHT has enough room for the rootZ without exceeding double-precision
|
||||||
|
---
|
||||||
|
---@type integer
|
||||||
|
ZIndex.MIN_Z = -999
|
||||||
|
---@type integer
|
||||||
|
ZIndex.MAX_Z = 999
|
||||||
|
---@type integer
|
||||||
|
ZIndex.ROOT_WEIGHT = 10000000000
|
||||||
|
---@type integer
|
||||||
|
ZIndex.DEPTH_WEIGHT = 1000
|
||||||
|
|
||||||
|
--- Clamp a z-index value to the valid range
|
||||||
|
---@param value number
|
||||||
|
---@return integer
|
||||||
|
function ZIndex.clamp(value)
|
||||||
|
if value < ZIndex.MIN_Z then
|
||||||
|
return ZIndex.MIN_Z
|
||||||
|
elseif value > ZIndex.MAX_Z then
|
||||||
|
return ZIndex.MAX_Z
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
return ZIndex
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
-- modules/behaviors/Animated.lua
|
||||||
|
--
|
||||||
|
-- Concrete behavior: animation update, interpolation application, chaining
|
||||||
|
-- resolution, and transition wiring.
|
||||||
|
--
|
||||||
|
-- Task 06 of the behavior-mode-unification refactor. Moves the entire
|
||||||
|
-- animation-update block out of Element:update (lines ~2761-2800) into
|
||||||
|
-- `Animated.onUpdate(element, dt)`, and the `_ColorModule`/`_TransformModule`
|
||||||
|
-- init-time wiring into `Animated.onAttach(element)`.
|
||||||
|
--
|
||||||
|
-- This behavior is UNIQUE among the behavior set because it can attach
|
||||||
|
-- AFTER element creation. Animation is opt-in: a plain Element created without
|
||||||
|
-- `transitions` and without an `animation` field never attaches Animated.
|
||||||
|
-- The moment something creates an animation on the element — either directly
|
||||||
|
-- (`element.animation = Animation.new(...)`, `element:fadeIn(...)`) or via a
|
||||||
|
-- transition firing in `setProperty` — `Animated.ensureAttached(element)`
|
||||||
|
-- attaches this behavior on demand so subsequent `Element:update` frames
|
||||||
|
-- dispatch to `Animated.onUpdate`.
|
||||||
|
--
|
||||||
|
-- Attachment rule (shouldAttach): true when `props.transitions` is set OR an
|
||||||
|
-- `element.animation` already exists at runtime. The runtime arm covers the
|
||||||
|
-- late-attach case (animateTo / fadeIn / direct animation assignment).
|
||||||
|
--
|
||||||
|
-- State ownership (per the locked Behavior contract):
|
||||||
|
-- * Per-element runtime state lives ON THE ELEMENT (`element.animation`).
|
||||||
|
-- * The behavior instance itself is stateless and shared across elements.
|
||||||
|
-- * Element-class-level dependencies (Element._Animation, Element._Color,
|
||||||
|
-- Element._Transform) are resolved from the owning element's metatable,
|
||||||
|
-- exactly like Clickable does — keeping the behavior stateless without
|
||||||
|
-- expanding the 6-hook signature.
|
||||||
|
--
|
||||||
|
-- saveState/restoreState are no-ops: animations are ephemeral (an in-flight
|
||||||
|
-- animation is not part of immediate-mode persisted state — the next frame
|
||||||
|
-- re-evaluates transitions / re-applies animations fresh). Persisted scalar
|
||||||
|
-- props (`opacity`, `x`, ...) survive via Element.saveState's `_props` block,
|
||||||
|
-- not via the animation.
|
||||||
|
|
||||||
|
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||||
|
local Behavior = require(_pkg .. "Behavior")
|
||||||
|
|
||||||
|
-- Resolve the Element class from an element instance.
|
||||||
|
-- Element instances are created via `setmetatable({}, Element)` in _construct,
|
||||||
|
-- so their metatable IS the Element class — giving us Element._Animation,
|
||||||
|
-- Element._Color, Element._Transform, etc. without threading deps through the
|
||||||
|
-- behavior hook signature.
|
||||||
|
local function ElementClass(element)
|
||||||
|
return getmetatable(element)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- ensureAnimationModuleWiring — set Element._Animation._ColorModule /
|
||||||
|
-- _TransformModule. Idempotent; called from both onAttach and onUpdate so it
|
||||||
|
-- works even when an animation was assigned by a caller that bypassed
|
||||||
|
-- onAttach (direct `element.animation = Animation.new(...)`).
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function ensureAnimationModuleWiring(element)
|
||||||
|
local Element = ElementClass(element)
|
||||||
|
local Animation = Element._Animation
|
||||||
|
if not Animation then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- Ensure animation has Color module reference for color interpolation
|
||||||
|
if not Animation._ColorModule and Element._Color then
|
||||||
|
Animation._ColorModule = Element._Color
|
||||||
|
end
|
||||||
|
-- Ensure animation has Transform module reference for transform interpolation
|
||||||
|
if not Animation._TransformModule and Element._Transform then
|
||||||
|
Animation._TransformModule = Element._Transform
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- shouldAttach (class-level predicate, no element required)
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- True when the element declares transitions up front OR already has an
|
||||||
|
-- animation attached. The `animation` arm is consulted by ensureAttached at
|
||||||
|
-- runtime (after creation); the `transitions` arm lets Animated auto-attach
|
||||||
|
-- during Element.new for elements that pre-declare transitions.
|
||||||
|
local function shouldAttach(props)
|
||||||
|
if not props then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if props.transitions ~= nil then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
-- Late-attach case: an animation was assigned after creation. When ensure
|
||||||
|
-- Attached passes the element instance as `props`, this arm catches it.
|
||||||
|
if type(props) == "table" and props.animation ~= nil then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- ensureAttached — dynamic late-attach entry point
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Idempotently attach the Animated behavior to an element that just gained an
|
||||||
|
-- animation (via animateTo / fadeIn / direct assignment / a firing transition
|
||||||
|
-- in setProperty). Called from Element.setProperty when a transition fires and
|
||||||
|
-- from the transition helper methods on Element. Safe to call when already
|
||||||
|
-- attached (no-op / returns false).
|
||||||
|
--
|
||||||
|
-- `animatedBehavior` is the shared behavior instance resolved lazily by
|
||||||
|
-- Element (see Element._resolveAnimatedBehavior). The behavior is looked up
|
||||||
|
-- from the registry once and cached on the class.
|
||||||
|
--
|
||||||
|
-- Returns true if the behavior was attached this call, false otherwise.
|
||||||
|
local function ensureAttached(element, animatedBehavior)
|
||||||
|
if not element or not animatedBehavior then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
-- Already attached? Avoid duplicate entries within one element lifetime
|
||||||
|
-- (a behavior may legitimately be re-added across immediate-mode frames
|
||||||
|
-- since Element is recreated each frame, but within one lifetime at most
|
||||||
|
-- once).
|
||||||
|
local behaviors = element.behaviors
|
||||||
|
if behaviors then
|
||||||
|
for i = 1, #behaviors do
|
||||||
|
if behaviors[i] == animatedBehavior then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.insert(element.behaviors, animatedBehavior)
|
||||||
|
animatedBehavior.onAttach(element)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- onAttach — initialize Animation module references (formerly the
|
||||||
|
-- Element._Animation._ColorModule / _TransformModule wiring in Element:update
|
||||||
|
-- lines ~2772-2778).
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function onAttach(element)
|
||||||
|
ensureAnimationModuleWiring(element)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- onUpdate — the animation update + interpolation + chain-resolution block
|
||||||
|
-- (formerly Element:update lines ~2761-2800).
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function onUpdate(element, dt)
|
||||||
|
local animation = element.animation
|
||||||
|
if not animation then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- (Re)ensure module wiring is present in case the Animation instance was
|
||||||
|
-- created by a caller that bypassed onAttach (e.g. direct
|
||||||
|
-- `element.animation = Animation.new(...)`). Cheap idempotent writes.
|
||||||
|
ensureAnimationModuleWiring(element)
|
||||||
|
|
||||||
|
local finished = animation:update(dt, element)
|
||||||
|
if finished then
|
||||||
|
-- Animation:update() already called onComplete callback.
|
||||||
|
-- Check for chained animation.
|
||||||
|
if animation._next then
|
||||||
|
element.animation = animation._next
|
||||||
|
elseif animation._nextFactory and type(animation._nextFactory) == "function" then
|
||||||
|
local success, nextAnim = pcall(animation._nextFactory, element)
|
||||||
|
if success and nextAnim then
|
||||||
|
element.animation = nextAnim
|
||||||
|
else
|
||||||
|
element.animation = nil
|
||||||
|
end
|
||||||
|
else
|
||||||
|
element.animation = nil
|
||||||
|
end
|
||||||
|
else
|
||||||
|
-- Apply animation interpolation during update.
|
||||||
|
animation:applyInterpolation(element)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- saveState / restoreState — no-ops (animations are ephemeral).
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Animations are not persisted across immediate-mode frames — they are
|
||||||
|
-- re-derived each frame from transitions / direct calls. The element's scalar
|
||||||
|
-- props (opacity, x, ...) are persisted by Element.saveState's _props block,
|
||||||
|
-- so a completed animation's final visual state still survives recreation.
|
||||||
|
-- While an animation is mid-flight in immediate mode, the element is recreated
|
||||||
|
-- and the animation is NOT carried over (intentional — animating in immediate
|
||||||
|
-- mode requires setting up the animation each frame).
|
||||||
|
local function saveState()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function restoreState()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Build the (stateless, shared) behavior instance.
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- onDetach/onDraw omitted: they default to no-ops (the behavior allocates no
|
||||||
|
-- behavior-local state and animations have no draw pass). Animation state lives
|
||||||
|
-- on the element (`element.animation`); nothing to tear down on detach.
|
||||||
|
--
|
||||||
|
-- We build the immutable behavior via Behavior.new (for validation + freeze +
|
||||||
|
-- isBehavior parity with Clickable), then expose the late-attach helper on a
|
||||||
|
-- thin module table since the frozen instance cannot accept new keys. The
|
||||||
|
-- module table passes the behavior to the registry while making
|
||||||
|
-- `Animated.ensureAttached` callable from Element.setProperty / the transition
|
||||||
|
-- helpers — exactly as the task spec requires.
|
||||||
|
local behavior = Behavior.new({
|
||||||
|
onAttach = onAttach,
|
||||||
|
onUpdate = onUpdate,
|
||||||
|
saveState = saveState,
|
||||||
|
restoreState = restoreState,
|
||||||
|
shouldAttach = shouldAttach,
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Thin module table: exposes the behavior instance (for the registry) plus the
|
||||||
|
-- late-attach helper (for Element.setProperty). All hooks delegate to the
|
||||||
|
-- frozen behavior instance so dispatch sites get the validated, frozen
|
||||||
|
-- implementation. shouldAttach is also exposed at module level (mirrors
|
||||||
|
-- Clickable.shouldAttach) for tests/callers without an element.
|
||||||
|
local Animated = {
|
||||||
|
behavior = behavior,
|
||||||
|
ensureAttached = ensureAttached,
|
||||||
|
shouldAttach = shouldAttach,
|
||||||
|
onAttach = onAttach,
|
||||||
|
onUpdate = onUpdate,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Metatable so the module table itself satisfies the duck-typed registry
|
||||||
|
-- contract (iterating `Element._behaviorRegistry` calls `behavior.shouldAttach`
|
||||||
|
-- and `behavior.onAttach` / `behavior.onUpdate` directly). Falls through to the
|
||||||
|
-- frozen behavior instance for every hook.
|
||||||
|
setmetatable(Animated, {
|
||||||
|
__index = behavior,
|
||||||
|
__tostring = function()
|
||||||
|
return "Animated"
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
return Animated
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
-- modules/behaviors/Clickable.lua
|
||||||
|
--
|
||||||
|
-- Concrete behavior: mouse/touch event handling, pressed-state tracking,
|
||||||
|
-- hit-testing, and theme-state sync.
|
||||||
|
--
|
||||||
|
-- This is the largest behavior in the behavior-mode-unification refactor
|
||||||
|
-- (~200 LOC moved out of Element:update / _initSubSystems / saveState).
|
||||||
|
-- Task 02 extracts the entire `if self.onEvent or self.themeComponent or
|
||||||
|
-- self.editable or self._selectState or self.selectOption then ... end` block
|
||||||
|
-- from Element:update (hit-testing, mouse/touch event processing, immediate-
|
||||||
|
-- mode state save, theme-state update) plus EventHandler creation (formerly the
|
||||||
|
-- first half of Element:_initSubSystems) plus pressed-state drawing (formerly a
|
||||||
|
-- render layer in Renderer) plus EventHandler save/restore.
|
||||||
|
--
|
||||||
|
-- Attachment rule (shouldAttach): the same predicate that previously guarded
|
||||||
|
-- mouse-event processing in Element:update. An element owns the EventHandler /
|
||||||
|
-- gets press feedback exactly when it is interactive: when it declares an
|
||||||
|
-- `onEvent` callback, a `themeComponent`, is `editable`, or participates in a
|
||||||
|
-- Select group (selectParent / selectOption). A plain passive element never
|
||||||
|
-- attaches Clickable and therefore never allocates an EventHandler.
|
||||||
|
--
|
||||||
|
-- Element retains only the `self._eventHandler` field; Clickable owns it on
|
||||||
|
-- attach. All other Element paths that touched the EventHandler (handleTouchEvent,
|
||||||
|
-- handleGesture, getTouches) already nil-guard `self._eventHandler`, so they keep
|
||||||
|
-- working unchanged for non-clickable elements.
|
||||||
|
--
|
||||||
|
-- State ownership (per the locked Behavior contract):
|
||||||
|
-- * Per-element runtime state lives ON THE ELEMENT (self._eventHandler etc.).
|
||||||
|
-- * The behavior instance itself is stateless and shared across elements.
|
||||||
|
-- * Element-class-level dependencies (EventHandler factory, StateManager,
|
||||||
|
-- Context) are resolved from the owning element's metatable (the Element
|
||||||
|
-- class set by Element:_construct). This keeps the behavior stateless while
|
||||||
|
-- avoiding a dependency-injection parameter that would violate the locked
|
||||||
|
-- 6-hook signature `(element, ...)`.
|
||||||
|
|
||||||
|
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||||
|
local Behavior = require(_pkg .. "Behavior")
|
||||||
|
|
||||||
|
-- Resolve the Element class from an element instance.
|
||||||
|
-- Element instances are created via `setmetatable({}, Element)` in _construct,
|
||||||
|
-- so their metatable IS the Element class — giving us Element._EventHandler,
|
||||||
|
-- Element._eventHandlerDeps, Element._StateManager, Element._Context, etc.
|
||||||
|
-- without threading deps through the behavior hook signature.
|
||||||
|
local function ElementClass(element)
|
||||||
|
return getmetatable(element)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- shouldAttach (class-level predicate, no element required)
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Mirrors the cases that previously caused Element to allocate + use an
|
||||||
|
-- EventHandler. MUST cover every element that touches the EventHandler at
|
||||||
|
-- runtime: click (onEvent), theme press-feedback (themeComponent), text mouse
|
||||||
|
-- interaction (editable), Select groups (selectParent / selectOption), touch
|
||||||
|
-- callbacks (onTouchEvent), and gesture callbacks (onGesture). selectParent /
|
||||||
|
-- selectOption are the props that produce _selectState during _initSubSystems;
|
||||||
|
-- checking the props (rather than the runtime _selectState) lets shouldAttach
|
||||||
|
-- run before the Select subsystem is initialized.
|
||||||
|
local function shouldAttach(props)
|
||||||
|
props = props or {}
|
||||||
|
return props.onEvent ~= nil
|
||||||
|
or props.themeComponent ~= nil
|
||||||
|
or props.editable == true
|
||||||
|
or props.onTouchEvent ~= nil
|
||||||
|
or props.onGesture ~= nil
|
||||||
|
or props.selectOption ~= nil
|
||||||
|
or props.selectParent ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- onAttach — create the EventHandler (formerly Element:_initSubSystems
|
||||||
|
-- lines ~640-690) and restore immediate-mode EventHandler state.
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function onAttach(element)
|
||||||
|
local Element = ElementClass(element)
|
||||||
|
|
||||||
|
local eventHandlerConfig = {
|
||||||
|
-- element.onEvent is source of truth; not cached on handler
|
||||||
|
onEventDeferred = element.onEventDeferred,
|
||||||
|
-- element.onTouchEvent is source of truth; not cached on handler
|
||||||
|
onTouchEventDeferred = element.onTouchEventDeferred,
|
||||||
|
-- element.onGesture is source of truth; not cached on handler
|
||||||
|
onGestureDeferred = element.onGestureDeferred,
|
||||||
|
touchEnabled = element.touchEnabled,
|
||||||
|
multiTouchEnabled = element.multiTouchEnabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- In immediate mode, restore EventHandler state from StateManager so pressed
|
||||||
|
-- / hovered / click-count survive the per-frame element recreation cycle.
|
||||||
|
-- Mode-aware via Context.isImmediateMode (behavior-mode-unification task 11):
|
||||||
|
-- in retained mode the eventHandler persists, so nothing to restore.
|
||||||
|
if Element._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then
|
||||||
|
local state = Element._StateManager.getState(element._stateId)
|
||||||
|
if state then
|
||||||
|
-- Restore EventHandler state from StateManager (sparse storage — provide defaults)
|
||||||
|
eventHandlerConfig._pressed = state._pressed or {}
|
||||||
|
eventHandlerConfig._lastClickTime = state._lastClickTime
|
||||||
|
eventHandlerConfig._lastClickButton = state._lastClickButton
|
||||||
|
eventHandlerConfig._clickCount = state._clickCount or 0
|
||||||
|
eventHandlerConfig._dragStartX = state._dragStartX or {}
|
||||||
|
eventHandlerConfig._dragStartY = state._dragStartY or {}
|
||||||
|
eventHandlerConfig._lastMouseX = state._lastMouseX or {}
|
||||||
|
eventHandlerConfig._lastMouseY = state._lastMouseY or {}
|
||||||
|
eventHandlerConfig._hovered = state._hovered
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
element._eventHandler = Element._EventHandler.new(eventHandlerConfig, Element._eventHandlerDeps)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function onDetach(element)
|
||||||
|
-- Clear focus callbacks read by KeyboardNavigation / TextEditor:focus so the
|
||||||
|
-- element's closure references can be collected in immediate mode (formerly
|
||||||
|
-- part of Element:_cleanup). The EventHandler instance itself is INTENTIONALLY
|
||||||
|
-- kept: Element:_cleanup preserves element structure for inspection (the
|
||||||
|
-- stale-element refs are released when the element is GC'd). onEvent,
|
||||||
|
-- onTouchEvent, onGesture are also left intact — the Renderer/EventHandler
|
||||||
|
-- read those directly from the element (not the cache), so clearing them
|
||||||
|
-- would break retained mode.
|
||||||
|
element.onFocus = nil
|
||||||
|
element.onBlur = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- onUpdate — the mouse hit-testing + event-processing + theme-state +
|
||||||
|
-- immediate-mode save block (formerly Element:update lines ~2813-2960).
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function onUpdate(element, dt)
|
||||||
|
local Element = ElementClass(element)
|
||||||
|
local eventHandler = element._eventHandler
|
||||||
|
if not eventHandler then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local mx, my = love.mouse.getPosition()
|
||||||
|
|
||||||
|
-- Clickable area is the border box (x, y already includes padding)
|
||||||
|
-- BORDER-BOX MODEL: Use stored border-box dimensions for hit detection
|
||||||
|
local bx = element.x
|
||||||
|
local by = element.y
|
||||||
|
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
|
||||||
|
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
|
||||||
|
|
||||||
|
-- Account for scroll offsets from parent containers
|
||||||
|
-- Walk up the parent chain and accumulate scroll offsets. This stays in
|
||||||
|
-- Clickable because it's an interaction concern (hit-testing), not layout.
|
||||||
|
local scrollOffsetX = 0
|
||||||
|
local scrollOffsetY = 0
|
||||||
|
local current = element.parent
|
||||||
|
while current do
|
||||||
|
local overflowX = current.overflowX or current.overflow
|
||||||
|
local overflowY = current.overflowY or current.overflow
|
||||||
|
local hasScrollableOverflow = (
|
||||||
|
overflowX == "scroll"
|
||||||
|
or overflowX == "auto"
|
||||||
|
or overflowY == "scroll"
|
||||||
|
or overflowY == "auto"
|
||||||
|
or overflowX == "hidden"
|
||||||
|
or overflowY == "hidden"
|
||||||
|
)
|
||||||
|
if hasScrollableOverflow then
|
||||||
|
scrollOffsetX = scrollOffsetX + (current._scrollX or 0)
|
||||||
|
scrollOffsetY = scrollOffsetY + (current._scrollY or 0)
|
||||||
|
end
|
||||||
|
current = current.parent
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Adjust mouse position by accumulated scroll offset for hit testing
|
||||||
|
local adjustedMx = mx + scrollOffsetX
|
||||||
|
local adjustedMy = my + scrollOffsetY
|
||||||
|
local isHovering = adjustedMx >= bx and adjustedMx <= bx + bw and adjustedMy >= by and adjustedMy <= by + bh
|
||||||
|
|
||||||
|
-- Check if this is the topmost interactive element at the mouse position
|
||||||
|
-- (z-index ordering). This prevents blocked/occluded elements from
|
||||||
|
-- receiving interactions or visual feedback. A single mode-agnostic lookup
|
||||||
|
-- via `Context.findInteractiveAtPosition` (unified-event-routing task 05)
|
||||||
|
-- replaces the previous immediate/retained-mode split that used
|
||||||
|
-- `getTopElementAt` in immediate mode and `_activeEventElement` in retained
|
||||||
|
-- mode. `findInteractiveAtPosition` routes every hit test through
|
||||||
|
-- `pointHitsElement` (the single canonical `display == false` guard) and
|
||||||
|
-- resolves occlusion by z-index in both modes, so the active element is the
|
||||||
|
-- same one that would receive a hit under the cursor.
|
||||||
|
local topElement = Element._Context.findInteractiveAtPosition(mx, my)
|
||||||
|
local isActiveElement = (topElement == element or topElement == nil)
|
||||||
|
|
||||||
|
-- Reset scrollbar press flag at start of each frame
|
||||||
|
eventHandler:resetScrollbarPressFlag()
|
||||||
|
|
||||||
|
-- Process mouse events through EventHandler FIRST
|
||||||
|
-- This ensures pressed states are updated before theme state is calculated
|
||||||
|
eventHandler:processMouseEvents(element, mx, my, isHovering, isActiveElement)
|
||||||
|
|
||||||
|
-- In immediate mode, save EventHandler state to StateManager after
|
||||||
|
-- processing events so it survives the per-frame recreation.
|
||||||
|
if element._stateId and Element._Context.isImmediateMode() and element._stateId ~= "" then
|
||||||
|
local eventHandlerState = eventHandler:getState()
|
||||||
|
Element._StateManager.updateState(element._stateId, {
|
||||||
|
_pressed = eventHandlerState._pressed,
|
||||||
|
_lastClickTime = eventHandlerState._lastClickTime,
|
||||||
|
_lastClickButton = eventHandlerState._lastClickButton,
|
||||||
|
_clickCount = eventHandlerState._clickCount,
|
||||||
|
_dragStartX = eventHandlerState._dragStartX,
|
||||||
|
_dragStartY = eventHandlerState._dragStartY,
|
||||||
|
_lastMouseX = eventHandlerState._lastMouseX,
|
||||||
|
_lastMouseY = eventHandlerState._lastMouseY,
|
||||||
|
_hovered = eventHandlerState._hovered,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Update theme state based on interaction. themeComponent state update
|
||||||
|
-- lives in Clickable because it is driven by hover/press state; the actual
|
||||||
|
-- theme RENDERING is the Themed behavior (task 07).
|
||||||
|
if element.themeComponent then
|
||||||
|
-- Check if any button is pressed via EventHandler
|
||||||
|
local anyPressed = eventHandler:isAnyButtonPressed()
|
||||||
|
|
||||||
|
-- Update theme state via ThemeManager
|
||||||
|
local isFocused = Element._Context.getFocused() == element
|
||||||
|
local newThemeState =
|
||||||
|
element._themeManager:updateState(isHovering and isActiveElement, anyPressed, isFocused, element.disabled)
|
||||||
|
|
||||||
|
if element._stateId and Element._Context.isImmediateMode() then
|
||||||
|
local hover = newThemeState == "hover"
|
||||||
|
local pressed = newThemeState == "pressed"
|
||||||
|
local focused = isFocused
|
||||||
|
|
||||||
|
Element._StateManager.updateState(element._stateId, {
|
||||||
|
hover = hover,
|
||||||
|
pressed = pressed,
|
||||||
|
focused = focused,
|
||||||
|
disabled = element.disabled,
|
||||||
|
active = element.active,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
if element._renderer then
|
||||||
|
element._renderer:setThemeState(newThemeState)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Process touch events through EventHandler
|
||||||
|
eventHandler:processTouchEvents(element)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- onDraw — pressed-state visual feedback (formerly Renderer Layer 5).
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Draws the grey pressed overlay when any mouse button is currently pressed on
|
||||||
|
-- the element. Delegates the actual pixels to Renderer:drawPressedState (which
|
||||||
|
-- owns the RoundedRect + opacity math) but drives the DECISION + transform
|
||||||
|
-- context here, so the renderer no longer needs the `if element.onEvent ...`
|
||||||
|
-- behavioral branch. Honors disableHighlight (themes handle their own visual
|
||||||
|
-- feedback) exactly as the old render layer did.
|
||||||
|
local function onDraw(element)
|
||||||
|
if element.disableHighlight then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local eventHandler = element._eventHandler
|
||||||
|
if not eventHandler then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local anyPressed = false
|
||||||
|
local pressedState = eventHandler:getState()._pressed or {}
|
||||||
|
for _, pressed in pairs(pressedState) do
|
||||||
|
if pressed then
|
||||||
|
anyPressed = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not anyPressed then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local renderer = element._renderer
|
||||||
|
if not renderer then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
|
||||||
|
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
|
||||||
|
|
||||||
|
-- Apply the element transform around the overlay, mirroring how the
|
||||||
|
-- Renderer wrapped its whole command buffer (pressed state was a render
|
||||||
|
-- layer subject to the same transform).
|
||||||
|
local Element = ElementClass(element)
|
||||||
|
local Transform = Element._Transform
|
||||||
|
local hasTransform = element.transform ~= nil and Transform ~= nil and not Transform.isIdentity(element.transform)
|
||||||
|
if hasTransform then
|
||||||
|
Transform.apply(element.transform, element.x, element.y, element.width, element.height)
|
||||||
|
end
|
||||||
|
|
||||||
|
renderer:drawPressedState(element.x, element.y, bw, bh, element.opacity, element.cornerRadius)
|
||||||
|
|
||||||
|
if hasTransform then
|
||||||
|
Transform.unapply()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- saveState / restoreState — EventHandler state (formerly the eventHandler
|
||||||
|
-- branches of Element:saveState / Element:restoreState).
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function saveState(element)
|
||||||
|
if element._eventHandler then
|
||||||
|
return { eventHandler = element._eventHandler:getState() }
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function restoreState(element, state)
|
||||||
|
if not state then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if element._eventHandler and state.eventHandler then
|
||||||
|
element._eventHandler:setState(state.eventHandler)
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Build the (stateless, shared, immutable) behavior instance.
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local Clickable = Behavior.new({
|
||||||
|
onAttach = onAttach,
|
||||||
|
onDetach = onDetach,
|
||||||
|
onUpdate = onUpdate,
|
||||||
|
onDraw = onDraw,
|
||||||
|
saveState = saveState,
|
||||||
|
restoreState = restoreState,
|
||||||
|
shouldAttach = shouldAttach,
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Expose the predicate at module level so callers/tests can reference it
|
||||||
|
-- directly without an element instance (mirrors Behavior.shouldAttach).
|
||||||
|
Clickable.shouldAttach = shouldAttach
|
||||||
|
|
||||||
|
return Clickable
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
-- modules/behaviors/Imageable.lua
|
||||||
|
--
|
||||||
|
-- Concrete behavior: image loading + image rendering config.
|
||||||
|
--
|
||||||
|
-- Imageable owns the image side of the Renderer: it runs the deferred image-
|
||||||
|
-- load pipeline (cache check → defer → load → fire onImageLoad/onImageError
|
||||||
|
-- callbacks), populates the resolved `_loadedImage` cache on both the element
|
||||||
|
-- and the shared renderer, and persists that cache across immediate-mode
|
||||||
|
-- recreation. It is the behavior-mode-unification replacement for the image-
|
||||||
|
-- loading half of Element:_initImageAndRenderer and the deferred
|
||||||
|
-- Element:_loadImage method (behavior-mode-unification task 07).
|
||||||
|
--
|
||||||
|
-- Image value props (imagePath/image/objectFit/objectPosition/imageOpacity/
|
||||||
|
-- imageRepeat/imageTint) are bound on the ELEMENT by Element:_applyProps and read
|
||||||
|
-- from the element at draw time (Renderer._executeDrawCommand image branch) —
|
||||||
|
-- Imageable does NOT mirror them onto the renderer, so bare writes and
|
||||||
|
-- setProperty(...) are immediately consistent. Only the resolved _loadedImage
|
||||||
|
-- cache (the love.Image produced by the load pipeline) is renderer-mirrored,
|
||||||
|
-- because Renderer:draw reads `self._loadedImage`.
|
||||||
|
--
|
||||||
|
-- Runtime reload: setProperty("imagePath", ...) / setProperty("image", ...) and
|
||||||
|
-- the bare-write-equivalent setImage* flows route through element._reloadImage
|
||||||
|
-- (installed below) which re-runs the load pipeline. See
|
||||||
|
-- TestRetainedPropertyConsistency (image props) and TestImageableIntegration.
|
||||||
|
--
|
||||||
|
-- Attachment rule (shouldAttach): an element owns image concern exactly when it
|
||||||
|
-- declares an `imagePath` (load-from-path) or a direct `image` (already-loaded
|
||||||
|
-- love.Image). Mirrors the old `if self.imagePath / if self.image` init branches.
|
||||||
|
--
|
||||||
|
-- Pairing with Themed: Themed.onAttach creates the Renderer with theme/blur
|
||||||
|
-- config; Imageable.onAttach enriches the SAME renderer instance with image
|
||||||
|
-- config + kicks off loading. They share `element._renderer`. In the registry
|
||||||
|
-- Imageable runs after Themed, so the renderer already exists; the create-or-
|
||||||
|
-- reuse guard below covers the defensive case where Imageable attaches first.
|
||||||
|
--
|
||||||
|
-- onDraw: the image LAYER is rendered by the integrated `Renderer:draw` call
|
||||||
|
-- (owned by the Themed behavior) which executes the renderer's `image` draw
|
||||||
|
-- command using the config Imageable.onAttach wired. Imageable.onDraw is
|
||||||
|
-- therefore a no-op for the draw call itself — there is no separate
|
||||||
|
-- `_renderer:_drawImage` entry point; pixel emission lives in the integrated
|
||||||
|
-- Renderer:draw command buffer. Splitting it out would require Renderer surgery
|
||||||
|
-- with no behavioral gain (Renderer:draw already conditionally skips the image
|
||||||
|
-- layer when no image is loaded).
|
||||||
|
--
|
||||||
|
-- State ownership (per the locked Behavior contract):
|
||||||
|
-- * Per-element runtime state lives ON THE ELEMENT (`element._loadedImage`,
|
||||||
|
-- `element._renderer._loadedImage`). The behavior instance is stateless.
|
||||||
|
-- * saveState/restoreState persist `_loadedImage` across immediate-mode frames
|
||||||
|
-- so the image renders even if the ImageCache is cleared between frames and
|
||||||
|
-- so the renderer's loaded-image cache survives element recreation.
|
||||||
|
|
||||||
|
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||||
|
local Behavior = require(_pkg .. "Behavior")
|
||||||
|
|
||||||
|
-- Lua 5.4 removed the global `unpack`; mirror Element's alias.
|
||||||
|
local unpack = table.unpack or unpack
|
||||||
|
|
||||||
|
-- Resolve the Element class from an element instance (mirrors Clickable/Themed).
|
||||||
|
local function ElementClass(element)
|
||||||
|
return getmetatable(element)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- shouldAttach (class-level predicate, no element required)
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function shouldAttach(props)
|
||||||
|
props = props or {}
|
||||||
|
return props.imagePath ~= nil or props.image ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Image callback helper (moved from Element._fireImageCallback).
|
||||||
|
-- Fires a user-supplied image callback (onImageLoad/onImageError) under pcall,
|
||||||
|
-- honoring the onXDeferred flag when `honorDeferred` is true, and emits a single
|
||||||
|
-- EVT_002 warn on failure. The direct-`image` sync init path passes
|
||||||
|
-- honorDeferred=false to preserve immediate firing (image is already loaded).
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function fireImageCallback(element, callbackField, honorDeferred, ...)
|
||||||
|
local cb = element[callbackField]
|
||||||
|
if type(cb) ~= "function" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local Element = ElementClass(element)
|
||||||
|
local argc = select("#", ...)
|
||||||
|
local args = { ... }
|
||||||
|
local function invoke()
|
||||||
|
local ok, err = pcall(cb, element, unpack(args, 1, argc))
|
||||||
|
if not ok then
|
||||||
|
Element._ErrorHandler:warn("Element", "EVT_002", {
|
||||||
|
callback = callbackField,
|
||||||
|
error = tostring(err),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if honorDeferred and element[callbackField .. "Deferred"] then
|
||||||
|
Element._Context.deferCallback(invoke)
|
||||||
|
else
|
||||||
|
invoke()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Deferred image loader (replaces Element:_loadImage).
|
||||||
|
--
|
||||||
|
-- Invoked by Element's deferred-method dispatcher via the instance closure that
|
||||||
|
-- onAttach installs on `element._loadImage`. Loads the image from cache or disk
|
||||||
|
-- (I/O), updates BOTH the element and renderer `_loadedImage` caches so the
|
||||||
|
-- image draws after an async load, and fires the load/error callback (deferred,
|
||||||
|
-- honoring onImageLoadDeferred / onImageErrorDeferred).
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function loadImage(element)
|
||||||
|
if not element.imagePath or element.image then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local Element = ElementClass(element)
|
||||||
|
local loadedImage, err = Element._ImageCache.load(element.imagePath)
|
||||||
|
if loadedImage then
|
||||||
|
element._loadedImage = loadedImage
|
||||||
|
if element._renderer then
|
||||||
|
element._renderer._loadedImage = loadedImage
|
||||||
|
end
|
||||||
|
fireImageCallback(element, "onImageLoad", true, loadedImage)
|
||||||
|
else
|
||||||
|
fireImageCallback(element, "onImageError", true, err or "Unknown error")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- reloadImage — recompute the loaded-image cache from the current image/imagePath.
|
||||||
|
--
|
||||||
|
-- This is the single entry point for (re)loading after either initial attach or
|
||||||
|
-- a runtime property change (see Element._specialSetHandlers.imagePath/image,
|
||||||
|
-- which call element:_reloadImage()). Precedence matches onAttach: a direct
|
||||||
|
-- `image` wins over `imagePath`; `nil` for both clears the cache.
|
||||||
|
--
|
||||||
|
-- * direct image → set _loadedImage immediately, fire onImageLoad SYNC (the
|
||||||
|
-- image is already loaded; honorDeferred=false preserves the
|
||||||
|
-- original synchronous init contract).
|
||||||
|
-- * imagePath → cache CHECK only (no I/O) so a cached image can draw this
|
||||||
|
-- frame, then defer the loader (_loadImage) for the actual
|
||||||
|
-- I/O + deferred callbacks. load bails if `image` is later set.
|
||||||
|
-- * neither → clear _loadedImage on both element + renderer.
|
||||||
|
--
|
||||||
|
-- Image value props (objectFit/imageOpacity/imageRepeat/imageTint/objectPosition)
|
||||||
|
-- and imagePath/image themselves live on the ELEMENT as source of truth; the
|
||||||
|
-- renderer reads them at draw time, so reloadImage does NOT mirror them onto the
|
||||||
|
-- renderer — only the resolved _loadedImage cache is pushed.
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function reloadImage(element)
|
||||||
|
local Element = ElementClass(element)
|
||||||
|
local renderer = element._renderer
|
||||||
|
if element.image then
|
||||||
|
element._loadedImage = element.image
|
||||||
|
if renderer then
|
||||||
|
renderer._loadedImage = element.image
|
||||||
|
end
|
||||||
|
fireImageCallback(element, "onImageLoad", false, element.image)
|
||||||
|
elseif element.imagePath then
|
||||||
|
-- Cache check (no I/O). Populate both caches immediately if cached so the
|
||||||
|
-- image can draw this frame without waiting for the deferred load.
|
||||||
|
local cached = Element._ImageCache.get(element.imagePath)
|
||||||
|
element._loadedImage = cached
|
||||||
|
if renderer then
|
||||||
|
renderer._loadedImage = cached
|
||||||
|
end
|
||||||
|
-- Kick off the deferred I/O load + callbacks (idempotent: loadImage bails
|
||||||
|
-- if image is set or imagePath is nil by the time it runs).
|
||||||
|
if element._loadImage then
|
||||||
|
element:_deferMethod("_loadImage")
|
||||||
|
end
|
||||||
|
else
|
||||||
|
element._loadedImage = nil
|
||||||
|
if renderer then
|
||||||
|
renderer._loadedImage = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- onAttach — enrich the shared renderer with image config + kick off loading
|
||||||
|
-- (formerly the image block of Element:_initImageAndRenderer).
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function onAttach(element)
|
||||||
|
local Element = ElementClass(element)
|
||||||
|
|
||||||
|
-- Ensure the renderer exists (Thamed normally creates it; this create-or-reuse
|
||||||
|
-- guard is defensive for the Imageable-attaches-first ordering).
|
||||||
|
if not element._renderer then
|
||||||
|
element._renderer = Element._Renderer.new({
|
||||||
|
theme = element.theme,
|
||||||
|
scaleCorners = element.scaleCorners,
|
||||||
|
scalingAlgorithm = element.scalingAlgorithm,
|
||||||
|
contentBlur = element.contentBlur,
|
||||||
|
backdropBlur = element.backdropBlur,
|
||||||
|
}, Element._rendererDeps)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Install the (re)load hooks as instance methods so Element's
|
||||||
|
-- deferred-method dispatcher / setProperty special handlers can trigger a
|
||||||
|
-- reload without Element needing a behavior reference. This keeps Element
|
||||||
|
-- decoupled from the Imageable behavior (mirrors the stateless-behavior +
|
||||||
|
-- element-owned-state contract). Image value props and imagePath/image live
|
||||||
|
-- on the element as source of truth (read at draw time); only the resolved
|
||||||
|
-- _loadedImage cache is mirrored onto the renderer by reloadImage.
|
||||||
|
element._loadImage = function(el)
|
||||||
|
loadImage(el)
|
||||||
|
end
|
||||||
|
element._reloadImage = function(el)
|
||||||
|
reloadImage(el)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Initial load: compute _loadedImage + defer the I/O load.
|
||||||
|
reloadImage(element)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- onDraw — no-op (see file header: the image layer is rendered by the integrated
|
||||||
|
-- Renderer:draw call owned by the Themed behavior, using the config wired here).
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- saveState / restoreState — `_loadedImage` cache (for immediate-mode).
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function saveState(element)
|
||||||
|
if element._loadedImage ~= nil then
|
||||||
|
return { _loadedImage = element._loadedImage }
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function restoreState(element, state)
|
||||||
|
if not state or state._loadedImage == nil then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local loadedImage = state._loadedImage
|
||||||
|
element._loadedImage = loadedImage
|
||||||
|
if element._renderer then
|
||||||
|
element._renderer._loadedImage = loadedImage
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- onDetach — release image-load callback closures so the element can be GC'd
|
||||||
|
-- cleanly in immediate mode (formerly part of Element:_cleanup). The cached
|
||||||
|
-- `_loadedImage` is reproduced on the next attach via the Imageable saveState
|
||||||
|
-- -> restoreState cycle, so dropping the live references is always safe.
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function onDetach(element)
|
||||||
|
element.onImageLoad = nil
|
||||||
|
element.onImageError = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Build the (stateless, shared, immutable) behavior instance.
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local Imageable = Behavior.new({
|
||||||
|
onAttach = onAttach,
|
||||||
|
onDetach = onDetach,
|
||||||
|
onUpdate = function() end,
|
||||||
|
onDraw = function() end,
|
||||||
|
saveState = saveState,
|
||||||
|
restoreState = restoreState,
|
||||||
|
shouldAttach = shouldAttach,
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Expose the predicate at module level so callers/tests can reference it
|
||||||
|
-- directly without an element instance (mirrors Behavior.shouldAttach /
|
||||||
|
-- Clickable.shouldAttach). `loadImage` is NOT exposed on the (frozen) behavior
|
||||||
|
-- instance; it is captured as a module-local upvalue by the onAttach closure that
|
||||||
|
-- installs `element._loadImage`.
|
||||||
|
Imageable.shouldAttach = shouldAttach
|
||||||
|
|
||||||
|
return Imageable
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
-- modules/behaviors/Persistable.lua
|
||||||
|
--
|
||||||
|
-- Concrete behavior: generic public-property persistence across the immediate-
|
||||||
|
-- mode recreation cycle (behavior-mode-unification task 12).
|
||||||
|
--
|
||||||
|
-- Owns the ONE piece of Element save/restore state that is NOT subsystem state:
|
||||||
|
-- the snapshot of an element's own public scalar fields (`text`, `display`,
|
||||||
|
-- `opacity`, `x`, `width`, ...). Event-driven mutations to these fields (a
|
||||||
|
-- release callback changing `text`, a toggle hiding a panel via `display =
|
||||||
|
-- false`) must survive the per-frame Element recreation that defines immediate
|
||||||
|
-- mode. Persistable captures them in `saveState` and reapplies them in
|
||||||
|
-- `restoreState`, so the caller never branches on mode.
|
||||||
|
--
|
||||||
|
-- This behavior is the final home for the former `Element:saveState` `_props`
|
||||||
|
-- block and the former `Element:restoreState` `_props` block (~20 LOC moved out
|
||||||
|
-- of Element.lua). With it in place, `Element:saveState` / `Element:restoreState`
|
||||||
|
-- collapse to a pure behavior-dispatch loop and Element owns zero property-
|
||||||
|
-- extraction logic — every persisted slice is owned by exactly one behavior.
|
||||||
|
--
|
||||||
|
-- Attachment rule (shouldAttach): every element. Persistable attaches
|
||||||
|
-- unconditionally (mirrors the pre-refactor invariant that every element's
|
||||||
|
-- public scalar props were scanned). The actual snapshot is mode-gated inside
|
||||||
|
-- `saveState` (immediate-mode-only, matching the legacy contract); in retained
|
||||||
|
-- mode `saveState` returns nil and `restoreState` is a no-op unless a snapshot
|
||||||
|
-- is explicitly passed.
|
||||||
|
--
|
||||||
|
-- Registry ordering: Persistable is intentionally placed LAST in the behavior
|
||||||
|
-- registry. `restoreState` applies `_props` AFTER every other behavior has
|
||||||
|
-- hydrated its subsystem state, so a persisted public-prop mutation (e.g.
|
||||||
|
-- `text = "mutated"`) overrides the freshly-restored TextEditor/Select state —
|
||||||
|
-- preserving the legacy restore ordering (behaviors first, `_props` tail).
|
||||||
|
--
|
||||||
|
-- State ownership (per the locked Behavior contract):
|
||||||
|
-- * The persisted props live ON the element (they ARE the element's public
|
||||||
|
-- fields). The behavior instance is stateless + immutable and shared.
|
||||||
|
-- * The snapshot is returned under the `_props` key (prefixed with `_` so
|
||||||
|
-- the public-prop scan itself skips it — avoiding self-recursion).
|
||||||
|
|
||||||
|
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||||
|
local Behavior = require(_pkg .. "Behavior")
|
||||||
|
|
||||||
|
-- Resolve the Element class from an element instance (mirrors Clickable /
|
||||||
|
-- Themed). Element instances are created via `setmetatable({}, Element)`, so
|
||||||
|
-- their metatable IS the Element class — giving access to Element._StateManager
|
||||||
|
-- without threading deps through the hook signature.
|
||||||
|
local function ElementClass(element)
|
||||||
|
return getmetatable(element)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- shouldAttach (class-level predicate, no element required)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Every element's public scalar props are persistable, so this behavior
|
||||||
|
-- attaches unconditionally. The mode gate lives inside saveState (it needs the
|
||||||
|
-- runtime mode, which is only available with an element via StateManager).
|
||||||
|
local function shouldAttach()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- saveState — snapshot public scalar fields (immediate-mode-only).
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Mirrors the former `Element:saveState` `_props` block exactly:
|
||||||
|
-- * Only string keys NOT prefixed with `_` (so internal fields like
|
||||||
|
-- `_renderer`, `_themeState`, `_initProps` are excluded).
|
||||||
|
-- * Only scalar values (numbers, strings, booleans); tables and functions
|
||||||
|
-- are excluded (children, padding, onEvent, ...).
|
||||||
|
-- Returns `{ _props = {...} }` when there is at least one persistable prop and
|
||||||
|
-- the element is in immediate mode; nil otherwise (retained mode no-op —
|
||||||
|
-- state lives on the element directly there, so nothing to snapshot).
|
||||||
|
local function saveState(element)
|
||||||
|
local Element = ElementClass(element)
|
||||||
|
if not Element._StateManager.isImmediateMode() then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local props = {}
|
||||||
|
for k, v in pairs(element) do
|
||||||
|
if type(k) == "string" and k:sub(1, 1) ~= "_" and type(v) ~= "table" and type(v) ~= "function" then
|
||||||
|
props[k] = v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if next(props) then
|
||||||
|
return { _props = props }
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- restoreState — reapply the persisted public-prop snapshot onto a fresh
|
||||||
|
-- element (mode-agnostic; only fires when a `_props` slice is present).
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Applies persisted mutations on top of whatever the constructor + other
|
||||||
|
-- behaviors already set, so event-driven changes from the previous frame
|
||||||
|
-- override the declarative props of the recreated element. Runs last in the
|
||||||
|
-- behavior dispatch (Persistable is the registry tail) to preserve the legacy
|
||||||
|
-- restore ordering (subsystem restore first, `_props` override last).
|
||||||
|
local function restoreState(element, state)
|
||||||
|
if not state or not state._props then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
for k, v in pairs(state._props) do
|
||||||
|
element[k] = v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- onAttach / onUpdate / onDraw / onDetach — no-ops.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Persistable owns no subsystem and allocates no per-element state (the
|
||||||
|
-- "state" it persists IS the element's own fields). The lifecycle is purely
|
||||||
|
-- save/restore.
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Build the (stateless, shared, immutable) behavior instance.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
local Persistable = Behavior.new({
|
||||||
|
saveState = saveState,
|
||||||
|
restoreState = restoreState,
|
||||||
|
shouldAttach = shouldAttach,
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Expose the predicate at module level so callers/tests can reference it
|
||||||
|
-- directly without an element instance (mirrors Behavior.shouldAttach /
|
||||||
|
-- Clickable.shouldAttach).
|
||||||
|
Persistable.shouldAttach = shouldAttach
|
||||||
|
|
||||||
|
return Persistable
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
-- 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
|
||||||
|
-- Lift the parent scissor while drawing scrollbars so they render fully
|
||||||
|
-- visible, then RESTORE it: clearing it outright let every later sibling
|
||||||
|
-- draw unclipped (scrolled page content over the pinned header).
|
||||||
|
local sx, sy, sw, sh = love.graphics.getScissor()
|
||||||
|
love.graphics.setScissor()
|
||||||
|
element._renderer:drawScrollbars(element, element.x, element.y, element.width, element.height, scrollbarDims)
|
||||||
|
if sx then love.graphics.setScissor(sx, sy, sw, sh) end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- saveState / restoreState — ScrollManager state snapshot for immediate-mode
|
||||||
|
-- recreation (formerly the inline blocks in Element:saveState/
|
||||||
|
-- Element:restoreState). Returns a table merged under the `scrollManager` key
|
||||||
|
-- by Element:saveState's behavior loop, mirroring the legacy contract.
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function saveState(element)
|
||||||
|
local sm = element._scrollManager
|
||||||
|
if not sm then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return { scrollManager = sm:getState() }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function restoreState(element, state)
|
||||||
|
if not state then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local sm = element._scrollManager
|
||||||
|
local smState = state.scrollManager
|
||||||
|
if sm and smState then
|
||||||
|
sm:setState(smState)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local Scrollable = Behavior.new({
|
||||||
|
onAttach = onAttach,
|
||||||
|
onDetach = function() end,
|
||||||
|
onUpdate = onUpdate,
|
||||||
|
onDraw = onDraw,
|
||||||
|
saveState = saveState,
|
||||||
|
restoreState = restoreState,
|
||||||
|
drawLayer = "overlay",
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Expose the predicate at module level so callers/tests can reference it
|
||||||
|
-- directly without an element instance (mirrors Clickable.shouldAttach /
|
||||||
|
-- Selectable.shouldAttach).
|
||||||
|
Scrollable.shouldAttach = shouldAttach
|
||||||
|
|
||||||
|
return Scrollable
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
-- modules/behaviors/Selectable.lua
|
||||||
|
--
|
||||||
|
-- Concrete behavior: Select state-machine lifecycle for dropdown-style
|
||||||
|
-- select groups. Owns the per-element Select subsystem initialization, the
|
||||||
|
-- managed-frame layout sync each frame, and select save/restore across the
|
||||||
|
-- immediate-mode recreation cycle.
|
||||||
|
--
|
||||||
|
-- This behavior consolidates the legacy `if self._selectState` / `if
|
||||||
|
-- self.selectOption` branches that previously lived inside Element.lua:
|
||||||
|
--
|
||||||
|
-- * Select subsystem init (formerly Element:_initSubSystems lines ~810-825 —
|
||||||
|
-- `Select.initSelectParent` / `Select.initSelectOption`).
|
||||||
|
-- * Managed-frame adoption (formerly Element:_initPositioning lines ~1700-
|
||||||
|
-- 1702 — `Select.adoptSelectFrame`).
|
||||||
|
-- * Per-frame frame-state sync (formerly Element:update line ~2747 —
|
||||||
|
-- `Select.ensureFrameState`).
|
||||||
|
-- * Save/restore of select open/value/label (formerly the `select` branch of
|
||||||
|
-- Element:saveState / Element:restoreState).
|
||||||
|
--
|
||||||
|
-- Element retains `self._selectState` and `self.selectOption` for backward-
|
||||||
|
-- compat field access; runtime state lives ON THE ELEMENT. The behavior itself
|
||||||
|
-- is stateless + immutable (a single shared instance attaches to every
|
||||||
|
-- selectable element).
|
||||||
|
--
|
||||||
|
-- The 20 Element select-API delegate methods (openSelect, closeSelect,
|
||||||
|
-- toggleSelect, isSelectOpen, getSelectValue, setSelectValue, ...) stay as
|
||||||
|
-- 1-line forwarders into the Select module — the behavior owns the
|
||||||
|
-- *lifecycle* (attach / update / save / restore / detach), not the API
|
||||||
|
-- surface (per task 05 spec notes).
|
||||||
|
--
|
||||||
|
-- State ownership (per the locked Behavior contract):
|
||||||
|
-- * Per-element runtime state lives ON THE ELEMENT (self._selectState,
|
||||||
|
-- self.selectOption, self._selectParentElement, ...).
|
||||||
|
-- * The behavior instance is stateless + immutable and shared across elements.
|
||||||
|
-- * Element-class-level dependencies are resolved via `getmetatable(element)`
|
||||||
|
-- (which IS the Element class set by Element._construct), so the hook
|
||||||
|
-- signature stays exactly `(element, ...)` with no DI parameters.
|
||||||
|
|
||||||
|
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||||
|
local Behavior = require(_pkg .. "Behavior")
|
||||||
|
|
||||||
|
-- Resolve the Element class from an element instance.
|
||||||
|
-- `setmetatable({}, Element)` in `_construct` makes the instance metatable BE
|
||||||
|
-- the Element class, so this yields Element._Select, Element._Context,
|
||||||
|
-- Element._StateManager, etc. without threading deps through the hook signature.
|
||||||
|
local function ElementClass(element)
|
||||||
|
return getmetatable(element)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- shouldAttach (class-level predicate, no element required)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Mirrors the cases that previously caused Element to initialize a Select
|
||||||
|
-- subsystem. An element owns select state exactly when it declares a
|
||||||
|
-- `selectParent` config (the dropdown trigger) or a `selectOption` config (an
|
||||||
|
-- option inside a dropdown). Checking the props (rather than the runtime
|
||||||
|
-- `_selectState`) lets shouldAttach run before onAttach initializes the
|
||||||
|
-- subsystem, matching the auto-attach contract established by Clickable /
|
||||||
|
-- TextEditable.
|
||||||
|
local function shouldAttach(props)
|
||||||
|
props = props or {}
|
||||||
|
return type(props.selectParent) == "table" or type(props.selectOption) == "table"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- onAttach — initialize the Select subsystem (formerly Element:_initSubSystems
|
||||||
|
-- lines ~810-825) and adopt the managed frame (formerly Element:_initPositioning
|
||||||
|
-- lines ~1700-1702).
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
local function onAttach(element)
|
||||||
|
local Element = ElementClass(element)
|
||||||
|
|
||||||
|
-- Initialize the appropriate select role. Mirrors the legacy _initSubSystems
|
||||||
|
-- block exactly: selectParent → initSelectParent (sets _selectState +
|
||||||
|
-- immediate-mode restore from StateManager); selectOption → initSelectOption
|
||||||
|
-- (sets the option value/label/disabled).
|
||||||
|
if type(element.selectParent) == "table" then
|
||||||
|
Element._Select.initSelectParent(element, element.selectParent)
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(element.selectOption) == "table" then
|
||||||
|
Element._Select.initSelectOption(element, element.selectOption)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Adopt the managed dropdown frame. This was formerly the tail of
|
||||||
|
-- _initPositioning (after the select parent's own addChild). It creates the
|
||||||
|
-- select anchor, reparents the frame under it, and syncs visibility. Moving
|
||||||
|
-- it here is safe because onAttach runs after _initPositioning: the parent's
|
||||||
|
-- own positioning is finalized, so the anchor's geometry can be computed.
|
||||||
|
if element._selectState and type(element.selectParent) == "table" and element.selectParent.selectFrame ~= nil then
|
||||||
|
Element._Select.adoptSelectFrame(element, element.selectParent.selectFrame)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Backfill option registration for children added BEFORE this behavior
|
||||||
|
-- attached. The auto-attach pass runs at the very end of Element.new
|
||||||
|
-- (after _finalizeConstruction, which processes declarative `children`).
|
||||||
|
-- Declarative select-option children are addChild'd to this element during
|
||||||
|
-- _finalizeConstruction — at that point _selectState did not yet exist (this
|
||||||
|
-- onAttach had not run), so their registerWithSelectParent call walked the
|
||||||
|
-- parent chain, found no _selectState, and returned early. Re-scan now that
|
||||||
|
-- _selectState is initialized so these options are registered + reparented
|
||||||
|
-- into the managed frame exactly like runtime-added options.
|
||||||
|
-- (registerWithSelectParent is idempotent — it skips options already
|
||||||
|
-- registered — so this is a no-op for children added after _selectState was
|
||||||
|
-- set, e.g. the common `FlexLove.new({ parent = sp, selectOption = {...} })`
|
||||||
|
-- pattern.)
|
||||||
|
if element._selectState then
|
||||||
|
for _, child in ipairs(element.children) do
|
||||||
|
if child.selectOption then
|
||||||
|
Element._Select.registerWithSelectParent(child)
|
||||||
|
Element._Select.attachOptionToManagedFrame(child)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function onDetach(element)
|
||||||
|
-- Clear select-managed fields so the element can be GC'd cleanly in immediate
|
||||||
|
-- mode (formerly part of Element:_cleanup). This mirrors the select-clearing
|
||||||
|
-- block that lived in Element:_cleanup; Element:destroy separately routes
|
||||||
|
-- through Select.cleanupDestroy for full teardown (idempotent with this).
|
||||||
|
if element.selectParent then
|
||||||
|
element.selectParent.onChange = nil
|
||||||
|
end
|
||||||
|
element._selectState = nil
|
||||||
|
element._managedSelectOwner = nil
|
||||||
|
element._managedSelectFrame = nil
|
||||||
|
element._managedSelectAnchor = nil
|
||||||
|
element._managedSelectBaseOpacity = nil
|
||||||
|
element._managedSelectBaseVisibility = nil
|
||||||
|
element._managedSelectBaseDisabled = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- onUpdate — per-frame managed-frame layout sync (formerly Element:update
|
||||||
|
-- line ~2747 — `Select.ensureFrameState`).
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
local function onUpdate(element, dt)
|
||||||
|
local Element = ElementClass(element)
|
||||||
|
Element._Select.ensureFrameState(element)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- onDraw — no-op.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Select rendering is driven by the managed frame / anchor elements themselves
|
||||||
|
-- (visibility synced by Select.syncManagedFrameVisibility), not by the select
|
||||||
|
-- parent's draw path. The parent's own pixels are the theme/renderer's job.
|
||||||
|
local function onDraw() end
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- saveState / restoreState — select open/value/label (formerly the `select`
|
||||||
|
-- branch of Element:saveState / Element:restoreState).
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Returns a snapshot under the `select` key to match the legacy immediate-mode
|
||||||
|
-- restoreState contract (Element:restoreState looked up state.select). The
|
||||||
|
-- behavior-dispatch loop merges behavior snapshots into the top-level state
|
||||||
|
-- table, so returning { select = ... } slots in identically to the old inline
|
||||||
|
-- `state.select = selectState` assignment.
|
||||||
|
local function saveState(element)
|
||||||
|
local Element = ElementClass(element)
|
||||||
|
local selectState = Element._Select.saveState(element)
|
||||||
|
if selectState then
|
||||||
|
return { select = selectState }
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Consumes the previously-saved snapshot keyed under `select`. The behavior-
|
||||||
|
-- dispatch loop passes the FULL top-level state table; this hook reads only
|
||||||
|
-- its own `state.select` slice, mirroring the legacy `if state.select then`
|
||||||
|
-- guard in Element:restoreState.
|
||||||
|
local function restoreState(element, state)
|
||||||
|
if not state then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local Element = ElementClass(element)
|
||||||
|
if state.select then
|
||||||
|
Element._Select.restoreState(element, state.select)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Build the (stateless, shared, immutable) behavior instance.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
local Selectable = Behavior.new({
|
||||||
|
onAttach = onAttach,
|
||||||
|
onDetach = onDetach,
|
||||||
|
onUpdate = onUpdate,
|
||||||
|
onDraw = onDraw,
|
||||||
|
saveState = saveState,
|
||||||
|
restoreState = restoreState,
|
||||||
|
shouldAttach = shouldAttach,
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Expose the predicate at module level so callers/tests can reference it
|
||||||
|
-- directly without an element instance (mirrors Behavior.shouldAttach).
|
||||||
|
Selectable.shouldAttach = shouldAttach
|
||||||
|
|
||||||
|
return Selectable
|
||||||
@@ -0,0 +1,576 @@
|
|||||||
|
-- modules/behaviors/TextEditable.lua
|
||||||
|
--
|
||||||
|
-- Concrete behavior: TextEditor subsystem ownership — text editing, cursor
|
||||||
|
-- management, text selection, text-related input handling, and text-editor
|
||||||
|
-- state save/restore.
|
||||||
|
--
|
||||||
|
-- This behavior consolidates the legacy `if self._textEditor` nil-guard
|
||||||
|
-- patterns that previously lived inside Element.lua:
|
||||||
|
--
|
||||||
|
-- * TextEditor creation + immediate-mode state restore (formerly
|
||||||
|
-- Element:_initSubSystems lines ~813-830 — the `if self.editable then
|
||||||
|
-- self._textEditor = Element._TextEditor.new {...}` block).
|
||||||
|
-- * Cursor-blink update (formerly Element:update line ~2810 —
|
||||||
|
-- `if self._textEditor then self._textEditor:update(self, dt) end`).
|
||||||
|
-- * The 27 text-editor delegate methods (formerly Element:setText /
|
||||||
|
-- getText / setCursorPosition / setSelection / focus / textinput /
|
||||||
|
-- keypressed / _handleTextClick / _handleTextDrag / ...). Each was a 3-line
|
||||||
|
-- nil-guard stub (check `_textEditor`, forward call, end). They are now
|
||||||
|
-- module-level functions on this behavior; Element retains only 1-line
|
||||||
|
-- forwarders that route through `Element._TextEditable.<fn>(self, ...)`.
|
||||||
|
-- * Text-editor state save/restore (formerly the textEditor branch of
|
||||||
|
-- Element:saveState / Element:restoreState), including the cursor/selection
|
||||||
|
-- field sync and the text-selection drag-tracking fields
|
||||||
|
-- (`_mouseDownPosition` / `_textDragOccurred`).
|
||||||
|
--
|
||||||
|
-- Element retains the `self._textEditor` field for backward-compat field
|
||||||
|
-- access (Renderer:drawText reads it directly for cursor/selection rendering);
|
||||||
|
-- runtime state lives ON THE ELEMENT. The behavior itself is stateless +
|
||||||
|
-- immutable + shared across elements.
|
||||||
|
--
|
||||||
|
-- State ownership (per the locked Behavior contract):
|
||||||
|
-- * Per-element runtime state lives ON THE ELEMENT (`self._textEditor`,
|
||||||
|
-- `self._mouseDownPosition`, `self._textDragOccurred`). The behavior
|
||||||
|
-- instance is stateless + immutable and shared across all editable
|
||||||
|
-- elements.
|
||||||
|
-- * Element-class-level dependencies are resolved via `getmetatable(element)`
|
||||||
|
-- (which IS the Element class set by Element._construct), so the hook
|
||||||
|
-- signature stays exactly `(element, ...)` with no DI parameters.
|
||||||
|
--
|
||||||
|
-- onDraw is a no-op: text/cursor/selection rendering stays in the Renderer's
|
||||||
|
-- command buffer (Layer 4 "text"), driven by the Thamed behavior's single
|
||||||
|
-- `Renderer:draw` call. The Renderer's `drawText` already reads
|
||||||
|
-- `element._textEditor` for cursor/selection, so TextEditable OWNS the
|
||||||
|
-- subsystem that drawText consumes, but the draw dispatch stays in the
|
||||||
|
-- renderer to preserve the unified transform/scissor command-buffer ordering
|
||||||
|
-- (mirrors Selectable.onDraw's no-op precedent, where rendering is owned by a
|
||||||
|
-- different layer). Hoisting drawText into this behavior's onDraw would
|
||||||
|
-- double-render text, since the Renderer command buffer already emits a "text"
|
||||||
|
-- layer for every element.
|
||||||
|
|
||||||
|
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||||
|
local Behavior = require(_pkg .. "Behavior")
|
||||||
|
|
||||||
|
-- Resolve the Element class from an element instance (mirrors Clickable /
|
||||||
|
-- Selectable). `setmetatable({}, Element)` in `_construct` makes the instance
|
||||||
|
-- metatable BE the Element class, so this yields Element._TextEditor,
|
||||||
|
-- Element._textEditorDeps, Element._Context, Element._StateManager, etc.
|
||||||
|
-- without threading deps through the hook signature.
|
||||||
|
local function ElementClass(element)
|
||||||
|
return getmetatable(element)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- shouldAttach (class-level predicate, no element required)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Mirrors the spec predicate: attach when the element is text-editable OR
|
||||||
|
-- carries text content. onAttach only ALLOCATES a TextEditor when
|
||||||
|
-- `element.editable` is true (preserving the pre-refactor creation invariant
|
||||||
|
-- "TextEditor created iff editable"), so non-editable text labels attach the
|
||||||
|
-- behavior but allocate no TextEditor — their onUpdate/onDraw/saveState are
|
||||||
|
-- nil-guarded no-ops, and the Element forwarders route them through the
|
||||||
|
-- non-editable branch of each delegate function (reads/writes `element.text`
|
||||||
|
-- directly). This keeps shouldAttach faithful to the spec while preserving
|
||||||
|
-- exact pre-refactor allocation behavior.
|
||||||
|
local function shouldAttach(props)
|
||||||
|
props = props or {}
|
||||||
|
return props.editable == true or props.text ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- onAttach — create the TextEditor (formerly Element:_initSubSystems lines
|
||||||
|
-- ~813-830) and restore immediate-mode TextEditor state.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
local function onAttach(element)
|
||||||
|
local Element = ElementClass(element)
|
||||||
|
|
||||||
|
-- Only editable elements own a TextEditor. Preserves the exact pre-refactor
|
||||||
|
-- creation guard (`if self.editable then ... end`) — non-editable text
|
||||||
|
-- elements attach the behavior (so their forwarders route through a single
|
||||||
|
-- code path) but allocate no TextEditor.
|
||||||
|
if not element.editable then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Config is sourced from element fields (bound by _applyProps / _initVisualState
|
||||||
|
-- before _attachBehaviors runs at the tail of Element.new) — NOT from raw
|
||||||
|
-- props. The callbacks (onFocus/onBlur/onTextInput/onTextChange/onEnter) are
|
||||||
|
-- schema-bound element fields by this point, and `element.text` is set by
|
||||||
|
-- _initVisualState, so no `props` reference is needed here (the hook
|
||||||
|
-- signature is `(element)`).
|
||||||
|
element._textEditor = Element._TextEditor.new({
|
||||||
|
editable = element.editable,
|
||||||
|
multiline = element.multiline,
|
||||||
|
passwordMode = element.passwordMode,
|
||||||
|
textWrap = element.textWrap,
|
||||||
|
maxLines = element.maxLines,
|
||||||
|
maxLength = element.maxLength,
|
||||||
|
placeholder = element.placeholder,
|
||||||
|
inputType = element.inputType,
|
||||||
|
textOverflow = element.textOverflow,
|
||||||
|
scrollable = element.scrollable,
|
||||||
|
autoGrow = element.autoGrow,
|
||||||
|
selectOnFocus = element.selectOnFocus,
|
||||||
|
cursorColor = element.cursorColor,
|
||||||
|
selectionColor = element.selectionColor,
|
||||||
|
cursorBlinkRate = element.cursorBlinkRate,
|
||||||
|
text = element.text or "",
|
||||||
|
onFocus = element.onFocus,
|
||||||
|
onBlur = element.onBlur,
|
||||||
|
onTextInput = element.onTextInput,
|
||||||
|
onTextChange = element.onTextChange,
|
||||||
|
onEnter = element.onEnter,
|
||||||
|
}, Element._textEditorDeps)
|
||||||
|
|
||||||
|
-- Restore TextEditor state from StateManager in immediate mode. Mirrors the
|
||||||
|
-- legacy _initSubSystems immediate-mode restore. Safe to run here (after
|
||||||
|
-- _construct registered the element with StateManager) — the StateManager
|
||||||
|
-- lookup is sparse and returns nil for a fresh element. Mode-aware via
|
||||||
|
-- Context.isImmediateMode (behavior-mode-unification task 11).
|
||||||
|
if Element._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then
|
||||||
|
local state = Element._StateManager.getState(element._stateId)
|
||||||
|
if state and state.textEditor then
|
||||||
|
element._textEditor:setState(state.textEditor, element)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function onDetach(element)
|
||||||
|
-- Clear text-input callback closures read by TextEditor / KeyboardNavigation
|
||||||
|
-- so the element's closure references can be collected in immediate mode
|
||||||
|
-- (formerly part of Element:_cleanup). The TextEditor instance itself is
|
||||||
|
-- INTENTIONALLY kept: Element:_cleanup preserves element structure for
|
||||||
|
-- inspection (released when the element is GC'd).
|
||||||
|
element.onTextInput = nil
|
||||||
|
element.onTextChange = nil
|
||||||
|
element.onEnter = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- onUpdate — cursor-blink animation (formerly Element:update line ~2810).
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Drives TextEditor:update (cursor blink + blink-pause timer). Guarded on
|
||||||
|
-- `element._textEditor` because non-editable text elements attach this
|
||||||
|
-- behavior (per shouldAttach) but own no TextEditor. Element:update contains
|
||||||
|
-- zero text-editor references — the dispatch loop calls this hook.
|
||||||
|
local function onUpdate(element, dt)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
textEditor:update(element, dt)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- onDraw — no-op (see file header: text rendering stays in the Renderer
|
||||||
|
-- command buffer driven by the Thamed behavior's Renderer:draw call).
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
local function onDraw() end
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- saveState / restoreState — TextEditor state + text-selection drag
|
||||||
|
-- tracking (formerly the textEditor branch of Element:saveState /
|
||||||
|
-- Element:restoreState, including the _mouseDownPosition / _textDragOccurred
|
||||||
|
-- fields).
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Returns a snapshot under the `textEditor` key to match the legacy immediate-
|
||||||
|
-- mode restoreState contract (Element:restoreState looked up state.textEditor).
|
||||||
|
-- The behavior-dispatch loop in Element:saveState merges behavior snapshots
|
||||||
|
-- into the top-level state table, so returning { textEditor = ... } slots in
|
||||||
|
-- identically to the old inline `state.textEditor = self._textEditor:getState()`
|
||||||
|
-- assignment. The drag-tracking fields are merged at the top level too
|
||||||
|
-- (matching the legacy `state._mouseDownPosition` / `state._textDragOccurred`
|
||||||
|
-- assignments) since they are text-selection state.
|
||||||
|
local function saveState(element)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if not textEditor then
|
||||||
|
-- Non-editable text element: still persist drag-tracking fields if set
|
||||||
|
-- (they are only ever set for editable elements, but persist defensively).
|
||||||
|
local hasDragState = element._mouseDownPosition ~= nil or element._textDragOccurred ~= nil
|
||||||
|
if not hasDragState then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local snapshot = {}
|
||||||
|
if element._mouseDownPosition ~= nil then
|
||||||
|
snapshot._mouseDownPosition = element._mouseDownPosition
|
||||||
|
end
|
||||||
|
if element._textDragOccurred ~= nil then
|
||||||
|
snapshot._textDragOccurred = element._textDragOccurred
|
||||||
|
end
|
||||||
|
return snapshot
|
||||||
|
end
|
||||||
|
|
||||||
|
local snapshot = { textEditor = textEditor:getState() }
|
||||||
|
if element._mouseDownPosition ~= nil then
|
||||||
|
snapshot._mouseDownPosition = element._mouseDownPosition
|
||||||
|
end
|
||||||
|
if element._textDragOccurred ~= nil then
|
||||||
|
snapshot._textDragOccurred = element._textDragOccurred
|
||||||
|
end
|
||||||
|
return snapshot
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Consumes the previously-saved snapshot keyed under `textEditor` plus the
|
||||||
|
-- drag-tracking fields. The behavior-dispatch loop passes the FULL top-level
|
||||||
|
-- state table; this hook reads only its own slices, mirroring the legacy
|
||||||
|
-- `if self._textEditor and state.textEditor then ... end` guard.
|
||||||
|
local function restoreState(element, state)
|
||||||
|
if not state then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor and state.textEditor then
|
||||||
|
textEditor:setState(state.textEditor, element)
|
||||||
|
-- Sync TextEditor's focus/cursor/selection state to Element for theme
|
||||||
|
-- management (mirrors the legacy restoreState field sync).
|
||||||
|
element._focused = textEditor._focused
|
||||||
|
element._cursorPosition = textEditor._cursorPosition
|
||||||
|
element._selectionStart = textEditor._selectionStart
|
||||||
|
element._selectionEnd = textEditor._selectionEnd
|
||||||
|
element._textBuffer = textEditor._textBuffer
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Restore drag-tracking state for text selection (top-level keys).
|
||||||
|
if state._mouseDownPosition ~= nil then
|
||||||
|
element._mouseDownPosition = state._mouseDownPosition
|
||||||
|
end
|
||||||
|
if state._textDragOccurred ~= nil then
|
||||||
|
element._textDragOccurred = state._textDragOccurred
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Text-editor delegate functions.
|
||||||
|
--
|
||||||
|
-- These are the module-level implementations of the 27 text-editor delegate
|
||||||
|
-- methods that previously lived on Element. Each mirrors the pre-refactor
|
||||||
|
-- Element method body VERBATIM (with `self` → `element`), including the
|
||||||
|
-- `element._textEditor` nil-guard: the guard is required because (a) non-
|
||||||
|
-- editable text elements attach this behavior (per shouldAttach) but own no
|
||||||
|
-- TextEditor, and (b) Element forwards these methods BEFORE onAttach has run
|
||||||
|
-- (e.g. an `onCreate` callback firing during _finalizeConstruction, which
|
||||||
|
-- runs before _attachBehaviors). The nil-guards live in THIS file (not in
|
||||||
|
-- Element.lua), so the Element.lua `if self._textEditor` count drops to 0.
|
||||||
|
--
|
||||||
|
-- Element retains 1-line forwarders: `Element.setText = function(self, text)
|
||||||
|
-- return Element._TextEditable.setText(self, text) end` (etc.), so external
|
||||||
|
-- callers (EventHandler, KeyboardNavigation, game UI) keep working unchanged.
|
||||||
|
--
|
||||||
|
-- The TextEditor API is mixed: most methods take the element as first arg
|
||||||
|
-- (`te:method(element, ...)` — "passesSelf"); a few getters omit it
|
||||||
|
-- (`te:method()`). The delegation contract is pinned by
|
||||||
|
-- subsystem_delegation_test.lua, so this mapping must match TextEditor's
|
||||||
|
-- method signatures exactly.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- --- Cursor management (passesSelf = element forwarded) ------------------
|
||||||
|
|
||||||
|
local function setCursorPosition(element, position)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
textEditor:setCursorPosition(element, position)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function getCursorPosition(element)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
return textEditor:getCursorPosition()
|
||||||
|
end
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local function moveCursorBy(element, delta)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
textEditor:moveCursorBy(element, delta)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function moveCursorToStart(element)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
textEditor:moveCursorToStart(element)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function moveCursorToEnd(element)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
textEditor:moveCursorToEnd(element)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function moveCursorToLineStart(element)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
textEditor:moveCursorToLineStart(element)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function moveCursorToLineEnd(element)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
textEditor:moveCursorToLineEnd(element)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function moveCursorToPreviousWord(element)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
textEditor:moveCursorToPreviousWord(element)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function moveCursorToNextWord(element)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
textEditor:moveCursorToNextWord(element)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- --- Selection management ------------------------------------------------
|
||||||
|
|
||||||
|
local function setSelection(element, startPos, endPos)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
textEditor:setSelection(element, startPos, endPos)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function getSelection(element)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
return textEditor:getSelection()
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function hasSelection(element)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor ~= nil then
|
||||||
|
return textEditor:hasSelection()
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clearSelection(element)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
textEditor:clearSelection(element)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function selectAll(element)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
textEditor:selectAll(element)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function getSelectedText(element)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
return textEditor:getSelectedText()
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function deleteSelection(element)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
return textEditor:deleteSelection(element)
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- --- Focus management ----------------------------------------------------
|
||||||
|
|
||||||
|
local function focus(element)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
textEditor:focus(element)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function blur(element)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
textEditor:blur(element)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isFocused(element)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
return textEditor:isFocused()
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- --- Text buffer management (with post-delegation sync) ------------------
|
||||||
|
-- These methods sync `element.text` from the TextEditor result + drive
|
||||||
|
-- auto-grow, exactly as the legacy Element methods did.
|
||||||
|
|
||||||
|
local function getText(element)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
return textEditor:getText()
|
||||||
|
end
|
||||||
|
return element.text or ""
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setText(element, text)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
textEditor:setText(element, text)
|
||||||
|
element.text = textEditor:getText() -- Sync display text
|
||||||
|
textEditor:updateAutoGrowHeight(element)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
element.text = text
|
||||||
|
end
|
||||||
|
|
||||||
|
local function insertText(element, text, position)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
textEditor:insertText(element, text, position)
|
||||||
|
element.text = textEditor:getText() -- Sync display text
|
||||||
|
textEditor:updateAutoGrowHeight(element)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function deleteText(element, startPos, endPos)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
textEditor:deleteText(element, startPos, endPos)
|
||||||
|
element.text = textEditor:getText() -- Sync display text
|
||||||
|
textEditor:updateAutoGrowHeight(element)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function replaceText(element, startPos, endPos, newText)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
textEditor:replaceText(element, startPos, endPos, newText)
|
||||||
|
element.text = textEditor:getText() -- Sync display text
|
||||||
|
textEditor:updateAutoGrowHeight(element)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- --- Mouse text selection ------------------------------------------------
|
||||||
|
|
||||||
|
local function handleTextClick(element, mouseX, mouseY, clickCount)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
textEditor:handleTextClick(element, mouseX, mouseY, clickCount)
|
||||||
|
-- Store mouse down position on element for drag tracking
|
||||||
|
if clickCount == 1 then
|
||||||
|
element._mouseDownPosition = textEditor:mouseToTextPosition(element, mouseX, mouseY)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function handleTextDrag(element, mouseX, mouseY)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
textEditor:handleTextDrag(element, mouseX, mouseY)
|
||||||
|
element._textDragOccurred = textEditor._textDragOccurred
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- --- Keyboard input ------------------------------------------------------
|
||||||
|
|
||||||
|
local function textinput(element, text)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
textEditor:handleTextInput(element, text)
|
||||||
|
element.text = textEditor:getText() -- Sync display text
|
||||||
|
textEditor:updateAutoGrowHeight(element)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function keypressed(element, key, scancode, isrepeat)
|
||||||
|
local textEditor = element._textEditor
|
||||||
|
if textEditor then
|
||||||
|
textEditor:handleKeyPress(element, key, scancode, isrepeat)
|
||||||
|
element.text = textEditor:getText() -- Sync display text
|
||||||
|
textEditor:updateAutoGrowHeight(element)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Build the (stateless, shared, immutable) behavior instance + thin module
|
||||||
|
-- table exposing the delegate functions (mirrors the Animated pattern).
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
local behavior = Behavior.new({
|
||||||
|
onAttach = onAttach,
|
||||||
|
onDetach = onDetach,
|
||||||
|
onUpdate = onUpdate,
|
||||||
|
onDraw = onDraw,
|
||||||
|
saveState = saveState,
|
||||||
|
restoreState = restoreState,
|
||||||
|
shouldAttach = shouldAttach,
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Thin module table: exposes the frozen behavior instance (for the registry)
|
||||||
|
-- plus the text-editor delegate functions (for Element's 1-line forwarders).
|
||||||
|
-- All hooks delegate to the frozen behavior instance so dispatch sites get
|
||||||
|
-- the validated, frozen implementation. shouldAttach is also exposed at module
|
||||||
|
-- level (mirrors Clickable.shouldAttach) for tests/callers without an element.
|
||||||
|
local TextEditable = {
|
||||||
|
behavior = behavior,
|
||||||
|
shouldAttach = shouldAttach,
|
||||||
|
onAttach = onAttach,
|
||||||
|
onUpdate = onUpdate,
|
||||||
|
onDraw = onDraw,
|
||||||
|
saveState = saveState,
|
||||||
|
restoreState = restoreState,
|
||||||
|
-- Text-editor delegate functions (Element forwarders route through these):
|
||||||
|
setCursorPosition = setCursorPosition,
|
||||||
|
getCursorPosition = getCursorPosition,
|
||||||
|
moveCursorBy = moveCursorBy,
|
||||||
|
moveCursorToStart = moveCursorToStart,
|
||||||
|
moveCursorToEnd = moveCursorToEnd,
|
||||||
|
moveCursorToLineStart = moveCursorToLineStart,
|
||||||
|
moveCursorToLineEnd = moveCursorToLineEnd,
|
||||||
|
moveCursorToPreviousWord = moveCursorToPreviousWord,
|
||||||
|
moveCursorToNextWord = moveCursorToNextWord,
|
||||||
|
setSelection = setSelection,
|
||||||
|
getSelection = getSelection,
|
||||||
|
hasSelection = hasSelection,
|
||||||
|
clearSelection = clearSelection,
|
||||||
|
selectAll = selectAll,
|
||||||
|
getSelectedText = getSelectedText,
|
||||||
|
deleteSelection = deleteSelection,
|
||||||
|
focus = focus,
|
||||||
|
blur = blur,
|
||||||
|
isFocused = isFocused,
|
||||||
|
getText = getText,
|
||||||
|
setText = setText,
|
||||||
|
insertText = insertText,
|
||||||
|
deleteText = deleteText,
|
||||||
|
replaceText = replaceText,
|
||||||
|
_handleTextClick = handleTextClick,
|
||||||
|
_handleTextDrag = handleTextDrag,
|
||||||
|
textinput = textinput,
|
||||||
|
keypressed = keypressed,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Metatable so the module table itself satisfies the duck-typed registry
|
||||||
|
-- contract (iterating `Element._behaviorRegistry` calls `behavior.shouldAttach`
|
||||||
|
-- and `behavior.onAttach` / `behavior.onUpdate` directly). Falls through to the
|
||||||
|
-- frozen behavior instance for every hook / isBehavior parity.
|
||||||
|
setmetatable(TextEditable, {
|
||||||
|
__index = behavior,
|
||||||
|
__tostring = function()
|
||||||
|
return "TextEditable"
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
return TextEditable
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
-- modules/behaviors/Themed.lua
|
||||||
|
--
|
||||||
|
-- Concrete behavior: Renderer ownership + theme-state rendering.
|
||||||
|
--
|
||||||
|
-- Themed owns the per-element Renderer instance and the single
|
||||||
|
-- `Renderer:draw` call that paints the core visual layers (background, image,
|
||||||
|
-- theme 9-patch, borders, text, customDraw). It is the behavior-mode-unification
|
||||||
|
-- replacement for the former `_initImageAndRenderer` Renderer creation block and
|
||||||
|
-- the former first `self._renderer:draw(self, backdropCanvas)` call in
|
||||||
|
-- Element:draw (behavior-mode-unification task 07).
|
||||||
|
--
|
||||||
|
-- Attachment rule (shouldAttach): every renderable Element. The pre-refactor
|
||||||
|
-- code unconditionally created a Renderer for every Element and unconditionally
|
||||||
|
-- called `Renderer:draw` in Element:draw; Themed mirrors that invariant so the
|
||||||
|
-- Renderer is always available to subsystems that depend on it (TextEditor font
|
||||||
|
-- / wrap delegation, ScrollManager scrollbar drawing) AND so visual rendering of
|
||||||
|
-- background / border / theme / image layers is preserved for every element.
|
||||||
|
-- Restricting attachment to `themeComponent`-only elements would break editable
|
||||||
|
-- text fields and scrollable containers (which need a Renderer for subsystem
|
||||||
|
-- delegation even when they have no theme component). The 9-patch theme-state
|
||||||
|
-- rendering within `Renderer:draw` is a no-op for elements without a
|
||||||
|
-- `themeComponent`, so always-attaching carries no rendering cost.
|
||||||
|
--
|
||||||
|
-- Themed and Imageable are paired (both configure the same `element._renderer`):
|
||||||
|
-- Themed.onAttach creates the Renderer with the theme/blur config; Imageable
|
||||||
|
-- (attached for imagePath/image elements) enriches the SAME renderer instance with
|
||||||
|
-- image config + deferred image loading. They share `element._renderer`.
|
||||||
|
--
|
||||||
|
-- onUpdate is a no-op: theme-state transitions are DRIVEN by the Clickable
|
||||||
|
-- behavior (whose onUpdate recomputes hover/press/focus and calls
|
||||||
|
-- `renderer:setThemeState`). Themed only READS that state for rendering, so it has
|
||||||
|
-- no per-frame update work.
|
||||||
|
--
|
||||||
|
-- saveState owns the blur-region snapshot (`state.blur`): the per-frame blur
|
||||||
|
-- geometry + radius/quality used by the Blur cache for invalidation (formerly
|
||||||
|
-- the inline `if self.backdropBlur or self.contentBlur` block of
|
||||||
|
-- Element:saveState — behavior-mode-unification task 12). restoreState is a
|
||||||
|
-- no-op: blur cache data is used for invalidation, not restoration (the Blur
|
||||||
|
-- cache is keyed by element id and cleared via `Blur.clearElementCache` from
|
||||||
|
-- FlexLove.endFrame, not replayed through restoreState).
|
||||||
|
--
|
||||||
|
-- State ownership (per the locked Behavior contract):
|
||||||
|
-- * Per-element runtime state lives ON THE ELEMENT (`element._renderer`,
|
||||||
|
-- `element._themeState`, `element.backdropBlur`, `element.contentBlur`).
|
||||||
|
-- The behavior instance is stateless and shared.
|
||||||
|
-- * `element._renderer` is recreated on attach; onDetach is a no-op — the
|
||||||
|
-- reference is released when the element is GC'd (Element:_cleanup keeps
|
||||||
|
-- element structure for inspection).
|
||||||
|
|
||||||
|
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||||
|
local Behavior = require(_pkg .. "Behavior")
|
||||||
|
|
||||||
|
-- Resolve the Element class from an element instance (mirrors Clickable).
|
||||||
|
-- Element instances are created via `setmetatable({}, Element)`, so their
|
||||||
|
-- metatable IS the Element class — giving access to Element._Renderer,
|
||||||
|
-- Element._rendererDeps, etc. without threading deps through the hook signature.
|
||||||
|
local function ElementClass(element)
|
||||||
|
return getmetatable(element)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- shouldAttach (class-level predicate, no element required)
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Returns true for every renderable Element. See file header for the rationale:
|
||||||
|
-- the pre-refactor invariant was "every Element has a Renderer; Element:draw
|
||||||
|
-- always calls Renderer:draw", and Thamed is the behavior-system embodiment of
|
||||||
|
-- that invariant. Returns true for `themeComponent`-bearing props (the spec's
|
||||||
|
-- headline case) and for every other element so subsystems/rendering stay intact.
|
||||||
|
local function shouldAttach(props)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- onAttach — create the Renderer with theme/blur config (formerly the
|
||||||
|
-- Renderer.new block of Element:_initImageAndRenderer).
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function onAttach(element)
|
||||||
|
local Element = ElementClass(element)
|
||||||
|
|
||||||
|
-- Create-or-reuse the Renderer. Thamed is the first render behavior in the
|
||||||
|
-- registry, so it normally creates the instance; Imageable (if attached) will
|
||||||
|
-- reuse this same instance for image config. Guarded so Imageable-onAttach-
|
||||||
|
-- first (defensive) does not clobber an existing renderer.
|
||||||
|
if element._renderer then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- NOTE: backgroundColor/borderColor/opacity/cornerRadius/themeComponent are
|
||||||
|
-- intentionally NOT passed here. Renderer:draw() reads them from the element
|
||||||
|
-- as the single source of truth (see Renderer.lua draw()). Only renderer-owned
|
||||||
|
-- state (theme, blur) is cached on the renderer; image config is added by the
|
||||||
|
-- Imageable behavior. border is element-sourced too.
|
||||||
|
element._renderer = Element._Renderer.new({
|
||||||
|
theme = element.theme,
|
||||||
|
scaleCorners = element.scaleCorners,
|
||||||
|
scalingAlgorithm = element.scalingAlgorithm,
|
||||||
|
contentBlur = element.contentBlur,
|
||||||
|
backdropBlur = element.backdropBlur,
|
||||||
|
}, Element._rendererDeps)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- onDraw — the single Renderer:draw call (formerly the first call in
|
||||||
|
-- Element:draw). Paints all core visual layers for this element.
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function onDraw(element, ctx)
|
||||||
|
local renderer = element._renderer
|
||||||
|
if not renderer then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
renderer:draw(element, ctx and ctx.backdropCanvas)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- onDetach — no-op. Element:_cleanup preserves element structure for
|
||||||
|
-- inspection (the original invariant), so the Renderer reference is released
|
||||||
|
-- when the element is GC'd rather than torn down here. Present as an explicit
|
||||||
|
-- hook so the behavior conforms to the full lifecycle contract.
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function onDetach() end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- saveState — blur-region snapshot (formerly the `blur` branch of
|
||||||
|
-- Element:saveState). Returns `{ blur = {...} }` when the element configures a
|
||||||
|
-- backdrop or content blur, so the Blur cache can invalidate by element id;
|
||||||
|
-- nil otherwise. Mode-agnostic to match the legacy contract (the snapshot is
|
||||||
|
-- only read back by the cache-invalidation path, which itself is
|
||||||
|
-- immediate-mode-only via FlexLove.endFrame).
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function saveState(element)
|
||||||
|
if not (element.backdropBlur or element.contentBlur) then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local blur = {
|
||||||
|
_blurX = element.x,
|
||||||
|
_blurY = element.y,
|
||||||
|
_blurWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right),
|
||||||
|
_blurHeight = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom),
|
||||||
|
}
|
||||||
|
if element.backdropBlur then
|
||||||
|
blur._backdropBlurRadius = element.backdropBlur.radius
|
||||||
|
blur._backdropBlurQuality = element.backdropBlur.quality or 5
|
||||||
|
end
|
||||||
|
if element.contentBlur then
|
||||||
|
blur._contentBlurRadius = element.contentBlur.radius
|
||||||
|
blur._contentBlurQuality = element.contentBlur.quality or 5
|
||||||
|
end
|
||||||
|
return { blur = blur }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- restoreState — no-op: blur cache data is used for invalidation, not
|
||||||
|
-- restoration (see file header). Present so the behavior conforms to the
|
||||||
|
-- lifecycle contract without replaying geometry that the cache recomputes.
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Build the (stateless, shared, immutable) behavior instance.
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local Themed = Behavior.new({
|
||||||
|
onAttach = onAttach,
|
||||||
|
onDetach = onDetach,
|
||||||
|
onUpdate = function() end,
|
||||||
|
onDraw = onDraw,
|
||||||
|
saveState = saveState,
|
||||||
|
restoreState = function() end,
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Expose the predicate at module level so callers/tests can reference it
|
||||||
|
-- directly without an element instance (mirrors Behavior.shouldAttach /
|
||||||
|
-- Clickable.shouldAttach).
|
||||||
|
Themed.shouldAttach = shouldAttach
|
||||||
|
|
||||||
|
return Themed
|
||||||
@@ -0,0 +1,662 @@
|
|||||||
|
---@class SelectOptionProps
|
||||||
|
---@field value any -- Stable option value owned by the parent select
|
||||||
|
---@field label string? -- Optional label override, falls back to the element text
|
||||||
|
---@field disabled boolean? -- Whether the option can be selected
|
||||||
|
local SelectOptionProps = {}
|
||||||
|
|
||||||
|
---@class SelectParentProps
|
||||||
|
---@field value any -- Currently selected option value
|
||||||
|
---@field open boolean? -- Initial open state for the select container
|
||||||
|
---@field placeholder string? -- Fallback text when no option is selected
|
||||||
|
---@field selectFrame Element? -- Optional pre-instantiated dropdown container; intended to be unattached before being adopted by the select
|
||||||
|
---@field onChange fun(element:Element, value:any, option:SelectOptionProps)? -- Called when selection changes
|
||||||
|
local SelectParentProps = {}
|
||||||
|
|
||||||
|
---@class Animation
|
||||||
|
local Animation = {}
|
||||||
|
|
||||||
|
---@class Color
|
||||||
|
local Color = {}
|
||||||
|
|
||||||
|
---@class Theme
|
||||||
|
local Theme = {}
|
||||||
|
|
||||||
|
---@class ThemeManager
|
||||||
|
local ThemeManager = {}
|
||||||
|
|
||||||
|
--=====================================--
|
||||||
|
-- For Animation.lua
|
||||||
|
--=====================================--
|
||||||
|
---@alias EasingFunction fun(t:number): number
|
||||||
|
|
||||||
|
---@class AnimationProps
|
||||||
|
---@field duration number -- Duration in seconds
|
||||||
|
---@field start table -- Starting values (can contain: width, height, opacity, x, y, gap, imageOpacity, backgroundColor, borderColor, textColor, padding, margin, cornerRadius, transform, etc.)
|
||||||
|
---@field final table -- Final values (same properties as start)
|
||||||
|
---@field easing string? -- Easing function name: "linear", "easeInQuad", "easeOutQuad", "easeInOutQuad", "easeInCubic", "easeOutCubic", "easeInOutCubic", "easeInQuart", "easeOutQuart", "easeInExpo", "easeOutExpo" (default: "linear")
|
||||||
|
---@field keyframes AnimationKeyframe[]? -- Array of keyframes for complex animations
|
||||||
|
---@field onStart fun(animation:Animation, element:Element?)? -- Called when animation starts
|
||||||
|
---@field onUpdate fun(animation:Animation, element:Element?, progress:number)? -- Called each frame with progress (0-1)
|
||||||
|
---@field onComplete fun(animation:Animation, element:Element?)? -- Called when animation completes
|
||||||
|
---@field onCancel fun(animation:Animation, element:Element?)? -- Called when animation is cancelled
|
||||||
|
---@field transform TransformProps? -- Additional transform properties (legacy support)
|
||||||
|
---@field transition table? -- Transition properties (legacy support)
|
||||||
|
local AnimationProps = {}
|
||||||
|
|
||||||
|
---@class Transform
|
||||||
|
---@field rotate number? Rotation in radians (default: 0)
|
||||||
|
---@field scaleX number? X-axis scale (default: 1)
|
||||||
|
---@field scaleY number? Y-axis scale (default: 1)
|
||||||
|
---@field translateX number? X translation in pixels (default: 0)
|
||||||
|
---@field translateY number? Y translation in pixels (default: 0)
|
||||||
|
---@field skewX number? X-axis skew in radians (default: 0)
|
||||||
|
---@field skewY number? Y-axis skew in radians (default: 0)
|
||||||
|
---@field originX number? Transform origin X (0-1, default: 0.5)
|
||||||
|
---@field originY number? Transform origin Y (0-1, default: 0.5)
|
||||||
|
local Transform = {}
|
||||||
|
|
||||||
|
---@alias TransformProps Transform
|
||||||
|
|
||||||
|
---@class TransitionProps
|
||||||
|
---@field duration number?
|
||||||
|
---@field easing string?
|
||||||
|
---@field delay number?
|
||||||
|
---@field onComplete fun(element:Element)?
|
||||||
|
|
||||||
|
--=====================================--
|
||||||
|
-- For Element.lua
|
||||||
|
--=====================================--
|
||||||
|
---@class ElementProps
|
||||||
|
---@field id string? -- Unique identifier for the element (auto-generated in immediate mode if not provided)
|
||||||
|
---@field mode "immediate"|"retained"|nil -- Lifecycle mode override: "immediate" (auto-managed state), "retained" (manual state), nil (use global mode from FlexLove.getMode(), default)
|
||||||
|
---@field parent Element? -- Parent element for hierarchical structure
|
||||||
|
---@field x number|string|CalcObject? -- X coordinate: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: 0)
|
||||||
|
---@field y number|string|CalcObject? -- Y coordinate: number (px), string ("50%", "10vh"), or CalcObject from FlexLove.calc() (default: 0)
|
||||||
|
---@field z number? -- Z-index for layering (default: 0, clamped to -999..999)
|
||||||
|
---@field tabIndex number? -- Tab navigation order: >0 (explicit order, visited first), 0 or nil (natural document order), -1 (excluded from keyboard navigation)
|
||||||
|
---@field width number|string|CalcObject? -- Width of the element: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: calculated automatically)
|
||||||
|
---@field height number|string|CalcObject? -- Height of the element: number (px), string ("50%", "10vh"), or CalcObject from FlexLove.calc() (default: calculated automatically)
|
||||||
|
---@field minWidth number|string|CalcObject? -- Minimum width constraint: number (px), string ("50%", "10vw"), or CalcObject. Clamps both fixed `width` and the flex-distributed main size when horizontal.
|
||||||
|
---@field maxWidth number|string|CalcObject? -- Maximum width constraint: number (px), string ("50%", "10vw"), or CalcObject. Clamps both fixed `width` and the flex-distributed main size when horizontal.
|
||||||
|
---@field minHeight number|string|CalcObject? -- Minimum height constraint: number (px), string ("50%", "10vh"), or CalcObject. Clamps both fixed `height` and the flex-distributed main size when vertical.
|
||||||
|
---@field maxHeight number|string|CalcObject? -- Maximum height constraint: number (px), string ("50%", "10vh"), or CalcObject. Clamps both fixed `height` and the flex-distributed main size when vertical.
|
||||||
|
---@field top number|string|CalcObject? -- Offset from top edge: number (px), string ("50%", "10vh"), or CalcObject (CSS-style positioning)
|
||||||
|
---@field right number|string|CalcObject? -- Offset from right edge: number (px), string ("50%", "10vw"), or CalcObject (CSS-style positioning)
|
||||||
|
---@field bottom number|string|CalcObject? -- Offset from bottom edge: number (px), string ("50%", "10vh"), or CalcObject (CSS-style positioning)
|
||||||
|
---@field left number|string|CalcObject? -- Offset from left edge: number (px), string ("50%", "10vw"), or CalcObject (CSS-style positioning)
|
||||||
|
---@field border Border? -- Border configuration for the element
|
||||||
|
---@field borderColor Color? -- Color of the border (default: black)
|
||||||
|
---@field opacity number? -- Element opacity 0-1 (default: 1)
|
||||||
|
---@field visibility "visible"|"hidden"? -- Element visibility (default: "visible")
|
||||||
|
---@field display boolean? -- Whether element participates in layout, rendering, and hit testing (default: true). Set false for CSS display:none behavior (zero layout space, no rendering, no hit testing). NOTE: In retained mode, toggling at runtime requires setting the parent's `_dirty = true` or calling `layoutChildren()` on the parent to trigger re-layout.
|
||||||
|
---@field backgroundColor Color? -- Background color (default: transparent)
|
||||||
|
---@field cornerRadius number|{topLeft:number?, topRight:number?, bottomLeft:number?, bottomRight:number?}? -- Corner radius: number (all corners) or table for individual corners (default: 0)
|
||||||
|
---@field gap number|string|CalcObject? -- Space between children elements: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: 0)
|
||||||
|
---@field padding number|string|CalcObject|{top:number|string|CalcObject?, right:number|string|CalcObject?, bottom:number|string|CalcObject?, left:number|string|CalcObject?, horizontal:number|string|CalcObject?, vertical:number|string|CalcObject?}? -- Padding around children: single value, string, CalcObject for all sides, or table for individual sides (default: {top=0, right=0, bottom=0, left=0})
|
||||||
|
---@field margin number|string|CalcObject|{top:number|string|CalcObject?, right:number|string|CalcObject?, bottom:number|string|CalcObject?, left:number|string|CalcObject?, horizontal:number|string|CalcObject?, vertical:number|string|CalcObject?}? -- Margin around element: single value, string, CalcObject for all sides, or table for individual sides (default: {top=0, right=0, bottom=0, left=0})
|
||||||
|
---@field text string? -- Text content to display (default: nil)
|
||||||
|
---@field textAlign TextAlignSpec? -- Alignment of the text content: simple string, compound string ("top-left"), or {horizontal, vertical} table (default: START)
|
||||||
|
---@field textColor Color? -- Color of the text content (default: black or theme text color)
|
||||||
|
---@field textSize number|string? -- Font size: number (px), string with units ("2vh", "10%"), or preset ("xxs"|"xs"|"sm"|"md"|"lg"|"xl"|"xxl"|"3xl"|"4xl") (default: "md" or 12px)
|
||||||
|
---@field minTextSize number? -- Minimum text size in pixels for auto-scaling
|
||||||
|
---@field maxTextSize number? -- Maximum text size in pixels for auto-scaling
|
||||||
|
---@field fontFamily string? -- Font family name from theme or path to font file (default: theme default or system default, inherits from parent)
|
||||||
|
---@field autoScaleText boolean? -- Whether text should auto-scale with window size (default: true)
|
||||||
|
---@field positioning Positioning? -- Layout positioning mode: "absolute"|"relative"|"flex"|"grid" (default: RELATIVE)
|
||||||
|
---@field flexDirection FlexDirection? -- Direction of flex layout: "horizontal"|"vertical"|"row"|"column"|"row-reverse"|"column-reverse"|"horizontal-reverse"|"vertical-reverse" (row→horizontal, column→vertical, row-reverse→horizontal-reverse, column-reverse→vertical-reverse, default: HORIZONTAL)
|
||||||
|
---@field justifyContent JustifyContent? -- Alignment of items along main axis (default: FLEX_START)
|
||||||
|
---@field alignItems AlignItems? -- Alignment of items along cross axis (default: STRETCH)
|
||||||
|
---@field alignContent AlignContent? -- Alignment of lines in multi-line flex containers (default: STRETCH)
|
||||||
|
---@field flexWrap FlexWrap? -- Whether children wrap to multiple lines: "nowrap"|"wrap"|"wrap-reverse" (default: NOWRAP)
|
||||||
|
---@field flex number|string? -- Shorthand for flexGrow, flexShrink, flexBasis: number (flex-grow only), string ("1 0 auto"), or nil (default: nil)
|
||||||
|
---@field flexGrow number? -- How much the element should grow relative to siblings (default: 0)
|
||||||
|
---@field flexShrink number? -- How much the element should shrink relative to siblings (default: 1)
|
||||||
|
---@field flexBasis number|string|CalcObject? -- Initial size before growing/shrinking: number (px), string ("50%", "10vw", "auto"), or CalcObject (default: "auto")
|
||||||
|
---@field justifySelf JustifySelf? -- Alignment of the item itself along main axis (default: AUTO)
|
||||||
|
---@field alignSelf AlignSelf? -- Alignment of the item itself along cross axis (default: AUTO)
|
||||||
|
---@field onEvent fun(element:Element, event:InputEvent)? -- Callback function for interaction events
|
||||||
|
---@field onEventDeferred boolean? -- Whether onEvent callback should be deferred until after canvases are released (default: false)
|
||||||
|
---@field onFocus fun(element:Element)? -- Callback when element receives focus
|
||||||
|
---@field onFocusDeferred boolean? -- Whether onFocus callback should be deferred (default: false)
|
||||||
|
---@field dropFocusOnSelection boolean? -- Override keyboard-navigation focus drop after Enter/Space activation (default: nil, uses KeyboardNavigation.config.dropFocusOnSelection)
|
||||||
|
---@field onBlur fun(element:Element)? -- Callback when element loses focus
|
||||||
|
---@field onBlurDeferred boolean? -- Whether onBlur callback should be deferred (default: false)
|
||||||
|
---@field onTextInput fun(element:Element, text:string)? -- Callback when text is input
|
||||||
|
---@field onTextInputDeferred boolean? -- Whether onTextInput callback should be deferred (default: false)
|
||||||
|
---@field onTextChange fun(element:Element, text:string)? -- Callback when text content changes
|
||||||
|
---@field onTextChangeDeferred boolean? -- Whether onTextChange callback should be deferred (default: false)
|
||||||
|
---@field onEnter fun(element:Element)? -- Callback when Enter key is pressed
|
||||||
|
---@field onEnterDeferred boolean? -- Whether onEnter callback should be deferred (default: false)
|
||||||
|
---@field onCreate fun(element:Element, props:table)? -- Callback when element is created, receives the element and original creation props
|
||||||
|
---@field onCreateDeferred boolean? -- Whether onCreate callback should be deferred (default: false)
|
||||||
|
---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Callback for touch-specific events (touchpress, touchmove, touchrelease)
|
||||||
|
---@field onTouchEventDeferred boolean? -- Whether onTouchEvent callback should be deferred (default: false)
|
||||||
|
---@field onGesture fun(element:Element, gesture:table)? -- Callback for recognized gestures (tap, swipe, pinch, etc.)
|
||||||
|
---@field onGestureDeferred boolean? -- Whether onGesture callback should be deferred (default: false)
|
||||||
|
---@field touchEnabled boolean? -- Whether the element responds to touch events (default: true)
|
||||||
|
---@field multiTouchEnabled boolean? -- Whether the element supports multiple simultaneous touches (default: false)
|
||||||
|
---@field transform TransformProps? -- Transform properties for animations and styling
|
||||||
|
---@field transition TransitionProps? -- Transition settings for animations
|
||||||
|
---@field customDraw fun(element:Element)? -- Custom rendering callback called after standard rendering but before visual feedback (default: nil)
|
||||||
|
---@field gridRows number|table? -- Number of equal 1fr rows, or array of track specs (e.g. {"1fr","100px","auto"})
|
||||||
|
---@field gridColumns number|table? -- Number of equal 1fr columns, or array of track specs (e.g. {"1fr","100px","auto"})
|
||||||
|
---@field columnGap number|string|CalcObject? -- Gap between grid columns: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: 0)
|
||||||
|
---@field rowGap number|string|CalcObject? -- Gap between grid rows: number (px), string ("50%", "10vh"), or CalcObject from FlexLove.calc() (default: 0)
|
||||||
|
---@field theme string? -- Theme name to use (e.g., "space", "metal"). Defaults to theme from flexlove.init()
|
||||||
|
---@field themeComponent string? -- Theme component to use (e.g., "panel", "button", "input"). If nil, no theme is applied
|
||||||
|
---@field disabled boolean? -- Whether the element is disabled (default: false)
|
||||||
|
---@field active boolean? -- Whether the element is active/focused (for inputs, default: false)
|
||||||
|
---@field disableHighlight boolean? -- Whether to disable the pressed state highlight overlay (default: false, or true when using themeComponent)
|
||||||
|
---@field themeStateLock boolean|string? -- Lock theme state: true/"default" = lock to base state, false = normal behavior, string = specific state ("hover", "pressed", "active", "disabled") (default: false)
|
||||||
|
---@field themeComponentDisabledStates string[]? -- List of theme states to suppress visually (e.g. {"hover", "pressed"}). Interaction logic still fires.
|
||||||
|
---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Multiplier for auto-sized content dimensions (default: sourced from theme or {1, 1})
|
||||||
|
---@field scaleCorners number? -- Scale multiplier for 9-patch corners/edges. E.g., 2 = 2x size (overrides theme setting)
|
||||||
|
---@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for 9-patch corners: "nearest" (sharp/pixelated) or "bilinear" (smooth) (overrides theme setting)
|
||||||
|
---@field contentBlur {radius:number, quality:number?}? -- Blur the element's content including children (radius: pixels, quality: 1-10, default(quality): 5)
|
||||||
|
---@field backdropBlur {radius:number, quality:number?}? -- Blur content behind the element (radius: pixels, quality: 1-10, default(quality): 5)
|
||||||
|
---@field editable boolean? -- Whether the element is editable (default: false)
|
||||||
|
---@field multiline boolean? -- Whether the element supports multiple lines (default: false)
|
||||||
|
---@field textWrap boolean|"word"|"char"? -- Text wrapping mode (default: false for single-line, "word" for multi-line)
|
||||||
|
---@field maxLines number? -- Maximum number of lines (default: nil)
|
||||||
|
---@field maxLength number? -- Maximum text length in characters (default: nil)
|
||||||
|
---@field placeholder string? -- Placeholder text when empty (default: nil)
|
||||||
|
---@field passwordMode boolean? -- Whether to display text as password (default: false, disables multiline)
|
||||||
|
---@field inputType "text"|"number"|"email"|"url"? -- Input type for validation (default: "text")
|
||||||
|
---@field textOverflow "clip"|"ellipsis"|"scroll"? -- Text overflow behavior (default: "clip")
|
||||||
|
---@field scrollable boolean? -- Whether text is scrollable (default: false for single-line, true for multi-line)
|
||||||
|
---@field autoGrow boolean? -- Whether element auto-grows with text (default: false for single-line, true for multi-line)
|
||||||
|
---@field selectOnFocus boolean? -- Whether to select all text on focus (default: false)
|
||||||
|
---@field cursorColor Color? -- Cursor color (default: nil, uses textColor)
|
||||||
|
---@field selectionColor Color? -- Selection background color (default: nil, uses theme or default)
|
||||||
|
---@field cursorBlinkRate number? -- Cursor blink rate in seconds (default: 0.5)
|
||||||
|
---@field selectParent SelectParentProps? -- Parent-owned select/dropdown state and callbacks
|
||||||
|
---@field selectOption SelectOptionProps? -- Option metadata attached to a child of a select parent
|
||||||
|
---@field overflow "visible"|"hidden"|"scroll"|"auto"? -- Overflow behavior (default: "hidden")
|
||||||
|
---@field overflowX "visible"|"hidden"|"scroll"|"auto"? -- X-axis overflow (overrides overflow)
|
||||||
|
---@field overflowY "visible"|"hidden"|"scroll"|"auto"? -- Y-axis overflow (overrides overflow)
|
||||||
|
---@field scrollbarWidth number? -- Width of scrollbar track in pixels (default: 12)
|
||||||
|
---@field scrollbarColor Color? -- Scrollbar thumb color (default: Color.new(0.5, 0.5, 0.5, 0.8))
|
||||||
|
---@field scrollbarTrackColor Color? -- Scrollbar track color (default: Color.new(0.2, 0.2, 0.2, 0.5))
|
||||||
|
---@field scrollbarRadius number? -- Corner radius for scrollbar (default: 6)
|
||||||
|
---@field scrollbarPadding number? -- Padding between scrollbar and edge (default: 2)
|
||||||
|
---@field scrollSpeed number? -- Pixels per wheel notch (default: 20)
|
||||||
|
---@field invertScroll boolean? -- Invert mouse wheel scroll direction (default: false)
|
||||||
|
---@field smoothScrollEnabled boolean? -- Enable smooth scrolling animation for wheel events (default: false)
|
||||||
|
---@field scrollBarStyle string? -- Scrollbar style name from theme (selects from theme.scrollbars, default: uses first scrollbar or fallback rendering)
|
||||||
|
---@field scrollbarKnobOffset number|{x:number, y:number}|{horizontal:number, vertical:number}? -- Offset for scrollbar knob/handle position in pixels (number for both axes, or table for per-axis control, default: 0, adds to theme offset)
|
||||||
|
---@field scrollbarPlacement "reserve-space"|"overlay"? -- Scrollbar rendering mode: "reserve-space" (reduces content area, default) or "overlay" (renders over content)
|
||||||
|
---@field scrollbarBalance boolean? -- When true, reserve scrollbar space on both sides of content for visual balance (default: false)
|
||||||
|
---@field hideScrollbars boolean|{vertical:boolean, horizontal:boolean}? -- Hide scrollbars (boolean for both, or table for individual control, default: false)
|
||||||
|
---@field imagePath string? -- Path to image file (auto-loads via ImageCache)
|
||||||
|
---@field image love.Image? -- Image object to display
|
||||||
|
---@field objectFit "fill"|"contain"|"cover"|"scale-down"|"none"? -- Image fit mode (default: "fill")
|
||||||
|
---@field objectPosition string? -- Image position like "center center", "top left", "50% 50%" (default: "center center")
|
||||||
|
---@field imageOpacity number? -- Image opacity 0-1 (default: 1, combines with element opacity)
|
||||||
|
---@field imageRepeat "no-repeat"|"repeat"|"repeat-x"|"repeat-y"|"space"|"round"? -- Image repeat/tiling mode (default: "no-repeat")
|
||||||
|
---@field imageTint Color? -- Color to tint the image (default: nil/white, no tint)
|
||||||
|
---@field onImageLoad fun(element:Element, image:love.Image)? -- Callback when image loads successfully
|
||||||
|
---@field onImageLoadDeferred boolean? -- Whether onImageLoad callback should be deferred (default: false)
|
||||||
|
---@field onImageError fun(element:Element, error:string)? -- Callback when image fails to load
|
||||||
|
---@field onImageErrorDeferred boolean? -- Whether onImageError callback should be deferred (default: false)
|
||||||
|
---@field _scrollX number? -- Internal: scroll X position (restored in immediate mode)
|
||||||
|
---@field _scrollY number? -- Internal: scroll Y position (restored in immediate mode)
|
||||||
|
---@field children? ElementProps[]
|
||||||
|
---@field userdata table? -- User-defined data storage for custom properties
|
||||||
|
---@field ariaRole ARIA? -- ARIA role for screen readers (e.g., "button", "link", "dialog")
|
||||||
|
---@field ariaLabel string? -- Accessible name for screen readers (overrides text content)
|
||||||
|
---@field ariaDescribedBy string? -- ID of element that describes this element
|
||||||
|
---@field ariaExpanded boolean? -- Whether element is expanded/collapsed (for containers)
|
||||||
|
---@field ariaPressed boolean? -- Whether element is pressed (for toggle buttons)
|
||||||
|
---@field ariaChecked boolean? -- Whether element is checked (for checkboxes/radios)
|
||||||
|
---@field ariaDisabled boolean? -- Whether element is disabled (overrides disabled property)
|
||||||
|
---@field ariaBusy boolean? -- Whether element is processing (for live regions)
|
||||||
|
---@field ariaLive "off"|"polite"|"assertive"? -- Live region priority for announcements
|
||||||
|
local ElementProps = {}
|
||||||
|
|
||||||
|
---@class Border
|
||||||
|
---@field top boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0)
|
||||||
|
---@field right boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0)
|
||||||
|
---@field bottom boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0)
|
||||||
|
---@field left boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0)
|
||||||
|
local Border = {}
|
||||||
|
|
||||||
|
--=====================================--
|
||||||
|
-- For KeyboardNavigation.lua
|
||||||
|
--=====================================--
|
||||||
|
---@class KeyboardNavigationKeyConfig
|
||||||
|
---@field next string -- Key used to move to the next focusable element
|
||||||
|
---@field previous string -- Key used to move to the previous focusable element
|
||||||
|
---@field up string -- Key used for directional navigation upward
|
||||||
|
---@field down string -- Key used for directional navigation downward
|
||||||
|
---@field left string -- Key used for directional navigation leftward
|
||||||
|
---@field right string -- Key used for directional navigation rightward
|
||||||
|
---@field activate string[] -- Keys that activate the currently focused element
|
||||||
|
---@field dismiss string -- Key used to dismiss or clear the currently focused element
|
||||||
|
---@field toggleDebug string -- Key used to toggle keyboard-navigation debug tooling
|
||||||
|
---@field inspect string -- Key used to inspect the currently focused element in developer tools
|
||||||
|
local KeyboardNavigationKeyConfig = {}
|
||||||
|
|
||||||
|
---@class KeyboardNavigationDeveloperToolsConfig
|
||||||
|
---@field enabled boolean? -- Enable keyboard-navigation developer tools (default: true)
|
||||||
|
---@field showProperties boolean? -- Show focused element properties in developer tools (default: true)
|
||||||
|
---@field highlightColor number[]? -- RGBA color used for keyboard-navigation debug highlighting (default: {1, 0.8, 0, 0.5})
|
||||||
|
local KeyboardNavigationDeveloperToolsConfig = {}
|
||||||
|
|
||||||
|
---@class KeyboardNavigationFocusIndicatorConfig
|
||||||
|
---@field enabled boolean? -- Enable the keyboard focus indicator (default: true)
|
||||||
|
---@field color number[]? -- RGBA color of the focus indicator (default: {0.2, 0.6, 1.0, 0.8})
|
||||||
|
---@field lineWidth number? -- Focus indicator stroke width in pixels (default: 2)
|
||||||
|
---@field inset number? -- Offset from the element bounds in pixels (default: -3)
|
||||||
|
---@field borderRadius number? -- Focus indicator border radius in pixels (default: 4)
|
||||||
|
---@field animationDuration number? -- Focus indicator entrance animation duration in seconds (default: 0.15)
|
||||||
|
---@field pulseEnabled boolean? -- Enable pulse animation for the focus indicator when supported
|
||||||
|
---@field pulseDuration number? -- Seconds per pulse cycle
|
||||||
|
---@field pulseScaleMin number? -- Minimum scale during pulse animation
|
||||||
|
---@field pulseScaleMax number? -- Maximum scale during pulse animation
|
||||||
|
---@field draw fun(element:Element, bounds:table, style:KeyboardNavigationFocusIndicatorConfig)? -- Custom focus indicator renderer
|
||||||
|
local KeyboardNavigationFocusIndicatorConfig = {}
|
||||||
|
|
||||||
|
---@class KeyboardNavigationConfig
|
||||||
|
---@field enabled boolean? -- Enable or disable keyboard navigation globally (default: true)
|
||||||
|
---@field debugMode boolean? -- Enable keyboard-navigation debug logging (default: false)
|
||||||
|
---@field keys KeyboardNavigationKeyConfig? -- Key bindings used by keyboard navigation
|
||||||
|
---@field wrapAround boolean? -- Allow wrapping from last to first focusable element (default: true)
|
||||||
|
---@field directionalNavigation boolean? -- Enable arrow-key directional navigation (default: true)
|
||||||
|
---@field focusVisible boolean? -- Show the focus indicator for keyboard-driven focus (default: true)
|
||||||
|
---@field autofocusOnCreate boolean? -- Auto-focus the first focusable element on creation (default: false)
|
||||||
|
---@field dropFocusOnSelection boolean? -- Drop focus after Enter/Space activates an element (default: true)
|
||||||
|
---@field developerTools KeyboardNavigationDeveloperToolsConfig? -- Developer tool settings for keyboard navigation
|
||||||
|
---@field focusIndicator KeyboardNavigationFocusIndicatorConfig? -- Focus indicator style configuration
|
||||||
|
local KeyboardNavigationConfig = {}
|
||||||
|
|
||||||
|
--=====================================--
|
||||||
|
-- For FlexLove.init()
|
||||||
|
--=====================================--
|
||||||
|
---@class FlexLoveConfig
|
||||||
|
---@field baseScale {width:number?, height:number?}? -- Base resolution for responsive scaling (default: nil, no scaling)
|
||||||
|
---@field theme string|ThemeDefinition? -- Theme name (string) or ThemeDefinition to use (default: nil, no theme)
|
||||||
|
---@field immediateMode boolean? -- Enable immediate mode (React-like, recreates UI each frame) vs retained mode (default: false)
|
||||||
|
---@field autoFrameManagement boolean? -- Automatically call beginFrame/endFrame (default: false)
|
||||||
|
---@field stateRetentionFrames number? -- Number of frames to retain unused state in immediate mode (default: 60)
|
||||||
|
---@field maxStateEntries number? -- Maximum number of state entries before forcing cleanup (default: 1000)
|
||||||
|
---@field includeStackTrace boolean? -- Include stack traces in error messages (default: true)
|
||||||
|
---@field reportingLogLevel LOG_LEVEL? -- Error log level: 1: critical, 2: error, 3: warn, 4: info, 5: debug/all (default: 3:warn)
|
||||||
|
---@field errorLogTarget string? -- Error log target: "console", "file", "both" (default: "console")
|
||||||
|
---@field errorLogFile string? -- Path to error log file (default: "flexlove_errors.log")
|
||||||
|
---@field errorLogMaxSize number? -- Maximum error log file size in bytes (default: 1048576, 1MB)
|
||||||
|
---@field maxErrorLogFiles number? -- Maximum number of rotated error log files (default: 5)
|
||||||
|
---@field errorLogRotateEnabled boolean? -- Enable error log rotation (default: true)
|
||||||
|
---@field performanceMonitoring boolean? -- Enable performance monitoring (default: true)
|
||||||
|
---@field performanceHudKey string? -- Key to toggle performance HUD (default: "f3")
|
||||||
|
---@field performanceHudPosition {x:number, y:number}? -- Position of performance HUD (default: {x=10, y=10})
|
||||||
|
---@field performanceWarningThreshold number? -- Frame time warning threshold in ms (default: 13.0)
|
||||||
|
---@field performanceCriticalThreshold number? -- Frame time critical threshold in ms (default: 16.67)
|
||||||
|
---@field performanceLogToConsole boolean? -- Log performance metrics to console (default: false)
|
||||||
|
---@field performanceWarnings boolean? -- Enable performance warnings (default: false)
|
||||||
|
---@field memoryProfiling boolean? -- Enable memory profiling (default: false, auto-enabled in immediate mode)
|
||||||
|
---@field gcStrategy string? -- Garbage collection strategy: "auto", "periodic", "manual", "disabled" (default: "auto")
|
||||||
|
---@field gcMemoryThreshold number? -- Memory threshold in MB before forcing GC (default: 100)
|
||||||
|
---@field gcInterval number? -- Frames between GC steps in periodic mode (default: 60)
|
||||||
|
---@field gcStepSize number? -- Work units per GC step, higher = more aggressive (default: 200)
|
||||||
|
---@field immediateModeBlurOptimizations boolean? -- Cache blur canvases in immediate mode to avoid re-rendering each frame (default: true)
|
||||||
|
---@field keyboardNavigation boolean|KeyboardNavigationConfig? -- Enable keyboard navigation with defaults (`true`) or provide configuration overrides
|
||||||
|
---@field debugDraw boolean? -- Enable debug draw overlay showing element boundaries with random colors (default: false)
|
||||||
|
---@field debugDrawKey string? -- Key to toggle debug draw overlay at runtime (default: nil, no toggle key)
|
||||||
|
local FlexLoveConfig = {}
|
||||||
|
|
||||||
|
--=====================================--
|
||||||
|
-- Public FlexLove API
|
||||||
|
--=====================================--
|
||||||
|
---@alias TextAlignCompound "top-left" | "top-center" | "top-right" | "center-left" | "center-center" | "center-right" | "bottom-left" | "bottom-center" | "bottom-right"
|
||||||
|
---@alias TextAlignSpec TextAlign | TextAlignCompound | {horizontal: TextAlign, vertical: TextAlignVertical}
|
||||||
|
|
||||||
|
---@class FlexLoveEnums
|
||||||
|
---@field TextAlign TextAlign
|
||||||
|
---@field TextAlignVertical TextAlignVertical
|
||||||
|
---@field Positioning Positioning
|
||||||
|
---@field FlexDirection FlexDirection
|
||||||
|
---@field JustifyContent JustifyContent
|
||||||
|
---@field JustifySelf JustifySelf
|
||||||
|
---@field AlignItems AlignItems
|
||||||
|
---@field AlignSelf AlignSelf
|
||||||
|
---@field AlignContent AlignContent
|
||||||
|
---@field FlexWrap FlexWrap
|
||||||
|
---@field TextSize TextSize
|
||||||
|
---@field ImageRepeat ImageRepeat
|
||||||
|
---@field ARIA ARIA
|
||||||
|
local FlexLoveEnums = {}
|
||||||
|
|
||||||
|
---@class AnimationKeyframe
|
||||||
|
---@field at number -- Normalized time position (0-1)
|
||||||
|
---@field values table -- Property values at this keyframe
|
||||||
|
---@field easing string|EasingFunction? -- Easing used between this and the next keyframe
|
||||||
|
local AnimationKeyframe = {}
|
||||||
|
|
||||||
|
---@class AnimationGroupProps
|
||||||
|
---@field animations Animation[] -- Animations to coordinate
|
||||||
|
---@field mode "parallel"|"sequence"|"stagger"? -- Group playback mode (default: "parallel")
|
||||||
|
---@field stagger number? -- Delay between staggered animations in seconds (default: 0.1)
|
||||||
|
---@field onComplete fun(group:AnimationGroup)? -- Called when all animations complete
|
||||||
|
---@field onStart fun(group:AnimationGroup)? -- Called when the group starts
|
||||||
|
local AnimationGroupProps = {}
|
||||||
|
|
||||||
|
---@class AnimationGroup
|
||||||
|
---@field animations Animation[]
|
||||||
|
---@field mode "parallel"|"sequence"|"stagger"
|
||||||
|
---@field stagger number
|
||||||
|
---@field onComplete fun(group:AnimationGroup)?
|
||||||
|
---@field onStart fun(group:AnimationGroup)?
|
||||||
|
local AnimationGroup = {}
|
||||||
|
|
||||||
|
---@class Animation
|
||||||
|
---@field duration number
|
||||||
|
---@field start table
|
||||||
|
---@field final table
|
||||||
|
---@field elapsed number
|
||||||
|
---@field easing EasingFunction
|
||||||
|
---@field keyframes AnimationKeyframe[]?
|
||||||
|
---@field transform TransformProps?
|
||||||
|
---@field transition TransitionProps?
|
||||||
|
---@field onStart fun(animation:Animation, element:Element?)?
|
||||||
|
---@field onUpdate fun(animation:Animation, element:Element?, progress:number)?
|
||||||
|
---@field onComplete fun(animation:Animation, element:Element?)?
|
||||||
|
---@field onCancel fun(animation:Animation, element:Element?)?
|
||||||
|
---@field update fun(self:Animation, dt:number, element:table?): boolean
|
||||||
|
---@field findKeyframes fun(self:Animation, progress:number): AnimationKeyframe?, AnimationKeyframe?
|
||||||
|
---@field lerpKeyframes fun(self:Animation, prevFrame:AnimationKeyframe, nextFrame:AnimationKeyframe, easedT:number): table
|
||||||
|
---@field interpolate fun(self:Animation): table
|
||||||
|
---@field apply fun(self:Animation, element:table)
|
||||||
|
---@field pause fun(self:Animation)
|
||||||
|
---@field resume fun(self:Animation)
|
||||||
|
---@field isPaused fun(self:Animation): boolean
|
||||||
|
---@field reverse fun(self:Animation)
|
||||||
|
---@field isReversed fun(self:Animation): boolean
|
||||||
|
---@field setSpeed fun(self:Animation, speed:number)
|
||||||
|
---@field getSpeed fun(self:Animation): number
|
||||||
|
---@field seek fun(self:Animation, time:number)
|
||||||
|
---@field getState fun(self:Animation): string
|
||||||
|
---@field cancel fun(self:Animation, element:table?)
|
||||||
|
---@field reset fun(self:Animation)
|
||||||
|
---@field getProgress fun(self:Animation): number
|
||||||
|
---@field chain fun(self:Animation, nextAnimation:Animation|function): Animation
|
||||||
|
---@field delay fun(self:Animation, seconds:number): Animation
|
||||||
|
---@field repeatCount fun(self:Animation, count:number): Animation
|
||||||
|
---@field yoyo fun(self:Animation, enabled:boolean?): Animation
|
||||||
|
---@class AnimationModule
|
||||||
|
---@field Easing table<string, EasingFunction|fun(...):EasingFunction> -- Built-in easing functions and easing factories
|
||||||
|
---@field Transform table? -- Animation transform helpers exposed by the animation module
|
||||||
|
---@field Group AnimationGroup -- Animation group class table
|
||||||
|
---@field new fun(props:AnimationProps): Animation
|
||||||
|
---@field fade fun(duration:number, fromOpacity:number, toOpacity:number, easing:string?): Animation
|
||||||
|
---@field scale fun(duration:number, fromScale:{width:number, height:number}, toScale:{width:number, height:number}, easing:string?): Animation
|
||||||
|
---@field keyframes fun(props:{duration:number, keyframes:AnimationKeyframe[], onStart:function?, onUpdate:function?, onComplete:function?, onCancel:function?}): Animation
|
||||||
|
---@field chainSequence fun(animations:Animation[]): Animation
|
||||||
|
local AnimationModule = {}
|
||||||
|
|
||||||
|
---@class ColorInputTable
|
||||||
|
---@field [1] number?
|
||||||
|
---@field [2] number?
|
||||||
|
---@field [3] number?
|
||||||
|
---@field [4] number?
|
||||||
|
---@field r number?
|
||||||
|
---@field g number?
|
||||||
|
---@field b number?
|
||||||
|
---@field a number?
|
||||||
|
local ColorInputTable = {}
|
||||||
|
|
||||||
|
---@alias ColorInput string|Color|ColorInputTable
|
||||||
|
|
||||||
|
---@class ColorModule
|
||||||
|
---@field new fun(r:number?, g:number?, b:number?, a:number?): Color
|
||||||
|
---@field fromHex fun(hexWithTag:string): Color
|
||||||
|
---@field validateColorChannel fun(value:any, max:number?): boolean, number?
|
||||||
|
---@field validateHexColor fun(hex:string): boolean, string?
|
||||||
|
---@field validateRGBColor fun(r:number, g:number, b:number, a:number?, max:number?): boolean, string?
|
||||||
|
---@field isValidColorFormat fun(value:any): string?
|
||||||
|
---@field sanitizeColor fun(value:any, default:Color?): Color
|
||||||
|
---@field parse fun(value:any): Color
|
||||||
|
---@field lerp fun(colorA:Color, colorB:Color, t:number): Color
|
||||||
|
local ColorModule = {}
|
||||||
|
|
||||||
|
---@class ThemeManagerConfig
|
||||||
|
---@field theme string? -- Theme name override
|
||||||
|
---@field themeComponent string? -- Component name to resolve from the theme
|
||||||
|
---@field disabled boolean? -- Force disabled theme state
|
||||||
|
---@field active boolean? -- Force active theme state
|
||||||
|
---@field disableHighlight boolean? -- Disable pressed highlight overlay
|
||||||
|
---@field themeStateLock boolean|string? -- Lock the theme state to base/default or a named state
|
||||||
|
---@field themeComponentDisabledStates string[]? -- List of theme states to suppress visually
|
||||||
|
---@field scaleCorners number? -- Scale multiplier for 9-patch corners and edges
|
||||||
|
---@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for non-stretched theme regions
|
||||||
|
local ThemeManagerConfig = {}
|
||||||
|
|
||||||
|
---@class ThemeRegion
|
||||||
|
---@field x number
|
||||||
|
---@field y number
|
||||||
|
---@field w number
|
||||||
|
---@field h number
|
||||||
|
local ThemeRegion = {}
|
||||||
|
|
||||||
|
---@class ThemeComponent
|
||||||
|
---@field atlas string|love.Image?
|
||||||
|
---@field insets {left:number, top:number, right:number, bottom:number}?
|
||||||
|
---@field regions {topLeft:ThemeRegion, topCenter:ThemeRegion, topRight:ThemeRegion, middleLeft:ThemeRegion, middleCenter:ThemeRegion, middleRight:ThemeRegion, bottomLeft:ThemeRegion, bottomCenter:ThemeRegion, bottomRight:ThemeRegion}?
|
||||||
|
---@field stretch {horizontal:table<integer, string>, vertical:table<integer, string>}?
|
||||||
|
---@field states table<string, ThemeComponent>?
|
||||||
|
---@field contentAutoSizingMultiplier {width:number?, height:number?}?
|
||||||
|
---@field scaleCorners number?
|
||||||
|
---@field scalingAlgorithm "nearest"|"bilinear"?
|
||||||
|
---@field knobOffset number|{x:number, y:number}|{horizontal:number, vertical:number}?
|
||||||
|
local ThemeComponent = {}
|
||||||
|
|
||||||
|
---@class ThemeDefinition
|
||||||
|
---@field name string
|
||||||
|
---@field atlas string|love.Image?
|
||||||
|
---@field components table<string, ThemeComponent>
|
||||||
|
---@field scrollbars table<string, ThemeComponent>?
|
||||||
|
---@field colors table<string, Color>?
|
||||||
|
---@field fonts table<string, string>?
|
||||||
|
---@field contentAutoSizingMultiplier {width:number?, height:number?}?
|
||||||
|
local ThemeDefinition = {}
|
||||||
|
|
||||||
|
---@class Theme
|
||||||
|
---@field name string
|
||||||
|
---@field atlas love.Image?
|
||||||
|
---@field atlasData love.ImageData?
|
||||||
|
---@field components table<string, ThemeComponent>
|
||||||
|
---@field scrollbars table<string, ThemeComponent>
|
||||||
|
---@field colors table<string, Color>
|
||||||
|
---@field fonts table<string, string>
|
||||||
|
---@field contentAutoSizingMultiplier {width:number?, height:number?}?
|
||||||
|
---@class ThemeManager
|
||||||
|
---@field theme string?
|
||||||
|
---@field themeComponent string?
|
||||||
|
---@field disabled boolean
|
||||||
|
---@field active boolean
|
||||||
|
---@field disableHighlight boolean?
|
||||||
|
---@field themeStateLock boolean|string?
|
||||||
|
---@field themeComponentDisabledStates table<string, boolean>
|
||||||
|
---@field scaleCorners number?
|
||||||
|
---@field scalingAlgorithm "nearest"|"bilinear"?
|
||||||
|
---@field updateState fun(self:ThemeManager, isHovered:boolean, isPressed:boolean, isFocused:boolean, isDisabled:boolean): string
|
||||||
|
---@field getState fun(self:ThemeManager): string
|
||||||
|
---@field setState fun(self:ThemeManager, state:string)
|
||||||
|
---@field hasThemeComponent fun(self:ThemeManager): boolean
|
||||||
|
---@field getTheme fun(self:ThemeManager): Theme?
|
||||||
|
---@field getComponent fun(self:ThemeManager): ThemeComponent?
|
||||||
|
---@field getStateComponent fun(self:ThemeManager): ThemeComponent?
|
||||||
|
---@field getScrollbarComponent fun(self:ThemeManager, scrollbarName:string?): ThemeComponent?
|
||||||
|
---@field getStyle fun(self:ThemeManager, property:string): any?
|
||||||
|
---@field _getScaledContentPaddingForState fun(self:ThemeManager, state:string, borderBoxWidth:number, borderBoxHeight:number): table?
|
||||||
|
---@field getScaledContentPaddingForState fun(self:ThemeManager, state:string, borderBoxWidth:number, borderBoxHeight:number): table? -- deprecated, use getScaledContentPadding
|
||||||
|
---@field getScaledContentPadding fun(self:ThemeManager, borderBoxWidth:number, borderBoxHeight:number): table?
|
||||||
|
---@field getContentAutoSizingMultiplier fun(self:ThemeManager): table?
|
||||||
|
---@field getDefaultFontFamily fun(self:ThemeManager): string?
|
||||||
|
---@field setTheme fun(self:ThemeManager, themeName:string?, componentName:string?)
|
||||||
|
---@field validateThemeStateLock fun(self:ThemeManager): boolean
|
||||||
|
---@class Color
|
||||||
|
---@field r number
|
||||||
|
---@field g number
|
||||||
|
---@field b number
|
||||||
|
---@field a number
|
||||||
|
---@field toRGBA fun(self:Color): number, number, number, number
|
||||||
|
---@class ThemeModule
|
||||||
|
---@field Manager ThemeManager -- Theme manager class table
|
||||||
|
---@field new fun(definition:ThemeDefinition): Theme
|
||||||
|
---@field load fun(path:string): Theme?
|
||||||
|
---@field setActive fun(themeOrName:string|Theme)
|
||||||
|
---@field getActive fun(): Theme?
|
||||||
|
---@field getComponent fun(componentName:string, state:string?): ThemeComponent?
|
||||||
|
---@field getDefaultScrollbar fun(): ThemeComponent?
|
||||||
|
---@field getScrollbar fun(scrollbarName:string, state:string?): ThemeComponent?
|
||||||
|
---@field getFont fun(fontName:string): string?
|
||||||
|
---@field getColor fun(colorName:string): Color?
|
||||||
|
---@field hasActive fun(): boolean
|
||||||
|
---@field getRegisteredThemes fun(): table<string, Theme>
|
||||||
|
---@field getColorNames fun(): string[]
|
||||||
|
---@field getAllColors fun(): table<string, Color>
|
||||||
|
---@field getColorOrDefault fun(colorName:string, fallback:Color): Color
|
||||||
|
---@field get fun(themeName:string): Theme?
|
||||||
|
---@field validateTheme fun(theme:table?, options:table?): boolean, table
|
||||||
|
---@field sanitizeTheme fun(theme:table?): table
|
||||||
|
local ThemeModule = {}
|
||||||
|
|
||||||
|
---@class FlexLove
|
||||||
|
---@field _VERSION string
|
||||||
|
---@field _DESCRIPTION string
|
||||||
|
---@field _URL string
|
||||||
|
---@field _LICENSE string
|
||||||
|
---@field Animation AnimationModule?
|
||||||
|
---@field Color ColorModule
|
||||||
|
---@field Theme ThemeModule?
|
||||||
|
---@field enums FlexLoveEnums
|
||||||
|
---@field isReady fun(): boolean
|
||||||
|
---@field init fun(config:FlexLoveConfig?)
|
||||||
|
---@field setKeyboardNavigationDebug fun(enabled:boolean)
|
||||||
|
---@field enableKeyboardNavigation fun(config:KeyboardNavigationConfig?)
|
||||||
|
---@field deferCallback fun(callback:function)
|
||||||
|
---@field executeDeferredCallbacks fun()
|
||||||
|
---@field resize fun()
|
||||||
|
---@field setMode fun(mode:"immediate"|"retained")
|
||||||
|
---@field getMode fun(): "immediate"|"retained"
|
||||||
|
---@field beginFrame fun()
|
||||||
|
---@field endFrame fun()
|
||||||
|
---@field draw fun(gameDrawFunc:function|nil, postDrawFunc:function|nil)
|
||||||
|
---@field getElementAtPosition fun(x:number, y:number): Element?
|
||||||
|
---@field update fun(dt:number)
|
||||||
|
---@field collectGarbage fun(mode:string?, stepSize:number?): number?
|
||||||
|
---@field setGCStrategy fun(strategy:"auto"|"periodic"|"manual"|"disabled")
|
||||||
|
---@field getGCStats fun(): GCStats
|
||||||
|
---@field textinput fun(text:string)
|
||||||
|
---@field keypressed fun(key:string, scancode:string, isrepeat:boolean)
|
||||||
|
---@field wheelmoved fun(dx:number, dy:number)
|
||||||
|
---@field touchpressed fun(id:lightuserdata, x:number, y:number, dx:number, dy:number, pressure:number)
|
||||||
|
---@field touchmoved fun(id:lightuserdata, x:number, y:number, dx:number, dy:number, pressure:number)
|
||||||
|
---@field touchreleased fun(id:lightuserdata, x:number, y:number, dx:number, dy:number, pressure:number)
|
||||||
|
---@field getActiveTouchCount fun(): number
|
||||||
|
---@field getTouchOwner fun(touchId:string): Element?
|
||||||
|
---@field getById fun(id:string): Element?
|
||||||
|
---@field destroy fun()
|
||||||
|
---@field new fun(props:ElementProps, callback:function?): Element?
|
||||||
|
---@field getStateCount fun(): number
|
||||||
|
---@field clearState fun(id:string)
|
||||||
|
---@field clearAllStates fun()
|
||||||
|
---@field getStateStats fun(): table
|
||||||
|
---@field calc fun(expr:string): CalcObject
|
||||||
|
---@field getFocusedElement fun(): Element?
|
||||||
|
---@field setFocusedElement fun(element:Element?)
|
||||||
|
---@field clearFocus fun()
|
||||||
|
---@field setDebugDraw fun(enabled:boolean)
|
||||||
|
---@field getDebugDraw fun(): boolean
|
||||||
|
local FlexLove = {}
|
||||||
|
|
||||||
|
--=====================================--
|
||||||
|
-- For State Persistence
|
||||||
|
--=====================================--
|
||||||
|
---@class ElementStateData
|
||||||
|
---@field _focused boolean?
|
||||||
|
---@field eventHandler table? -- EventHandler state
|
||||||
|
---@field textEditor table? -- TextEditor state
|
||||||
|
---@field scrollManager table? -- ScrollManager state
|
||||||
|
---@field blur BlurCacheData? -- Blur cache invalidation data
|
||||||
|
|
||||||
|
---@class BlurCacheData
|
||||||
|
---@field _blurX number
|
||||||
|
---@field _blurY number
|
||||||
|
---@field _blurWidth number
|
||||||
|
---@field _blurHeight number
|
||||||
|
---@field _backdropBlurRadius number?
|
||||||
|
---@field _backdropBlurQuality number?
|
||||||
|
---@field _contentBlurRadius number?
|
||||||
|
---@field _contentBlurQuality number?
|
||||||
|
|
||||||
|
--=====================================--
|
||||||
|
-- For Calc.lua
|
||||||
|
--=====================================--
|
||||||
|
---@class CalcDependencies
|
||||||
|
---@field ErrorHandler ErrorHandler? -- Error handler module
|
||||||
|
|
||||||
|
---@class CalcToken
|
||||||
|
---@field type string -- Token type: "NUMBER", "UNIT", "PLUS", "MINUS", "MULTIPLY", "DIVIDE", "LPAREN", "RPAREN", "EOF"
|
||||||
|
---@field value number? -- Numeric value (for NUMBER tokens)
|
||||||
|
---@field unit string? -- Unit type: "px", "%", "vw", "vh" (for NUMBER tokens)
|
||||||
|
|
||||||
|
---@class CalcASTNode
|
||||||
|
---@field type string -- Node type: "number", "add", "subtract", "multiply", "divide"
|
||||||
|
---@field value number? -- Numeric value (for "number" nodes)
|
||||||
|
---@field unit string? -- Unit type (for "number" nodes)
|
||||||
|
---@field left CalcASTNode? -- Left operand (for operator nodes)
|
||||||
|
---@field right CalcASTNode? -- Right operand (for operator nodes)
|
||||||
|
|
||||||
|
---@class CalcObject
|
||||||
|
---@field _isCalc boolean -- Marker to identify calc objects (always true)
|
||||||
|
---@field _expr string -- Original expression string
|
||||||
|
---@field _ast CalcASTNode? -- Parsed abstract syntax tree (nil if parsing failed)
|
||||||
|
---@field _error string? -- Error message if parsing failed
|
||||||
|
|
||||||
|
--=====================================--
|
||||||
|
-- For FlexLove.lua Internals
|
||||||
|
--=====================================--
|
||||||
|
---@class GCConfig
|
||||||
|
---@field strategy string -- "auto", "periodic", "manual", or "disabled"
|
||||||
|
---@field memoryThreshold number -- MB before forcing GC
|
||||||
|
---@field interval number -- Frames between GC steps (for periodic mode)
|
||||||
|
---@field stepSize number -- Work units per GC step (higher = more aggressive)
|
||||||
|
|
||||||
|
---@class GCState
|
||||||
|
---@field framesSinceLastGC number -- Frames elapsed since last GC
|
||||||
|
---@field lastMemory number -- Last recorded memory usage in MB
|
||||||
|
---@field gcCount number -- Total number of GC operations performed
|
||||||
|
|
||||||
|
---@class GCStats
|
||||||
|
---@field gcCount number -- Total number of GC operations performed
|
||||||
|
---@field framesSinceLastGC number -- Frames elapsed since last GC
|
||||||
|
---@field currentMemoryMB number -- Current memory usage in MB
|
||||||
|
---@field strategy string -- Current GC strategy
|
||||||
|
---@field threshold number -- Memory threshold in MB
|
||||||
|
|
||||||
|
---@class FlexLoveDependencies
|
||||||
|
---@field Context table -- Context module
|
||||||
|
---@field Theme Theme? -- Theme module
|
||||||
|
---@field Color Color -- Color module
|
||||||
|
---@field Calc Calc -- Calc module
|
||||||
|
---@field Units table -- Units module
|
||||||
|
---@field Blur table? -- Blur module
|
||||||
|
---@field ImageRenderer table? -- ImageRenderer module
|
||||||
|
---@field ImageScaler table? -- ImageScaler module
|
||||||
|
---@field NinePatch table? -- NinePatch module
|
||||||
|
---@field RoundedRect table -- RoundedRect module
|
||||||
|
---@field ImageCache table? -- ImageCache module
|
||||||
|
---@field utils table -- Utils module
|
||||||
|
---@field Grid table -- Grid module
|
||||||
|
---@field InputEvent table -- InputEvent module
|
||||||
|
---@field GestureRecognizer table? -- GestureRecognizer module
|
||||||
|
---@field StateManager StateManager -- StateManager module
|
||||||
|
---@field TextEditor table -- TextEditor module
|
||||||
|
---@field LayoutEngine LayoutEngine -- LayoutEngine module
|
||||||
|
---@field Renderer table -- Renderer module
|
||||||
|
---@field EventHandler EventHandler -- EventHandler module
|
||||||
|
---@field ScrollManager table -- ScrollManager module
|
||||||
|
---@field ErrorHandler ErrorHandler -- ErrorHandler module
|
||||||
|
---@field Performance Performance? -- Performance module
|
||||||
|
---@field Transform table? -- Transform module
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
local modulePath = (...):match("(.-)[^%.]+$")
|
||||||
|
local function req(name)
|
||||||
|
return require(modulePath .. name)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Focused sub-modules (utils now re-exports their surfaces as backward-compatible
|
||||||
|
-- aliases so call sites needn't change). Loaded eagerly so the aliases resolve.
|
||||||
|
local NumberValidation = req("NumberValidation")
|
||||||
|
local TextSanitizer = req("TextSanitizer")
|
||||||
|
local PathValidator = req("PathValidator")
|
||||||
|
local FontCache = req("FontCache")
|
||||||
|
local Enums = req("Enums")
|
||||||
|
|
||||||
|
-- ErrorHandler is injected via init() (safeLoadImage closes over this upvalue).
|
||||||
|
local ErrorHandler = nil
|
||||||
|
|
||||||
|
local enums = Enums.enums
|
||||||
|
|
||||||
|
-- Generic math, table, and path helpers (utils' own concern).
|
||||||
|
-- All validation, font-cache, text-sanitization, and path-validation logic
|
||||||
|
-- lives in the focused sub-modules above and is re-exported below.
|
||||||
|
|
||||||
|
--- Get current keyboard modifiers state
|
||||||
|
---@return {shift:boolean, ctrl:boolean, alt:boolean, super:boolean}
|
||||||
|
local function getModifiers()
|
||||||
|
return {
|
||||||
|
shift = love.keyboard.isDown("lshift", "rshift"),
|
||||||
|
ctrl = love.keyboard.isDown("lctrl", "rctrl"),
|
||||||
|
alt = love.keyboard.isDown("lalt", "ralt"),
|
||||||
|
---@diagnostic disable-next-line
|
||||||
|
super = love.keyboard.isDown("lgui", "rgui"), -- cmd/windows key
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local TEXT_SIZE_PRESETS = {
|
||||||
|
["2xs"] = 0.75,
|
||||||
|
xxs = 0.75,
|
||||||
|
xs = 1.25,
|
||||||
|
sm = 1.75,
|
||||||
|
md = 2.25,
|
||||||
|
lg = 2.75,
|
||||||
|
xl = 3.5,
|
||||||
|
xxl = 4.5,
|
||||||
|
["2xl"] = 4.5,
|
||||||
|
["3xl"] = 5.0,
|
||||||
|
["4xl"] = 7.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
--- Resolve text size preset to viewport units
|
||||||
|
---@param sizeValue string|number
|
||||||
|
---@return number?, string?
|
||||||
|
local function resolveTextSizePreset(sizeValue)
|
||||||
|
if type(sizeValue) == "string" then
|
||||||
|
local preset = TEXT_SIZE_PRESETS[sizeValue]
|
||||||
|
if preset then
|
||||||
|
return preset, "vh"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Auto-detect the base path where FlexLove is located
|
||||||
|
---@return string filesystemPath
|
||||||
|
local function getFlexLoveBasePath()
|
||||||
|
local info = debug.getinfo(1, "S")
|
||||||
|
if info and info.source then
|
||||||
|
local source = info.source
|
||||||
|
if source:sub(1, 1) == "@" then
|
||||||
|
source = source:sub(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
local filesystemPath = source:match("(.*/)")
|
||||||
|
if filesystemPath then
|
||||||
|
local fsPath = filesystemPath
|
||||||
|
fsPath = fsPath:gsub("^%./", "")
|
||||||
|
fsPath = fsPath:gsub("/$", "")
|
||||||
|
fsPath = fsPath:gsub("/modules$", "")
|
||||||
|
return fsPath
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return "libs"
|
||||||
|
end
|
||||||
|
|
||||||
|
local FLEXLOVE_FILESYSTEM_PATH = getFlexLoveBasePath()
|
||||||
|
|
||||||
|
--- Helper function to resolve paths relative to FlexLove
|
||||||
|
---@param path string
|
||||||
|
---@return string
|
||||||
|
local function resolveImagePath(path)
|
||||||
|
if path:match("^/") or path:match("^[A-Z]:") then
|
||||||
|
return path
|
||||||
|
end
|
||||||
|
return FLEXLOVE_FILESYSTEM_PATH .. "/" .. path
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Math utilities
|
||||||
|
|
||||||
|
--- Clamp a value between optional min/max bounds. Either bound may be nil.
|
||||||
|
--- When both bounds are inverted (min > max), max wins (matches CSS behavior).
|
||||||
|
---@param value number Value to clamp
|
||||||
|
---@param min number|nil Minimum value (nil = no lower bound)
|
||||||
|
---@param max number|nil Maximum value (nil = no upper bound)
|
||||||
|
---@return number Clamped value
|
||||||
|
local function clamp(value, min, max)
|
||||||
|
if min and value < min then
|
||||||
|
value = min
|
||||||
|
end
|
||||||
|
if max and value > max then
|
||||||
|
value = max
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Linear interpolation between two values
|
||||||
|
---@param a number Start value
|
||||||
|
---@param b number End value
|
||||||
|
---@param t number Interpolation factor (0-1)
|
||||||
|
---@return number Interpolated value
|
||||||
|
local function lerp(a, b, t)
|
||||||
|
return a + (b - a) * t
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Round a number to the nearest integer
|
||||||
|
---@param value number Value to round
|
||||||
|
---@return number Rounded value
|
||||||
|
local function round(value)
|
||||||
|
return math.floor(value + 0.5)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Image utilities
|
||||||
|
|
||||||
|
--- Safely load an image with error handling
|
||||||
|
--- Returns both Image and ImageData to avoid deprecated getData() API
|
||||||
|
---@param imagePath string Path to image file
|
||||||
|
---@return love.Image?, love.ImageData?, string? Returns image, imageData, or nil with error message
|
||||||
|
local function safeLoadImage(imagePath)
|
||||||
|
local success, imageData = pcall(function()
|
||||||
|
return love.image.newImageData(imagePath)
|
||||||
|
end)
|
||||||
|
|
||||||
|
if not success then
|
||||||
|
local errorMsg = string.format("Failed to load image data: %s - %s", imagePath, tostring(imageData))
|
||||||
|
if ErrorHandler then
|
||||||
|
ErrorHandler:warn("utils", "RES_004", {
|
||||||
|
resourceType = "image data",
|
||||||
|
path = imagePath,
|
||||||
|
error = tostring(imageData),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
return nil, nil, errorMsg
|
||||||
|
end
|
||||||
|
|
||||||
|
local imageSuccess, image = pcall(function()
|
||||||
|
return love.graphics.newImage(imageData)
|
||||||
|
end)
|
||||||
|
|
||||||
|
if imageSuccess then
|
||||||
|
return image, imageData, nil
|
||||||
|
else
|
||||||
|
local errorMsg = string.format("Failed to create image: %s - %s", imagePath, tostring(image))
|
||||||
|
if ErrorHandler then
|
||||||
|
ErrorHandler:warn("utils", "RES_004", {
|
||||||
|
resourceType = "image",
|
||||||
|
path = imagePath,
|
||||||
|
error = tostring(image),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
return nil, nil, errorMsg
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Color manipulation utilities
|
||||||
|
|
||||||
|
--- Brighten a color by a factor
|
||||||
|
---@param r number Red component (0-1)
|
||||||
|
---@param g number Green component (0-1)
|
||||||
|
---@param b number Blue component (0-1)
|
||||||
|
---@param a number Alpha component (0-1)
|
||||||
|
---@param factor number Brightness factor (e.g., 1.2 for 20% brighter)
|
||||||
|
---@return number, number, number, number Brightened color components
|
||||||
|
local function brightenColor(r, g, b, a, factor)
|
||||||
|
return math.min(1, r * factor), math.min(1, g * factor), math.min(1, b * factor), a
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Property normalization utilities
|
||||||
|
|
||||||
|
--- Normalize a boolean or table property with vertical/horizontal fields
|
||||||
|
---@param value boolean|table|nil Input value (boolean applies to both, table for individual control)
|
||||||
|
---@param defaultValue boolean Default value if nil (default: false)
|
||||||
|
---@return table Normalized table with vertical and horizontal fields
|
||||||
|
local function normalizeBooleanTable(value, defaultValue)
|
||||||
|
defaultValue = defaultValue or false
|
||||||
|
|
||||||
|
if value == nil then
|
||||||
|
return { vertical = defaultValue, horizontal = defaultValue }
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(value) == "boolean" then
|
||||||
|
return { vertical = value, horizontal = value }
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(value) == "table" then
|
||||||
|
return {
|
||||||
|
vertical = value.vertical ~= nil and value.vertical or defaultValue,
|
||||||
|
horizontal = value.horizontal ~= nil and value.horizontal or defaultValue,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return { vertical = defaultValue, horizontal = defaultValue }
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Normalize an offset value to {x, y} or {horizontal, vertical} format
|
||||||
|
---@param value number|table|nil Input value (number applies to both, table for individual control)
|
||||||
|
---@param defaultValue number Default value if nil (default: 0)
|
||||||
|
---@return table Normalized table with x/y or horizontal/vertical fields
|
||||||
|
local function normalizeOffsetTable(value, defaultValue)
|
||||||
|
defaultValue = defaultValue or 0
|
||||||
|
|
||||||
|
if value == nil then
|
||||||
|
return { x = defaultValue, y = defaultValue, horizontal = defaultValue, vertical = defaultValue }
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(value) == "number" then
|
||||||
|
return { x = value, y = value, horizontal = value, vertical = value }
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(value) == "table" then
|
||||||
|
-- Support both {x, y} and {horizontal, vertical} formats
|
||||||
|
local x = value.x or value.horizontal or defaultValue
|
||||||
|
local y = value.y or value.vertical or defaultValue
|
||||||
|
return {
|
||||||
|
x = x,
|
||||||
|
y = y,
|
||||||
|
horizontal = x,
|
||||||
|
vertical = y,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return { x = defaultValue, y = defaultValue, horizontal = defaultValue, vertical = defaultValue }
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Apply content auto-sizing multiplier to a dimension
|
||||||
|
---@param value number The dimension value
|
||||||
|
---@param multiplier table? The contentAutoSizingMultiplier table {width:number?, height:number?}
|
||||||
|
---@param axis "width"|"height" Which axis to apply
|
||||||
|
---@return number The multiplied value
|
||||||
|
local function applyContentMultiplier(value, multiplier, axis)
|
||||||
|
if multiplier and multiplier[axis] then
|
||||||
|
return value * multiplier[axis]
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Initialize dependencies
|
||||||
|
---@param deps table Dependencies: { ErrorHandler = ErrorHandler }
|
||||||
|
local function init(deps)
|
||||||
|
if type(deps) == "table" then
|
||||||
|
ErrorHandler = deps.ErrorHandler
|
||||||
|
end
|
||||||
|
-- Propagate shared ErrorHandler to focused sub-modules that need it.
|
||||||
|
NumberValidation.init({ ErrorHandler = ErrorHandler, clamp = clamp })
|
||||||
|
TextSanitizer.init({ ErrorHandler = ErrorHandler })
|
||||||
|
FontCache.init({ ErrorHandler = ErrorHandler, resolveImagePath = resolveImagePath })
|
||||||
|
-- PathValidator has no external dependencies.
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
enums = enums,
|
||||||
|
FONT_CACHE = FontCache.FONT_CACHE,
|
||||||
|
resolveTextSizePreset = resolveTextSizePreset,
|
||||||
|
getModifiers = getModifiers,
|
||||||
|
TEXT_SIZE_PRESETS = TEXT_SIZE_PRESETS,
|
||||||
|
init = init,
|
||||||
|
clamp = clamp,
|
||||||
|
-- Alias for `clamp`; exposed under the size-clamping name so Element/LayoutEngine
|
||||||
|
-- and tests can reference min/max content-size clamping explicitly.
|
||||||
|
clampSize = clamp,
|
||||||
|
lerp = lerp,
|
||||||
|
round = round,
|
||||||
|
safeLoadImage = safeLoadImage,
|
||||||
|
brightenColor = brightenColor,
|
||||||
|
resolveImagePath = resolveImagePath,
|
||||||
|
normalizeBooleanTable = normalizeBooleanTable,
|
||||||
|
normalizeOffsetTable = normalizeOffsetTable,
|
||||||
|
applyContentMultiplier = applyContentMultiplier,
|
||||||
|
-- Backward-compatible aliases (delegated to focused sub-modules)
|
||||||
|
validateEnum = NumberValidation.validateEnum,
|
||||||
|
validateRange = NumberValidation.validateRange,
|
||||||
|
validateType = NumberValidation.validateType,
|
||||||
|
isNaN = NumberValidation.isNaN,
|
||||||
|
isInfinity = NumberValidation.isInfinity,
|
||||||
|
validateNumber = NumberValidation.validateNumber,
|
||||||
|
sanitizeNumber = NumberValidation.sanitizeNumber,
|
||||||
|
validateInteger = NumberValidation.validateInteger,
|
||||||
|
validatePercentage = NumberValidation.validatePercentage,
|
||||||
|
validateOpacity = NumberValidation.validateOpacity,
|
||||||
|
validateDegrees = NumberValidation.validateDegrees,
|
||||||
|
validateCoordinate = NumberValidation.validateCoordinate,
|
||||||
|
validateDimension = NumberValidation.validateDimension,
|
||||||
|
normalizePath = PathValidator.normalizePath,
|
||||||
|
sanitizePath = PathValidator.sanitizePath,
|
||||||
|
isPathSafe = PathValidator.isPathSafe,
|
||||||
|
validatePath = PathValidator.validatePath,
|
||||||
|
getFileExtension = PathValidator.getFileExtension,
|
||||||
|
hasAllowedExtension = PathValidator.hasAllowedExtension,
|
||||||
|
sanitizeText = TextSanitizer.sanitizeText,
|
||||||
|
validateTextInput = TextSanitizer.validateTextInput,
|
||||||
|
validateTextRange = TextSanitizer.validateTextRange,
|
||||||
|
escapeHtml = TextSanitizer.escapeHtml,
|
||||||
|
escapeLuaPattern = TextSanitizer.escapeLuaPattern,
|
||||||
|
stripNonPrintable = TextSanitizer.stripNonPrintable,
|
||||||
|
resolveFontPath = FontCache.resolveFontPath,
|
||||||
|
getFont = FontCache.getFont,
|
||||||
|
getFontCacheStats = FontCache.getFontCacheStats,
|
||||||
|
setFontCacheSize = FontCache.setFontCacheSize,
|
||||||
|
clearFontCache = FontCache.clearFontCache,
|
||||||
|
preloadFont = FontCache.preloadFont,
|
||||||
|
resetFontCacheStats = FontCache.resetFontCacheStats,
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
||||||
|
|
||||||
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
||||||
local LaunchOptions = require("src.core.LaunchOptions")
|
|
||||||
local NxDisplay = require("src.core.NxDisplay")
|
local NxDisplay = require("src.core.NxDisplay")
|
||||||
|
|
||||||
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
||||||
@@ -30,21 +29,6 @@ end
|
|||||||
|
|
||||||
local Game, EditorApp, Importer, TouchEditor
|
local Game, EditorApp, Importer, TouchEditor
|
||||||
|
|
||||||
-- #887: quit-to-launcher state, shared by love.load and love.quit (both need
|
|
||||||
-- it, so it is declared here rather than next to love.quit).
|
|
||||||
-- * launchedIntoGame -- a --game / POKEPORT_GAME shortcut booted this
|
|
||||||
-- session straight into a game, so there is no launcher behind it and a
|
|
||||||
-- window close must exit. Restarting instead re-read the same shortcut
|
|
||||||
-- and came right back into the game, and the next close did it again:
|
|
||||||
-- the app could not be closed at all (macOS feels this worst, where the
|
|
||||||
-- red X, Cmd+Q and the Dock's Quit are all the same quit event).
|
|
||||||
-- * RELAUNCH_MARKER -- written in the save dir just before the #785
|
|
||||||
-- restart, so the fresh boot ignores any boot-straight-into-a-game
|
|
||||||
-- option exactly once and keeps #785's promise of landing in the
|
|
||||||
-- launcher, whatever put the game on screen this time.
|
|
||||||
local launchedIntoGame = false
|
|
||||||
local RELAUNCH_MARKER = "relaunch_to_launcher.txt"
|
|
||||||
|
|
||||||
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
|
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
|
||||||
local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
|
local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
|
||||||
-- coroutine that receives `Game` and yields once per
|
-- coroutine that receives `Game` and yields once per
|
||||||
@@ -329,47 +313,6 @@ function love.load(args)
|
|||||||
return
|
return
|
||||||
end
|
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.
|
|
||||||
-- A window close that restarted us into the launcher (#785) leaves the
|
|
||||||
-- marker behind: consume it and stay on the launcher, or the shortcut below
|
|
||||||
-- would boot the same game again and that close would restart again,
|
|
||||||
-- forever (#887). Consumed on read, so the very next launch is normal.
|
|
||||||
local relaunched = love.filesystem.getInfo(RELAUNCH_MARKER) ~= nil
|
|
||||||
if relaunched then pcall(love.filesystem.remove, RELAUNCH_MARKER) end
|
|
||||||
|
|
||||||
local launchGame, launchSlot = LaunchOptions.resolve(arg)
|
|
||||||
if launchGame and not relaunched and not LaunchOptions.forceLauncher(arg) then
|
|
||||||
if RomImporter.isReady(launchGame) then
|
|
||||||
if launchSlot then LaunchOptions.selectSlot(launchGame, launchSlot) end
|
|
||||||
-- No launcher behind this session: love.quit must exit, not restart.
|
|
||||||
launchedIntoGame = true
|
|
||||||
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
|
-- 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
|
-- 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
|
-- Choose ROM / drag-drop when it is not. Any dropped .gb is routed by its
|
||||||
@@ -654,7 +597,7 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
|
|||||||
-- Android's synthesized mouse twin so Import cannot double-fire (#553).
|
-- Android's synthesized mouse twin so Import cannot double-fire (#553).
|
||||||
return Importer:touchpressed(id, x, y, dx, dy, pressure)
|
return Importer:touchpressed(id, x, y, dx, dy, pressure)
|
||||||
end
|
end
|
||||||
Game:touchpressed(id, x, y, dx, dy, pressure)
|
Game:touchpressed(id, x, y)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.touchmoved(id, x, y, dx, dy, pressure)
|
function love.touchmoved(id, x, y, dx, dy, pressure)
|
||||||
@@ -666,7 +609,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
|
|||||||
if Importer then
|
if Importer then
|
||||||
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
||||||
end
|
end
|
||||||
Game:touchmoved(id, x, y, dx, dy, pressure)
|
Game:touchmoved(id, x, y)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.touchreleased(id, x, y, dx, dy, pressure)
|
function love.touchreleased(id, x, y, dx, dy, pressure)
|
||||||
@@ -678,7 +621,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
|
|||||||
if Importer then
|
if Importer then
|
||||||
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
||||||
end
|
end
|
||||||
Game:touchreleased(id, x, y, dx, dy, pressure)
|
Game:touchreleased(id, x, y)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.wheelmoved(x, y)
|
function love.wheelmoved(x, y)
|
||||||
@@ -691,33 +634,7 @@ function love.wheelmoved(x, y)
|
|||||||
Game:wheelmoved(x, y)
|
Game:wheelmoved(x, y)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- #781: Linux X11 multi-monitor with the primary display away from desktop
|
|
||||||
-- (0,0): SDL's polled mouse state can come back in desktop-virtual
|
|
||||||
-- coordinates while the event stream stays window-relative, which strands
|
|
||||||
-- every polled consumer (launcher Kit rising-edge clicks, the pad-cursor
|
|
||||||
-- motion yield, PadCursor) on coordinates no hit test can match. Sanitize
|
|
||||||
-- the poll once here: remember the last window-relative event coordinates
|
|
||||||
-- and substitute them whenever the polled value falls outside the window.
|
|
||||||
-- Linux only -- macOS / Windows / mobile keep the stock function, and the
|
|
||||||
-- NX launcher shim still composes because it captures whatever
|
|
||||||
-- love.mouse.getPosition is at bridge time (_ensureNxPointerBridge).
|
|
||||||
local eventMouseX, eventMouseY
|
|
||||||
if love.system and love.system.getOS() == "Linux"
|
|
||||||
and love.mouse and love.mouse.getPosition then
|
|
||||||
local polledGetPosition = love.mouse.getPosition
|
|
||||||
love.mouse.getPosition = function()
|
|
||||||
local x, y = polledGetPosition()
|
|
||||||
local w, h = love.graphics.getDimensions()
|
|
||||||
if x < 0 or y < 0 or x > w or y > h then
|
|
||||||
if eventMouseX then return eventMouseX, eventMouseY end
|
|
||||||
return math.max(0, math.min(x, w)), math.max(0, math.min(y, h))
|
|
||||||
end
|
|
||||||
return x, y
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
function love.mousepressed(x, y, button, istouch)
|
function love.mousepressed(x, y, button, istouch)
|
||||||
if not istouch then eventMouseX, eventMouseY = x, y end
|
|
||||||
if TouchEditor then
|
if TouchEditor then
|
||||||
-- Android primary touch already arrived via love.touchpressed; a second
|
-- Android primary touch already arrived via love.touchpressed; a second
|
||||||
-- mouse path would double-fire Done / begin a second drag.
|
-- mouse path would double-fire Done / begin a second drag.
|
||||||
@@ -741,19 +658,12 @@ function love.mousepressed(x, y, button, istouch)
|
|||||||
if istouch and love.system.getOS() == "Android" then return end
|
if istouch and love.system.getOS() == "Android" then return end
|
||||||
return EditorApp.mousepressed(x, y, button)
|
return EditorApp.mousepressed(x, y, button)
|
||||||
end
|
end
|
||||||
if mouseTouch then
|
if mouseTouch and Game and button == 1 then
|
||||||
-- the mouse is standing in for a finger: the touch path owns it, and
|
Game:touchpressed("mouse", x, y)
|
||||||
-- 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
|
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
|
end
|
||||||
|
|
||||||
function love.mousereleased(x, y, button, istouch)
|
function love.mousereleased(x, y, button)
|
||||||
if TouchEditor then
|
if TouchEditor then
|
||||||
if love.system.getOS() == "Android" then return end
|
if love.system.getOS() == "Android" then return end
|
||||||
return TouchEditor.mousereleased(x, y, button)
|
return TouchEditor.mousereleased(x, y, button)
|
||||||
@@ -762,25 +672,20 @@ function love.mousereleased(x, y, button, istouch)
|
|||||||
if editorMode and EditorApp.mousereleased then
|
if editorMode and EditorApp.mousereleased then
|
||||||
return EditorApp.mousereleased(x, y, button)
|
return EditorApp.mousereleased(x, y, button)
|
||||||
end
|
end
|
||||||
if mouseTouch then
|
if mouseTouch and Game and button == 1 then
|
||||||
if Game and button == 1 then Game:touchreleased("mouse", x, y) end
|
Game:touchreleased("mouse", x, y)
|
||||||
return
|
|
||||||
end
|
end
|
||||||
if Game then Game:mousereleased(x, y, button, istouch) end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.mousemoved(x, y, dx, dy, istouch)
|
function love.mousemoved(x, y)
|
||||||
if not istouch then eventMouseX, eventMouseY = x, y end
|
|
||||||
if TouchEditor then
|
if TouchEditor then
|
||||||
if love.system.getOS() == "Android" then return end
|
if love.system.getOS() == "Android" then return end
|
||||||
return TouchEditor.mousemoved(x, y)
|
return TouchEditor.mousemoved(x, y)
|
||||||
end
|
end
|
||||||
if editorMode or Importer then return end
|
if editorMode or Importer then return end
|
||||||
if mouseTouch then
|
if mouseTouch and Game and love.mouse.isDown(1) then
|
||||||
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
|
Game:touchmoved("mouse", x, y)
|
||||||
return
|
|
||||||
end
|
end
|
||||||
if Game then Game:mousemoved(x, y, dx, dy, istouch) end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.textinput(text)
|
function love.textinput(text)
|
||||||
@@ -791,43 +696,9 @@ function love.textinput(text)
|
|||||||
end
|
end
|
||||||
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()
|
function love.quit()
|
||||||
if editorMode and EditorApp.quit then
|
if editorMode and EditorApp.quit then
|
||||||
-- true blocks the quit (unsaved-changes prompt). A quit that proceeds
|
return EditorApp.quit() -- return true to abort quit
|
||||||
-- must fall through to the worker shutdowns below instead of returning:
|
|
||||||
-- the bundled editor opens from a live launcher whose update-check and
|
|
||||||
-- fetch-pool workers are still parked in Channel:demand(), and returning
|
|
||||||
-- here skipped their "quit" push, so the process outlived the closed
|
|
||||||
-- window and kept the install folder locked on Windows (#727).
|
|
||||||
if EditorApp.quit() then return true end
|
|
||||||
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")
|
|
||||||
-- #887: a shortcut session (--game / POKEPORT_GAME) has no launcher to go
|
|
||||||
-- back to and the restart would re-read the shortcut, so it exits instead.
|
|
||||||
if Game and not Importer and not quitToLauncher and not scripted
|
|
||||||
and not launchedIntoGame then
|
|
||||||
quitToLauncher = true
|
|
||||||
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
|
|
||||||
-- once, so the restart really does land in the launcher (#887). A failed
|
|
||||||
-- write only costs that suppression, so it must never block the restart.
|
|
||||||
pcall(love.filesystem.write, RELAUNCH_MARKER, "1")
|
|
||||||
require("src.core.HostShell").restart()
|
|
||||||
return true -- abort this quit; the restart lands back in the launcher
|
|
||||||
end
|
end
|
||||||
pcall(function()
|
pcall(function()
|
||||||
require("src.core.DiscordPresence").shutdown()
|
require("src.core.DiscordPresence").shutdown()
|
||||||
@@ -842,12 +713,6 @@ function love.quit()
|
|||||||
if package.loaded["src.update.Check"] then
|
if package.loaded["src.update.Check"] then
|
||||||
pcall(package.loaded["src.update.Check"].shutdown)
|
pcall(package.loaded["src.update.Check"].shutdown)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
function love.filedropped(file)
|
function love.filedropped(file)
|
||||||
|
|||||||
@@ -37,13 +37,6 @@
|
|||||||
|
|
||||||
#include "filesystem/physfs/PhysfsIo.h"
|
#include "filesystem/physfs/PhysfsIo.h"
|
||||||
|
|
||||||
// #604 / #839: the SAF bridges below must hand GameActivity the exact
|
|
||||||
// directory physfs mounted as the save dir -- the same contract the iOS
|
|
||||||
// GRPickerBridge already gets (mobile/ios/patch_love_src.py,
|
|
||||||
// gr_saveDirectory) -- instead of letting Java recompute the root on its
|
|
||||||
// own, which can name a different volume on merged / adopted-SD storage.
|
|
||||||
#include "filesystem/Filesystem.h"
|
|
||||||
|
|
||||||
namespace love
|
namespace love
|
||||||
{
|
{
|
||||||
namespace android
|
namespace android
|
||||||
@@ -190,19 +183,6 @@ void vibrate(double seconds)
|
|||||||
env->DeleteLocalRef(activity);
|
env->DeleteLocalRef(activity);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The directory physfs actually mounted as the save dir, or "" before the
|
|
||||||
// filesystem module is up. GameActivity must copy SAF picks HERE: its own
|
|
||||||
// getExternalFilesDir(null) recomputation can disagree with the mounted
|
|
||||||
// root on merged / adopted-SD storage (#604, #839).
|
|
||||||
static const char *bridgeSaveDirectory()
|
|
||||||
{
|
|
||||||
auto fs = Module::getInstance<love::filesystem::Filesystem>(Module::M_FILESYSTEM);
|
|
||||||
if (fs == nullptr)
|
|
||||||
return "";
|
|
||||||
const char *dir = fs->getSaveDirectory();
|
|
||||||
return dir != nullptr ? dir : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
bool showFilePicker(const char *destFilename)
|
bool showFilePicker(const char *destFilename)
|
||||||
{
|
{
|
||||||
if (destFilename == nullptr || destFilename[0] == '\0')
|
if (destFilename == nullptr || destFilename[0] == '\0')
|
||||||
@@ -212,11 +192,9 @@ bool showFilePicker(const char *destFilename)
|
|||||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||||
|
|
||||||
jmethodID method = env->GetStaticMethodID(activity, "showFilePicker",
|
jmethodID method = env->GetStaticMethodID(activity, "showFilePicker",
|
||||||
"(Ljava/lang/String;Ljava/lang/String;)Z");
|
"(Ljava/lang/String;)Z");
|
||||||
jstring jname = env->NewStringUTF(destFilename);
|
jstring jname = env->NewStringUTF(destFilename);
|
||||||
jstring jsavedir = env->NewStringUTF(bridgeSaveDirectory());
|
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
|
||||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname, jsavedir);
|
|
||||||
env->DeleteLocalRef(jsavedir);
|
|
||||||
env->DeleteLocalRef(jname);
|
env->DeleteLocalRef(jname);
|
||||||
|
|
||||||
env->DeleteLocalRef(activity);
|
env->DeleteLocalRef(activity);
|
||||||
@@ -232,11 +210,9 @@ bool showCreateDocument(const char *suggestedName)
|
|||||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||||
|
|
||||||
jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument",
|
jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument",
|
||||||
"(Ljava/lang/String;Ljava/lang/String;)Z");
|
"(Ljava/lang/String;)Z");
|
||||||
jstring jname = env->NewStringUTF(suggestedName);
|
jstring jname = env->NewStringUTF(suggestedName);
|
||||||
jstring jsavedir = env->NewStringUTF(bridgeSaveDirectory());
|
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
|
||||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname, jsavedir);
|
|
||||||
env->DeleteLocalRef(jsavedir);
|
|
||||||
env->DeleteLocalRef(jname);
|
env->DeleteLocalRef(jname);
|
||||||
|
|
||||||
env->DeleteLocalRef(activity);
|
env->DeleteLocalRef(activity);
|
||||||
@@ -283,19 +259,7 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
|
|||||||
return false;
|
return false;
|
||||||
|
|
||||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
// NOT FindClass: this is the one bridge called off the main thread
|
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||||
// (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
|
// Old APK / new liblove skew: report "no transport" the same way a
|
||||||
// missing curl does, instead of aborting on a missing method (#597).
|
// missing curl does, instead of aborting on a missing method (#597).
|
||||||
|
|||||||
@@ -116,18 +116,6 @@ public class GameActivity extends SDLActivity {
|
|||||||
// bad ROM instead of installing it (#553).
|
// bad ROM instead of installing it (#553).
|
||||||
private String pendingPickFilename = PICKED_ROM_FILENAME;
|
private String pendingPickFilename = PICKED_ROM_FILENAME;
|
||||||
private static final String STATE_PENDING_PICK = "pendingPickFilename";
|
private static final String STATE_PENDING_PICK = "pendingPickFilename";
|
||||||
// Absolute save directory physfs actually mounted, as reported by the
|
|
||||||
// native bridge call that opened the picker (love/src/common/android.cpp,
|
|
||||||
// bridgeSaveDirectory). This activity used to recompute
|
|
||||||
// getExternalFilesDir(null)/save/<identity> on its own at result time; on
|
|
||||||
// merged / adopted-SD storage that can name a different volume than the
|
|
||||||
// one LOVE mounted, so the copied pick (and pick_error.flag) landed where
|
|
||||||
// Lua never scans -- the launcher then "did nothing" after a pick (#604)
|
|
||||||
// and the folders a file manager can browse stayed empty while the game
|
|
||||||
// saved fine elsewhere (#839). Empty string means "not told yet": fall
|
|
||||||
// back to the historical computation.
|
|
||||||
private String pendingPickSaveDir = "";
|
|
||||||
private static final String STATE_PENDING_PICK_DIR = "pendingPickSaveDir";
|
|
||||||
private static final String STATE_PENDING_CREATE = "pendingCreateSuggestedName";
|
private static final String STATE_PENDING_CREATE = "pendingCreateSuggestedName";
|
||||||
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
|
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
|
||||||
private String pendingCreateSuggestedName = "export.sav";
|
private String pendingCreateSuggestedName = "export.sav";
|
||||||
@@ -198,8 +186,6 @@ public class GameActivity extends SDLActivity {
|
|||||||
// a recreated activity still lands under the basename it asked for.
|
// a recreated activity still lands under the basename it asked for.
|
||||||
String pick = savedInstanceState.getString(STATE_PENDING_PICK);
|
String pick = savedInstanceState.getString(STATE_PENDING_PICK);
|
||||||
if (pick != null) pendingPickFilename = pick;
|
if (pick != null) pendingPickFilename = pick;
|
||||||
String pickDir = savedInstanceState.getString(STATE_PENDING_PICK_DIR);
|
|
||||||
if (pickDir != null) pendingPickSaveDir = pickDir;
|
|
||||||
String create = savedInstanceState.getString(STATE_PENDING_CREATE);
|
String create = savedInstanceState.getString(STATE_PENDING_CREATE);
|
||||||
if (create != null) pendingCreateSuggestedName = create;
|
if (create != null) pendingCreateSuggestedName = create;
|
||||||
}
|
}
|
||||||
@@ -481,23 +467,13 @@ public class GameActivity extends SDLActivity {
|
|||||||
* @param destFilename basename under the app save identity (e.g.
|
* @param destFilename basename under the app save identity (e.g.
|
||||||
* picked_rom.gb, picked_mod.zip, picked_save.sav)
|
* picked_rom.gb, picked_mod.zip, picked_save.sav)
|
||||||
*/
|
*/
|
||||||
/** Legacy single-argument entry; resolves the save dir itself. */
|
|
||||||
@Keep
|
@Keep
|
||||||
public static boolean showFilePicker(String destFilename) {
|
public static boolean showFilePicker(String destFilename) {
|
||||||
return showFilePicker(destFilename, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Keep
|
|
||||||
public static boolean showFilePicker(String destFilename, String saveDir) {
|
|
||||||
GameActivity self = (GameActivity) mSingleton;
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
if (self == null) return false;
|
if (self == null) return false;
|
||||||
if (destFilename == null || destFilename.length() == 0) {
|
if (destFilename == null || destFilename.length() == 0) {
|
||||||
destFilename = PICKED_ROM_FILENAME;
|
destFilename = PICKED_ROM_FILENAME;
|
||||||
}
|
}
|
||||||
// Remember where LOVE's filesystem is really mounted so
|
|
||||||
// onActivityResult copies the pick there, not into a recomputed
|
|
||||||
// (possibly different-volume) root (#604, #839).
|
|
||||||
self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
|
|
||||||
// Reject path separators so a hostile JNI caller cannot escape the
|
// Reject path separators so a hostile JNI caller cannot escape the
|
||||||
// save identity directory.
|
// save identity directory.
|
||||||
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
|
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
|
||||||
@@ -668,14 +644,8 @@ public class GameActivity extends SDLActivity {
|
|||||||
* return degrades on the Lua side (RomImporter export) to "Exported
|
* return degrades on the Lua side (RomImporter export) to "Exported
|
||||||
* inside the app folder", which is the correct pre-KitKat behavior.
|
* inside the app folder", which is the correct pre-KitKat behavior.
|
||||||
*/
|
*/
|
||||||
/** Legacy single-argument entry; resolves the save dir itself. */
|
|
||||||
@Keep
|
@Keep
|
||||||
public static boolean showCreateDocument(String suggestedName) {
|
public static boolean showCreateDocument(String suggestedName) {
|
||||||
return showCreateDocument(suggestedName, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Keep
|
|
||||||
public static boolean showCreateDocument(String suggestedName, String saveDir) {
|
|
||||||
if (android.os.Build.VERSION.SDK_INT < 19) return false;
|
if (android.os.Build.VERSION.SDK_INT < 19) return false;
|
||||||
// (see showFilePicker for why the import side got a pre-19 path)
|
// (see showFilePicker for why the import side got a pre-19 path)
|
||||||
GameActivity self = (GameActivity) mSingleton;
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
@@ -687,12 +657,9 @@ public class GameActivity extends SDLActivity {
|
|||||||
Log.d("GameActivity", "refusing unsafe create name: " + suggestedName);
|
Log.d("GameActivity", "refusing unsafe create name: " + suggestedName);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Route through the mounted save dir (#604, #839): Lua staged
|
File source = new File(
|
||||||
// pending_export.sav where physfs writes, which is not necessarily
|
new File(self.getExternalFilesDir(null), "save"),
|
||||||
// where a fresh getExternalFilesDir(null) points on merged /
|
ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME);
|
||||||
// adopted-SD storage.
|
|
||||||
self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
|
|
||||||
File source = new File(self.saveIdentityDir(), PENDING_EXPORT_FILENAME);
|
|
||||||
if (!source.isFile()) {
|
if (!source.isFile()) {
|
||||||
Log.d("GameActivity", "no pending export at " + source);
|
Log.d("GameActivity", "no pending export at " + source);
|
||||||
return false;
|
return false;
|
||||||
@@ -713,21 +680,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private File saveIdentityDir() {
|
private File saveIdentityDir() {
|
||||||
// Prefer the mounted save dir the last bridge call reported: the
|
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
|
||||||
// recomputation below can name a different volume than the one LOVE
|
|
||||||
// mounted on merged / adopted-SD storage (#604, #839).
|
|
||||||
if (pendingPickSaveDir != null && pendingPickSaveDir.length() > 0) {
|
|
||||||
return new File(pendingPickSaveDir);
|
|
||||||
}
|
|
||||||
File ext = getExternalFilesDir(null);
|
|
||||||
if (ext == null) {
|
|
||||||
// Shared storage unavailable (ejected / mid-adoption): without
|
|
||||||
// this guard File(null, "save") silently built the RELATIVE
|
|
||||||
// path save/<identity>, mkdirs() failed against "/", and the
|
|
||||||
// pick was dropped with no message at all (#604).
|
|
||||||
ext = getFilesDir();
|
|
||||||
}
|
|
||||||
return new File(new File(ext, "save"), ROM_SAVE_IDENTITY);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drops a small flag file in the save identity for Lua to consume on focus. */
|
/** Drops a small flag file in the save identity for Lua to consume on focus. */
|
||||||
@@ -922,7 +875,6 @@ public class GameActivity extends SDLActivity {
|
|||||||
protected void onSaveInstanceState(Bundle outState) {
|
protected void onSaveInstanceState(Bundle outState) {
|
||||||
super.onSaveInstanceState(outState);
|
super.onSaveInstanceState(outState);
|
||||||
outState.putString(STATE_PENDING_PICK, pendingPickFilename);
|
outState.putString(STATE_PENDING_PICK, pendingPickFilename);
|
||||||
outState.putString(STATE_PENDING_PICK_DIR, pendingPickSaveDir);
|
|
||||||
outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName);
|
outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,76 +12,6 @@
|
|||||||
"tintColor": "3b5ca8",
|
"tintColor": "3b5ca8",
|
||||||
"category": "games",
|
"category": "games",
|
||||||
"versions": [
|
"versions": [
|
||||||
{
|
|
||||||
"version": "0.1.74",
|
|
||||||
"date": "2026-08-06",
|
|
||||||
"size": 9588946,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.74/gen1recomp-0.1.74-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #883 Evolution Stones consumed if evolution is cancelled\n- #887 MacOS Closing game just keeps reopening\n- #894 Max Repel usable in battle\n\n## Contributors\n\n- @bryanthaboi\n- @MarceloMachadoxD\n- @ratherDashing"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.1.73",
|
|
||||||
"date": "2026-08-06",
|
|
||||||
"size": 9585729,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.73/gen1recomp-0.1.73-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #878 Mod API: allow active screen states to be hidden from the main render\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.1.72",
|
|
||||||
"date": "2026-08-05",
|
|
||||||
"size": 9586678,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.72/gen1recomp-0.1.72-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #604 Android, retroid pocket 2+ Rom won't import\n- #666 Pikachu emotions are not working on Android\n- #716 Lock Auto Rotate Mobile\n- #727 [Bug] [Windows] Gen1 Recomp \"still in use\" after closing\n- #763 Some EVENTS are turned off\n- #781 Mouse cursor broken on Linux with multi-monitor X11 setup\n- #784 Leech Seed effect\n- #799 Held direction randomly stops player movement (requires re-input)\n- #801 Cannot update mods from the launcher (MacOS)\n- #810 Launcher menu cuts off in vertical mode iOS\n- #828 Closing the app causes settings in launcher to reset\n- #834 Mod import failing\n- #838 Exporting save file Pokemon Yellow\n- #839 AYN Thor Misplaced Data files\n- #849 Public folder support on iOS\n- #852 Cannot switch between saves states on smaller 4:3 screen or in vertical mode\n- #857 Mt. Moon Fossils Reappeared and Won’t Disappear.\n- #863 [Yellow] When you use stairs, Pikachu shouldn't be next to you in the new area\n- #864 Faithful Ratio\n- #867 Missing Dialogue after defeating Marowak in Pokemon Tower\n- #869 Giovanni moves up to the player too early\n- #870 Start Menu on Classic Color\n- #872 Missing text when finding an item with full inventory\n\n## Contributors\n\n- @bryanthaboi"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.1.71",
|
|
||||||
"date": "2026-08-05",
|
|
||||||
"size": 9569738,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.71/gen1recomp-0.1.71-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #806 Haptic Feedback for On-screen controls (mobile)\n- #809 NPC Stuck in Rock when battle triggered and pushing rock towards it\n- #853 When selecting one of the two pokemon after defeating the Karate Master it should show the Pokedex entry\n- #854 Textbox disappears when the yes/no dialogue appears\n- #860 Disabled moves can still be used in the turn they were disabled\n- #862 Casino Poster Rocket Grunt walks into the poster and doesn't \"Dang!\"\n- #865 [Yellow] James doesn't move in multiple encounters\n- #866 [Yellow] Dialogue in wrong \"order\" after multiple encounters (J+J, Giovanni, probably more)\n\n## Contributors\n\n- @bryanthaboi"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.1.70",
|
|
||||||
"date": "2026-08-05",
|
|
||||||
"size": 9562731,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #590 Launcher/Save Editor/Dex\n- #788 Fly to the Pokemon Centers in the Routes is not available in original\n- #795 Fly logic is drastically different from the originals.\n- #796 Using Rare Candy from the menu closes it out.\n- #797 Gym Leaders giving items when your bag is full / Bypassing bag limit.\n- #805 Escape Rope Moltres Tower\n- #826 \"Super effective\" and \"not very effective\" SFX are reversed\n- #833 Cancelling nickname entry results in \"A\" as the nickname\n- #835 Restarting the launcher forgets the last rom used\n- #837 Wrong sound effect for Pikachu when entering battle\n- #844 Blizzard sound effect.\n- #845 Moderate issue: Fuchsia City binoculars.\n- #846 Surfing speed after using the bicycle.\n- #847 Minor issues related to the endgame.\n\n## Contributors\n\n- @bryanthaboi\n- @dburton95\n- @johnjohto\n- @KikiManjaro"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.1.69",
|
|
||||||
"date": "2026-08-04",
|
|
||||||
"size": 9552851,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.69/gen1recomp-0.1.69-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #768 Menu behaviour for Pokemon and HM moves\n- #785 Back to launcher\n- #792 HM moves in the wrong position in the menu.\n- #807 Expose gameplay pointer events and source-safe mod input injection\n- #811 Untranslatables\n- #814 [minor thing] bold arrow on move swap (select)\n\n## Contributors\n\n- @bryanthaboi\n- @johnjohto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.1.68",
|
|
||||||
"date": "2026-08-04",
|
|
||||||
"size": 9768011,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.68/gen1recomp-0.1.68-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @caorthann-celt"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.1.67",
|
|
||||||
"date": "2026-08-04",
|
|
||||||
"size": 9767619,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.67/gen1recomp-0.1.67-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #373 Lines at Title Screen\n- #644 Substitute failing causes visual issues\n- #673 iOS: thin vertical seam below the START menu panel (v0.1.56, iPhone 15 Pro Max)\n- #703 Credits playing too fast (song ends after it should)\n- #726 Surfing Pikachu Minigame Broken\n- #737 Battle menu move cursor fails to reset to the first slot after switching Pokémon\n- #750 NPC (possibly player) trades still graphically broken\n- #752 Launcher exports save to AppData while in portable mode\n- #764 Trainer Fanfare doesn't play\n- #765 Text Advance broken in certain aspects\n- #768 Menu behaviour for Pokemon and HM moves\n- #773 Battle screen colours messed up when BG = World, battle in un-flashed Rock Tunnel\n- #774 bug(build): Desktop build can reject a valid game archive under pipefail\n- #775 TM42 Dream Eater dialog.\n- #777 Battle Screen is Very dark\n- #780 Do not delete save\n- #782 Giovanni battle at Silph Co plays wrong song\n\n## Contributors\n\n- @bryanthaboi\n- @luisgonzaleznf\n- @ShaneMcGovernIE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.1.66",
|
|
||||||
"date": "2026-08-04",
|
|
||||||
"size": 9749980,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.66/gen1recomp-0.1.66-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #691 Save file transfer\n- #743 Cannot Scroll Main Menu/Mod Menu\n- #779 \"Enemy \" untranslateable\n\n## Contributors\n\n- @bryanthaboi\n- @jherediagu\n- @ShaneMcGovernIE\n- @vegerot"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "0.1.65",
|
|
||||||
"date": "2026-08-03",
|
|
||||||
"size": 9376618,
|
|
||||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.65/gen1recomp-0.1.65-ios.ipa",
|
|
||||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #592 Screen Orientation\n- #702 FLY overworld animation incorrect/incomplete\n- #748 Launcher Menu has overlap\n- #754 Can push boulders with STRENGTH through walls\n- #758 PC to Mac Online Multiplayer Disconnects Shortly After Starting Match\n\n## Contributors\n\n- @andrewqsantos\n- @Bortlesboat\n- @bryanthaboi\n- @castdrian\n- @johnjohto\n- @ShaneMcGovernIE"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"version": "0.1.64",
|
"version": "0.1.64",
|
||||||
"date": "2026-08-03",
|
"date": "2026-08-03",
|
||||||
|
|||||||
Binary file not shown.
@@ -1,72 +0,0 @@
|
|||||||
#---------------------------------------------------------------------------------
|
|
||||||
# One-shot OTA bootstrap NRO (copied into the main launcher's romfs).
|
|
||||||
#---------------------------------------------------------------------------------
|
|
||||||
.SUFFIXES:
|
|
||||||
|
|
||||||
ifeq ($(strip $(DEVKITPRO)),)
|
|
||||||
|
|
||||||
.PHONY: all
|
|
||||||
all:
|
|
||||||
@echo "DEVKITPRO not set — bootstrap builds with the launcher only."
|
|
||||||
@false
|
|
||||||
|
|
||||||
else
|
|
||||||
|
|
||||||
TOPDIR ?= $(CURDIR)
|
|
||||||
include $(DEVKITPRO)/libnx/switch_rules
|
|
||||||
|
|
||||||
TARGET := ota-bootstrap
|
|
||||||
BUILD := build
|
|
||||||
SOURCES := .
|
|
||||||
INCLUDES := include
|
|
||||||
ROMFS :=
|
|
||||||
|
|
||||||
APP_TITLE := gen1recomp OTA
|
|
||||||
APP_AUTHOR := bryanthaboi, port by andrewqsantos
|
|
||||||
APP_VERSION := 0.0.0
|
|
||||||
|
|
||||||
ARCH := -march=armv8-a+crc+crypto -mtune=cortex-a57 -mtp=soft -fPIE
|
|
||||||
CFLAGS := -g -Wall -O2 -ffunction-sections $(ARCH) $(DEFINES)
|
|
||||||
CFLAGS += $(INCLUDE) -D__SWITCH__
|
|
||||||
ASFLAGS := -g $(ARCH)
|
|
||||||
LDFLAGS = -specs=$(DEVKITPRO)/libnx/switch.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map)
|
|
||||||
LIBS := -lnx
|
|
||||||
LIBDIRS := $(LIBNX)
|
|
||||||
|
|
||||||
ifneq ($(BUILD),$(notdir $(CURDIR)))
|
|
||||||
export OUTPUT := $(CURDIR)/$(TARGET)
|
|
||||||
export TOPDIR := $(CURDIR)
|
|
||||||
export VPATH := $(foreach dir,$(SOURCES),$(CURDIR)/$(dir))
|
|
||||||
export DEPSDIR := $(CURDIR)/$(BUILD)
|
|
||||||
CFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c)))
|
|
||||||
export LD := $(CC)
|
|
||||||
export OFILES := $(CFILES:.c=.o)
|
|
||||||
export INCLUDE := $(foreach dir,$(INCLUDES),-I$(CURDIR)/$(dir)) \
|
|
||||||
$(foreach dir,$(LIBDIRS),-I$(dir)/include) \
|
|
||||||
-I$(CURDIR)/$(BUILD)
|
|
||||||
export LIBPATHS := $(foreach dir,$(LIBDIRS),-L$(dir)/lib)
|
|
||||||
export APP_ICON := $(LIBNX)/default_icon.jpg
|
|
||||||
export NROFLAGS += --icon=$(APP_ICON)
|
|
||||||
export NROFLAGS += --nacp=$(CURDIR)/$(TARGET).nacp
|
|
||||||
|
|
||||||
.PHONY: $(BUILD) clean all
|
|
||||||
|
|
||||||
all: $(BUILD)
|
|
||||||
|
|
||||||
$(BUILD):
|
|
||||||
@[ -d $@ ] || mkdir -p $@
|
|
||||||
@$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile
|
|
||||||
|
|
||||||
clean:
|
|
||||||
@echo clean ...
|
|
||||||
@rm -fr $(BUILD) $(TARGET).nro $(TARGET).nacp $(TARGET).elf
|
|
||||||
|
|
||||||
else
|
|
||||||
.PHONY: all
|
|
||||||
DEPENDS := $(OFILES:.o=.d)
|
|
||||||
all: $(OUTPUT).nro
|
|
||||||
$(OUTPUT).nro: $(OUTPUT).elf $(OUTPUT).nacp
|
|
||||||
$(OUTPUT).elf: $(OFILES)
|
|
||||||
-include $(DEPENDS)
|
|
||||||
endif
|
|
||||||
endif
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/*
|
|
||||||
* Tiny one-shot helper: swap a staged launcher NRO into place, then load the game.
|
|
||||||
* The main OTA launcher chainloads here because a running NRO cannot replace itself
|
|
||||||
* on sdmc/FAT.
|
|
||||||
*/
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <string.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
|
|
||||||
#if defined(__SWITCH__)
|
|
||||||
#include <switch.h>
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#define INSTALL_DIR "sdmc:/switch/gen1recomp"
|
|
||||||
#define STAGED INSTALL_DIR "/gen1recomp.nro.staged"
|
|
||||||
#define LAUNCHER INSTALL_DIR "/gen1recomp.nro"
|
|
||||||
#define GAME INSTALL_DIR "/gen1recomp-game.nro"
|
|
||||||
|
|
||||||
int main(int argc, char **argv) {
|
|
||||||
(void)argc;
|
|
||||||
(void)argv;
|
|
||||||
|
|
||||||
#if defined(__SWITCH__)
|
|
||||||
remove(LAUNCHER);
|
|
||||||
if (rename(STAGED, LAUNCHER) != 0) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
Result rc = envSetNextLoad(GAME, GAME);
|
|
||||||
if (R_FAILED(rc)) return 1;
|
|
||||||
return 0;
|
|
||||||
#else
|
|
||||||
fprintf(stderr, "ota-bootstrap: host stub (would rename %s -> %s, load %s)\n", STAGED, LAUNCHER,
|
|
||||||
GAME);
|
|
||||||
return 0;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
#---------------------------------------------------------------------------------
|
|
||||||
# gen1recomp Switch native OTA launcher
|
|
||||||
# Requires: DEVKITPRO + switch-curl, switch-mbedtls, switch-zlib, switch-zziplib
|
|
||||||
# Host protocol tests: make host-test (no DEVKITPRO needed)
|
|
||||||
#---------------------------------------------------------------------------------
|
|
||||||
.SUFFIXES:
|
|
||||||
|
|
||||||
#---------------------------------------------------------------------------------
|
|
||||||
# Host-only path when DEVKITPRO is unset
|
|
||||||
#---------------------------------------------------------------------------------
|
|
||||||
ifeq ($(strip $(DEVKITPRO)),)
|
|
||||||
|
|
||||||
.PHONY: host-test clean-host
|
|
||||||
host-test:
|
|
||||||
@mkdir -p build-host
|
|
||||||
cc -std=c11 -Wall -Wextra -Iinclude -o build-host/test_ota_protocol \
|
|
||||||
src/ota_protocol.c host/test_ota_protocol.c
|
|
||||||
./build-host/test_ota_protocol
|
|
||||||
|
|
||||||
clean-host:
|
|
||||||
rm -rf build-host
|
|
||||||
|
|
||||||
%:
|
|
||||||
@echo "DEVKITPRO not set — only 'make host-test' is available."
|
|
||||||
@echo "Run: bash scripts/switch/install_devkitpro_deps.sh"
|
|
||||||
@false
|
|
||||||
|
|
||||||
else
|
|
||||||
|
|
||||||
#---------------------------------------------------------------------------------
|
|
||||||
TOPDIR ?= $(CURDIR)
|
|
||||||
include $(DEVKITPRO)/libnx/switch_rules
|
|
||||||
|
|
||||||
#---------------------------------------------------------------------------------
|
|
||||||
TARGET := gen1recomp
|
|
||||||
BUILD := build
|
|
||||||
SOURCES := src
|
|
||||||
DATA := data
|
|
||||||
INCLUDES := include
|
|
||||||
ROMFS := romfs
|
|
||||||
|
|
||||||
APP_TITLE := gen1recomp
|
|
||||||
APP_AUTHOR := bryanthaboi, port by andrewqsantos
|
|
||||||
# Overridable: scripts/switch/build_ota_launcher.sh passes release X.Y.Z
|
|
||||||
APP_VERSION ?= 0.0.0
|
|
||||||
|
|
||||||
# Prefer project Switch icon if present
|
|
||||||
ICON := $(TOPDIR)/../assets/icon.jpg
|
|
||||||
LOGO_RGBA_SRC := $(TOPDIR)/../assets/logo.rgba
|
|
||||||
LOGO_ROMFS := $(CURDIR)/$(ROMFS)/logo.rgba
|
|
||||||
|
|
||||||
#---------------------------------------------------------------------------------
|
|
||||||
ARCH := -march=armv8-a+crc+crypto -mtune=cortex-a57 -mtp=soft -fPIE
|
|
||||||
|
|
||||||
CFLAGS := -g -Wall -O2 -ffunction-sections $(ARCH) $(DEFINES)
|
|
||||||
CFLAGS += $(INCLUDE) -D__SWITCH__
|
|
||||||
|
|
||||||
CXXFLAGS := $(CFLAGS) -fno-rtti -fno-exceptions
|
|
||||||
|
|
||||||
ASFLAGS := -g $(ARCH)
|
|
||||||
LDFLAGS = -specs=$(DEVKITPRO)/libnx/switch.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map)
|
|
||||||
|
|
||||||
LIBS := -lcurl -lzzip -lmbedtls -lmbedx509 -lmbedcrypto -lz -lnx
|
|
||||||
|
|
||||||
LIBDIRS := $(PORTLIBS) $(LIBNX)
|
|
||||||
|
|
||||||
#---------------------------------------------------------------------------------
|
|
||||||
ifneq ($(BUILD),$(notdir $(CURDIR)))
|
|
||||||
#---------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export OUTPUT := $(CURDIR)/$(TARGET)
|
|
||||||
export TOPDIR := $(CURDIR)
|
|
||||||
|
|
||||||
export VPATH := $(foreach dir,$(SOURCES),$(CURDIR)/$(dir)) \
|
|
||||||
$(foreach dir,$(DATA),$(CURDIR)/$(dir))
|
|
||||||
|
|
||||||
export DEPSDIR := $(CURDIR)/$(BUILD)
|
|
||||||
|
|
||||||
CFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c)))
|
|
||||||
CPPFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.cpp)))
|
|
||||||
SFILES := $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.s)))
|
|
||||||
BINFILES := $(foreach dir,$(DATA),$(notdir $(wildcard $(dir)/*.*)))
|
|
||||||
|
|
||||||
#---------------------------------------------------------------------------------
|
|
||||||
# use CXX for linking C++ projects, CC for standard C
|
|
||||||
#---------------------------------------------------------------------------------
|
|
||||||
ifeq ($(strip $(CPPFILES)),)
|
|
||||||
export LD := $(CC)
|
|
||||||
else
|
|
||||||
export LD := $(CXX)
|
|
||||||
endif
|
|
||||||
#---------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export OFILES_BIN := $(addsuffix .o,$(BINFILES))
|
|
||||||
export OFILES_SRC := $(CPPFILES:.cpp=.o) $(CFILES:.c=.o) $(SFILES:.s=.o)
|
|
||||||
export OFILES := $(OFILES_BIN) $(OFILES_SRC)
|
|
||||||
|
|
||||||
export INCLUDE := $(foreach dir,$(INCLUDES),-I$(CURDIR)/$(dir)) \
|
|
||||||
$(foreach dir,$(LIBDIRS),-I$(dir)/include) \
|
|
||||||
-I$(CURDIR)/$(BUILD)
|
|
||||||
|
|
||||||
export LIBPATHS := $(foreach dir,$(LIBDIRS),-L$(dir)/lib)
|
|
||||||
|
|
||||||
ifeq ($(strip $(ICON)),)
|
|
||||||
icons := $(wildcard *.jpg)
|
|
||||||
ifneq (,$(findstring $(TARGET).jpg,$(icons)))
|
|
||||||
export APP_ICON := $(TOPDIR)/$(TARGET).jpg
|
|
||||||
else
|
|
||||||
ifneq (,$(findstring icon.jpg,$(icons)))
|
|
||||||
export APP_ICON := $(TOPDIR)/icon.jpg
|
|
||||||
endif
|
|
||||||
endif
|
|
||||||
else
|
|
||||||
ifeq ($(wildcard $(ICON)),)
|
|
||||||
# fall back to libnx default if project icon missing
|
|
||||||
export APP_ICON := $(LIBNX)/default_icon.jpg
|
|
||||||
else
|
|
||||||
export APP_ICON := $(ICON)
|
|
||||||
endif
|
|
||||||
endif
|
|
||||||
|
|
||||||
ifeq ($(strip $(NO_ICON)),)
|
|
||||||
export NROFLAGS += --icon=$(APP_ICON)
|
|
||||||
endif
|
|
||||||
|
|
||||||
ifeq ($(strip $(NO_NACP)),)
|
|
||||||
export NROFLAGS += --nacp=$(CURDIR)/$(TARGET).nacp
|
|
||||||
endif
|
|
||||||
|
|
||||||
ifneq ($(ROMFS),)
|
|
||||||
export NROFLAGS += --romfsdir=$(CURDIR)/$(ROMFS)
|
|
||||||
endif
|
|
||||||
|
|
||||||
BOOTSTRAP_DIR := $(TOPDIR)/../ota-bootstrap
|
|
||||||
BOOTSTRAP_ROMFS := $(CURDIR)/$(ROMFS)/ota-bootstrap.nro
|
|
||||||
|
|
||||||
.PHONY: $(BUILD) clean all host-test sync-romfs bootstrap-romfs
|
|
||||||
|
|
||||||
#---------------------------------------------------------------------------------
|
|
||||||
all: sync-romfs $(BUILD)
|
|
||||||
|
|
||||||
CACERT_URL := https://curl.se/ca/cacert.pem
|
|
||||||
CACERT_ROMFS := $(CURDIR)/$(ROMFS)/cacert.pem
|
|
||||||
|
|
||||||
bootstrap-romfs:
|
|
||||||
@$(MAKE) --no-print-directory -C $(BOOTSTRAP_DIR) all
|
|
||||||
@mkdir -p $(CURDIR)/$(ROMFS)
|
|
||||||
@cp -f $(BOOTSTRAP_DIR)/ota-bootstrap.nro $(BOOTSTRAP_ROMFS)
|
|
||||||
|
|
||||||
sync-romfs: bootstrap-romfs
|
|
||||||
@mkdir -p $(CURDIR)/$(ROMFS)
|
|
||||||
@[ -f "$(LOGO_RGBA_SRC)" ] || (echo "missing $(LOGO_RGBA_SRC) — run: python3 scripts/switch/bake_ota_logo.py" && exit 1)
|
|
||||||
@cp -f "$(LOGO_RGBA_SRC)" "$(LOGO_ROMFS)"
|
|
||||||
@if ! curl -sfL --time-cond $(CACERT_ROMFS) -o $(CACERT_ROMFS) $(CACERT_URL); then \
|
|
||||||
[ -f $(CACERT_ROMFS) ] || (echo "sync-romfs: failed to fetch cacert.pem" && exit 1); \
|
|
||||||
fi
|
|
||||||
|
|
||||||
$(BUILD): sync-romfs
|
|
||||||
@[ -d $@ ] || mkdir -p $@
|
|
||||||
@$(MAKE) --no-print-directory -C $(BUILD) -f $(CURDIR)/Makefile
|
|
||||||
|
|
||||||
#---------------------------------------------------------------------------------
|
|
||||||
clean:
|
|
||||||
@echo clean ...
|
|
||||||
@$(MAKE) --no-print-directory -C $(BOOTSTRAP_DIR) clean || true
|
|
||||||
@rm -fr $(BUILD) $(TARGET).nro $(TARGET).nacp $(TARGET).elf $(TARGET).lst build-host
|
|
||||||
@rm -f $(CURDIR)/$(ROMFS)/logo.rgba $(CURDIR)/$(ROMFS)/logo.png
|
|
||||||
|
|
||||||
host-test:
|
|
||||||
@mkdir -p build-host
|
|
||||||
cc -std=c11 -Wall -Wextra -Iinclude -o build-host/test_ota_protocol \
|
|
||||||
src/ota_protocol.c host/test_ota_protocol.c
|
|
||||||
./build-host/test_ota_protocol
|
|
||||||
|
|
||||||
#---------------------------------------------------------------------------------
|
|
||||||
else
|
|
||||||
.PHONY: all
|
|
||||||
|
|
||||||
DEPENDS := $(OFILES:.o=.d)
|
|
||||||
|
|
||||||
#---------------------------------------------------------------------------------
|
|
||||||
all: $(OUTPUT).nro
|
|
||||||
|
|
||||||
$(OUTPUT).nro: $(OUTPUT).elf $(OUTPUT).nacp
|
|
||||||
|
|
||||||
$(OUTPUT).elf: $(OFILES)
|
|
||||||
|
|
||||||
-include $(DEPENDS)
|
|
||||||
|
|
||||||
#---------------------------------------------------------------------------------
|
|
||||||
endif
|
|
||||||
#---------------------------------------------------------------------------------
|
|
||||||
endif
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# Native Switch OTA launcher
|
|
||||||
|
|
||||||
In-console updates for Gen1Recomp on Nintendo Switch. This NRO is the **hbmenu
|
|
||||||
entry** (`gen1recomp.nro`). It checks GitHub Releases quietly (no UI when you
|
|
||||||
are already up to date or offline). Only if a newer release exists does it show
|
|
||||||
a **launcher-style screen** (black + RGB rail + project logo + flat A/B
|
|
||||||
buttons), download the same `gen1recomp-*-switch.zip` used for install, verify
|
|
||||||
SHA-256 from `sha256sums.txt`, replace **both** `gen1recomp-game.nro` and
|
|
||||||
`gen1recomp.nro` (matching NACP version for hbmenu/Sphaira), then load the game
|
|
||||||
with `envSetNextLoad`.
|
|
||||||
|
|
||||||
The LÖVE self-updater (`src/update/Check.lua`) stays **disabled** on NX.
|
|
||||||
Wire format (also in Lua): `src/update/SwitchOta.lua`.
|
|
||||||
NACP icon: `ports/switch/assets/icon.jpg`.
|
|
||||||
|
|
||||||
## Layout on microSD
|
|
||||||
|
|
||||||
```text
|
|
||||||
sdmc:/switch/gen1recomp/gen1recomp.nro <- this launcher
|
|
||||||
sdmc:/switch/gen1recomp/gen1recomp-game.nro <- fused LÖVE game
|
|
||||||
sdmc:/switch/gen1recomp/version.txt <- installed X.Y.Z
|
|
||||||
sdmc:/switch/gen1recomp/pokemon-love2d/ <- saves (never touched by OTA)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Host tests (no DEVKITPRO)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ports/switch/ota-launcher
|
|
||||||
make host-test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Switch build (DEVKITPRO)
|
|
||||||
|
|
||||||
Needs `DEVKITPRO` with packages roughly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
(dkp-)pacman -S --needed switch-curl switch-mbedtls switch-zlib switch-zziplib
|
|
||||||
# or: bash scripts/switch/install_devkitpro_deps.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export DEVKITPRO=/opt/devkitpro # typical
|
|
||||||
cd ports/switch/ota-launcher
|
|
||||||
make
|
|
||||||
# -> gen1recomp.nro
|
|
||||||
```
|
|
||||||
|
|
||||||
Or from repo root (as part of `--fused`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/build_switch.sh --fetch --fused --version X.Y.Z
|
|
||||||
```
|
|
||||||
|
|
||||||
Standalone launcher build:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/switch/build_ota_launcher.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Docker fallback uses the same pin as fused builds (`scripts/switch/dkp-docker.image`).
|
|
||||||
|
|
||||||
## OTA logo asset
|
|
||||||
|
|
||||||
The launcher draws a pre-scaled logo from `romfs:/logo.rgba` (no PNG decoder in
|
|
||||||
the NRO). The baked blob lives at `../assets/logo.rgba` and is copied into romfs at
|
|
||||||
build time. After changing `assets/logo/logo.png`, regenerate:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/switch/bake_ota_logo.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Requires Pillow, or on macOS uses `sips` when Pillow is not installed.
|
|
||||||
|
|
||||||
## Packaging
|
|
||||||
|
|
||||||
`scripts/switch/pack_sd_zip.sh GAME_NRO VERSION OUT_ZIP LAUNCHER_NRO` writes both
|
|
||||||
NROs into the SD zip. That zip is also what OTA downloads.
|
|
||||||
Manifest: `scripts/switch/ota_launcher.manifest`.
|
|
||||||
|
|
||||||
## Status / known gaps
|
|
||||||
|
|
||||||
- Zip extraction uses `switch-zziplib` (`ota_unzip.c`) on device.
|
|
||||||
- OTA replaces game + launcher from the install zip (NACP versions stay aligned).
|
|
||||||
The running launcher cannot overwrite its own NRO on sdmc/FAT; a tiny
|
|
||||||
`ota-bootstrap.nro` (embedded in romfs) chainloads once to swap the staged
|
|
||||||
launcher, then loads the game.
|
|
||||||
- HTTPS uses Mozilla CA bundle in romfs (`cacert.pem`, fetched at build time). `ota_net_init()` mounts romfs before the quiet release check.
|
|
||||||
- Sphaira HOME forwarders cache metadata until reinstalled (see docs/switch-install.md).
|
|
||||||
- Release runner: `switch-dev` + (`install_devkitpro_deps.sh` **or** Docker)
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
/* Host-only unit tests for ota_protocol.c — no libnx. */
|
|
||||||
#include "ota_protocol.h"
|
|
||||||
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
static int g_fail = 0;
|
|
||||||
|
|
||||||
static void expect(int cond, const char *msg) {
|
|
||||||
if (!cond) {
|
|
||||||
fprintf(stderr, "FAIL: %s\n", msg);
|
|
||||||
g_fail++;
|
|
||||||
} else {
|
|
||||||
printf("PASS: %s\n", msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(void) {
|
|
||||||
expect(ota_compare_semver("1.2.0", "1.1.0") == 1, "semver newer");
|
|
||||||
expect(ota_compare_semver("1.1.0", "1.1.0") == 0, "semver equal");
|
|
||||||
expect(ota_compare_semver("1.0.0", "1.1.0") == -1, "semver older");
|
|
||||||
expect(ota_is_ota_asset_name("gen1recomp-1.5.0-switch.zip"), "ota asset name");
|
|
||||||
expect(!ota_is_ota_asset_name("gen1recomp-1.5.0-switch-ota.zip"), "reject legacy ota zip name");
|
|
||||||
expect(!ota_is_ota_asset_name("gen1recomp-1.5.0.love"), "reject love payload");
|
|
||||||
|
|
||||||
const char *json =
|
|
||||||
"{"
|
|
||||||
"\"tag_name\":\"v1.5.0\","
|
|
||||||
"\"assets\":["
|
|
||||||
"{\"name\":\"gen1recomp-1.5.0-switch.zip\","
|
|
||||||
"\"browser_download_url\":\"https://example/switch.zip\"},"
|
|
||||||
"{\"name\":\"sha256sums.txt\",\"browser_download_url\":\"https://example/sums\"}"
|
|
||||||
"]"
|
|
||||||
"}";
|
|
||||||
ota_release_t rel;
|
|
||||||
expect(ota_parse_release(json, &rel) == 1, "parse release");
|
|
||||||
expect(strcmp(rel.version, "1.5.0") == 0, "release version");
|
|
||||||
expect(strcmp(rel.asset_name, "gen1recomp-1.5.0-switch.zip") == 0, "release asset");
|
|
||||||
|
|
||||||
ota_decision_t d;
|
|
||||||
ota_decide_update("1.4.0", &rel, &d);
|
|
||||||
expect(strcmp(d.status, "available") == 0, "decide available");
|
|
||||||
ota_decide_update("1.5.0", &rel, &d);
|
|
||||||
expect(strcmp(d.status, "uptodate") == 0, "decide uptodate");
|
|
||||||
|
|
||||||
ota_release_t bad;
|
|
||||||
expect(ota_parse_release("{\"tag_name\":\"v1.5.0\",\"assets\":[]}", &bad) == 0,
|
|
||||||
"missing ota asset");
|
|
||||||
expect(strcmp(bad.reason, "missing_ota_asset") == 0, "missing_ota_asset reason");
|
|
||||||
|
|
||||||
/* GitHub releases/latest shape: release-level name + fat uploader before browser_download_url. */
|
|
||||||
const char *github_json =
|
|
||||||
"{"
|
|
||||||
"\"tag_name\":\"v0.1.70\","
|
|
||||||
"\"name\":\"0.1.70\","
|
|
||||||
"\"assets\":["
|
|
||||||
"{"
|
|
||||||
"\"url\":\"https://api.github.com/repos/bryanthaboi/gen1recomp/releases/assets/502823880\","
|
|
||||||
"\"id\":502823880,"
|
|
||||||
"\"name\":\"gen1recomp-0.1.70-switch.zip\","
|
|
||||||
"\"label\":\"\","
|
|
||||||
"\"uploader\":{"
|
|
||||||
"\"login\":\"github-actions[bot]\","
|
|
||||||
"\"id\":41898282,"
|
|
||||||
"\"node_id\":\"MDM6Qm90NDE4OTgyODI=\","
|
|
||||||
"\"avatar_url\":\"https://avatars.githubusercontent.com/in/15368?v=4\","
|
|
||||||
"\"gravatar_id\":\"\","
|
|
||||||
"\"url\":\"https://api.github.com/users/github-actions%5Bbot%5D\","
|
|
||||||
"\"html_url\":\"https://github.com/apps/github-actions\","
|
|
||||||
"\"followers_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/followers\","
|
|
||||||
"\"following_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}\","
|
|
||||||
"\"gists_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}\","
|
|
||||||
"\"starred_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}\","
|
|
||||||
"\"subscriptions_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions\","
|
|
||||||
"\"organizations_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/orgs\","
|
|
||||||
"\"repos_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/repos\","
|
|
||||||
"\"events_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}\","
|
|
||||||
"\"received_events_url\":\"https://api.github.com/users/github-actions%5Bbot%5D/received_events\","
|
|
||||||
"\"type\":\"Bot\","
|
|
||||||
"\"user_view_type\":\"public\","
|
|
||||||
"\"site_admin\":false"
|
|
||||||
"},"
|
|
||||||
"\"content_type\":\"application/zip\","
|
|
||||||
"\"state\":\"uploaded\","
|
|
||||||
"\"size\":9000573,"
|
|
||||||
"\"browser_download_url\":\"https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip\""
|
|
||||||
"}"
|
|
||||||
"]"
|
|
||||||
"}";
|
|
||||||
ota_release_t gh;
|
|
||||||
expect(ota_parse_release(github_json, &gh) == 1, "parse github-shaped release");
|
|
||||||
expect(strcmp(gh.version, "0.1.70") == 0, "github release version");
|
|
||||||
expect(strcmp(gh.asset_name, "gen1recomp-0.1.70-switch.zip") == 0, "github release asset");
|
|
||||||
expect(strcmp(gh.download_url,
|
|
||||||
"https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-switch.zip") ==
|
|
||||||
0,
|
|
||||||
"github release download url");
|
|
||||||
ota_decide_update("0.1.69", &gh, &d);
|
|
||||||
expect(strcmp(d.status, "available") == 0, "github release decide 0.1.69->0.1.70");
|
|
||||||
|
|
||||||
const char *sums = "abc123 gen1recomp-1.5.0-switch.zip\n";
|
|
||||||
ota_verify_t v;
|
|
||||||
ota_verify_sha256("gen1recomp-1.5.0-switch.zip", "abc123", sums, &v);
|
|
||||||
expect(v.ok == 1, "sha ok");
|
|
||||||
ota_verify_sha256("gen1recomp-1.5.0-switch.zip", "deadbeef", sums, &v);
|
|
||||||
expect(v.ok == 0 && strcmp(v.reason, "hash_mismatch") == 0, "sha mismatch");
|
|
||||||
ota_verify_sha256("gen1recomp-1.5.0-switch.zip", "abc123", "", &v);
|
|
||||||
expect(v.ok == 0 && strcmp(v.reason, "sum_not_found") == 0, "sha missing sum rejected");
|
|
||||||
|
|
||||||
ota_apply_plan_t plan;
|
|
||||||
ota_plan_atomic_apply("switch/gen1recomp", "/tmp/x", &plan);
|
|
||||||
expect(strstr(plan.steps[0], "copy_to_part:") != NULL, "plan copy");
|
|
||||||
expect(strstr(plan.steps[1], "rename:") != NULL, "plan rename");
|
|
||||||
expect(strstr(plan.steps[2], "copy_to_part:launcher") != NULL, "plan launcher copy");
|
|
||||||
expect(strstr(plan.steps[3], "rename:") != NULL, "plan launcher rename");
|
|
||||||
expect(strstr(plan.steps[4], "env_set_next_load:") != NULL, "plan handoff");
|
|
||||||
expect(strstr(plan.preserve, "pokemon-love2d") != NULL, "preserve saves");
|
|
||||||
expect(strstr(plan.forbidden_delete, "delete:") != NULL, "forbid delete saves");
|
|
||||||
expect(strstr(plan.forbidden_direct, "write_direct:") != NULL, "forbid direct write");
|
|
||||||
|
|
||||||
ota_offline_t off;
|
|
||||||
ota_offline_events_t ev = {0};
|
|
||||||
ev.user_skip = 1;
|
|
||||||
ota_offline_policy(1.0, &ev, &off);
|
|
||||||
expect(strcmp(off.action, "play_installed") == 0, "skip plays installed");
|
|
||||||
ev.user_skip = 0;
|
|
||||||
ev.network_ok = 0;
|
|
||||||
ota_offline_policy(1.0, &ev, &off);
|
|
||||||
expect(strcmp(off.action, "play_installed") == 0, "offline plays installed");
|
|
||||||
ev.network_ok = 1;
|
|
||||||
ota_offline_policy(6.0, &ev, &off);
|
|
||||||
expect(strcmp(off.reason, "timeout") == 0, "timeout 6s");
|
|
||||||
ota_offline_policy(2.0, &ev, &off);
|
|
||||||
expect(strcmp(off.action, "keep_checking") == 0, "still checking");
|
|
||||||
|
|
||||||
expect(OTA_CHECK_TIMEOUT_SEC == 6, "timeout constant 6");
|
|
||||||
|
|
||||||
if (g_fail) {
|
|
||||||
fprintf(stderr, "%d failure(s)\n", g_fail);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
printf("all ota_protocol host tests passed\n");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
#ifndef GEN1_OTA_FS_H
|
|
||||||
#define GEN1_OTA_FS_H
|
|
||||||
|
|
||||||
#include "ota_protocol.h"
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/* Read installed game version from sibling version.txt or NRO nacp-less stamp file.
|
|
||||||
* Expects switch/gen1recomp/version.txt containing X.Y.Z. Returns 0 on success. */
|
|
||||||
int ota_fs_read_installed_version(const char *install_dir, char *out, size_t out_len);
|
|
||||||
|
|
||||||
/* SHA-256 hex (lowercase) of file. Returns 0 on success. */
|
|
||||||
int ota_fs_sha256_file(const char *path, char *out_hex, size_t out_len);
|
|
||||||
|
|
||||||
/* Apply verified temp payload (extracted game NRO) using atomic plan. Returns 0 on success. */
|
|
||||||
int ota_fs_atomic_replace_game(const char *install_dir, const char *verified_game_nro,
|
|
||||||
char *err, size_t err_len);
|
|
||||||
|
|
||||||
/* Atomically replace any named NRO under install_dir (copy→.part→rename). */
|
|
||||||
int ota_fs_atomic_replace_nro(const char *install_dir, const char *nro_name,
|
|
||||||
const char *verified_nro, char *err, size_t err_len);
|
|
||||||
|
|
||||||
/* Stage a new launcher + copy romfs bootstrap; chainload bootstrap_out next.
|
|
||||||
* The running launcher cannot replace its own NRO on sdmc/FAT. */
|
|
||||||
int ota_fs_stage_launcher_bootstrap(const char *install_dir, const char *verified_launcher,
|
|
||||||
char *bootstrap_out, size_t bootstrap_out_len, char *err,
|
|
||||||
size_t err_len);
|
|
||||||
|
|
||||||
/* Hand off to game NRO via envSetNextLoad (Switch) or no-op stub (host). */
|
|
||||||
int ota_fs_handoff_to_game(const char *game_nro_path, char *err, size_t err_len);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
#ifndef GEN1_OTA_NET_H
|
|
||||||
#define GEN1_OTA_NET_H
|
|
||||||
|
|
||||||
#include <stddef.h>
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/* fraction is 0..1 when Content-Length is known, else -1 for indeterminate. */
|
|
||||||
typedef void (*ota_net_progress_fn)(void *userdata, double fraction);
|
|
||||||
|
|
||||||
/* Mount romfs (CA bundle) and init libcurl. Call before any HTTPS download. */
|
|
||||||
int ota_net_init(void);
|
|
||||||
void ota_net_shutdown(void);
|
|
||||||
|
|
||||||
/* Download URL into memory buffer (caller frees *out). Returns 0 on success. */
|
|
||||||
int ota_net_download_buffer(const char *url, long timeout_ms, char **out, size_t *out_len,
|
|
||||||
char *err, size_t err_len);
|
|
||||||
|
|
||||||
/* Download URL to a filesystem path. progress may be NULL. Returns 0 on success. */
|
|
||||||
int ota_net_download_file(const char *url, const char *path, long timeout_ms, char *err,
|
|
||||||
size_t err_len, ota_net_progress_fn progress, void *progress_ud);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
#ifndef GEN1_OTA_PROTOCOL_H
|
|
||||||
#define GEN1_OTA_PROTOCOL_H
|
|
||||||
|
|
||||||
#include <stddef.h>
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#define OTA_CHECK_TIMEOUT_SEC 6
|
|
||||||
#define OTA_GAME_NRO_NAME "gen1recomp-game.nro"
|
|
||||||
#define OTA_LAUNCHER_NRO_NAME "gen1recomp.nro"
|
|
||||||
#define OTA_SAVE_DIR_NAME "pokemon-love2d"
|
|
||||||
#define OTA_INSTALL_DIR "switch/gen1recomp"
|
|
||||||
#define OTA_LAUNCHER_STAGED_SUFFIX ".staged"
|
|
||||||
#define OTA_BOOTSTRAP_ROMFS "romfs:/ota-bootstrap.nro"
|
|
||||||
#define OTA_BOOTSTRAP_SD_NAME "ota-bootstrap.nro"
|
|
||||||
#define OTA_RELEASES_API \
|
|
||||||
"https://api.github.com/repos/bryanthaboi/gen1recomp/releases/latest"
|
|
||||||
|
|
||||||
/* Mirrors src/update/SwitchOta.lua — keep semantics in lockstep. */
|
|
||||||
|
|
||||||
int ota_compare_semver(const char *a, const char *b);
|
|
||||||
int ota_is_ota_asset_name(const char *name);
|
|
||||||
int ota_version_from_ota_asset(const char *name, char *out, size_t out_len);
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
int ok; /* 1 on success */
|
|
||||||
char reason[64];
|
|
||||||
char tag[64];
|
|
||||||
char version[32];
|
|
||||||
char asset_name[128];
|
|
||||||
char download_url[512];
|
|
||||||
} ota_release_t;
|
|
||||||
|
|
||||||
int ota_parse_release(const char *json_text, ota_release_t *out);
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
char status[32]; /* uptodate | available | error */
|
|
||||||
char reason[64];
|
|
||||||
char version[32];
|
|
||||||
char asset_name[128];
|
|
||||||
char download_url[512];
|
|
||||||
} ota_decision_t;
|
|
||||||
|
|
||||||
void ota_decide_update(const char *installed_version, const ota_release_t *release,
|
|
||||||
ota_decision_t *out);
|
|
||||||
|
|
||||||
/* sums: newline-separated sha256sums.txt body */
|
|
||||||
int ota_lookup_sum(const char *sums_text, const char *asset_name, char *out_hex,
|
|
||||||
size_t out_len);
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
int ok;
|
|
||||||
char reason[64];
|
|
||||||
} ota_verify_t;
|
|
||||||
|
|
||||||
void ota_verify_sha256(const char *asset_name, const char *actual_hex,
|
|
||||||
const char *sums_text, ota_verify_t *out);
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
char steps[5][520]; /* human-readable ops */
|
|
||||||
char preserve[256];
|
|
||||||
char forbidden_delete[256];
|
|
||||||
char forbidden_direct[256];
|
|
||||||
char part_path[256];
|
|
||||||
char game_nro[240];
|
|
||||||
char launcher_nro[240];
|
|
||||||
char launcher_part[256];
|
|
||||||
char next_load[240];
|
|
||||||
} ota_apply_plan_t;
|
|
||||||
|
|
||||||
void ota_plan_atomic_apply(const char *install_dir, const char *verified_temp,
|
|
||||||
ota_apply_plan_t *out);
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
char action[32]; /* play_installed | keep_checking */
|
|
||||||
char reason[64];
|
|
||||||
char message[128];
|
|
||||||
} ota_offline_t;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
int user_skip;
|
|
||||||
int network_ok; /* 0 = offline/fail, 1 = ok, -1 = unknown */
|
|
||||||
int api_error;
|
|
||||||
} ota_offline_events_t;
|
|
||||||
|
|
||||||
void ota_offline_policy(double elapsed_sec, const ota_offline_events_t *events,
|
|
||||||
ota_offline_t *out);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif /* GEN1_OTA_PROTOCOL_H */
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
#ifndef GEN1_OTA_UI_H
|
|
||||||
#define GEN1_OTA_UI_H
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/* Framebuffer UI matching the in-game launcher look (black + RGB rail +
|
|
||||||
* flat semantic buttons). Silent by default — only call when user-facing. */
|
|
||||||
|
|
||||||
/* Returns 1 = update, 0 = skip / play installed. */
|
|
||||||
int ota_ui_prompt_update(const char *installed, const char *latest);
|
|
||||||
|
|
||||||
/* Status screen (download / install). Redraws each call. */
|
|
||||||
void ota_ui_show_status(const char *title, const char *detail);
|
|
||||||
|
|
||||||
/* Progress 0..1; detail optional. */
|
|
||||||
void ota_ui_show_progress(const char *title, const char *detail, float progress01);
|
|
||||||
|
|
||||||
/* Error / info + wait for B. */
|
|
||||||
void ota_ui_alert(const char *title, const char *line1, const char *line2);
|
|
||||||
|
|
||||||
/* User-friendly error with optional technical detail and installed-version footer. */
|
|
||||||
void ota_ui_alert_error(const char *title, const char *friendly, const char *technical,
|
|
||||||
const char *installed_version);
|
|
||||||
|
|
||||||
/* Missing game install screen; wait for +. */
|
|
||||||
void ota_ui_missing_game(void);
|
|
||||||
|
|
||||||
/* Tear down framebuffer if active. Safe to call when UI never opened. */
|
|
||||||
void ota_ui_shutdown(void);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#ifndef GEN1_OTA_UNZIP_H
|
|
||||||
#define GEN1_OTA_UNZIP_H
|
|
||||||
|
|
||||||
#include <stddef.h>
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/* Extract member_name from zip_path into dest_path.
|
|
||||||
* Tries exact member_name, then common prefixes (switch/gen1recomp/).
|
|
||||||
* Returns 0 on success. */
|
|
||||||
int ota_unzip_extract_file(const char *zip_path, const char *member_name, const char *dest_path,
|
|
||||||
char *err, size_t err_len);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
/*
|
|
||||||
* gen1recomp Switch OTA launcher
|
|
||||||
*
|
|
||||||
* Quiet by default: checks GitHub Releases with no UI. Only shows the
|
|
||||||
* branded launcher-style screen when an update is available. Downloads the
|
|
||||||
* same SD zip (gen1recomp-*-switch.zip), verifies SHA-256, replaces game +
|
|
||||||
* launcher NROs, then envSetNextLoad. Never touches pokemon-love2d/.
|
|
||||||
* LÖVE self-updater stays off on NX.
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "ota_fs.h"
|
|
||||||
#include "ota_net.h"
|
|
||||||
#include "ota_protocol.h"
|
|
||||||
#include "ota_ui.h"
|
|
||||||
#include "ota_unzip.h"
|
|
||||||
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
#if defined(__SWITCH__)
|
|
||||||
#include <sys/stat.h>
|
|
||||||
#include <switch.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
#else
|
|
||||||
#include <sys/stat.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#define SD_INSTALL_DIR "sdmc:/switch/gen1recomp"
|
|
||||||
#define CHECK_TIMEOUT_MS (OTA_CHECK_TIMEOUT_SEC * 1000L)
|
|
||||||
#define GAME_MEMBER_IN_ZIP "switch/gen1recomp/" OTA_GAME_NRO_NAME
|
|
||||||
#define LAUNCHER_MEMBER_IN_ZIP "switch/gen1recomp/" OTA_LAUNCHER_NRO_NAME
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
const char *detail;
|
|
||||||
float base;
|
|
||||||
float span;
|
|
||||||
} download_progress_ctx_t;
|
|
||||||
|
|
||||||
static void on_download_progress(void *userdata, double fraction) {
|
|
||||||
download_progress_ctx_t *ctx = (download_progress_ctx_t *)userdata;
|
|
||||||
if (!ctx) return;
|
|
||||||
float p = ctx->base;
|
|
||||||
if (fraction >= 0.0) p += ctx->span * (float)fraction;
|
|
||||||
else p += ctx->span * 0.1f;
|
|
||||||
if (p > ctx->base + ctx->span) p = ctx->base + ctx->span;
|
|
||||||
ota_ui_show_progress("Step 1/3: Downloading...", ctx->detail, p);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void show_update_error(const char *title, const char *friendly, const char *technical,
|
|
||||||
const char *installed) {
|
|
||||||
ota_ui_alert_error(title, friendly, technical, installed);
|
|
||||||
}
|
|
||||||
|
|
||||||
static int run_update_flow(const char *install_dir) {
|
|
||||||
char installed[64] = "0.0.0";
|
|
||||||
(void)ota_fs_read_installed_version(install_dir, installed, sizeof(installed));
|
|
||||||
|
|
||||||
char *json = NULL;
|
|
||||||
size_t json_len = 0;
|
|
||||||
char err[256];
|
|
||||||
err[0] = '\0';
|
|
||||||
|
|
||||||
if (ota_net_download_buffer(OTA_RELEASES_API, CHECK_TIMEOUT_MS, &json, &json_len, err,
|
|
||||||
sizeof(err)) != 0) {
|
|
||||||
free(json);
|
|
||||||
return 0; /* offline / timeout — play installed, no UI */
|
|
||||||
}
|
|
||||||
|
|
||||||
ota_release_t rel;
|
|
||||||
if (!ota_parse_release(json, &rel)) {
|
|
||||||
free(json);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
free(json);
|
|
||||||
|
|
||||||
ota_decision_t dec;
|
|
||||||
ota_decide_update(installed, &rel, &dec);
|
|
||||||
if (strcmp(dec.status, "uptodate") == 0 || strcmp(dec.status, "error") == 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ota_ui_prompt_update(installed, dec.version)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
char updates_dir[192];
|
|
||||||
char zip_path[256];
|
|
||||||
char sums_path[256];
|
|
||||||
snprintf(updates_dir, sizeof(updates_dir), "%s/updates", install_dir);
|
|
||||||
snprintf(zip_path, sizeof(zip_path), "%s/updates/%s", install_dir, dec.asset_name);
|
|
||||||
snprintf(sums_path, sizeof(sums_path), "%s/updates/sha256sums.txt", install_dir);
|
|
||||||
|
|
||||||
#if defined(__SWITCH__)
|
|
||||||
mkdir(updates_dir, 0755);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
download_progress_ctx_t dl_ctx = {"This may take a minute.", 0.f, 0.5f};
|
|
||||||
ota_ui_show_progress("Step 1/3: Downloading...", dl_ctx.detail, dl_ctx.base);
|
|
||||||
if (ota_net_download_file(dec.download_url, zip_path, 180000L, err, sizeof(err),
|
|
||||||
on_download_progress, &dl_ctx) != 0) {
|
|
||||||
show_update_error("Download failed",
|
|
||||||
"Could not download the update. Check Wi-Fi and try again.", err, installed);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
ota_ui_show_progress("Step 2/3: Verifying...", "Checking file integrity.", 0.55f);
|
|
||||||
char sums_url[512];
|
|
||||||
snprintf(sums_url, sizeof(sums_url),
|
|
||||||
"https://github.com/bryanthaboi/gen1recomp/releases/download/%s/sha256sums.txt",
|
|
||||||
rel.tag);
|
|
||||||
if (ota_net_download_file(sums_url, sums_path, CHECK_TIMEOUT_MS, err, sizeof(err), NULL,
|
|
||||||
NULL) != 0) {
|
|
||||||
remove(zip_path);
|
|
||||||
show_update_error("Could not verify", "Downloaded file could not be checked.", err, installed);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
FILE *sf = fopen(sums_path, "rb");
|
|
||||||
if (!sf) {
|
|
||||||
remove(zip_path);
|
|
||||||
show_update_error("Could not verify", "Could not read checksum file.", "fopen sums failed",
|
|
||||||
installed);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
fseek(sf, 0, SEEK_END);
|
|
||||||
long slen = ftell(sf);
|
|
||||||
fseek(sf, 0, SEEK_SET);
|
|
||||||
char *sums = (char *)malloc((size_t)slen + 1);
|
|
||||||
if (!sums) {
|
|
||||||
fclose(sf);
|
|
||||||
remove(zip_path);
|
|
||||||
show_update_error("Could not verify", "Not enough memory to verify update.", "malloc failed",
|
|
||||||
installed);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
fread(sums, 1, (size_t)slen, sf);
|
|
||||||
sums[slen] = '\0';
|
|
||||||
fclose(sf);
|
|
||||||
|
|
||||||
ota_ui_show_progress("Step 2/3: Verifying...", "Computing checksum...", 0.65f);
|
|
||||||
char hex[96];
|
|
||||||
if (ota_fs_sha256_file(zip_path, hex, sizeof(hex)) != 0) {
|
|
||||||
free(sums);
|
|
||||||
remove(zip_path);
|
|
||||||
show_update_error("Could not verify", "Could not read downloaded update file.",
|
|
||||||
"sha256 file read failed", installed);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
ota_verify_t ver;
|
|
||||||
ota_verify_sha256(dec.asset_name, hex, sums, &ver);
|
|
||||||
free(sums);
|
|
||||||
if (!ver.ok) {
|
|
||||||
remove(zip_path);
|
|
||||||
show_update_error("Update check failed",
|
|
||||||
"Downloaded file did not match expected checksum.", ver.reason, installed);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
char extracted[256];
|
|
||||||
char extracted_launcher[256];
|
|
||||||
snprintf(extracted, sizeof(extracted), "%s/updates/gen1recomp-game.nro.verified", install_dir);
|
|
||||||
snprintf(extracted_launcher, sizeof(extracted_launcher), "%s/updates/gen1recomp.nro.verified",
|
|
||||||
install_dir);
|
|
||||||
|
|
||||||
#if defined(__SWITCH__)
|
|
||||||
ota_ui_show_progress("Step 3/3: Installing...", "Keeping your saves safe.", 0.8f);
|
|
||||||
if (ota_unzip_extract_file(zip_path, GAME_MEMBER_IN_ZIP, extracted, err, sizeof(err)) != 0) {
|
|
||||||
if (ota_unzip_extract_file(zip_path, OTA_GAME_NRO_NAME, extracted, err, sizeof(err)) != 0) {
|
|
||||||
remove(zip_path);
|
|
||||||
show_update_error("Could not extract",
|
|
||||||
"Update zip is missing game files or is corrupted.", err, installed);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ota_fs_atomic_replace_game(install_dir, extracted, err, sizeof(err)) != 0) {
|
|
||||||
remove(extracted);
|
|
||||||
remove(zip_path);
|
|
||||||
show_update_error("Could not install",
|
|
||||||
"Could not replace game on microSD. Free up space and try again.", err,
|
|
||||||
installed);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
remove(extracted);
|
|
||||||
|
|
||||||
int have_launcher = 0;
|
|
||||||
if (ota_unzip_extract_file(zip_path, LAUNCHER_MEMBER_IN_ZIP, extracted_launcher, err,
|
|
||||||
sizeof(err)) == 0 ||
|
|
||||||
ota_unzip_extract_file(zip_path, OTA_LAUNCHER_NRO_NAME, extracted_launcher, err,
|
|
||||||
sizeof(err)) == 0) {
|
|
||||||
have_launcher = 1;
|
|
||||||
}
|
|
||||||
if (!have_launcher) {
|
|
||||||
remove(zip_path);
|
|
||||||
show_update_error("Could not extract",
|
|
||||||
"Update zip is missing launcher files.", err, installed);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
char bootstrap_path[256];
|
|
||||||
bootstrap_path[0] = '\0';
|
|
||||||
if (ota_fs_stage_launcher_bootstrap(install_dir, extracted_launcher, bootstrap_path,
|
|
||||||
sizeof(bootstrap_path), err, sizeof(err)) != 0) {
|
|
||||||
remove(extracted_launcher);
|
|
||||||
remove(zip_path);
|
|
||||||
show_update_error("Could not install",
|
|
||||||
"Game updated but launcher could not be staged. Reinstall from the SD zip.",
|
|
||||||
err, installed);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
remove(extracted_launcher);
|
|
||||||
|
|
||||||
char vpath[192];
|
|
||||||
snprintf(vpath, sizeof(vpath), "%s/version.txt", install_dir);
|
|
||||||
FILE *vf = fopen(vpath, "wb");
|
|
||||||
if (vf) {
|
|
||||||
fprintf(vf, "%s\n", dec.version);
|
|
||||||
fclose(vf);
|
|
||||||
}
|
|
||||||
|
|
||||||
ota_ui_show_progress("Ready", "Finishing update...", 1.0f);
|
|
||||||
svcSleepThread(600000000ULL);
|
|
||||||
remove(zip_path);
|
|
||||||
ota_ui_shutdown();
|
|
||||||
|
|
||||||
if (ota_fs_handoff_to_game(bootstrap_path, err, sizeof(err)) != 0) {
|
|
||||||
show_update_error("Could not install", "Launcher bootstrap failed.", err, installed);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return 2; /* chainload bootstrap; main must not hand off to game */
|
|
||||||
#else
|
|
||||||
(void)extracted;
|
|
||||||
(void)extracted_launcher;
|
|
||||||
#endif
|
|
||||||
remove(zip_path);
|
|
||||||
ota_ui_shutdown();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(int argc, char **argv) {
|
|
||||||
(void)argc;
|
|
||||||
(void)argv;
|
|
||||||
|
|
||||||
const char *install = SD_INSTALL_DIR;
|
|
||||||
int update_rc = 0;
|
|
||||||
|
|
||||||
#if defined(__SWITCH__)
|
|
||||||
socketInitializeDefault();
|
|
||||||
padConfigureInput(1, HidNpadStyleSet_NpadStandard);
|
|
||||||
if (ota_net_init() == 0) {
|
|
||||||
update_rc = run_update_flow(install);
|
|
||||||
ota_net_shutdown();
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
update_rc = run_update_flow(install);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if (update_rc == 2) {
|
|
||||||
#if defined(__SWITCH__)
|
|
||||||
socketExit();
|
|
||||||
#endif
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
char game[192];
|
|
||||||
snprintf(game, sizeof(game), "%s/%s", install, OTA_GAME_NRO_NAME);
|
|
||||||
char err[128];
|
|
||||||
err[0] = '\0';
|
|
||||||
if (ota_fs_handoff_to_game(game, err, sizeof(err)) != 0) {
|
|
||||||
ota_ui_missing_game();
|
|
||||||
}
|
|
||||||
|
|
||||||
ota_ui_shutdown();
|
|
||||||
|
|
||||||
#if defined(__SWITCH__)
|
|
||||||
socketExit();
|
|
||||||
#endif
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
#include "ota_fs.h"
|
|
||||||
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
#if defined(__SWITCH__)
|
|
||||||
#include <switch.h>
|
|
||||||
#include <mbedtls/sha256.h>
|
|
||||||
#include <sys/stat.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
#else
|
|
||||||
#include <unistd.h>
|
|
||||||
#endif
|
|
||||||
|
|
||||||
int ota_fs_read_installed_version(const char *install_dir, char *out, size_t out_len) {
|
|
||||||
if (!out || out_len == 0) return -1;
|
|
||||||
out[0] = '\0';
|
|
||||||
char path[256];
|
|
||||||
snprintf(path, sizeof(path), "%s/version.txt", install_dir ? install_dir : OTA_INSTALL_DIR);
|
|
||||||
FILE *fp = fopen(path, "rb");
|
|
||||||
if (!fp) return -1;
|
|
||||||
if (!fgets(out, (int)out_len, fp)) {
|
|
||||||
fclose(fp);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
fclose(fp);
|
|
||||||
/* trim */
|
|
||||||
size_t n = strlen(out);
|
|
||||||
while (n > 0 && (out[n - 1] == '\n' || out[n - 1] == '\r' || out[n - 1] == ' ')) {
|
|
||||||
out[--n] = '\0';
|
|
||||||
}
|
|
||||||
return n > 0 ? 0 : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ota_fs_sha256_file(const char *path, char *out_hex, size_t out_len) {
|
|
||||||
if (!path || !out_hex || out_len < 65) return -1;
|
|
||||||
out_hex[0] = '\0';
|
|
||||||
FILE *fp = fopen(path, "rb");
|
|
||||||
if (!fp) return -1;
|
|
||||||
|
|
||||||
#if defined(__SWITCH__)
|
|
||||||
mbedtls_sha256_context ctx;
|
|
||||||
mbedtls_sha256_init(&ctx);
|
|
||||||
mbedtls_sha256_starts(&ctx, 0);
|
|
||||||
unsigned char buf[8192];
|
|
||||||
size_t n;
|
|
||||||
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) {
|
|
||||||
mbedtls_sha256_update(&ctx, buf, n);
|
|
||||||
}
|
|
||||||
fclose(fp);
|
|
||||||
unsigned char digest[32];
|
|
||||||
mbedtls_sha256_finish(&ctx, digest);
|
|
||||||
mbedtls_sha256_free(&ctx);
|
|
||||||
for (int i = 0; i < 32; i++) snprintf(out_hex + i * 2, out_len - (size_t)(i * 2), "%02x", digest[i]);
|
|
||||||
return 0;
|
|
||||||
#else
|
|
||||||
/* Host: shell out to shasum/sha256sum for the host test path if needed.
|
|
||||||
* Protocol host tests do not call this; return not-implemented. */
|
|
||||||
fclose(fp);
|
|
||||||
(void)out_len;
|
|
||||||
return -1;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
static int copy_file(const char *from, const char *to) {
|
|
||||||
FILE *in = fopen(from, "rb");
|
|
||||||
if (!in) return -1;
|
|
||||||
FILE *out = fopen(to, "wb");
|
|
||||||
if (!out) {
|
|
||||||
fclose(in);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
char buf[8192];
|
|
||||||
size_t n;
|
|
||||||
while ((n = fread(buf, 1, sizeof(buf), in)) > 0) {
|
|
||||||
if (fwrite(buf, 1, n, out) != n) {
|
|
||||||
fclose(in);
|
|
||||||
fclose(out);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fclose(in);
|
|
||||||
fclose(out);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ota_fs_atomic_replace_game(const char *install_dir, const char *verified_game_nro,
|
|
||||||
char *err, size_t err_len) {
|
|
||||||
return ota_fs_atomic_replace_nro(install_dir, OTA_GAME_NRO_NAME, verified_game_nro, err,
|
|
||||||
err_len);
|
|
||||||
}
|
|
||||||
|
|
||||||
int ota_fs_atomic_replace_nro(const char *install_dir, const char *nro_name,
|
|
||||||
const char *verified_nro, char *err, size_t err_len) {
|
|
||||||
if (!nro_name || !*nro_name || !verified_nro || !*verified_nro) {
|
|
||||||
if (err && err_len) snprintf(err, err_len, "missing nro paths");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
char dest[240];
|
|
||||||
char part[256];
|
|
||||||
snprintf(dest, sizeof(dest), "%s/%s", install_dir ? install_dir : OTA_INSTALL_DIR, nro_name);
|
|
||||||
snprintf(part, sizeof(part), "%s.part", dest);
|
|
||||||
if (copy_file(verified_nro, part) != 0) {
|
|
||||||
if (err && err_len) snprintf(err, err_len, "copy to .part failed (%s)", nro_name);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
/* sdmc/FAT (Switch) and Windows do not replace an existing dest on rename. */
|
|
||||||
remove(dest);
|
|
||||||
if (rename(part, dest) != 0) {
|
|
||||||
if (err && err_len) snprintf(err, err_len, "rename .part -> %s failed", nro_name);
|
|
||||||
remove(part);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ota_fs_stage_launcher_bootstrap(const char *install_dir, const char *verified_launcher,
|
|
||||||
char *bootstrap_out, size_t bootstrap_out_len, char *err,
|
|
||||||
size_t err_len) {
|
|
||||||
if (!verified_launcher || !*verified_launcher) {
|
|
||||||
if (err && err_len) snprintf(err, err_len, "missing staged launcher path");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (!bootstrap_out || bootstrap_out_len == 0) {
|
|
||||||
if (err && err_len) snprintf(err, err_len, "missing bootstrap output buffer");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
bootstrap_out[0] = '\0';
|
|
||||||
|
|
||||||
const char *base = install_dir ? install_dir : OTA_INSTALL_DIR;
|
|
||||||
char staged[256];
|
|
||||||
char bootstrap[256];
|
|
||||||
char updates[192];
|
|
||||||
snprintf(staged, sizeof(staged), "%s/%s%s", base, OTA_LAUNCHER_NRO_NAME,
|
|
||||||
OTA_LAUNCHER_STAGED_SUFFIX);
|
|
||||||
snprintf(updates, sizeof(updates), "%s/updates", base);
|
|
||||||
snprintf(bootstrap, sizeof(bootstrap), "%s/updates/%s", base, OTA_BOOTSTRAP_SD_NAME);
|
|
||||||
|
|
||||||
remove(staged);
|
|
||||||
if (copy_file(verified_launcher, staged) != 0) {
|
|
||||||
if (err && err_len) snprintf(err, err_len, "stage launcher failed");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#if defined(__SWITCH__)
|
|
||||||
mkdir(updates, 0755);
|
|
||||||
#endif
|
|
||||||
if (copy_file(OTA_BOOTSTRAP_ROMFS, bootstrap) != 0) {
|
|
||||||
remove(staged);
|
|
||||||
if (err && err_len) snprintf(err, err_len, "extract bootstrap failed");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
snprintf(bootstrap_out, bootstrap_out_len, "%s", bootstrap);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ota_fs_handoff_to_game(const char *game_nro_path, char *err, size_t err_len) {
|
|
||||||
if (!game_nro_path || !*game_nro_path) {
|
|
||||||
if (err && err_len) snprintf(err, err_len, "missing game path");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
#if defined(__SWITCH__)
|
|
||||||
/* argv for next load: empty args string is fine */
|
|
||||||
Result rc = envSetNextLoad(game_nro_path, game_nro_path);
|
|
||||||
if (R_FAILED(rc)) {
|
|
||||||
if (err && err_len) snprintf(err, err_len, "envSetNextLoad failed: 0x%x", rc);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
#else
|
|
||||||
if (err && err_len)
|
|
||||||
snprintf(err, err_len, "handoff stub (host): would envSetNextLoad %s", game_nro_path);
|
|
||||||
return 0; /* host stub succeeds for flow tests */
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
#include "ota_net.h"
|
|
||||||
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
#if defined(__SWITCH__)
|
|
||||||
#include <curl/curl.h>
|
|
||||||
#include <switch.h>
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(__SWITCH__)
|
|
||||||
#define OTA_CA_BUNDLE "romfs:/cacert.pem"
|
|
||||||
|
|
||||||
static int g_net_ready = 0;
|
|
||||||
|
|
||||||
static int ota_ca_bundle_ready(void) {
|
|
||||||
FILE *f = fopen(OTA_CA_BUNDLE, "rb");
|
|
||||||
if (!f) return 0;
|
|
||||||
fclose(f);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ota_net_init(void) {
|
|
||||||
if (g_net_ready) return 0;
|
|
||||||
if (R_FAILED(romfsInit())) return -1;
|
|
||||||
if (!ota_ca_bundle_ready()) return -1;
|
|
||||||
if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) return -1;
|
|
||||||
g_net_ready = 1;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void ota_net_shutdown(void) {
|
|
||||||
if (!g_net_ready) return;
|
|
||||||
curl_global_cleanup();
|
|
||||||
romfsExit();
|
|
||||||
g_net_ready = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void ota_net_configure_tls(CURL *curl) {
|
|
||||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
|
||||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
|
||||||
curl_easy_setopt(curl, CURLOPT_CAINFO, OTA_CA_BUNDLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
struct mem_buf {
|
|
||||||
char *data;
|
|
||||||
size_t len;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct file_progress {
|
|
||||||
ota_net_progress_fn fn;
|
|
||||||
void *userdata;
|
|
||||||
};
|
|
||||||
|
|
||||||
static size_t write_mem(char *ptr, size_t size, size_t nmemb, void *userdata) {
|
|
||||||
struct mem_buf *m = (struct mem_buf *)userdata;
|
|
||||||
size_t n = size * nmemb;
|
|
||||||
char *p = (char *)realloc(m->data, m->len + n + 1);
|
|
||||||
if (!p) return 0;
|
|
||||||
m->data = p;
|
|
||||||
memcpy(m->data + m->len, ptr, n);
|
|
||||||
m->len += n;
|
|
||||||
m->data[m->len] = '\0';
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
static size_t write_file(char *ptr, size_t size, size_t nmemb, void *userdata) {
|
|
||||||
return fwrite(ptr, size, nmemb, (FILE *)userdata);
|
|
||||||
}
|
|
||||||
|
|
||||||
static int xfer_progress(void *clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t ultotal,
|
|
||||||
curl_off_t ulnow) {
|
|
||||||
(void)ultotal;
|
|
||||||
(void)ulnow;
|
|
||||||
struct file_progress *fp = (struct file_progress *)clientp;
|
|
||||||
if (!fp || !fp->fn) return 0;
|
|
||||||
if (dltotal > 0) {
|
|
||||||
double frac = (double)dlnow / (double)dltotal;
|
|
||||||
if (frac < 0.0) frac = 0.0;
|
|
||||||
if (frac > 1.0) frac = 1.0;
|
|
||||||
fp->fn(fp->userdata, frac);
|
|
||||||
} else if (dlnow > 0) {
|
|
||||||
fp->fn(fp->userdata, -1.0);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
|
|
||||||
int ota_net_init(void) { return 0; }
|
|
||||||
void ota_net_shutdown(void) {}
|
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
||||||
int ota_net_download_buffer(const char *url, long timeout_ms, char **out, size_t *out_len,
|
|
||||||
char *err, size_t err_len) {
|
|
||||||
if (out) *out = NULL;
|
|
||||||
if (out_len) *out_len = 0;
|
|
||||||
if (!url || !out) {
|
|
||||||
if (err && err_len) snprintf(err, err_len, "bad args");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
#if defined(__SWITCH__)
|
|
||||||
CURL *curl = curl_easy_init();
|
|
||||||
if (!curl) {
|
|
||||||
if (err && err_len) snprintf(err, err_len, "curl_easy_init failed");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
struct mem_buf mem = {0};
|
|
||||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
|
||||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
|
||||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "gen1recomp-switch-ota");
|
|
||||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, timeout_ms);
|
|
||||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, timeout_ms);
|
|
||||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_mem);
|
|
||||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &mem);
|
|
||||||
ota_net_configure_tls(curl);
|
|
||||||
CURLcode rc = curl_easy_perform(curl);
|
|
||||||
curl_easy_cleanup(curl);
|
|
||||||
if (rc != CURLE_OK) {
|
|
||||||
free(mem.data);
|
|
||||||
if (err && err_len) snprintf(err, err_len, "%s", curl_easy_strerror(rc));
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
*out = mem.data;
|
|
||||||
if (out_len) *out_len = mem.len;
|
|
||||||
return 0;
|
|
||||||
#else
|
|
||||||
(void)timeout_ms;
|
|
||||||
if (err && err_len)
|
|
||||||
snprintf(err, err_len, "ota_net_download_buffer only available on __SWITCH__");
|
|
||||||
return -1;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
int ota_net_download_file(const char *url, const char *path, long timeout_ms, char *err,
|
|
||||||
size_t err_len, ota_net_progress_fn progress, void *progress_ud) {
|
|
||||||
if (!url || !path) {
|
|
||||||
if (err && err_len) snprintf(err, err_len, "bad args");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
#if defined(__SWITCH__)
|
|
||||||
FILE *fp = fopen(path, "wb");
|
|
||||||
if (!fp) {
|
|
||||||
if (err && err_len) snprintf(err, err_len, "fopen failed: %s", path);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
CURL *curl = curl_easy_init();
|
|
||||||
if (!curl) {
|
|
||||||
fclose(fp);
|
|
||||||
if (err && err_len) snprintf(err, err_len, "curl_easy_init failed");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
struct file_progress fp_cb = {progress, progress_ud};
|
|
||||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
|
||||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
|
||||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "gen1recomp-switch-ota");
|
|
||||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, timeout_ms);
|
|
||||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, timeout_ms);
|
|
||||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_file);
|
|
||||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
|
|
||||||
ota_net_configure_tls(curl);
|
|
||||||
if (progress) {
|
|
||||||
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
|
|
||||||
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, xfer_progress);
|
|
||||||
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &fp_cb);
|
|
||||||
}
|
|
||||||
CURLcode rc = curl_easy_perform(curl);
|
|
||||||
curl_easy_cleanup(curl);
|
|
||||||
fclose(fp);
|
|
||||||
if (rc != CURLE_OK) {
|
|
||||||
if (err && err_len) snprintf(err, err_len, "%s", curl_easy_strerror(rc));
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (progress) progress(progress_ud, 1.0);
|
|
||||||
return 0;
|
|
||||||
#else
|
|
||||||
(void)timeout_ms;
|
|
||||||
(void)progress;
|
|
||||||
(void)progress_ud;
|
|
||||||
if (err && err_len)
|
|
||||||
snprintf(err, err_len, "ota_net_download_file only available on __SWITCH__");
|
|
||||||
return -1;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
#include "ota_protocol.h"
|
|
||||||
|
|
||||||
#include <ctype.h>
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
int major;
|
|
||||||
int minor;
|
|
||||||
int patch;
|
|
||||||
int ok;
|
|
||||||
} semver_t;
|
|
||||||
|
|
||||||
static void set_reason(char *buf, size_t n, const char *r) {
|
|
||||||
if (!buf || n == 0) return;
|
|
||||||
snprintf(buf, n, "%s", r ? r : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
static int parse_semver(const char *s, semver_t *out) {
|
|
||||||
memset(out, 0, sizeof(*out));
|
|
||||||
if (!s || !*s) return 0;
|
|
||||||
if (s[0] == 'v' || s[0] == 'V') s++;
|
|
||||||
int maj = 0, min = 0, pat = 0;
|
|
||||||
char trail = 0;
|
|
||||||
if (sscanf(s, "%d.%d.%d%c", &maj, &min, &pat, &trail) != 3) return 0;
|
|
||||||
if (maj < 0 || min < 0 || pat < 0) return 0;
|
|
||||||
out->major = maj;
|
|
||||||
out->minor = min;
|
|
||||||
out->patch = pat;
|
|
||||||
out->ok = 1;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ota_compare_semver(const char *a, const char *b) {
|
|
||||||
semver_t pa, pb;
|
|
||||||
int oa = parse_semver(a, &pa);
|
|
||||||
int ob = parse_semver(b, &pb);
|
|
||||||
if (!oa && !ob) return 0;
|
|
||||||
if (!oa) return -1;
|
|
||||||
if (!ob) return 1;
|
|
||||||
if (pa.major != pb.major) return pa.major < pb.major ? -1 : 1;
|
|
||||||
if (pa.minor != pb.minor) return pa.minor < pb.minor ? -1 : 1;
|
|
||||||
if (pa.patch != pb.patch) return pa.patch < pb.patch ? -1 : 1;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ota_is_ota_asset_name(const char *name) {
|
|
||||||
int maj = 0, min = 0, pat = 0;
|
|
||||||
if (!name) return 0;
|
|
||||||
/* Unified SD/OTA asset: gen1recomp-X.Y.Z-switch.zip */
|
|
||||||
size_t len = strlen(name);
|
|
||||||
const char *suffix = "-switch.zip";
|
|
||||||
size_t slen = strlen(suffix);
|
|
||||||
if (len <= slen) return 0;
|
|
||||||
if (strcmp(name + len - slen, suffix) != 0) return 0;
|
|
||||||
if (strncmp(name, "gen1recomp-", 11) != 0) return 0;
|
|
||||||
if (sscanf(name + 11, "%d.%d.%d", &maj, &min, &pat) != 3) return 0;
|
|
||||||
char rebuilt[128];
|
|
||||||
snprintf(rebuilt, sizeof(rebuilt), "gen1recomp-%d.%d.%d-switch.zip", maj, min, pat);
|
|
||||||
return strcmp(name, rebuilt) == 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ota_version_from_ota_asset(const char *name, char *out, size_t out_len) {
|
|
||||||
int maj = 0, min = 0, pat = 0;
|
|
||||||
if (!out || out_len == 0) return 0;
|
|
||||||
out[0] = '\0';
|
|
||||||
if (!ota_is_ota_asset_name(name)) return 0;
|
|
||||||
if (sscanf(name + 11, "%d.%d.%d", &maj, &min, &pat) != 3) return 0;
|
|
||||||
snprintf(out, out_len, "%d.%d.%d", maj, min, pat);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
static const char *find_json_string(const char *json, const char *key, char *out, size_t out_len) {
|
|
||||||
char pattern[128];
|
|
||||||
snprintf(pattern, sizeof(pattern), "\"%s\"", key);
|
|
||||||
const char *p = strstr(json, pattern);
|
|
||||||
if (!p) return NULL;
|
|
||||||
p = strchr(p + strlen(pattern), ':');
|
|
||||||
if (!p) return NULL;
|
|
||||||
p++;
|
|
||||||
while (*p && isspace((unsigned char)*p)) p++;
|
|
||||||
if (*p != '"') return NULL;
|
|
||||||
p++;
|
|
||||||
size_t i = 0;
|
|
||||||
while (*p && *p != '"' && i + 1 < out_len) {
|
|
||||||
if (*p == '\\' && p[1]) {
|
|
||||||
p++;
|
|
||||||
out[i++] = *p++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out[i++] = *p++;
|
|
||||||
}
|
|
||||||
out[i] = '\0';
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Opening { of the JSON object that contains pos (walk backward). */
|
|
||||||
static const char *find_json_object_start(const char *pos, const char *json_start) {
|
|
||||||
if (!pos || !json_start || pos < json_start) return NULL;
|
|
||||||
int depth = 0;
|
|
||||||
const char *p = pos;
|
|
||||||
while (p >= json_start) {
|
|
||||||
if (*p == '}') depth++;
|
|
||||||
else if (*p == '{') {
|
|
||||||
if (depth == 0) return p;
|
|
||||||
depth--;
|
|
||||||
}
|
|
||||||
p--;
|
|
||||||
}
|
|
||||||
return NULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pointer just past the closing } of the object that starts at object_start. */
|
|
||||||
static const char *find_json_object_end(const char *object_start) {
|
|
||||||
if (!object_start || *object_start != '{') return NULL;
|
|
||||||
int depth = 1;
|
|
||||||
const char *p = object_start + 1;
|
|
||||||
while (*p) {
|
|
||||||
if (*p == '{') depth++;
|
|
||||||
else if (*p == '}') {
|
|
||||||
depth--;
|
|
||||||
if (depth == 0) return p + 1;
|
|
||||||
}
|
|
||||||
p++;
|
|
||||||
}
|
|
||||||
return NULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
int ota_parse_release(const char *json_text, ota_release_t *out) {
|
|
||||||
memset(out, 0, sizeof(*out));
|
|
||||||
if (!json_text || !*json_text) {
|
|
||||||
set_reason(out->reason, sizeof(out->reason), "empty_json");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (!find_json_string(json_text, "tag_name", out->tag, sizeof(out->tag))) {
|
|
||||||
set_reason(out->reason, sizeof(out->reason), "missing_tag");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
semver_t sv;
|
|
||||||
if (!parse_semver(out->tag, &sv)) {
|
|
||||||
set_reason(out->reason, sizeof(out->reason), "bad_tag");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
snprintf(out->version, sizeof(out->version), "%d.%d.%d", sv.major, sv.minor, sv.patch);
|
|
||||||
|
|
||||||
/* Scan for OTA asset name then browser_download_url inside the same asset object. */
|
|
||||||
const char *cursor = json_text;
|
|
||||||
while ((cursor = strstr(cursor, "\"name\"")) != NULL) {
|
|
||||||
char name[128];
|
|
||||||
if (!find_json_string(cursor, "name", name, sizeof(name))) {
|
|
||||||
cursor += 6;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!ota_is_ota_asset_name(name)) {
|
|
||||||
cursor += 6;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const char *asset_start = find_json_object_start(cursor, json_text);
|
|
||||||
const char *asset_end =
|
|
||||||
asset_start ? find_json_object_end(asset_start) : NULL;
|
|
||||||
if (!asset_start || !asset_end || asset_end <= cursor) {
|
|
||||||
cursor += 6;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
char url[512];
|
|
||||||
const char *u = NULL;
|
|
||||||
const char *scan = cursor;
|
|
||||||
while (scan < asset_end &&
|
|
||||||
(scan = strstr(scan, "\"browser_download_url\"")) != NULL && scan < asset_end) {
|
|
||||||
if (find_json_string(scan, "browser_download_url", url, sizeof(url))) {
|
|
||||||
u = url;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
scan += 21;
|
|
||||||
}
|
|
||||||
if (!u || !*u) {
|
|
||||||
cursor += 6;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
snprintf(out->asset_name, sizeof(out->asset_name), "%s", name);
|
|
||||||
snprintf(out->download_url, sizeof(out->download_url), "%s", u);
|
|
||||||
out->ok = 1;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
set_reason(out->reason, sizeof(out->reason), "missing_ota_asset");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void ota_decide_update(const char *installed_version, const ota_release_t *release,
|
|
||||||
ota_decision_t *out) {
|
|
||||||
memset(out, 0, sizeof(*out));
|
|
||||||
semver_t inst;
|
|
||||||
if (!parse_semver(installed_version, &inst)) {
|
|
||||||
snprintf(out->status, sizeof(out->status), "error");
|
|
||||||
set_reason(out->reason, sizeof(out->reason), "bad_installed_version");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!release || !release->ok) {
|
|
||||||
snprintf(out->status, sizeof(out->status), "error");
|
|
||||||
set_reason(out->reason, sizeof(out->reason), "bad_release");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ota_compare_semver(release->version, installed_version) <= 0) {
|
|
||||||
snprintf(out->status, sizeof(out->status), "uptodate");
|
|
||||||
snprintf(out->version, sizeof(out->version), "%s", installed_version);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
snprintf(out->status, sizeof(out->status), "available");
|
|
||||||
snprintf(out->version, sizeof(out->version), "%s", release->version);
|
|
||||||
snprintf(out->asset_name, sizeof(out->asset_name), "%s", release->asset_name);
|
|
||||||
snprintf(out->download_url, sizeof(out->download_url), "%s", release->download_url);
|
|
||||||
}
|
|
||||||
|
|
||||||
int ota_lookup_sum(const char *sums_text, const char *asset_name, char *out_hex, size_t out_len) {
|
|
||||||
if (out_hex && out_len) out_hex[0] = '\0';
|
|
||||||
if (!sums_text || !asset_name || !out_hex || out_len < 65) return 0;
|
|
||||||
const char *p = sums_text;
|
|
||||||
while (*p) {
|
|
||||||
char hex[96];
|
|
||||||
char name[256];
|
|
||||||
int n = 0;
|
|
||||||
if (sscanf(p, "%95s %255s%n", hex, name, &n) >= 2 && n > 0) {
|
|
||||||
const char *nm = name;
|
|
||||||
if (nm[0] == '*') nm++;
|
|
||||||
if (strncmp(nm, "./", 2) == 0) nm += 2;
|
|
||||||
if (strcmp(nm, asset_name) == 0) {
|
|
||||||
/* normalize hex to lowercase */
|
|
||||||
size_t i;
|
|
||||||
for (i = 0; hex[i] && i + 1 < out_len; i++)
|
|
||||||
out_hex[i] = (char)tolower((unsigned char)hex[i]);
|
|
||||||
out_hex[i] = '\0';
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
p += n;
|
|
||||||
while (*p && *p != '\n') p++;
|
|
||||||
if (*p == '\n') p++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
while (*p && *p != '\n') p++;
|
|
||||||
if (*p == '\n') p++;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void ota_verify_sha256(const char *asset_name, const char *actual_hex, const char *sums_text,
|
|
||||||
ota_verify_t *out) {
|
|
||||||
memset(out, 0, sizeof(*out));
|
|
||||||
if (!asset_name || !*asset_name) {
|
|
||||||
set_reason(out->reason, sizeof(out->reason), "bad_asset_name");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!sums_text) {
|
|
||||||
set_reason(out->reason, sizeof(out->reason), "missing_sums");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
char expected[96];
|
|
||||||
if (!ota_lookup_sum(sums_text, asset_name, expected, sizeof(expected))) {
|
|
||||||
set_reason(out->reason, sizeof(out->reason), "sum_not_found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!actual_hex || !*actual_hex) {
|
|
||||||
set_reason(out->reason, sizeof(out->reason), "missing_actual_hash");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
char actual[96];
|
|
||||||
size_t i;
|
|
||||||
for (i = 0; actual_hex[i] && i + 1 < sizeof(actual); i++)
|
|
||||||
actual[i] = (char)tolower((unsigned char)actual_hex[i]);
|
|
||||||
actual[i] = '\0';
|
|
||||||
if (strcmp(actual, expected) != 0) {
|
|
||||||
set_reason(out->reason, sizeof(out->reason), "hash_mismatch");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
out->ok = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
void ota_plan_atomic_apply(const char *install_dir, const char *verified_temp,
|
|
||||||
ota_apply_plan_t *out) {
|
|
||||||
memset(out, 0, sizeof(*out));
|
|
||||||
if (!install_dir || !*install_dir) install_dir = OTA_INSTALL_DIR;
|
|
||||||
if (!verified_temp) verified_temp = "";
|
|
||||||
snprintf(out->game_nro, sizeof(out->game_nro), "%s/%s", install_dir, OTA_GAME_NRO_NAME);
|
|
||||||
snprintf(out->part_path, sizeof(out->part_path), "%s.part", out->game_nro);
|
|
||||||
snprintf(out->launcher_nro, sizeof(out->launcher_nro), "%s/%s", install_dir,
|
|
||||||
OTA_LAUNCHER_NRO_NAME);
|
|
||||||
snprintf(out->launcher_part, sizeof(out->launcher_part), "%s.part", out->launcher_nro);
|
|
||||||
snprintf(out->next_load, sizeof(out->next_load), "%s", out->game_nro);
|
|
||||||
/* verified_temp is the game NRO; launcher replace uses a sibling verified path */
|
|
||||||
snprintf(out->steps[0], sizeof(out->steps[0]), "copy_to_part:%s->%s", verified_temp,
|
|
||||||
out->part_path);
|
|
||||||
snprintf(out->steps[1], sizeof(out->steps[1]), "rename:%s->%s", out->part_path, out->game_nro);
|
|
||||||
snprintf(out->steps[2], sizeof(out->steps[2]), "copy_to_part:launcher->%s", out->launcher_part);
|
|
||||||
snprintf(out->steps[3], sizeof(out->steps[3]), "rename:%s->%s", out->launcher_part,
|
|
||||||
out->launcher_nro);
|
|
||||||
snprintf(out->steps[4], sizeof(out->steps[4]), "env_set_next_load:%s", out->game_nro);
|
|
||||||
snprintf(out->preserve, sizeof(out->preserve), "%s/%s", install_dir, OTA_SAVE_DIR_NAME);
|
|
||||||
snprintf(out->forbidden_delete, sizeof(out->forbidden_delete), "delete:%s/%s", install_dir,
|
|
||||||
OTA_SAVE_DIR_NAME);
|
|
||||||
snprintf(out->forbidden_direct, sizeof(out->forbidden_direct), "write_direct:%s", out->game_nro);
|
|
||||||
}
|
|
||||||
|
|
||||||
void ota_offline_policy(double elapsed_sec, const ota_offline_events_t *events, ota_offline_t *out) {
|
|
||||||
memset(out, 0, sizeof(*out));
|
|
||||||
ota_offline_events_t ev = {0, -1, 0};
|
|
||||||
if (events) ev = *events;
|
|
||||||
if (ev.user_skip) {
|
|
||||||
snprintf(out->action, sizeof(out->action), "play_installed");
|
|
||||||
set_reason(out->reason, sizeof(out->reason), "user_skip");
|
|
||||||
snprintf(out->message, sizeof(out->message), "update skipped");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ev.api_error || ev.network_ok == 0) {
|
|
||||||
snprintf(out->action, sizeof(out->action), "play_installed");
|
|
||||||
set_reason(out->reason, sizeof(out->reason), "offline_or_error");
|
|
||||||
snprintf(out->message, sizeof(out->message),
|
|
||||||
"offline or update check failed — play installed version");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (elapsed_sec >= (double)OTA_CHECK_TIMEOUT_SEC) {
|
|
||||||
snprintf(out->action, sizeof(out->action), "play_installed");
|
|
||||||
set_reason(out->reason, sizeof(out->reason), "timeout");
|
|
||||||
snprintf(out->message, sizeof(out->message), "update check timed out after %ds",
|
|
||||||
OTA_CHECK_TIMEOUT_SEC);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
snprintf(out->action, sizeof(out->action), "keep_checking");
|
|
||||||
set_reason(out->reason, sizeof(out->reason), "in_flight");
|
|
||||||
}
|
|
||||||
@@ -1,595 +0,0 @@
|
|||||||
/*
|
|
||||||
* Switch OTA UI — matches in-game launcher language (Theme.lua):
|
|
||||||
* black field, RGB version rail, flat yellow/white buttons, white ink.
|
|
||||||
* Quiet until called. Uses framebuffer + 8x8 font + optional romfs logo.
|
|
||||||
*/
|
|
||||||
#include "ota_ui.h"
|
|
||||||
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
#if defined(__SWITCH__)
|
|
||||||
#include <switch.h>
|
|
||||||
|
|
||||||
#include "../third_party/font8x8_basic.h"
|
|
||||||
|
|
||||||
#define OTA_LOGO_ROMFS "romfs:/logo.rgba"
|
|
||||||
|
|
||||||
#define FB_W 1280
|
|
||||||
#define FB_H 720
|
|
||||||
|
|
||||||
#define TITLE_Y 200
|
|
||||||
#define LINE_TITLE 44
|
|
||||||
#define LINE_BODY 36
|
|
||||||
#define LINE_SMALL 28
|
|
||||||
#define WRAP_MAX_PX 720
|
|
||||||
|
|
||||||
/* Theme.PAL (0-255) -> RGBA8 */
|
|
||||||
#define COL_BG RGBA8_MAXALPHA(0, 0, 0)
|
|
||||||
#define COL_INK RGBA8_MAXALPHA(255, 255, 255)
|
|
||||||
#define COL_DETAIL RGBA8_MAXALPHA(200, 200, 200)
|
|
||||||
#define COL_MUTED RGBA8_MAXALPHA(150, 150, 150)
|
|
||||||
#define COL_INVERSE RGBA8_MAXALPHA(0, 0, 0)
|
|
||||||
#define COL_YELLOW RGBA8_MAXALPHA(255, 214, 0)
|
|
||||||
#define COL_GREEN RGBA8_MAXALPHA(0, 255, 140)
|
|
||||||
#define COL_RAIL_R RGBA8_MAXALPHA(255, 60, 72)
|
|
||||||
#define COL_RAIL_B RGBA8_MAXALPHA(70, 150, 255)
|
|
||||||
#define COL_RAIL_G RGBA8_MAXALPHA(255, 203, 5)
|
|
||||||
#define COL_LINE RGBA8_MAXALPHA(90, 90, 90)
|
|
||||||
#define COL_RAISED RGBA8_MAXALPHA(20, 20, 20)
|
|
||||||
|
|
||||||
static int g_ready = 0;
|
|
||||||
static Framebuffer g_fb;
|
|
||||||
static PadState g_pad;
|
|
||||||
static u32 *g_logo = NULL;
|
|
||||||
static int g_logo_w = 0, g_logo_h = 0;
|
|
||||||
|
|
||||||
static void fill_rect(u32 *fb, u32 stride_px, int x, int y, int w, int h, u32 color) {
|
|
||||||
if (w <= 0 || h <= 0) return;
|
|
||||||
if (x < 0) {
|
|
||||||
w += x;
|
|
||||||
x = 0;
|
|
||||||
}
|
|
||||||
if (y < 0) {
|
|
||||||
h += y;
|
|
||||||
y = 0;
|
|
||||||
}
|
|
||||||
if (x + w > FB_W) w = FB_W - x;
|
|
||||||
if (y + h > FB_H) h = FB_H - y;
|
|
||||||
if (w <= 0 || h <= 0) return;
|
|
||||||
for (int row = 0; row < h; row++) {
|
|
||||||
u32 *line = fb + (y + row) * stride_px + x;
|
|
||||||
for (int col = 0; col < w; col++) line[col] = color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Normalize UTF-8 punctuation to ASCII; drop other non-ASCII bytes. */
|
|
||||||
static size_t ota_ui_sanitize_ascii(const char *src, char *dst, size_t dst_len) {
|
|
||||||
if (!dst || dst_len == 0) return 0;
|
|
||||||
dst[0] = '\0';
|
|
||||||
if (!src) return 0;
|
|
||||||
size_t w = 0;
|
|
||||||
for (size_t i = 0; src[i] && w + 1 < dst_len;) {
|
|
||||||
unsigned char c = (unsigned char)src[i];
|
|
||||||
if (c < 0x80) {
|
|
||||||
dst[w++] = (char)c;
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c == 0xE2 && src[i + 1] && src[i + 2]) {
|
|
||||||
unsigned char b2 = (unsigned char)src[i + 1];
|
|
||||||
unsigned char b3 = (unsigned char)src[i + 2];
|
|
||||||
if (b2 == 0x80 && b3 == 0xA6) {
|
|
||||||
if (w + 3 < dst_len) {
|
|
||||||
dst[w++] = '.';
|
|
||||||
dst[w++] = '.';
|
|
||||||
dst[w++] = '.';
|
|
||||||
}
|
|
||||||
i += 3;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (b2 == 0x80 && (b3 == 0x94 || b3 == 0x93)) {
|
|
||||||
dst[w++] = '-';
|
|
||||||
i += 3;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (b2 == 0x80 && (b3 == 0x98 || b3 == 0x99)) {
|
|
||||||
dst[w++] = '\'';
|
|
||||||
i += 3;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ((c & 0xE0) == 0xC0) i += 2;
|
|
||||||
else if ((c & 0xF0) == 0xE0) i += 3;
|
|
||||||
else if ((c & 0xF8) == 0xF0) i += 4;
|
|
||||||
else i++;
|
|
||||||
}
|
|
||||||
dst[w] = '\0';
|
|
||||||
return w;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void draw_glyph(u32 *fb, u32 stride_px, int x, int y, char ch, u32 color, int scale) {
|
|
||||||
unsigned char c = (unsigned char)ch;
|
|
||||||
if (c > 127) return;
|
|
||||||
const char *bits = font8x8_basic[c];
|
|
||||||
for (int row = 0; row < 8; row++) {
|
|
||||||
unsigned char line = (unsigned char)bits[row];
|
|
||||||
for (int col = 0; col < 8; col++) {
|
|
||||||
if (line & (1 << col)) {
|
|
||||||
fill_rect(fb, stride_px, x + col * scale, y + row * scale, scale, scale, color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static int text_width(const char *s, int scale) {
|
|
||||||
if (!s) return 0;
|
|
||||||
return (int)strlen(s) * 8 * scale;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void draw_text(u32 *fb, u32 stride_px, int x, int y, const char *s, u32 color, int scale) {
|
|
||||||
if (!s) return;
|
|
||||||
char buf[512];
|
|
||||||
ota_ui_sanitize_ascii(s, buf, sizeof(buf));
|
|
||||||
int cx = x;
|
|
||||||
for (const char *p = buf; *p; p++) {
|
|
||||||
if (*p == '\n') {
|
|
||||||
cx = x;
|
|
||||||
y += 8 * scale + 4;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
draw_glyph(fb, stride_px, cx, y, *p, color, scale);
|
|
||||||
cx += 8 * scale;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void draw_text_centered(u32 *fb, u32 stride_px, int y, const char *s, u32 color, int scale) {
|
|
||||||
char buf[512];
|
|
||||||
ota_ui_sanitize_ascii(s, buf, sizeof(buf));
|
|
||||||
int w = text_width(buf, scale);
|
|
||||||
draw_text(fb, stride_px, (FB_W - w) / 2, y, buf, color, scale);
|
|
||||||
}
|
|
||||||
|
|
||||||
static int draw_text_wrapped_centered(u32 *fb, u32 stride_px, int y, const char *s, u32 color,
|
|
||||||
int scale, int max_px) {
|
|
||||||
if (!s || !*s) return y;
|
|
||||||
char buf[512];
|
|
||||||
ota_ui_sanitize_ascii(s, buf, sizeof(buf));
|
|
||||||
int char_w = 8 * scale;
|
|
||||||
int max_chars = max_px / char_w;
|
|
||||||
if (max_chars < 8) max_chars = 8;
|
|
||||||
|
|
||||||
char line[128];
|
|
||||||
int line_len = 0;
|
|
||||||
const char *word = buf;
|
|
||||||
int lines = 0;
|
|
||||||
|
|
||||||
while (*word) {
|
|
||||||
while (*word == ' ') word++;
|
|
||||||
if (!*word) break;
|
|
||||||
|
|
||||||
const char *end = word;
|
|
||||||
while (*end && *end != ' ' && *end != '\n') end++;
|
|
||||||
int wlen = (int)(end - word);
|
|
||||||
|
|
||||||
if (line_len > 0 && line_len + 1 + wlen > max_chars) {
|
|
||||||
line[line_len] = '\0';
|
|
||||||
draw_text_centered(fb, stride_px, y, line, color, scale);
|
|
||||||
y += 8 * scale + 4;
|
|
||||||
lines++;
|
|
||||||
line_len = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wlen > max_chars) {
|
|
||||||
if (line_len > 0) {
|
|
||||||
line[line_len] = '\0';
|
|
||||||
draw_text_centered(fb, stride_px, y, line, color, scale);
|
|
||||||
y += 8 * scale + 4;
|
|
||||||
lines++;
|
|
||||||
line_len = 0;
|
|
||||||
}
|
|
||||||
while (wlen > 0) {
|
|
||||||
int chunk = wlen > max_chars ? max_chars : wlen;
|
|
||||||
memcpy(line, word, (size_t)chunk);
|
|
||||||
line[chunk] = '\0';
|
|
||||||
draw_text_centered(fb, stride_px, y, line, color, scale);
|
|
||||||
y += 8 * scale + 4;
|
|
||||||
lines++;
|
|
||||||
word += chunk;
|
|
||||||
wlen -= chunk;
|
|
||||||
}
|
|
||||||
word = end;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line_len > 0) line[line_len++] = ' ';
|
|
||||||
memcpy(line + line_len, word, (size_t)wlen);
|
|
||||||
line_len += wlen;
|
|
||||||
line[line_len] = '\0';
|
|
||||||
word = (*end == '\n') ? end + 1 : end;
|
|
||||||
if (*(end - 1) == '\n' || *end == '\n') {
|
|
||||||
draw_text_centered(fb, stride_px, y, line, color, scale);
|
|
||||||
y += 8 * scale + 4;
|
|
||||||
lines++;
|
|
||||||
line_len = 0;
|
|
||||||
if (*end == '\n') word = end + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line_len > 0) {
|
|
||||||
draw_text_centered(fb, stride_px, y, line, color, scale);
|
|
||||||
y += 8 * scale + 4;
|
|
||||||
lines++;
|
|
||||||
}
|
|
||||||
if (lines == 0) return y;
|
|
||||||
return y;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void draw_rail(u32 *fb, u32 stride_px) {
|
|
||||||
int h = 6;
|
|
||||||
int third = FB_W / 3;
|
|
||||||
fill_rect(fb, stride_px, 0, 0, third, h, COL_RAIL_R);
|
|
||||||
fill_rect(fb, stride_px, third, 0, third, h, COL_RAIL_B);
|
|
||||||
fill_rect(fb, stride_px, third * 2, 0, FB_W - third * 2, h, COL_RAIL_G);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void blit_logo(u32 *fb, u32 stride_px, int dst_x, int dst_y, int max_w) {
|
|
||||||
if (!g_logo || g_logo_w <= 0 || g_logo_h <= 0) return;
|
|
||||||
int dw = g_logo_w;
|
|
||||||
int dh = g_logo_h;
|
|
||||||
if (dw > max_w) {
|
|
||||||
dh = dh * max_w / dw;
|
|
||||||
dw = max_w;
|
|
||||||
}
|
|
||||||
for (int y = 0; y < dh; y++) {
|
|
||||||
int sy = y * g_logo_h / dh;
|
|
||||||
for (int x = 0; x < dw; x++) {
|
|
||||||
int sx = x * g_logo_w / dw;
|
|
||||||
u32 px = g_logo[sy * g_logo_w + sx];
|
|
||||||
u8 a = (px >> 24) & 0xff;
|
|
||||||
if (a < 16) continue;
|
|
||||||
int dx = dst_x + x;
|
|
||||||
int dy = dst_y + y;
|
|
||||||
if (dx < 0 || dy < 0 || dx >= FB_W || dy >= FB_H) continue;
|
|
||||||
fb[dy * stride_px + dx] = px | 0xff000000u;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void load_logo(void) {
|
|
||||||
if (g_logo) return;
|
|
||||||
FILE *fp = fopen(OTA_LOGO_ROMFS, "rb");
|
|
||||||
if (!fp) return;
|
|
||||||
|
|
||||||
unsigned char header[8];
|
|
||||||
if (fread(header, 1, sizeof(header), fp) != sizeof(header)) {
|
|
||||||
fclose(fp);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int w = (int)(header[0] | (header[1] << 8) | (header[2] << 16) | (header[3] << 24));
|
|
||||||
int h = (int)(header[4] | (header[5] << 8) | (header[6] << 16) | (header[7] << 24));
|
|
||||||
if (w <= 0 || h <= 0 || w > 2048 || h > 2048) {
|
|
||||||
fclose(fp);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
size_t nbytes = (size_t)w * (size_t)h * 4u;
|
|
||||||
unsigned char *data = (unsigned char *)malloc(nbytes);
|
|
||||||
if (!data) {
|
|
||||||
fclose(fp);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (fread(data, 1, nbytes, fp) != nbytes) {
|
|
||||||
free(data);
|
|
||||||
fclose(fp);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fclose(fp);
|
|
||||||
|
|
||||||
g_logo = (u32 *)malloc((size_t)w * (size_t)h * sizeof(u32));
|
|
||||||
if (!g_logo) {
|
|
||||||
free(data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (int i = 0; i < w * h; i++) {
|
|
||||||
unsigned char *p = data + (size_t)i * 4u;
|
|
||||||
g_logo[i] = RGBA8(p[0], p[1], p[2], p[3]);
|
|
||||||
}
|
|
||||||
free(data);
|
|
||||||
g_logo_w = w;
|
|
||||||
g_logo_h = h;
|
|
||||||
}
|
|
||||||
|
|
||||||
static int ui_ensure(void) {
|
|
||||||
if (g_ready) return 1;
|
|
||||||
NWindow *win = nwindowGetDefault();
|
|
||||||
if (R_FAILED(framebufferCreate(&g_fb, win, FB_W, FB_H, PIXEL_FORMAT_RGBA_8888, 2))) return 0;
|
|
||||||
framebufferMakeLinear(&g_fb);
|
|
||||||
padInitializeDefault(&g_pad);
|
|
||||||
load_logo();
|
|
||||||
g_ready = 1;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
void ota_ui_shutdown(void) {
|
|
||||||
if (!g_ready) return;
|
|
||||||
framebufferClose(&g_fb);
|
|
||||||
free(g_logo);
|
|
||||||
g_logo = NULL;
|
|
||||||
g_logo_w = g_logo_h = 0;
|
|
||||||
g_ready = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void draw_button(u32 *fb, u32 stride_px, int x, int y, int w, int h, u32 fill, u32 ink,
|
|
||||||
const char *label, int focused) {
|
|
||||||
fill_rect(fb, stride_px, x, y, w, h, fill);
|
|
||||||
if (focused) {
|
|
||||||
fill_rect(fb, stride_px, x - 3, y - 3, w + 6, 2, COL_INK);
|
|
||||||
fill_rect(fb, stride_px, x - 3, y + h + 1, w + 6, 2, COL_INK);
|
|
||||||
fill_rect(fb, stride_px, x - 3, y - 3, 2, h + 6, COL_INK);
|
|
||||||
fill_rect(fb, stride_px, x + w + 1, y - 3, 2, h + 6, COL_INK);
|
|
||||||
} else {
|
|
||||||
fill_rect(fb, stride_px, x, y, w, 1, COL_LINE);
|
|
||||||
fill_rect(fb, stride_px, x, y + h - 1, w, 1, COL_LINE);
|
|
||||||
fill_rect(fb, stride_px, x, y, 1, h, COL_LINE);
|
|
||||||
fill_rect(fb, stride_px, x + w - 1, y, 1, h, COL_LINE);
|
|
||||||
}
|
|
||||||
int scale = 3;
|
|
||||||
char lbl[128];
|
|
||||||
ota_ui_sanitize_ascii(label, lbl, sizeof(lbl));
|
|
||||||
int max_tw = w - 24;
|
|
||||||
int tw = text_width(lbl, scale);
|
|
||||||
while (scale > 2 && tw > max_tw) {
|
|
||||||
scale--;
|
|
||||||
tw = text_width(lbl, scale);
|
|
||||||
}
|
|
||||||
int tx = x + (w - tw) / 2;
|
|
||||||
int ty = y + (h - 8 * scale) / 2;
|
|
||||||
draw_text(fb, stride_px, tx, ty, lbl, ink, scale);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void draw_chrome(u32 *fb, u32 stride_px) {
|
|
||||||
fill_rect(fb, stride_px, 0, 0, FB_W, FB_H, COL_BG);
|
|
||||||
draw_rail(fb, stride_px);
|
|
||||||
int logo_max = 320;
|
|
||||||
int logo_h = g_logo ? (g_logo_h * logo_max / g_logo_w) : 40;
|
|
||||||
if (logo_h > 90) logo_h = 90;
|
|
||||||
int logo_y = 48;
|
|
||||||
if (g_logo) {
|
|
||||||
int logo_w = logo_max;
|
|
||||||
if (g_logo_w * logo_h / g_logo_h < logo_max) logo_w = g_logo_w * logo_h / g_logo_h;
|
|
||||||
blit_logo(fb, stride_px, (FB_W - logo_w) / 2, logo_y, logo_max);
|
|
||||||
} else {
|
|
||||||
draw_text_centered(fb, stride_px, logo_y + 16, "gen1recomp", COL_INK, 4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void present(void (*paint)(u32 *fb, u32 stride_px, void *ctx), void *ctx) {
|
|
||||||
if (!ui_ensure()) return;
|
|
||||||
u32 stride = 0;
|
|
||||||
u32 *fb = (u32 *)framebufferBegin(&g_fb, &stride);
|
|
||||||
u32 stride_px = stride / sizeof(u32);
|
|
||||||
paint(fb, stride_px, ctx);
|
|
||||||
framebufferEnd(&g_fb);
|
|
||||||
}
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
const char *installed;
|
|
||||||
const char *latest;
|
|
||||||
int focus; /* 0 = Update, 1 = Play */
|
|
||||||
} prompt_ctx_t;
|
|
||||||
|
|
||||||
static void paint_prompt(u32 *fb, u32 stride_px, void *ctx) {
|
|
||||||
prompt_ctx_t *p = (prompt_ctx_t *)ctx;
|
|
||||||
draw_chrome(fb, stride_px);
|
|
||||||
|
|
||||||
int y = TITLE_Y - 20;
|
|
||||||
draw_text_centered(fb, stride_px, y, "Update available", COL_INK, 3);
|
|
||||||
y += LINE_TITLE;
|
|
||||||
|
|
||||||
char line[96];
|
|
||||||
snprintf(line, sizeof(line), "v%s to v%s", p->installed ? p->installed : "?",
|
|
||||||
p->latest ? p->latest : "?");
|
|
||||||
draw_text_centered(fb, stride_px, y, line, COL_YELLOW, 3);
|
|
||||||
y += LINE_TITLE + 8;
|
|
||||||
|
|
||||||
draw_text_centered(fb, stride_px, y, "Your saves stay on this console.", COL_MUTED, 2);
|
|
||||||
y += LINE_BODY + 28;
|
|
||||||
|
|
||||||
int bw = 600;
|
|
||||||
int bh = 64;
|
|
||||||
int bx = (FB_W - bw) / 2;
|
|
||||||
draw_button(fb, stride_px, bx, y, bw, bh, COL_YELLOW, COL_INVERSE, "(A) Update", p->focus == 0);
|
|
||||||
y += bh + 24;
|
|
||||||
draw_button(fb, stride_px, bx, y, bw, bh, COL_INK, COL_INVERSE, "(B) Play without update",
|
|
||||||
p->focus == 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
int ota_ui_prompt_update(const char *installed, const char *latest) {
|
|
||||||
if (!ui_ensure()) return 0;
|
|
||||||
prompt_ctx_t ctx = {installed, latest, 0};
|
|
||||||
while (appletMainLoop()) {
|
|
||||||
present(paint_prompt, &ctx);
|
|
||||||
padUpdate(&g_pad);
|
|
||||||
u64 k = padGetButtonsDown(&g_pad);
|
|
||||||
if (k & HidNpadButton_A) {
|
|
||||||
int do_update = (ctx.focus == 0);
|
|
||||||
if (!do_update) ota_ui_shutdown();
|
|
||||||
return do_update;
|
|
||||||
}
|
|
||||||
if (k & HidNpadButton_B) {
|
|
||||||
ota_ui_shutdown();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (k & (HidNpadButton_Up | HidNpadButton_Down | HidNpadButton_Left | HidNpadButton_Right)) {
|
|
||||||
ctx.focus = 1 - ctx.focus;
|
|
||||||
}
|
|
||||||
svcSleepThread(16000000ULL);
|
|
||||||
}
|
|
||||||
ota_ui_shutdown();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
const char *title;
|
|
||||||
const char *detail;
|
|
||||||
float progress;
|
|
||||||
int show_bar;
|
|
||||||
} status_ctx_t;
|
|
||||||
|
|
||||||
static void paint_progress_bar(u32 *fb, u32 stride_px, int y, float t) {
|
|
||||||
int bw = 520;
|
|
||||||
int bh = 18;
|
|
||||||
int bx = (FB_W - bw) / 2;
|
|
||||||
if (t < 0.f) t = 0.f;
|
|
||||||
if (t > 1.f) t = 1.f;
|
|
||||||
fill_rect(fb, stride_px, bx, y, bw, bh, COL_RAISED);
|
|
||||||
fill_rect(fb, stride_px, bx, y, bw, 1, COL_LINE);
|
|
||||||
fill_rect(fb, stride_px, bx, y + bh - 1, bw, 1, COL_LINE);
|
|
||||||
fill_rect(fb, stride_px, bx, y, 1, bh, COL_LINE);
|
|
||||||
fill_rect(fb, stride_px, bx + bw - 1, y, 1, bh, COL_LINE);
|
|
||||||
int fw = (int)(bw * t);
|
|
||||||
if (fw > 0) fill_rect(fb, stride_px, bx, y, fw, bh, COL_GREEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void paint_status(u32 *fb, u32 stride_px, void *ctx) {
|
|
||||||
status_ctx_t *s = (status_ctx_t *)ctx;
|
|
||||||
draw_chrome(fb, stride_px);
|
|
||||||
int y = TITLE_Y;
|
|
||||||
draw_text_centered(fb, stride_px, y, s->title ? s->title : "", COL_INK, 3);
|
|
||||||
y += LINE_TITLE;
|
|
||||||
if (s->detail && s->detail[0]) {
|
|
||||||
y = draw_text_wrapped_centered(fb, stride_px, y, s->detail, COL_DETAIL, 2, WRAP_MAX_PX);
|
|
||||||
y += LINE_SMALL;
|
|
||||||
}
|
|
||||||
if (s->show_bar) paint_progress_bar(fb, stride_px, y, s->progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
void ota_ui_show_status(const char *title, const char *detail) {
|
|
||||||
status_ctx_t s = {title, detail, 0.f, 0};
|
|
||||||
present(paint_status, &s);
|
|
||||||
}
|
|
||||||
|
|
||||||
void ota_ui_show_progress(const char *title, const char *detail, float progress01) {
|
|
||||||
status_ctx_t s = {title, detail, progress01, 1};
|
|
||||||
present(paint_status, &s);
|
|
||||||
}
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
const char *title;
|
|
||||||
const char *line1;
|
|
||||||
const char *line2;
|
|
||||||
const char *line3;
|
|
||||||
} alert_ctx_t;
|
|
||||||
|
|
||||||
static void paint_alert(u32 *fb, u32 stride_px, void *ctx) {
|
|
||||||
alert_ctx_t *a = (alert_ctx_t *)ctx;
|
|
||||||
draw_chrome(fb, stride_px);
|
|
||||||
int y = TITLE_Y;
|
|
||||||
draw_text_centered(fb, stride_px, y, a->title ? a->title : "", COL_YELLOW, 3);
|
|
||||||
y += LINE_TITLE;
|
|
||||||
if (a->line1 && a->line1[0])
|
|
||||||
y = draw_text_wrapped_centered(fb, stride_px, y, a->line1, COL_DETAIL, 2, WRAP_MAX_PX);
|
|
||||||
y += LINE_SMALL;
|
|
||||||
if (a->line2 && a->line2[0])
|
|
||||||
y = draw_text_wrapped_centered(fb, stride_px, y, a->line2, COL_MUTED, 2, WRAP_MAX_PX);
|
|
||||||
y += LINE_SMALL;
|
|
||||||
if (a->line3 && a->line3[0])
|
|
||||||
y = draw_text_wrapped_centered(fb, stride_px, y, a->line3, COL_MUTED, 2, WRAP_MAX_PX);
|
|
||||||
y += LINE_BODY + 16;
|
|
||||||
int bw = 360;
|
|
||||||
int bh = 56;
|
|
||||||
draw_button(fb, stride_px, (FB_W - bw) / 2, y, bw, bh, COL_INK, COL_INVERSE, "(B) Continue", 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
void ota_ui_alert(const char *title, const char *line1, const char *line2) {
|
|
||||||
if (!ui_ensure()) return;
|
|
||||||
alert_ctx_t a = {title, line1, line2, NULL};
|
|
||||||
while (appletMainLoop()) {
|
|
||||||
present(paint_alert, &a);
|
|
||||||
padUpdate(&g_pad);
|
|
||||||
if (padGetButtonsDown(&g_pad) & HidNpadButton_B) break;
|
|
||||||
svcSleepThread(16000000ULL);
|
|
||||||
}
|
|
||||||
ota_ui_shutdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
void ota_ui_alert_error(const char *title, const char *friendly, const char *technical,
|
|
||||||
const char *installed_version) {
|
|
||||||
if (!ui_ensure()) return;
|
|
||||||
char footer[96];
|
|
||||||
snprintf(footer, sizeof(footer), "Playing installed version (v%s).",
|
|
||||||
installed_version && installed_version[0] ? installed_version : "?");
|
|
||||||
char tech[256];
|
|
||||||
if (technical && technical[0]) {
|
|
||||||
ota_ui_sanitize_ascii(technical, tech, sizeof(tech));
|
|
||||||
if (strlen(tech) > 80) tech[80] = '\0';
|
|
||||||
} else {
|
|
||||||
tech[0] = '\0';
|
|
||||||
}
|
|
||||||
alert_ctx_t a = {title, friendly, tech[0] ? tech : NULL, footer};
|
|
||||||
while (appletMainLoop()) {
|
|
||||||
present(paint_alert, &a);
|
|
||||||
padUpdate(&g_pad);
|
|
||||||
if (padGetButtonsDown(&g_pad) & HidNpadButton_B) break;
|
|
||||||
svcSleepThread(16000000ULL);
|
|
||||||
}
|
|
||||||
ota_ui_shutdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void paint_missing(u32 *fb, u32 stride_px, void *ctx) {
|
|
||||||
(void)ctx;
|
|
||||||
draw_chrome(fb, stride_px);
|
|
||||||
int y = TITLE_Y - 10;
|
|
||||||
draw_text_centered(fb, stride_px, y, "Game files missing", COL_YELLOW, 3);
|
|
||||||
y += LINE_TITLE;
|
|
||||||
y = draw_text_wrapped_centered(fb, stride_px, y,
|
|
||||||
"Copy the Switch zip onto your microSD, then open gen1recomp again.",
|
|
||||||
COL_DETAIL, 2, WRAP_MAX_PX);
|
|
||||||
y += LINE_BODY + 16;
|
|
||||||
int bw = 320;
|
|
||||||
int bh = 56;
|
|
||||||
draw_button(fb, stride_px, (FB_W - bw) / 2, y, bw, bh, COL_INK, COL_INVERSE, "(+) Exit", 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
void ota_ui_missing_game(void) {
|
|
||||||
if (!ui_ensure()) return;
|
|
||||||
while (appletMainLoop()) {
|
|
||||||
present(paint_missing, NULL);
|
|
||||||
padUpdate(&g_pad);
|
|
||||||
if (padGetButtonsDown(&g_pad) & HidNpadButton_Plus) break;
|
|
||||||
svcSleepThread(16000000ULL);
|
|
||||||
}
|
|
||||||
ota_ui_shutdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
#else /* host stub */
|
|
||||||
|
|
||||||
int ota_ui_prompt_update(const char *installed, const char *latest) {
|
|
||||||
fprintf(stderr, "[ota_ui] update %s -> %s (host stub: skip)\n", installed ? installed : "?",
|
|
||||||
latest ? latest : "?");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
void ota_ui_show_status(const char *title, const char *detail) {
|
|
||||||
fprintf(stderr, "[ota_ui] %s %s\n", title ? title : "", detail ? detail : "");
|
|
||||||
}
|
|
||||||
void ota_ui_show_progress(const char *title, const char *detail, float progress01) {
|
|
||||||
fprintf(stderr, "[ota_ui] %s %s (%.0f%%)\n", title ? title : "", detail ? detail : "",
|
|
||||||
progress01 * 100.f);
|
|
||||||
}
|
|
||||||
void ota_ui_alert(const char *title, const char *line1, const char *line2) {
|
|
||||||
fprintf(stderr, "[ota_ui] alert: %s / %s / %s\n", title ? title : "", line1 ? line1 : "",
|
|
||||||
line2 ? line2 : "");
|
|
||||||
}
|
|
||||||
void ota_ui_alert_error(const char *title, const char *friendly, const char *technical,
|
|
||||||
const char *installed_version) {
|
|
||||||
fprintf(stderr, "[ota_ui] error: %s / %s / %s / v%s\n", title ? title : "",
|
|
||||||
friendly ? friendly : "", technical ? technical : "",
|
|
||||||
installed_version ? installed_version : "?");
|
|
||||||
}
|
|
||||||
void ota_ui_missing_game(void) { fprintf(stderr, "[ota_ui] missing game\n"); }
|
|
||||||
void ota_ui_shutdown(void) {}
|
|
||||||
|
|
||||||
#endif
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
#include "ota_unzip.h"
|
|
||||||
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
#if defined(__SWITCH__)
|
|
||||||
#include <fcntl.h>
|
|
||||||
#include <zzip/zzip.h>
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(__SWITCH__)
|
|
||||||
static int extract_member(ZZIP_DIR *dir, const char *member, const char *dest_path, char *err,
|
|
||||||
size_t err_len) {
|
|
||||||
ZZIP_FILE *zf = zzip_file_open(dir, member, O_RDONLY);
|
|
||||||
if (!zf) return 1; /* not found / cannot open */
|
|
||||||
|
|
||||||
FILE *out = fopen(dest_path, "wb");
|
|
||||||
if (!out) {
|
|
||||||
zzip_file_close(zf);
|
|
||||||
if (err && err_len) snprintf(err, err_len, "fopen dest failed: %s", dest_path);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
char buf[8192];
|
|
||||||
zzip_ssize_t n;
|
|
||||||
while ((n = zzip_file_read(zf, buf, sizeof(buf))) > 0) {
|
|
||||||
if ((zzip_ssize_t)fwrite(buf, 1, (size_t)n, out) != n) {
|
|
||||||
fclose(out);
|
|
||||||
zzip_file_close(zf);
|
|
||||||
if (err && err_len) snprintf(err, err_len, "fwrite failed");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fclose(out);
|
|
||||||
zzip_file_close(zf);
|
|
||||||
if (n < 0) {
|
|
||||||
if (err && err_len) snprintf(err, err_len, "zzip_file_read failed");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
int ota_unzip_extract_file(const char *zip_path, const char *member_name, const char *dest_path,
|
|
||||||
char *err, size_t err_len) {
|
|
||||||
if (!zip_path || !member_name || !dest_path) {
|
|
||||||
if (err && err_len) snprintf(err, err_len, "bad args");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
#if defined(__SWITCH__)
|
|
||||||
zzip_error_t zerr = ZZIP_NO_ERROR;
|
|
||||||
ZZIP_DIR *dir = zzip_dir_open(zip_path, &zerr);
|
|
||||||
if (!dir) {
|
|
||||||
if (err && err_len) snprintf(err, err_len, "zzip_dir_open failed (%d): %s", (int)zerr, zip_path);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
char alt1[192];
|
|
||||||
char alt2[192];
|
|
||||||
snprintf(alt1, sizeof(alt1), "switch/gen1recomp/%s", member_name);
|
|
||||||
snprintf(alt2, sizeof(alt2), "./%s", member_name);
|
|
||||||
|
|
||||||
const char *candidates[] = {member_name, alt1, alt2, NULL};
|
|
||||||
int rc = -1;
|
|
||||||
if (err && err_len) err[0] = '\0';
|
|
||||||
for (int i = 0; candidates[i]; i++) {
|
|
||||||
int t = extract_member(dir, candidates[i], dest_path, err, err_len);
|
|
||||||
if (t == 0) {
|
|
||||||
rc = 0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (t < 0) {
|
|
||||||
rc = -1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (rc != 0 && err && err_len && !err[0]) {
|
|
||||||
snprintf(err, err_len, "member not found in zip: %s", member_name);
|
|
||||||
}
|
|
||||||
zzip_dir_close(dir);
|
|
||||||
return rc;
|
|
||||||
#else
|
|
||||||
(void)zip_path;
|
|
||||||
(void)member_name;
|
|
||||||
(void)dest_path;
|
|
||||||
if (err && err_len) snprintf(err, err_len, "ota_unzip only available on __SWITCH__");
|
|
||||||
return -1;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
/**
|
|
||||||
* 8x8 monochrome bitmap fonts for rendering
|
|
||||||
* Author: Daniel Hepper <daniel@hepper.net>
|
|
||||||
*
|
|
||||||
* License: Public Domain
|
|
||||||
*
|
|
||||||
* Based on:
|
|
||||||
* // Summary: font8x8.h
|
|
||||||
* // 8x8 monochrome bitmap fonts for rendering
|
|
||||||
* //
|
|
||||||
* // Author:
|
|
||||||
* // Marcel Sondaar
|
|
||||||
* // International Business Machines (public domain VGA fonts)
|
|
||||||
* //
|
|
||||||
* // License:
|
|
||||||
* // Public Domain
|
|
||||||
*
|
|
||||||
* Fetched from: http://dimensionalrift.homelinux.net/combuster/mos3/?p=viewsource&file=/modules/gfx/font8_8.asm
|
|
||||||
**/
|
|
||||||
|
|
||||||
// Constant: font8x8_basic
|
|
||||||
// Contains an 8x8 font map for unicode points U+0000 - U+007F (basic latin)
|
|
||||||
char font8x8_basic[128][8] = {
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0000 (nul)
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0001
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0002
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0003
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0004
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0005
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0006
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0007
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0008
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0009
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000A
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000B
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000C
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000D
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000E
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+000F
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0010
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0011
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0012
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0013
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0014
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0015
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0016
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0017
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0018
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0019
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001A
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001B
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001C
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001D
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001E
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+001F
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0020 (space)
|
|
||||||
{ 0x18, 0x3C, 0x3C, 0x18, 0x18, 0x00, 0x18, 0x00}, // U+0021 (!)
|
|
||||||
{ 0x36, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0022 (")
|
|
||||||
{ 0x36, 0x36, 0x7F, 0x36, 0x7F, 0x36, 0x36, 0x00}, // U+0023 (#)
|
|
||||||
{ 0x0C, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x0C, 0x00}, // U+0024 ($)
|
|
||||||
{ 0x00, 0x63, 0x33, 0x18, 0x0C, 0x66, 0x63, 0x00}, // U+0025 (%)
|
|
||||||
{ 0x1C, 0x36, 0x1C, 0x6E, 0x3B, 0x33, 0x6E, 0x00}, // U+0026 (&)
|
|
||||||
{ 0x06, 0x06, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0027 (')
|
|
||||||
{ 0x18, 0x0C, 0x06, 0x06, 0x06, 0x0C, 0x18, 0x00}, // U+0028 (()
|
|
||||||
{ 0x06, 0x0C, 0x18, 0x18, 0x18, 0x0C, 0x06, 0x00}, // U+0029 ())
|
|
||||||
{ 0x00, 0x66, 0x3C, 0xFF, 0x3C, 0x66, 0x00, 0x00}, // U+002A (*)
|
|
||||||
{ 0x00, 0x0C, 0x0C, 0x3F, 0x0C, 0x0C, 0x00, 0x00}, // U+002B (+)
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x06}, // U+002C (,)
|
|
||||||
{ 0x00, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x00, 0x00}, // U+002D (-)
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x00}, // U+002E (.)
|
|
||||||
{ 0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, 0x00}, // U+002F (/)
|
|
||||||
{ 0x3E, 0x63, 0x73, 0x7B, 0x6F, 0x67, 0x3E, 0x00}, // U+0030 (0)
|
|
||||||
{ 0x0C, 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x3F, 0x00}, // U+0031 (1)
|
|
||||||
{ 0x1E, 0x33, 0x30, 0x1C, 0x06, 0x33, 0x3F, 0x00}, // U+0032 (2)
|
|
||||||
{ 0x1E, 0x33, 0x30, 0x1C, 0x30, 0x33, 0x1E, 0x00}, // U+0033 (3)
|
|
||||||
{ 0x38, 0x3C, 0x36, 0x33, 0x7F, 0x30, 0x78, 0x00}, // U+0034 (4)
|
|
||||||
{ 0x3F, 0x03, 0x1F, 0x30, 0x30, 0x33, 0x1E, 0x00}, // U+0035 (5)
|
|
||||||
{ 0x1C, 0x06, 0x03, 0x1F, 0x33, 0x33, 0x1E, 0x00}, // U+0036 (6)
|
|
||||||
{ 0x3F, 0x33, 0x30, 0x18, 0x0C, 0x0C, 0x0C, 0x00}, // U+0037 (7)
|
|
||||||
{ 0x1E, 0x33, 0x33, 0x1E, 0x33, 0x33, 0x1E, 0x00}, // U+0038 (8)
|
|
||||||
{ 0x1E, 0x33, 0x33, 0x3E, 0x30, 0x18, 0x0E, 0x00}, // U+0039 (9)
|
|
||||||
{ 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x00}, // U+003A (:)
|
|
||||||
{ 0x00, 0x0C, 0x0C, 0x00, 0x00, 0x0C, 0x0C, 0x06}, // U+003B (;)
|
|
||||||
{ 0x18, 0x0C, 0x06, 0x03, 0x06, 0x0C, 0x18, 0x00}, // U+003C (<)
|
|
||||||
{ 0x00, 0x00, 0x3F, 0x00, 0x00, 0x3F, 0x00, 0x00}, // U+003D (=)
|
|
||||||
{ 0x06, 0x0C, 0x18, 0x30, 0x18, 0x0C, 0x06, 0x00}, // U+003E (>)
|
|
||||||
{ 0x1E, 0x33, 0x30, 0x18, 0x0C, 0x00, 0x0C, 0x00}, // U+003F (?)
|
|
||||||
{ 0x3E, 0x63, 0x7B, 0x7B, 0x7B, 0x03, 0x1E, 0x00}, // U+0040 (@)
|
|
||||||
{ 0x0C, 0x1E, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x00}, // U+0041 (A)
|
|
||||||
{ 0x3F, 0x66, 0x66, 0x3E, 0x66, 0x66, 0x3F, 0x00}, // U+0042 (B)
|
|
||||||
{ 0x3C, 0x66, 0x03, 0x03, 0x03, 0x66, 0x3C, 0x00}, // U+0043 (C)
|
|
||||||
{ 0x1F, 0x36, 0x66, 0x66, 0x66, 0x36, 0x1F, 0x00}, // U+0044 (D)
|
|
||||||
{ 0x7F, 0x46, 0x16, 0x1E, 0x16, 0x46, 0x7F, 0x00}, // U+0045 (E)
|
|
||||||
{ 0x7F, 0x46, 0x16, 0x1E, 0x16, 0x06, 0x0F, 0x00}, // U+0046 (F)
|
|
||||||
{ 0x3C, 0x66, 0x03, 0x03, 0x73, 0x66, 0x7C, 0x00}, // U+0047 (G)
|
|
||||||
{ 0x33, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x33, 0x00}, // U+0048 (H)
|
|
||||||
{ 0x1E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+0049 (I)
|
|
||||||
{ 0x78, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E, 0x00}, // U+004A (J)
|
|
||||||
{ 0x67, 0x66, 0x36, 0x1E, 0x36, 0x66, 0x67, 0x00}, // U+004B (K)
|
|
||||||
{ 0x0F, 0x06, 0x06, 0x06, 0x46, 0x66, 0x7F, 0x00}, // U+004C (L)
|
|
||||||
{ 0x63, 0x77, 0x7F, 0x7F, 0x6B, 0x63, 0x63, 0x00}, // U+004D (M)
|
|
||||||
{ 0x63, 0x67, 0x6F, 0x7B, 0x73, 0x63, 0x63, 0x00}, // U+004E (N)
|
|
||||||
{ 0x1C, 0x36, 0x63, 0x63, 0x63, 0x36, 0x1C, 0x00}, // U+004F (O)
|
|
||||||
{ 0x3F, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x0F, 0x00}, // U+0050 (P)
|
|
||||||
{ 0x1E, 0x33, 0x33, 0x33, 0x3B, 0x1E, 0x38, 0x00}, // U+0051 (Q)
|
|
||||||
{ 0x3F, 0x66, 0x66, 0x3E, 0x36, 0x66, 0x67, 0x00}, // U+0052 (R)
|
|
||||||
{ 0x1E, 0x33, 0x07, 0x0E, 0x38, 0x33, 0x1E, 0x00}, // U+0053 (S)
|
|
||||||
{ 0x3F, 0x2D, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+0054 (T)
|
|
||||||
{ 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0x00}, // U+0055 (U)
|
|
||||||
{ 0x33, 0x33, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00}, // U+0056 (V)
|
|
||||||
{ 0x63, 0x63, 0x63, 0x6B, 0x7F, 0x77, 0x63, 0x00}, // U+0057 (W)
|
|
||||||
{ 0x63, 0x63, 0x36, 0x1C, 0x1C, 0x36, 0x63, 0x00}, // U+0058 (X)
|
|
||||||
{ 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x0C, 0x1E, 0x00}, // U+0059 (Y)
|
|
||||||
{ 0x7F, 0x63, 0x31, 0x18, 0x4C, 0x66, 0x7F, 0x00}, // U+005A (Z)
|
|
||||||
{ 0x1E, 0x06, 0x06, 0x06, 0x06, 0x06, 0x1E, 0x00}, // U+005B ([)
|
|
||||||
{ 0x03, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x40, 0x00}, // U+005C (\)
|
|
||||||
{ 0x1E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x1E, 0x00}, // U+005D (])
|
|
||||||
{ 0x08, 0x1C, 0x36, 0x63, 0x00, 0x00, 0x00, 0x00}, // U+005E (^)
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF}, // U+005F (_)
|
|
||||||
{ 0x0C, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+0060 (`)
|
|
||||||
{ 0x00, 0x00, 0x1E, 0x30, 0x3E, 0x33, 0x6E, 0x00}, // U+0061 (a)
|
|
||||||
{ 0x07, 0x06, 0x06, 0x3E, 0x66, 0x66, 0x3B, 0x00}, // U+0062 (b)
|
|
||||||
{ 0x00, 0x00, 0x1E, 0x33, 0x03, 0x33, 0x1E, 0x00}, // U+0063 (c)
|
|
||||||
{ 0x38, 0x30, 0x30, 0x3e, 0x33, 0x33, 0x6E, 0x00}, // U+0064 (d)
|
|
||||||
{ 0x00, 0x00, 0x1E, 0x33, 0x3f, 0x03, 0x1E, 0x00}, // U+0065 (e)
|
|
||||||
{ 0x1C, 0x36, 0x06, 0x0f, 0x06, 0x06, 0x0F, 0x00}, // U+0066 (f)
|
|
||||||
{ 0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x1F}, // U+0067 (g)
|
|
||||||
{ 0x07, 0x06, 0x36, 0x6E, 0x66, 0x66, 0x67, 0x00}, // U+0068 (h)
|
|
||||||
{ 0x0C, 0x00, 0x0E, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+0069 (i)
|
|
||||||
{ 0x30, 0x00, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1E}, // U+006A (j)
|
|
||||||
{ 0x07, 0x06, 0x66, 0x36, 0x1E, 0x36, 0x67, 0x00}, // U+006B (k)
|
|
||||||
{ 0x0E, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x1E, 0x00}, // U+006C (l)
|
|
||||||
{ 0x00, 0x00, 0x33, 0x7F, 0x7F, 0x6B, 0x63, 0x00}, // U+006D (m)
|
|
||||||
{ 0x00, 0x00, 0x1F, 0x33, 0x33, 0x33, 0x33, 0x00}, // U+006E (n)
|
|
||||||
{ 0x00, 0x00, 0x1E, 0x33, 0x33, 0x33, 0x1E, 0x00}, // U+006F (o)
|
|
||||||
{ 0x00, 0x00, 0x3B, 0x66, 0x66, 0x3E, 0x06, 0x0F}, // U+0070 (p)
|
|
||||||
{ 0x00, 0x00, 0x6E, 0x33, 0x33, 0x3E, 0x30, 0x78}, // U+0071 (q)
|
|
||||||
{ 0x00, 0x00, 0x3B, 0x6E, 0x66, 0x06, 0x0F, 0x00}, // U+0072 (r)
|
|
||||||
{ 0x00, 0x00, 0x3E, 0x03, 0x1E, 0x30, 0x1F, 0x00}, // U+0073 (s)
|
|
||||||
{ 0x08, 0x0C, 0x3E, 0x0C, 0x0C, 0x2C, 0x18, 0x00}, // U+0074 (t)
|
|
||||||
{ 0x00, 0x00, 0x33, 0x33, 0x33, 0x33, 0x6E, 0x00}, // U+0075 (u)
|
|
||||||
{ 0x00, 0x00, 0x33, 0x33, 0x33, 0x1E, 0x0C, 0x00}, // U+0076 (v)
|
|
||||||
{ 0x00, 0x00, 0x63, 0x6B, 0x7F, 0x7F, 0x36, 0x00}, // U+0077 (w)
|
|
||||||
{ 0x00, 0x00, 0x63, 0x36, 0x1C, 0x36, 0x63, 0x00}, // U+0078 (x)
|
|
||||||
{ 0x00, 0x00, 0x33, 0x33, 0x33, 0x3E, 0x30, 0x1F}, // U+0079 (y)
|
|
||||||
{ 0x00, 0x00, 0x3F, 0x19, 0x0C, 0x26, 0x3F, 0x00}, // U+007A (z)
|
|
||||||
{ 0x38, 0x0C, 0x0C, 0x07, 0x0C, 0x0C, 0x38, 0x00}, // U+007B ({)
|
|
||||||
{ 0x18, 0x18, 0x18, 0x00, 0x18, 0x18, 0x18, 0x00}, // U+007C (|)
|
|
||||||
{ 0x07, 0x0C, 0x0C, 0x38, 0x0C, 0x0C, 0x07, 0x00}, // U+007D (})
|
|
||||||
{ 0x6E, 0x3B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // U+007E (~)
|
|
||||||
{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} // U+007F
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user