Compare commits

..

200 Commits

Author SHA1 Message Date
bryanthaboi d18824cfae bang bang 2026-08-04 16:37:21 -04:00
bryanthaboi 12b4c93279 Merge branch 'dev' into performance-enhanced 2026-08-04 15:21:23 -04:00
bryanthaboi af47e19e1a Rebuild the launcher and save editor on a small immediate-mode UI kit
The launcher spent ~9ms per frame building and drawing, and the Find Mods
tab could hang the window for minutes. Both had the same root cause: a
retained UI tree rebuilt every frame, and blocking curl calls made from the
draw path.

Replace the vendored FlexLove engine (28.5k lines) with src/ui/kit/ (Kit,
Theme, Layout, Loader). The kit caches Text objects and all measurement,
allocates nothing in the steady state, and draws flat. Build+draw is now
under 1ms at every window size and on every tab (POKEPORT_LAUNCHER_PROF).

Move every network call off the render thread onto a love.thread pool
(src/net/Fetch.lua): mod index fetches, per-mod release checks, find-tab
stats, thumbnails and mod installs. Mod indexes prewarm at boot so the
Find Mods tab is populated before it is opened.

Paginate every list -- mods, find, save slots, settings, release notes,
versions -- with the page size derived from the real viewport height, so a
500-mod index costs what a 10-mod one does. Scrolling is gone.

Anything that waits now raises a non-dismissable loader; per-row background
work shows an inline spinner instead. The in-app updater moves to the top
right beside the settings gear and pulses when an update is waiting.

Theme is black with white outlines, no gradients or glows, and solid
colour-coded embossed buttons with bold labels. The game tabs keep their
cartridge colours. Everything is 1.3x larger. The save editor shares the
theme, and adding an item there is now a searchable pop-up like adding a
Pokemon.

Also:
- Reset rebinds, in Settings and under Touch Controls. Rebinds are additive
  (Input:applyBindings layers them over the defaults), so there was no
  in-game way to undo one.
- Launch options: --game red [--slot N] / POKEPORT_GAME boots straight into
  a game for shortcuts and frontends, falling back to that game's tab when
  its ROM is not imported.

Fixes found while porting:
- Ellipsis and letterspacing truncated bytes, not codepoints, so a
  multi-byte mod name crashed the first frame on a Japanese index.
  Measurement no longer throws on malformed input either.
- The new font set missed UiFont's kana fallback, rendering translated
  builds as tofu.
- Fetch workers idle in Channel:demand() and LOVE waits for live threads at
  exit, so the process outlived the window; quitting mid-download also
  waited on curl's 300s ceiling. Shut the pool down in love.quit and bound
  its transfer timeouts.
- In one column the save-slot card drew below the fold, over the footer,
  with no scrollbar left to reach it.

The two FlexLove engine tests guarded a scroll manager and an auto-height
propagation bug that no longer exist; replace them with a kit suite covering
page bounds, viewport sizing and UTF-8 truncation, and retarget the NX test
to assert the dependency is gone rather than that its perf guards are set.
2026-08-04 15:17:09 -04:00
bryanthaboi 4e666303d0 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-04 15:13:07 -04:00
bryanthaboi 0fa8206321 CLOSES #785, CLOSES #807, CLOSES #811, CLOSES #814 2026-08-04 15:13:06 -04:00
bryanthaboi 719ba49a85 Merge pull request #816 from johnjohto/fix-hm-menu-position-792 2026-08-04 13:18:38 -04:00
bryanthaboi f864837cbe Merge pull request #815 from johnjohto/fix-fly-menu-788-795 2026-08-04 13:18:25 -04:00
bryanthaboi 21191b8e80 Merge pull request #813 from johnjohto/fix-leech-seed-784 2026-08-04 13:18:08 -04:00
johnjohto 6426e913b6 Pin HM moves above STATS/SWITCH in the party submenu (#792) 2026-08-04 12:42:22 -04:00
johnjohto 3d3e42c6a3 Fix the fly map cursor start and cycle directions 2026-08-04 12:28:24 -04:00
johnjohto 6fe106f55c Stop offering the route Pokemon Centers as fly destinations 2026-08-04 12:28:08 -04:00
johnjohto ba8ac3d143 Tick residuals after each move in Gen 1 mode
In pokered, MainInBattleLoop calls HandlePoisonBurnLeechSeed right
after every Execute*Move (core.asm:426-464), so a seeded, poisoned or
burned mon takes its residual before the slower side acts. The port
ran the whole sweep in endOfTurn, which made leech seed behave like
Gen 3+ and never showed the drain animation (#784).

The residual sweep now runs per action under the gen1_faithful
ruleset, gated by a new residualAfterMove flag; modern_clean keeps
the end of round sweep. The leech seed drain plays the ABSORB
animation from the healing side, the way the original flips
hWhoseTurn before PlayMoveAnimation. Item, ball, failed run and
ghost-fear turns still tick the player's residual, matching
ExecutePlayerMoveDone.
2026-08-04 12:06:21 -04:00
bryanthaboi 1820f411ae Merge pull request #802 from caorthann-celt/dev
Add Xbox Dev Mode UWP support
2026-08-04 11:01:15 -04:00
Caorthann 8327942a9c Pin Xbox UWP builds to Visual Studio 2022 2026-08-04 15:51:55 +01:00
Caorthann aec7f6001a Fix love payload version stamping in CI 2026-08-04 15:48:48 +01:00
Caorthann ad9b03fe56 Add Xbox UWP build workflow 2026-08-04 15:36:35 +01:00
Caorthann 3b490bc42f Make UWP dependencies self contained 2026-08-04 15:35:52 +01:00
Caorthann dacd9abe73 Add Xbox UWP port 2026-08-04 15:35:52 +01:00
bryanthaboi 8fbe819493 bingus dingus 2026-08-04 10:32:51 -04:00
Shane McGovern 626080d228 Show mod downloads and release dates in the launcher MODS and Find Mods panels (#793)
* Resolve Find Mods stats from each mod's GitHub repo when the feed lacks them

A FIND MODS row now shows download/date stats even when its feed publishes
none: the row fetches the mod's own GitHub releases through the same
cached ModUpdate.fetchReleases the MODS tab uses (six-hour options cache,
so an installed mod's repo is instant). Feed-published stats still win
when present; otherwise one repo is fetched per frame -- the thumbnail
budget pattern -- so opening the tab never stalls for the whole listing.
ModUpdate.statsForReleases is the shared resolver.

* Fix crash opening the Find Mods tab: rename the stats cache field

The resolver stored results in self._findStats, which collides with the
method of the same name: self._findStats resolves through the metatable to
the function, so the or {} guard never fired and indexing it crashed the
launcher the moment the panel built. State now lives in _findStatsCache.

* Fix Find Mods crash: require ModUpdate in the find panel

buildFindPanel called ModUpdate.statsLine without a local require --
only buildModsPanel had one -- so opening the tab indexed a nil global.

* Retry Find Mods stats after failed repo fetches

A failed repo fetch (hourly GitHub API rate limit, transient network error)
was memoized as resolved, so a rate-limited first visit left those rows
empty for the whole session. Failures now schedule a 60s retry; a 404 is
still permanent so a renamed or vanished repo is fetched once.

* Add the MODS tab sort options to the Find Mods tab
2026-08-04 10:16:44 -04:00
bryanthaboi f56e82de81 translate the launcher: load mod string catalogs before it draws (#794)
the launcher runs before Game:load, so #767's Strings hooks had nothing
filling the catalog and a restart could not help - the ordering is the same
every launch. reads lang/strings.lua from enabled mods only, data not the
entry chunk, sandboxed.

plus a plain pixel fallback on the ui faces, or the kana draw as tofu.
2026-08-04 10:15:10 -04:00
bryanthaboi 7d3950658a launcher: route the last raw literals through strings (#791)
finishes what #767 started (thanks @hernan0078). most of that pr landed
already when the launcher panels moved to LauncherView; these six sites were
what was left, and they sit next to siblings that already call Strings.

also pins the delete chip to the wider of Delete/Sure? instead of to Delete.
english happens to have the longer word first, a translation need not.
2026-08-04 09:50:51 -04:00
bryanthaboi 3c1246ee06 Merge pull request #787 from luisgonzaleznf/fix/love12-scissor-bias
Fix LÖVE 12 anchored UI scissor seam
2026-08-04 09:45:17 -04:00
bryanthaboi d69965e3b1 Merge pull request #790 from bryanthaboi/ttf-box-color
fix ttf text erased after a box, and type names outside battle
2026-08-04 09:44:46 -04:00
bryanthaboi 30edac03a6 fix ttf text erased after a box, and type names outside battle
drawBox left the color white, which tiles ignore but ttf text does not, so
every label after a box drew white on white.

typechart only loaded on entering a battle, so the summary screen got raw
type ids back instead of display names.
2026-08-04 09:43:01 -04:00
bryanthaboi dd4aface93 test fixes 2026-08-04 09:41:44 -04:00
bryanthaboi b1350fdecd Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-04 09:25:30 -04:00
bryanthaboi f2d9b74490 CLOSES #644, CLOSES #703, CLOSES #726, CLOSES #737, CLOSES #750, CLOSES #752, CLOSES #764, CLOSES #765, CLOSES #768, CLOSES #773, CLOSES #774, CLOSES #775, CLOSES #777, CLOSES #780, CLOSES #782 2026-08-04 09:25:28 -04:00
luisgonzaleznf 84d09d0894 Fix LÖVE 12 scissor seam 2026-08-04 15:20:06 +02:00
bryanthaboi e61f231316 Merge pull request #789 from bryanthaboi/ttf-tiles
add ttf tiles option
2026-08-04 09:14:49 -04:00
bryanthaboi 33998aab5d add ttf tiles option
lets a translation keep chosen characters on the rom tiles instead of the ttf.
needed for japanese: sizing the font for kana makes latin narrower, which
knocks the party menu numbers out of line.
2026-08-04 09:11:05 -04:00
bryanthaboi 9ce7379946 Merge pull request #786 from ShaneMcGovernIE/find-mods-release-stats
Show feed-published release stats on Find Mods rows
2026-08-04 08:00:46 -04:00
Shane McGovern b692474e24 Show feed-published release stats on Find Mods rows
A mod index can now publish three optional per-entry fields -- downloads
(total across every release), first_release and last_release (ISO days) --
which the FIND MODS listing shows in the same gold line the MODS tab uses.

The fields are additive by design: feeds that carry them stay readable by
every build that predates them (schema_version stays 1), and feeds that do
not render exactly as before. ModUpdate.statsLine builds the shared line;
the MODS tab reuses it. Parser, formatting, and parse coverage are tested.
2026-08-04 12:37:28 +01:00
bryanthaboi 6cfc85ca83 icons update 2026-08-04 06:52:06 -04:00
bryanthaboi 3a6557ffe2 CLOSES #779 , CLOSES #743 + new font 2026-08-04 06:41:34 -04:00
bryanthaboi fe139ca7f4 CLOSES #691 maybe 2026-08-04 05:47:58 -04:00
bryanthaboi 5c617d3e32 Merge pull request #776 from vegerot/max/yellow-docs 2026-08-03 23:20:24 -04:00
Max 👨🏽‍💻 Coplan 8d11c04d43 fix(docs): add support for Blue and Yellow 2026-08-03 17:09:20 -07:00
bryanthaboi 8da51aa7ba Merge pull request #759 from jherediagu/fix/rom-text-statuses-items-evolve
Extend ROM-text messages to statuses, items and the learn/evolve flows
2026-08-03 17:23:34 -04:00
bryanthaboi 78f87111de Merge pull request #762 from ShaneMcGovernIE/launcher-mod-downloads
Show mod downloads and release dates in the launcher MODS panel
2026-08-03 17:23:26 -04:00
Shane McGovern 6ea59f1a79 Show mod downloads and release dates in the launcher MODS panel
The MODS tab now shows each installed mod's total GitHub downloads
(summed asset download_count across all releases), its first and latest
release dates, and a Sort row (Name / Popularity / Release date /
Last updated) persisted in options.modSort.

The launcher already fetched per-repo release lists for update checks, so
the data rides the same cached fetch: parseRelease keeps download_count
and published_at, writeCache persists them, and a cache entry written
before the fields existed is treated as stale and refetched once instead
of hiding the line behind an old cache.
2026-08-03 22:17:56 +01:00
bryanthaboi 51fb4db82e Merge origin/main into dev; resolve Game.lua gamepad speed-hotkey conflict 2026-08-03 17:17:26 -04:00
bryanthaboi 7f78de8718 Merge pull request #574 from andrewqsantos/feat/switch-nx
Nintendo Switch / love-nx support (#531)
2026-08-03 17:08:54 -04:00
bryanthaboi 76aab74bf1 potentially CLOSES #758 2026-08-03 17:08:42 -04:00
Juan Heredia a2bf08c6ff Extend ROM-text messages to statuses, items and the learn/evolve flows 2026-08-03 23:04:29 +02:00
bryanthaboi b831076839 CLOSES #592 2026-08-03 16:38:07 -04:00
bryanthaboi 45575a1fd8 Merge pull request #757 from Bortlesboat/fix/launcher-save-slot-overlap
Fix launcher save-slot panel overlap
2026-08-03 16:21:47 -04:00
bryanthaboi 56d92246cb commit o 2026-08-03 16:19:39 -04:00
Andrew Barnes 4a00f2335c Fix launcher save-slot panel overlap 2026-08-03 16:18:35 -04:00
github-actions e9cb830d71 chore(ios): update app-repo.json [skip ci] 2026-08-03 16:18:21 -04:00
bryanthaboi c2666d2b43 Merge pull request #756 from ShaneMcGovernIE/fix/boulder-wall-push
Stop boulders being pushed through walls (#754)
2026-08-03 16:14:42 -04:00
bryanthaboi 9ceec3070d Merge pull request #742 from ShaneMcGovernIE/speed-triggers
Cycle game speed from the analog triggers too (L2/R2)
2026-08-03 16:13:27 -04:00
bryanthaboi f89977a8e7 Merge pull request #747 from johnjohto/fix-fly-animation-702
Play the full Fly departure and landing animation (#702)
2026-08-03 16:11:40 -04:00
bryanthaboi 8d512b95cb Merge pull request #755 from castdrian/ios
fix(ios): support mod index networking and text input dismissal
2026-08-03 16:11:27 -04:00
Andrew Barnes 0914387c16 Fix launcher save-slot panel overlap 2026-08-03 15:53:49 -04:00
Andrew Quenehen 105672c132 fix(switch): restore pad cursor in Touch Controls and launcher overlays
Opening Touch Controls dropped the launcher virtual cursor and swallowed
gamepad input while touch still worked. Share PadCursor with the save
editor, forward pad events, and centralize overlay handoff/resume so both
hosts park and re-arm the pointer cleanly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:46:48 -03:00
Andrew Quenehen 0dd6ecc219 fix(switch): widen Pikachu PCM to 16-bit stereo without Source channel probe
Source:getChannelCount could skip the #626 widen on love-nx, and keeping
8-bit depth into a stereo buffer still sounded wrong on audren. Decode the
file via newSoundData, always emit 16-bit stereo like ChipSynth, and write
fresh pika-cry WAVs as stereo at extract time so re-imports skip the hop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:35:18 -03:00
Andrew Quenehen 9ef5c8dead test(switch): cover widenMono newSoundData hop and run NX suites in T0
The Yellow boot suite only recorded newSource, so a missing sound.newSoundData
wrap (the silent hole that motivated the full-surface overlay) could still go
green. Force mono on the Source stub, record newSoundData, and assert the cry
re-read lands on yellow/. Also pin the three NX suites in scripts/test.sh T0
and fix the seed_chunk global leak in the fallback suite.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:20:45 -03:00
Andrew Quenehen 12ff4dba2e fix(switch): wrap the whole read-side love API surface in the NX overlay
The overlay only wrapped the five loaders the boot path needed, leaving a
silent-failure hole: any future state (or current code like Sound.lua's
widenMono, which re-reads the pika-cry WAV via love.sound.newSoundData
with the caller's bare path) could load a generated asset through an
unwrapped API and silently degrade on hardware.

NxAssetOverlay now wraps every read-side love function that accepts a
filesystem path (filesystem.read/load/lines/newFileData/getInfo,
graphics.newImage/newFont, image.newImageData, audio.newSource,
sound.newSoundData, font.newFontData), so new states and mods fall inside
the Blue/Yellow fallback with zero per-call-site work.  Write-side
functions stay stock, proven by identity assertions in the fallback
suite.  The static guard's forbidden-literal list covers the same APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:14:33 -03:00
Shane McGovern ff9992dff8 Stop boulders being pushed through walls (#754)
checkBoulderPush had an isWarpTileCell escape hatch that let a boulder
be pushed onto any door/warp tile, walkable or not. In pokered,
CheckForCollisionWhenPushingBoulder walks the same wTilesetCollisionPtr
list as player movement (CheckTilePassable) -- there is no hole/warp
exception, so a boulder can never land on a cell the player cannot walk
onto.

The known push targets (CAVERN  holes, Victory Road switches) are
walkable tiles in their tileset's coll list already, so removing the
escape hatch only stops pushing boulders into walls.

Fixes #754
2026-08-03 20:09:29 +01:00
Andrew Quenehen 98c08e4b22 test(switch): close the silent-regression gaps the Yellow NX bug exposed
Three layers, all running without a ROM:

- nx_yellow_boot_test.lua: drives the real Yellow and Blue boot states
  (TitleState, YellowIntro + IntroMovie pre-roll for Yellow, IntroMovie
  direct for Blue, Sound.playPikaCry) against a broken-mount filesystem
  where generated art exists only under yellow|blue/, and asserts no bare
  assets/generated path ever reaches the raw love loaders.  This is the
  runtime complement to the static literal guard: data-driven manifest
  paths and formatted paths (cry_%02d.wav) are exactly what a source scan
  cannot see.  love_stub gains Image:setFilter/getFilter so IntroMovie
  constructs headless.

- CI path gate: switch-changes now also triggers on the NX runtime
  (NxAssetOverlay, Platform, GameVersion, CacheFs) and the NX engine
  suites, so src-side NX regressions rebuild the fused NRO instead of
  slipping through with green headless-only checks.

- switch-selftest runs the three NX engine suites headlessly on the
  fork-safe ubuntu runner, giving PR feedback before the self-hosted Mac
  build.  The content gate and switch-build.md docs were updated in sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:07:41 -03:00
Andrew Quenehen 0bd3b26acc Merge branch 'dev' into feat/switch-nx
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:47:28 -03:00
Adrian Castro 59b55a8f37 fix(ios): support mod index networking and text input dismissal 2026-08-03 20:43:27 +02:00
Andrew Quenehen 67e6b1fb04 fix(switch): centralize the NX asset fallback in a boot-time loader overlay
The scattered per-call-site prefix rewrites were a parallel track that any
future newImage("assets/generated/...") would silently bypass.  Replace
them with NxAssetOverlay: installed once from love.load on NX only, it
wraps newImage / newImageData / newSource / filesystem.read / getInfo so a
missing assets/generated path falls back to the active version's
blue|yellow copy.  Call sites return to plain love loader calls, and
Assets.resolve goes back to being the platform-free mod-override point.

Two deliberate exceptions remain: the chip-audio worker (separate Lua
state) keeps receiving the prefix explicitly via audio.programPrefix, and
data/generated module loads keep using CacheFs.readActive.

A new guard test (tests/engine/nx_generated_guard_test.lua) fails CI on
any direct love loader call with a literal assets/generated path, so the
class of bug cannot regress by accident.  scripts/test.sh --quick is
green across all tiers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:42:55 -03:00
Andrew Quenehen 17243a7d8b fix(switch): route remaining generated-art loads through Assets.resolve
Five more places called love.graphics.newImage directly on
assets/generated paths, bypassing the NX prefix rewrite:

- TradeAnim: cable/ball/bubble art
- TownMap: Kanto background, cursor, nest icon
- SurfingMinigame: surf bg/ob sheets
- BattleState: party ball row, substitute doll

All now resolve through Assets.resolve, which maps to the versioned
blue/ or yellow/ save-dir prefix on NX only. Desktop and Android keep
the mountVersion overlay behavior unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:31:58 -03:00
Andrew Quenehen 16b6b98ede fix(switch): pass NX cache prefix to the chip-audio worker
Yellow music was still silent because the background worker thread loads
ChipSynth.lua in a fresh Lua state with no GameVersion/Platform context.
The main thread's prefix never reached it.

ChipAudio.slimAudio now resolves the versioned cache prefix on the main
thread and includes it in the audio payload as `programPrefix`.
ChipSynth.loadBanks prefers `audio.programPrefix` when present, falling
back to its own NX detection for the sync path. Blue and Yellow are
handled the same way.

Tests cover the worker prefix hand-off and Blue's programs.bin path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:26:37 -03:00
Andrew Quenehen e4ce1063a1 fix(switch): extend NX-only asset prefix to audio and title/intro art
The previous NX gate only rewrote image paths that go through Assets.resolve.
Pokemon Yellow still had no sound and a blank title screen because:

- ChipSynth reads programs.bin directly via love.filesystem.read, bypassing
  Assets. On NX the unprefixed path is missing when the mount overlay fails,
  so the engine never built and every song/SFX was silent.
- Sound.playPikaCry loads pika_cries WAVs with love.audio.newSource, also
  bypassing Assets.resolve.
- TitleState, YellowIntro, and IntroMovie call love.graphics.newImage
  directly on unprefixed assets/generated paths, so the Pikachu title and
  intro atlases failed to load.

Fix: apply the same NX-only prefix rewrite in those four places.
Desktop/Android keep the existing mountVersion overlay behavior.

Also add ChipSynth._loadBanksForTest and tests covering the new paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:19:15 -03:00
Andrew Quenehen f099593136 fix(switch): write nx-asset-probe.log on every NX Play
Capture resolve paths and newImage open results for Yellow/Blue art
triage without enabling switch-debug.txt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:01:58 -03:00
Andrew Quenehen f944507520 fix(switch): gate Blue/Yellow asset path rewrite to NX only
Desktop and Android keep mountVersion as the overlay; only love-nx
resolves assets/generated to yellow|blue/ save-dir paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:59:44 -03:00
Andrew Quenehen 6824949247 fix(switch): resolve Blue/Yellow art to prefixed save-dir paths
NX fused mount often cannot expose assets/generated; open the real
yellow|blue/assets/generated file with newImage instead of FileData.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:56:19 -03:00
Andrew Quenehen 4f54255518 fix(switch): prefer Yellow/Blue asset bytes when mount overlay lies
Probe generated canaries after mountVersion and always readActive for
prefixed caches so sprites are not blanked by empty PhysFS stubs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:50:28 -03:00
Andrew Quenehen cfe482e1fc fix(switch): stop pad cursor flicker from SDL mouse drift
Disable mouse-yield on NX where stick/touch moves the system pointer between sparse axis events, clamp pad dt, pixel-snap the overlay, and soften FlexLove GC so the launcher cursor stays steady.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:31:05 -03:00
Andrew Quenehen 0e5ea26e36 fix(switch): stop NxDisplay setMode flicker on the launcher
Only resize when width/height change; love-nx flag mismatches were
recreating the EGL surface every frame.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:28:13 -03:00
Andrew Quenehen d5886e69aa fix(switch): load Yellow/Blue assets when PhysFS mount hides them
Mirror Data:load's versioned CacheFs read in Assets so Yellow-only NX
Play survives intro without needing a Red root cache mask.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:20:53 -03:00
github-actions 32fa215243 chore(ios): update app-repo.json [skip ci] 2026-08-03 13:15:01 -04:00
Andrew Quenehen dd5d8afd82 feat(switch): switch handheld 720p / docked 1080p at runtime
Unlock love-nx SDL dock/undock resizing and sync via NxDisplay so
booting docked is not stuck on the conf 720p hint.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:12:51 -03:00
johnjohto f19f4e9341 Play the full Fly departure and landing animation (#702) 2026-08-03 13:11:40 -04:00
bryanthaboi 3aec2ca89b Merge pull request #746 from bryanthaboi/dev
scrolling and bugs
2026-08-03 13:08:44 -04:00
bryanthaboi 0f7261dd92 CLOSES #623, CLOSES #624, CLOSES #636, CLOSES #637, CLOSES #639, CLOSES #650, CLOSES #697, CLOSES #704, CLOSES #722 2026-08-03 13:05:57 -04:00
Andrew Quenehen 0886573d34 fix(switch): cut launcher pad-cursor lag on NX
Skip per-frame mouse warps and FlexLove perf sampling on Switch, feed pad coords through a getPosition bridge, and park that shim before the save editor so desktop paths stay unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:00:06 -03:00
bryanthaboi 5c041ea857 snip for scroll 2026-08-03 12:39:36 -04:00
Andrew Quenehen 30cd9afc92 fix(build): keep desktop packer aligned with upstream dev
Leave pack_love.sh for Switch-only packaging so merge conflicts follow origin/dev.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 13:37:56 -03:00
Andrew Quenehen cceb74a025 Merge branch 'dev' into feat/switch-nx
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 13:35:35 -03:00
Andrew Quenehen 42540076c1 Fix NX Scan again to import only the open tab's ROM SHA-1.
A shared imports/ inbox with Red+Yellow was starting Red from the Yellow tab; match by GameVersion.forSha1 for the selected game and document the tab-scoped rescan.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 13:33:07 -03:00
github-actions a528868656 chore(ios): update app-repo.json [skip ci] 2026-08-03 12:30:20 -04:00
bryanthaboi c82af28fd4 Merge pull request #744 from bryanthaboi/dev
fix builds
2026-08-03 12:26:05 -04:00
bryanthaboi d72e5db0a5 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-03 12:21:46 -04:00
bryanthaboi 9907cfb0a0 fix builds 2026-08-03 12:21:32 -04:00
Shane McGovern a3cd5a132e Cycle game speed from the analog triggers too (L2/R2) 2026-08-03 17:19:53 +01:00
github-actions 0971b47bec chore(ios): update app-repo.json [skip ci] 2026-08-03 12:09:08 -04:00
Andrew Quenehen d86ac70d9e Merge branch 'dev' into feat/switch-nx 2026-08-03 13:03:25 -03:00
Andrew Quenehen be8023a994 Merge branch 'dev' into feat/switch-nx
Bring FlexLove launcher UI ("big ui moment"), iOS picker dismiss, and Metal updates into the Switch NX branch. Keep pack_love (with libs/), NX inbox/rescan paths, SwitchDiagnostics, and Save Editor PadInput; port Scan again labels into LauncherView.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 13:02:08 -03:00
Andrew Quenehen 50fd0e9c7d refactor(import): drop redundant mobileFileBridge or and unused ready
scanInbox never consulted ready, and mobileFileBridge already mirrors
android on Android/iOS, so the dual guard was a no-op.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 10:02:17 -03:00
Andrew Quenehen 17843c2f7a ci(nx): run switch transfer docs gate on Switch path changes
Wire tests/switch_transfer_docs_test.lua into switch-changes detection,
switch-selftest, and the ROM-free T0 lane so docs drift fails CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 10:01:47 -03:00
Andrew Quenehen 54c8d2706b docs(nx): drop VoxelMod from Switch chord and install docs
Ship stock engine chords only; community mods own their rebinds, and keys
2/3/5 are claimed by the engine before pipeline hotkeys run.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 10:00:59 -03:00
Andrew Quenehen 44bfb1b93f test(nx): move ROM-free NX suites into the engine tier
platform_nx_* and rom_importer_nx_* run with the love stub and must execute
in CI's ROM-free lane via tests/run_engine.lua, not only T3.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 09:58:53 -03:00
Andrew Quenehen 7a78992881 chore: drop tracked .specs from the tree
Local planning artifacts belong under gitignore (.*) and must not ship in
the PR; useful Switch decisions already live in docs/switch-*.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 09:58:26 -03:00
Andrew Quenehen d7581dbece Ship Switch releases as an SD-ready zip only.
Players extract one zip at the microSD root for install and update; saves under pokemon-love2d/ survive merge. Drop the bare .nro from GitHub Release assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 09:04:25 -03:00
Andrew Quenehen eeb8c89d3d fix(nx): avoid Strings gate false positive on hash ledger
Write the imported-sha1 newline with string.char(10) so gate_strings_coverage
does not treat the filesystem ledger separator as player-visible text.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:46:17 -03:00
Andrew Quenehen 2591fde764 feat(nx): per-game save inbox and export folders
Split Import/Export paths into imports/saves/{red,blue,yellow}/ and
exports/{red,blue,yellow}/ so MTP destinations match each launcher tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:34:13 -03:00
Andrew Quenehen a8f3a3155c fix(nx): harden save inbox against slot clones
Retire successful imports to *.sav.imported and record content hashes so
re-pressing Import save (or the same bytes under a new name) cannot clone
slots. Surface multi-import counts and the active game tab in the notice.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:26:56 -03:00
Andrew Quenehen 2a927651c5 docs(specs): mark switch-save-sav-inbox Execute complete
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:13:38 -03:00
Andrew Quenehen 62ef647811 docs(specs): re-validate NX save .sav inbox
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:12:57 -03:00
Andrew Quenehen fe4491a87b test(nx): strengthen saves inbox RES-01 and edge coverage
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:11:05 -03:00
Andrew Quenehen 105bf65e02 docs(specs): validation report for NX save .sav inbox
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:09:15 -03:00
Andrew Quenehen 8120f113af test(nx): wire saves inbox suite into run_tests
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:06:35 -03:00
Andrew Quenehen 7e0c64cb78 docs(specs): AD-012 NX save .sav inbox
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:06:16 -03:00
Andrew Quenehen 4afb54c54f docs(nx): save .sav inbox and exports paths
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:05:53 -03:00
Andrew Quenehen 74f6b68034 feat(nx): Export save shows MTP exports path
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:02:38 -03:00
Andrew Quenehen 5d1e7ff3c1 feat(nx): Import save uses imports/saves inbox
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:02:04 -03:00
Andrew Quenehen 3c628140f7 feat(nx): saves inbox scan with AppleDouble/retain guards
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 08:01:14 -03:00
Andrew Quenehen 8bf99c3318 fix(switch): restore Save Editor pad and touch input on NX
Route gamepad/touch into the editor instead of dropping them in editorMode,
and hit-test clicks at event coords so a finger tap is not lost under the
Joy-Con virtual cursor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 23:04:37 -03:00
Andrew Quenehen f0829ed54f Merge branch 'dev' into feat/switch-nx
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:30:18 -03:00
Andrew Quenehen 59dba325b9 docs(switch): finalize Switch docs for PR review
Drop WIP status language, document Joy-Con controls and shortcuts,
credit the port and V1 testing help, and record community V1 boot evidence.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:23:29 -03:00
Andrew Quenehen d13064ee64 ci(switch): run fused build only after offline selftest
Avoid burning the self-hosted Mac when the ubuntu packaging gate fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:08:49 -03:00
Andrew Quenehen 669c9f4d8b ci(switch): skip fused build on fork pull requests
Keep ubuntu selftest for fork→canonical PRs; leave iOS build policy alone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:08:36 -03:00
Andrew Quenehen 228306f883 ci(switch): run workflow content gate in headless suite
Path-gate the Lua gate file and invoke it from scripts/test.sh T0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:08:15 -03:00
Andrew Quenehen 0a2ef854e6 test(switch): harden CI workflow content gate
Forbid soft-fail on Switch jobs and require the sha256 sidecar path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:07:58 -03:00
Andrew Quenehen 61c471343a ci: stop artifact commenters from deleting each other
Upsert via comment-tag only so iOS and Switch PR comments coexist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 22:07:50 -03:00
Andrew Quenehen 95eb1c3a35 ci(release): clarify Switch hard-fail vs path-gated PR CI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:53:19 -03:00
Andrew Quenehen cf50976d65 docs(switch): document CI selftest, fused PR builds, release hard-fail
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:52:58 -03:00
Andrew Quenehen 3fcee7f951 ci(switch): comment NRO artifacts on pull requests
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:52:36 -03:00
Andrew Quenehen 17a4c83970 ci(switch): add canonical fused NRO artifact job
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:52:24 -03:00
Andrew Quenehen 9712fb1e9b ci(switch): path-gate offline selftest like iOS
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:52:01 -03:00
Andrew Quenehen cbf622f4aa Merge branch 'dev' into feat/switch-nx
Bring feat/switch-nx up to date with origin/dev (72 commits). Resolve
Input/RomImporter conflicts by keeping GamepadMap (NX face remap + dual-path
gate) while adopting upstream joyBindings rebinds (#632) and Enable-all mods
(#647). Gate shoulder GAME SPEED hotkeys when Select is held so Select+L
display chords still work.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:36:56 -03:00
Andrew Quenehen 0bb0518093 Merge branch 'dev' into feat/switch-nx
Bring Switch WIP up to date with current prod/dev (v0.1.54 / main tree).
2026-08-01 20:25:52 -03:00
Andrew Quenehen 41f549b271 fix(build): stop capturing pack_love stdout into LOVE_FILE
Status lines from pack_love polluted the path and broke version stamping, which would fail the desktop release step.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 20:24:30 -03:00
Andrew Quenehen ba8aa7b263 fix(switch): use English Scan again for NX inbox rescan
Match the project English default instead of a hardcoded Portuguese label.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 16:21:19 -03:00
Andrew Quenehen 243cb6a477 Merge branch 'dev' into feat/switch-nx
Sync upstream v0.1.52/v0.1.53 fixes: Oak PC flow, bindings, Android host restart, UTF-8 mod manifests.
2026-08-01 15:48:00 -03:00
Andrew Quenehen c987fefede docs(switch): document VoxelMod chords and Switch performance tips
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 15:46:35 -03:00
Andrew Quenehen 2a038f7112 fix(switch): send build progress to stderr during love packing
Keep pack_game_love command-substitution returning only the archive path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 15:27:24 -03:00
Andrew Quenehen 548e75a15e fix(switch): harden transfer docs gate and per-OS SD/FTP fallbacks
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:43:43 -03:00
Andrew Quenehen 339c85b590 docs(switch): close NXMOD-12 VoxelMod OLED smoke as pass
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:41:39 -03:00
Andrew Quenehen 0f0420314b docs(switch): point development guide at multi-method transfer docs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:41:15 -03:00
Andrew Quenehen a08b5e65f7 docs(switch): link install and build guides to transfer runbook
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:40:54 -03:00
Andrew Quenehen 99b89c4e5e docs(switch): add multi-OS transfer runbook and content gate
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:40:46 -03:00
Andrew Quenehen 20586e0c48 fix(switch): show creator and porter in fused NRO author
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:19:16 -03:00
Andrew Quenehen b54d0c605a fix(switch): stamp Version.lua, gate resume music, keep NX import hint
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:13:09 -03:00
Andrew Quenehen 2f75af902c docs(switch): record SWBLD fetch-fused Mac smoke and SD install
Operator confirmed scripts/build_switch.sh --fetch --fused --version
0.0.0-test produced gen1recomp-0.0.0-test-switch.nro (9147a64) and
copied it to sdmc:/switch/gen1recomp/ per switch-install.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:06:45 -03:00
Andrew Quenehen 9147a6413e fix(switch): detail love-nx fetch failures with retry hint
SWBLD-05: on download failure print URL, curl/wget exit and HTTP status,
and an explicit retry: scripts/build_switch.sh --fetch line. Extend the
offline selftest to cover that path without a live network asset.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:53:06 -03:00
Andrew Quenehen 9a467d4bf1 docs(switch): link install and build guides from README
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:50:17 -03:00
Andrew Quenehen e40f9337b6 docs(switch): add switch-install player guide
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:49:57 -03:00
Andrew Quenehen 1f0712d2d5 docs(switch): add switch-build contributor guide
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:49:45 -03:00
Andrew Quenehen 345bc9519a ci(release): publish fused Switch NRO on GitHub Releases
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:49:25 -03:00
Andrew Quenehen 0b30bb2eff test(switch): add offline build_switch packaging selftest
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:48:31 -03:00
Andrew Quenehen f0e88aa581 feat(switch): native-or-Docker fused NRO packaging with clear errors
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:48:04 -03:00
Andrew Quenehen 317893bd61 feat(switch): add --fetch and mode glossary to build_switch.sh
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:47:39 -03:00
Andrew Quenehen bd181271d9 fix(switch): tell users to run --fetch when love-nx pin missing
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:47:26 -03:00
Andrew Quenehen 4df09010a8 feat(switch): fetch pinned love-nx with checksum verify
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:47:09 -03:00
Andrew Quenehen 1a5b2b96dc chore(switch): pin devkitPro Docker image for fused fallback
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:46:57 -03:00
Andrew Quenehen 43d89813fc feat(switch): add shared packaging helpers in common.sh
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:46:51 -03:00
Andrew Quenehen 5bc76ce6fe Merge branch 'dev' into feat/switch-nx
Bring latest upstream fixes (Metal/iOS, encounter slide, second-screen seam) into the Switch NX feature branch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 12:12:07 -03:00
Andrew Quenehen 8654444d1a docs(switch): document WIP status, OLED-only limits, and manual deploy
Clarify that Mac+OpenMTP is contributor tooling (not the final product
contract), record Dusklight method references, and surface how/what we
tested so reviewers and the community can help finish the Switch port.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 06:07:18 -03:00
Andrew Quenehen 772c39edb8 feat(switch): use gen1recomp cover art for NRO icon
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 06:00:19 -03:00
Andrew Quenehen 74e7474349 fix(nx-mods): skip ROM AppleDouble sidecars and surface mixed failures
Ignore hidden ._*.gb in the ROM inbox like zips, keep mixed-rescan success notices while appending sibling errors, and cover FileData mount / PK rejection in unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:58:17 -03:00
Andrew Quenehen 3aefe41ea1 fix(nx-mods): skip MTP AppleDouble zips and mount archives in memory
Mac OpenMTP leaves ._*.zip sidecars that fail PhysFS mount and hide a good install; prefer FileData mount on Horizon and keep success notices when a sibling fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:53:48 -03:00
Andrew Quenehen 2223c31e93 docs(switch): add VoxelMod OLED smoke evidence scaffold
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:27:50 -03:00
Andrew Quenehen 568f0fa9eb docs(switch): document mod zip inbox and Joy-Con display chords
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:27:34 -03:00
Andrew Quenehen e91cf0f0c2 test(nx-mods): lock zip and chord edge regressions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:27:07 -03:00
Andrew Quenehen 8a1f583d88 feat(nx-mods): fire display hotkeys from Select+face chords
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:26:20 -03:00
Andrew Quenehen 7518319c80 feat(nx-mods): map Select+face chords to display hotkeys
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:25:26 -03:00
Andrew Quenehen 304df44742 feat(nx-mods): show Procurar novamente and MTP hint on MODS
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:23:19 -03:00
Andrew Quenehen e92bab6f4f feat(nx-mods): route chooseMod to inbox rescan on NX
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:22:08 -03:00
Andrew Quenehen 43755c5457 feat(nx-mods): rescan installs zips and retains failures
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:21:01 -03:00
Andrew Quenehen d4455c508f feat(nx-mods): scan imports/mods for zip candidates
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:19:59 -03:00
Andrew Quenehen 7d01242cd1 feat(nx-mods): add imports/mods inbox dir and MTP hint
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:19:26 -03:00
Andrew Quenehen 9d77189cec docs(switch): sync P0 fused matrix with T24 hardware pass
Verifier flagged stale deferred rows for P0-12/14/15 after fused
deploy evidence closed T24.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:05:43 -03:00
Andrew Quenehen c8adbdcc64 docs(switch): record fused NRO deploy evidence
T24 closed: fused boot/import, Red+Blue Play after Blue mount fix,
and NRO-only replace keeps saves on OLED.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 05:03:31 -03:00
Andrew Quenehen ac6dfe7134 fix(import): mount Blue/Yellow save-dir cache without FFI
NX Play for Blue failed because mountVersion relied on absolute
PHYSFS_mount first. Prefer love.filesystem.mount of blue|yellow, overlay
generated trees by version prefix, and align CacheFs.prefix in bootGame.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:57:17 -03:00
Andrew Quenehen b1ad7c7254 fix(switch): unhide fused save-dir generated cache on Play
PhysFS does not merge archive data/ with save-dir data/generated, so
fused NX Play crashed after import. Prepend-mount generated trees,
fall back to CacheFs.read in Data:load, keep multiline lua-error logs,
and skip Boot.run when network is unvalidated. T24 stays open.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:55:08 -03:00
Andrew Quenehen 6fb5602cb0 docs(switch): outline upstream PR split
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:33:23 -03:00
Andrew Quenehen b942d37944 docs(switch): complete P0/P1 hardware matrix
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:33:19 -03:00
Andrew Quenehen b7ee191b6c feat(debug): log Lua errors and document NX crash triage
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:33:14 -03:00
Andrew Quenehen 4ea24a0a6f feat(switch): build fused gen1recomp NRO
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:32:01 -03:00
Andrew Quenehen 6ad8c8d73d feat(switch): embed build-info metadata in artifacts
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:31:57 -03:00
Andrew Quenehen 8982952fa4 feat(switch): reject private content in love payload
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:31:37 -03:00
Andrew Quenehen b135878824 refactor(build): extract shared pack_love.sh helper
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:31:06 -03:00
Andrew Quenehen e9463d39aa docs(switch): record suspend and save persistence evidence
T19 closed: quit/reopen, suspend×10, and reboot persistence all pass
on OLED (operator report 2026-08-01).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:29:26 -03:00
Andrew Quenehen dcee7ca8e4 fix(build): avoid SIGPIPE from grep -q on love zip listing
Under pipefail, unzip|grep -q exits 141 when grep closes early on a
match and aborts mac pack. List once to a file, then grep the listing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:28:31 -03:00
Andrew Quenehen 1653aa0a5d docs(switch): close T16 naming re-verify with Nintendo UX
OLED pass at 2699c9a: physical A confirms, B cancels; game.love
a208b21e… recorded. T19 suspend×10 still open.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:28:31 -03:00
Andrew Quenehen 2699c9a2f9 fix(input): Nintendo A/B face map on NX
SDL labels south as a and east as b; on Switch remap so physical A
confirms and physical B cancels in launcher and NamingScreen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:24:57 -03:00
Andrew Quenehen efd81d8e34 fix(input): ignore raw face presses when Joy-Con is gamepad
love-nx emits gamepad+raw on one press; NamingScreen saw a+b and always
erased. Skip raw when isGamepad(); align NX Y→a/X→b; prefer A if both.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:15:47 -03:00
Andrew Quenehen 925b224eda docs(switch): record Joy-Con input evidence
T16 launcher/overworld pass; naming fail with dual-path diagnosis.
T19 quit/reopen save pass; suspend×10 still pending.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:15:47 -03:00
Andrew Quenehen 7504753ea8 fix(input): recover cleanly on joystick reconnect
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:53:43 -03:00
Andrew Quenehen da60f40dfc fix(input): reset controls on focus loss
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:53:37 -03:00
Andrew Quenehen 560ebc5997 fix(input): apply Switch-measured controller fallback
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:53:27 -03:00
Andrew Quenehen 361d4b81df feat(debug): add opt-in Switch input diagnostics
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:53:20 -03:00
Andrew Quenehen 83cf1fdb00 refactor(input): share gamepad map between launcher and gameplay
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:53:13 -03:00
Andrew Quenehen 228a0baf81 docs(switch): record OLED ROM import evidence
Phase 0 probe confirmed getOS NX and Joy-Con events; Red inbox
import reached Play. Naming-screen confirm flake deferred to input tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:48:57 -03:00
Andrew Quenehen df7cea4387 feat(import): scan NX inbox and import by canonical SHA-1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:20:16 -03:00
Andrew Quenehen 13b55a797e feat(import): show NX inbox path and rescan action
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:20:12 -03:00
Andrew Quenehen 1ec33b1374 refactor(import): separate NX capabilities from Android flags
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:20:09 -03:00
Andrew Quenehen c06991e03d fix(switch): disable unvalidated network features on NX
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:15:31 -03:00
Andrew Quenehen 2fcd7abbfd fix(platform): skip host shell spawn on NX
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:15:13 -03:00
Andrew Quenehen 5ca17cfc99 feat(conf): configure LÖVE window hints for NX
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:14:45 -03:00
Andrew Quenehen 05f1e3852c feat(platform): add NX capability detection module
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:14:38 -03:00
Andrew Quenehen 3b22a45a23 docs(switch): add MTP and Phase 0 hardware runbook
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:14:25 -03:00
Andrew Quenehen f172d3129f feat(switch): assemble loose nro + game.love dist
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:14:11 -03:00
Andrew Quenehen a7a84b6fab chore(switch): add minimal love-nx hardware probe
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:13:57 -03:00
Andrew Quenehen d4afa09e03 docs(switch): pin love-nx 11.5-nx1 manifest layout
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 03:13:46 -03:00
451 changed files with 81589 additions and 33666 deletions
+158
View File
@@ -102,6 +102,164 @@ jobs:
if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }}
run: security delete-keychain "$RUNNER_TEMP/gen1recomp-ci-signing.keychain-db" 2>/dev/null || true
switch-changes:
name: detect Switch changes
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.paths.outputs.changed }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: paths
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.sha }}
run: |
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics)_test\.lua$|tests/engine/platform_nx)'; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
switch-selftest:
name: Switch offline selftest
needs: switch-changes
if: needs.switch-changes.outputs.changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: install luajit
run: sudo apt-get update && sudo apt-get install -y luajit
- name: Switch offline selftest
run: bash scripts/switch/selftest_build_switch.sh
- name: verify_payload self-test
run: bash scripts/switch/verify_payload.sh --self-test
- name: Switch CI workflow content gate
run: luajit tests/switch_ci_workflows_test.lua
- name: Switch transfer docs content gate
run: luajit tests/switch_transfer_docs_test.lua
# NX runtime regressions gate this job via switch-changes; run the NX
# engine suites here too so a PR touching them gets feedback on the
# fork-safe ubuntu runner before the self-hosted Mac build.
- name: NX engine suites (headless)
run: |
luajit tests/engine/assets_version_fallback_test.lua
luajit tests/engine/nx_generated_guard_test.lua
luajit tests/engine/nx_yellow_boot_test.lua
switch-build:
name: Switch fused build
needs: [switch-changes, switch-selftest]
if: |
always()
&& needs.switch-changes.outputs.changed == 'true'
&& needs.switch-selftest.result == 'success'
&& github.repository == 'bryanthaboi/gen1recomp'
&& (github.event_name != 'pull_request'
|| github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ["self-hosted", "macOS"]
steps:
- uses: actions/checkout@v7
- name: Build Switch fused NRO
run: |
set -euo pipefail
VER="$(printf '%s' "$GITHUB_SHA" | cut -c1-7)"
scripts/build_switch.sh --fetch --fused --version "$VER"
echo "SWITCH_VER=$VER" >> "$GITHUB_ENV"
- name: upload Switch NRO artifact
uses: actions/upload-artifact@v7
with:
name: gen1recomp-switch-nro
path: |
dist/switch/gen1recomp-${{ env.SWITCH_VER }}-switch.nro
dist/switch/gen1recomp-${{ env.SWITCH_VER }}-switch.nro.sha256
if-no-files-found: error
retention-days: 7
xbox-uwp-changes:
name: detect Xbox UWP changes
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.paths.outputs.changed }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: paths
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.sha }}
run: |
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(ports/uwp/|scripts/build_xbox_uwp\.sh$|scripts/xbox-uwp/|scripts/pack_love\.sh$|\.github/workflows/(ci|release)\.yml$|src/core/Platform\.lua$|src/import/(CacheFs|RomImporter)\.lua$|src/update/Check\.lua$|tests/engine/(platform_nx|uwp_native_picker)_test\.lua$|tests/rom_importer_double_pick_test\.lua$)'; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
xbox-uwp-selftest:
name: Xbox UWP offline selftest
needs: xbox-uwp-changes
if: needs.xbox-uwp-changes.outputs.changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Xbox UWP offline selftest
run: bash scripts/xbox-uwp/selftest_build_xbox_uwp.sh
- name: Build shared payload
run: |
scripts/pack_love.sh \
--output .bazinga/work/ci-game.love \
--listing .bazinga/work/ci-love-listing.txt \
--version 0.0.0
- name: Upload shared payload
uses: actions/upload-artifact@v7
with:
name: gen1recomp-xbox-uwp-payload
path: .bazinga/work/ci-game.love
if-no-files-found: error
retention-days: 1
xbox-uwp-build:
name: Xbox UWP build
needs: [xbox-uwp-changes, xbox-uwp-selftest]
if: |
always()
&& needs.xbox-uwp-changes.outputs.changed == 'true'
&& needs.xbox-uwp-selftest.result == 'success'
runs-on: windows-2022
steps:
- uses: actions/checkout@v7
- name: Download shared payload
uses: actions/download-artifact@v8
with:
name: gen1recomp-xbox-uwp-payload
path: .bazinga/work
- name: Build Xbox UWP package
shell: bash
run: |
bash scripts/build_xbox_uwp.sh \
--release \
--version 0.0.0 \
--game-love .bazinga/work/ci-game.love
- name: Upload Xbox UWP package
uses: actions/upload-artifact@v7
with:
name: gen1recomp-xbox-uwp
path: |
dist/xbox-uwp/gen1recomp-0.0.0-xbox-uwp.zip
dist/xbox-uwp/gen1recomp-0.0.0-xbox-uwp.zip.sha256
if-no-files-found: error
retention-days: 7
headless:
name: headless suites (no ROM)
runs-on: ubuntu-latest
+1 -7
View File
@@ -29,13 +29,7 @@ jobs:
[ -n "$pr_number" ] || exit 0
echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT"
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
- name: Delete existing comment
if: steps.artifact.outputs.pr_number != ''
uses: izhangzhihao/delete-comment@master
with:
github_token: ${{ github.token }}
delete_user_name: github-actions[bot]
issue_number: ${{ steps.artifact.outputs.pr_number }}
# Upsert via comment-tag only — do not delete-all bot comments (clobbers Switch).
- name: Get build info
id: build-info
env:
+179 -34
View File
@@ -1,8 +1,9 @@
name: Release
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
# IPA, and the Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on
# the self-hosted Mac runner, and publishes them as a GitHub Release.
# IPA, a Nintendo Switch SD-ready zip (experimental), Xbox UWP, and the Anbernic
# RG34XXSP (Stock OS 64-bit MOD / PortMaster) port, then publishes them as a
# GitHub Release.
#
# Versioning:
# - First ever release is 0.1.0.
@@ -42,22 +43,17 @@ concurrency:
cancel-in-progress: false
jobs:
release:
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
version:
name: determine release version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.ver.outputs.version }}
tag: ${{ steps.ver.outputs.tag }}
steps:
# The self-hosted runner lives under the machine owner's home
# directory; mask it first so absolute paths in every later step's
# output show up as *** in the public workflow logs.
- name: Mask runner paths
run: echo "::add-mask::$HOME"
- name: Checkout
uses: actions/checkout@v7
- uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
- name: Determine version
id: ver
env:
@@ -65,7 +61,6 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
semver_re='^[0-9]+\.[0-9]+\.[0-9]+$'
# 1) Explicit override from a manual run.
@@ -95,7 +90,6 @@ jobs:
| grep -E "$semver_re" \
| sort -t. -k1,1n -k2,2n -k3,3n \
| tail -1 || true)"
if [ -z "$latest" ]; then
version="0.1.0"
echo "No existing release tag; starting at $version"
@@ -123,10 +117,127 @@ jobs:
echo "::error::Release $tag already exists. Pick a different version."
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
love-payload:
name: build release game.love
needs: version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Build shared payload
run: |
scripts/pack_love.sh \
--output dist/payload/game.love \
--listing dist/payload/love-listing.txt \
--version "${{ needs.version.outputs.version }}"
- name: Upload shared payload
uses: actions/upload-artifact@v7
with:
name: gen1recomp-release-love
path: dist/payload/game.love
if-no-files-found: error
retention-days: 1
xbox-uwp:
name: build Xbox UWP release
needs: [version, love-payload]
runs-on: windows-2022
steps:
- uses: actions/checkout@v7
- name: Download shared payload
uses: actions/download-artifact@v8
with:
name: gen1recomp-release-love
path: .bazinga/work
- name: Prepare signing certificate
shell: pwsh
env:
CERTIFICATE_BASE64: ${{ secrets.XBOX_UWP_SIGNING_CERTIFICATE }}
CERTIFICATE_PASSWORD: ${{ secrets.XBOX_UWP_SIGNING_PASSWORD }}
CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }}
run: |
if ($env:CANONICAL_REPOSITORY -eq 'true' -and
[string]::IsNullOrWhiteSpace($env:CERTIFICATE_BASE64)) {
throw 'XBOX_UWP_SIGNING_CERTIFICATE is not configured.'
}
if ([string]::IsNullOrWhiteSpace($env:CERTIFICATE_BASE64)) {
"UWP_PUBLISHER=CN=Gen1Recomp" | Out-File $env:GITHUB_ENV -Append
exit 0
}
$pfx = Join-Path $env:RUNNER_TEMP 'gen1recomp-uwp.pfx'
[IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:CERTIFICATE_BASE64))
$flags = [Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet
$cert = [Security.Cryptography.X509Certificates.X509Certificate2]::new(
$pfx, $env:CERTIFICATE_PASSWORD, $flags)
$cer = Join-Path $env:RUNNER_TEMP 'gen1recomp-uwp.cer'
[IO.File]::WriteAllBytes(
$cer,
$cert.Export([Security.Cryptography.X509Certificates.X509ContentType]::Cert))
Import-Certificate -FilePath $cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople | Out-Null
"UWP_PFX=$pfx" | Out-File $env:GITHUB_ENV -Append
"UWP_CERT_THUMBPRINT=$($cert.Thumbprint)" | Out-File $env:GITHUB_ENV -Append
"UWP_PUBLISHER=$($cert.Subject)" | Out-File $env:GITHUB_ENV -Append
- name: Build Xbox UWP package
shell: bash
run: |
bash scripts/build_xbox_uwp.sh \
--release \
--version "${{ needs.version.outputs.version }}" \
--publisher "$UWP_PUBLISHER" \
--game-love .bazinga/work/game.love
- name: Sign and stage Xbox UWP release
shell: pwsh
env:
CERTIFICATE_PASSWORD: ${{ secrets.XBOX_UWP_SIGNING_PASSWORD }}
run: |
if (-not $env:UWP_PFX) {
exit 0
}
scripts/xbox-uwp/stage_release.ps1 `
-Version '${{ needs.version.outputs.version }}' `
-Configuration Release `
-BuildInfo .bazinga/work/xbox-uwp-build-info.json `
-CertificatePath $env:UWP_PFX `
-CertificatePassword $env:CERTIFICATE_PASSWORD
- name: Upload Xbox UWP release
uses: actions/upload-artifact@v7
with:
name: gen1recomp-xbox-uwp-release
path: |
dist/xbox-uwp/gen1recomp-${{ needs.version.outputs.version }}-xbox-uwp.zip
dist/xbox-uwp/gen1recomp-${{ needs.version.outputs.version }}-xbox-uwp.zip.sha256
if-no-files-found: error
retention-days: 1
- name: Remove signing certificate
if: always()
shell: pwsh
run: |
if ($env:UWP_CERT_THUMBPRINT) {
Remove-Item "Cert:\LocalMachine\TrustedPeople\$env:UWP_CERT_THUMBPRINT" -ErrorAction SilentlyContinue
}
if ($env:UWP_PFX) {
Remove-Item $env:UWP_PFX -Force -ErrorAction SilentlyContinue
}
release:
needs: [version, xbox-uwp]
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
steps:
# The self-hosted runner lives under the machine owner's home
# directory; mask it first so absolute paths in every later step's
# output show up as *** in the public workflow logs.
- name: Mask runner paths
run: echo "::add-mask::$HOME"
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
- name: Import signing certificate into a temporary keychain
if: github.repository == 'bryanthaboi/gen1recomp'
run: |
@@ -171,12 +282,12 @@ jobs:
# notarize separately below so it uses secret credentials, not a
# login-keychain profile. "all" also builds the Linux AppImage,
# which needs no signing/notarization.
scripts/build.sh all --version "${{ steps.ver.outputs.version }}" --no-notarize
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize
- name: Build Android
run: |
set -euo pipefail
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
scripts/build_android.sh --version "${{ needs.version.outputs.version }}"
- name: Install xcbeautify
run: |
@@ -190,18 +301,29 @@ jobs:
set -euo pipefail
if [ "$CANONICAL_REPOSITORY" = true ]; then
scripts/build_ios.sh --fetch --device --release \
--version "${{ steps.ver.outputs.version }}"
--version "${{ needs.version.outputs.version }}"
else
scripts/build_ios.sh --fetch --release \
--version "${{ steps.ver.outputs.version }}"
--version "${{ needs.version.outputs.version }}"
fi
- name: Build Switch
run: |
set -euo pipefail
# Hard-fail gate: Switch ships with every release (never soft-fail).
# PR CI is path-gated (ubuntu selftest + canonical fused); release
# always builds Switch regardless of which files changed.
# Needs native switch-tools (nacptool/elf2nro) and/or Docker on the
# Mac self-hosted runner; see docs/switch-build.md.
scripts/build_switch.sh --fetch --fused \
--version "${{ needs.version.outputs.version }}"
- name: Build Anbernic RG34XXSP port
run: |
set -euo pipefail
# Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5
# runtime from PortMaster-GUI, so it needs no signing/notarization.
./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}"
./build-rg34xxsp.sh --version "${{ needs.version.outputs.version }}"
- name: Notarize & staple macOS app
if: github.repository == 'bryanthaboi/gen1recomp'
@@ -238,12 +360,19 @@ jobs:
ditto -c -k --sequesterRsrc --keepParent "$app" "$zip"
echo "Notarized + stapled ✓"
- name: Download Xbox UWP release
if: github.repository == 'bryanthaboi/gen1recomp'
uses: actions/download-artifact@v8
with:
name: gen1recomp-xbox-uwp-release
path: dist/xbox-uwp
- name: Stage release assets
if: github.repository == 'bryanthaboi/gen1recomp'
id: assets
run: |
set -euo pipefail
v="${{ steps.ver.outputs.version }}"
v="${{ needs.version.outputs.version }}"
outdir="dist/release"
rm -rf "$outdir"
mkdir -p "$outdir"
@@ -258,6 +387,16 @@ jobs:
[ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; }
cp "$ipa" "$outdir/gen1recomp-${v}-ios.ipa"
swzip="dist/switch/gen1recomp-${v}-switch.zip"
[ -f "$swzip" ] || { echo "::error::$swzip not found (expected from scripts/build_switch.sh --fused → pack_sd_zip.sh)"; exit 1; }
cp "$swzip" "$outdir/gen1recomp-${v}-switch.zip"
# Local fused .nro stays under dist/switch/ for PR CI / debug; release
# publishes the SD-ready zip only.
uwp="dist/xbox-uwp/gen1recomp-${v}-xbox-uwp.zip"
[ -f "$uwp" ] || { echo "::error::$uwp not found (expected from the Xbox UWP job)"; exit 1; }
cp "$uwp" "$outdir/gen1recomp-${v}-xbox-uwp.zip"
# Anbernic handheld port (suffix names the CFW it targets, so a
# future RG35XX/other-CFW pack can ship alongside it).
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
@@ -284,8 +423,8 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
v="${{ steps.ver.outputs.version }}"
tag="${{ steps.ver.outputs.tag }}"
v="${{ needs.version.outputs.version }}"
tag="${{ needs.version.outputs.tag }}"
# Issues this release closes. Three sources, deduped by number:
# 1. GitHub's own "closing issues" links on every PR whose
@@ -369,18 +508,24 @@ jobs:
fi
printf 'Release notes:\n%s\n' "$notes"
release_files=(
"dist/release/gen1recomp-${v}-macos.zip"
"dist/release/gen1recomp-${v}-windows.zip"
"dist/release/gen1recomp-${v}-linux.zip"
"dist/release/gen1recomp-${v}-android.apk"
"dist/release/gen1recomp-${v}-ios.ipa"
"dist/release/gen1recomp-${v}-switch.zip"
"dist/release/gen1recomp-${v}-xbox-uwp.zip"
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
"dist/release/gen1recomp-${v}.love"
"dist/release/sha256sums.txt"
)
gh release create "$tag" \
--target "$GITHUB_SHA" \
--title "$v" \
--notes "$notes" \
"dist/release/gen1recomp-${v}-macos.zip" \
"dist/release/gen1recomp-${v}-windows.zip" \
"dist/release/gen1recomp-${v}-linux.zip" \
"dist/release/gen1recomp-${v}-android.apk" \
"dist/release/gen1recomp-${v}-ios.ipa" \
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
"dist/release/gen1recomp-${v}.love" \
"dist/release/sha256sums.txt"
"${release_files[@]}"
echo "Published release $tag"
@@ -388,7 +533,7 @@ jobs:
if: github.repository == 'bryanthaboi/gen1recomp'
run: |
set -euo pipefail
v="${{ steps.ver.outputs.version }}"
v="${{ needs.version.outputs.version }}"
ipa="dist/release/gen1recomp-${v}-ios.ipa"
app_repo="mobile/ios/app-repo.json"
[ -f "$ipa" ] || { echo "::error::$ipa not found"; exit 1; }
@@ -0,0 +1,55 @@
name: Switch artifact comment
on:
workflow_run:
workflows: [ci]
types: [completed]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
comment:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- id: artifact
env:
GH_TOKEN: ${{ github.token }}
RUN_ID: ${{ github.event.workflow_run.id }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
run: |
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp-switch-nro") | .id')"
[ -n "$artifact_id" ] || exit 0
head_owner="${HEAD_REPOSITORY%%/*}"
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
[ -n "$pr_number" ] || exit 0
echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT"
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
# Upsert via comment-tag only — do not delete-all bot comments (clobbers iOS).
- name: Get build info
id: build-info
env:
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
commit_hash="$(printf '%s' "$HEAD_SHA" | cut -c1-7)"
build_time="$(date "+%Y-%m-%d %H:%M:%S")"
echo "hash=$commit_hash" >> "$GITHUB_OUTPUT"
echo "time=$build_time" >> "$GITHUB_OUTPUT"
- name: comment Switch artifact
if: steps.artifact.outputs.pr_number != ''
uses: thollander/actions-comment-pull-request@v3
with:
message: |
[gen1recomp-switch.nro](${{ steps.artifact.outputs.artifact_url }})
**Commit**: [#${{ steps.build-info.outputs.hash }}](https://github.com/${{ github.event.workflow_run.head_repository.full_name }}/commit/${{ github.event.workflow_run.head_sha }})
**Build Time**: `${{ steps.build-info.outputs.time }}`
<sub>This comment was automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})</sub>
pr-number: ${{ steps.artifact.outputs.pr_number }}
comment-tag: switch-build-result
github-token: ${{ github.token }}
+9 -1
View File
@@ -31,7 +31,10 @@ mobile/ios/love-src/
mobile/ios/cache/
mobile/ios/build/
# Final packaged build artifacts (mac/win/web/android/ios) — see scripts/build.sh
# love-nx vendor binaries (fetch per docs/switch-development.md; also covered by .*)
.bazinga/love-nx/
# Final packaged build artifacts (mac/win/web/android/ios/switch) — see scripts/build.sh
/dist/
# Legacy manual convenience-copy location (superseded by /dist/android/)
@@ -43,3 +46,8 @@ mobile/dist/
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
mobile/ios/bundle_id.local
# Xbox UWP build output
/ports/uwp/build/
/ports/uwp/third_party/*/source/
/ports/uwp/third_party/angle/depot_tools/
+24
View File
@@ -229,6 +229,27 @@ ships with every release as `gen1recomp-*-rg34xxsp-stockos64-mod.zip`.
Install steps, controls, and troubleshooting live in
[docs/anbernic-rg34xxsp.md](docs/anbernic-rg34xxsp.md).
## Nintendo Switch
Releases ship an SD-ready `gen1recomp-*-switch.zip` (issue
[#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Runtime target
is pinned [love-nx](https://github.com/retronx-team/love-nx) `11.5-nx1`.
Requires a console that can run Switch homebrew. Hardware evidence: **OLED**
(author) and **V1 / Erista** boot (community).
- Players: [docs/switch-install.md](docs/switch-install.md) — download the
zip, extract at the microSD root (install or update), title-override
launch, import your own legal ROM, Joy-Con controls and shortcuts.
- Builders: [docs/switch-build.md](docs/switch-build.md) — `--fetch` /
`--loose` / `--fused`, toolchain, Docker fallback, and **CI vs release**
(path-gated ubuntu selftest, canonical fused PR artifact, release hard-fail).
Limitations, Dusklight-derived method, and how we tested:
[docs/switch-development.md](docs/switch-development.md) and
[docs/switch-hardware-evidence.md](docs/switch-hardware-evidence.md). Community
help — especially HOS / love-nx packaging and broader hardware coverage — is
welcome.
## Modding
The game ships a native mod platform: content registries, events and hooks,
@@ -276,4 +297,7 @@ This project would not be possible without [pret](https://github.com/pret) >
the pret band of decompiling maniacs > and their
[pokered](https://github.com/pret/pokered) disassembly.
Nintendo Switch port: [andrewqsantos](https://github.com/andrewqsantos).
Switch hardware testing (V1 boot): [booshankles](https://github.com/booshankles).
<p align="center"><a href="https://boisclub.games"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/bcg.png"></a></p>
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
# Plain Pixel font
"Plain Pixel Font" by Douglas Vautour (Burpy Fresh) is licensed under
CC-BY 4.0: https://burpyfresh.itch.io
Version 0.009 (CJK character additions), unmodified. Characters for most
languages have a 5x11 base but can extend vertically; double-width
characters such as Hiragana and Katakana are 11x11.
Bundled so a translation mod can opt into TTF text rendering
(`mod.content.font:register("ttf", {})`; see the Translation support
section of docs/new-features.md) instead of drawing hundreds of glyph-page
tiles. The tile font extracted from the player's ROM stays the default.
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+15 -1
View File
@@ -58,7 +58,17 @@ function love.conf(t)
-- engine before conf runs (LÖVE 11.x / 11.5).
local osName = love._os
local mobile = osName == "Android" or osName == "iOS"
if mobile then
local nx = osName == "NX"
if nx then
-- Switch (love-nx): hint handheld 720p. SDL auto-switches portable↔dock
-- (720p↔1080p) only when the window is resizable and not exclusive
-- fullscreen; NxDisplay.sync also applies the size on boot and dock change.
t.window.width = 1280
t.window.height = 720
t.window.fullscreen = false
t.window.resizable = true
t.window.highdpi = false
elseif mobile then
-- resizable is what unlocks orientation. SDL's Android backend, given no
-- SDL_HINT_ORIENTATIONS (LÖVE sets none), calls setRequestedOrientation
-- at window creation -- FULL_SENSOR when the window is resizable (rotates
@@ -70,6 +80,10 @@ function love.conf(t)
-- just work. FULL_SENSOR ignores the device's rotation lock, so
-- GameActivity.setOrientationBis remaps it to FULL_USER after SDL has
-- run: same orientations allowed, but auto-rotate being off now wins.
-- A persisted ORIENTATION lock (#592) overrides all of this after boot:
-- src/core/Orientation.lua sets SDL_HINT_ORIENTATIONS over the FFI and
-- re-triggers the request, from main.lua for the launcher and from
-- Game:applyOptions in game.
-- iOS follows the Info.plist orientations
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
t.window.resizable = true
+67 -24
View File
@@ -766,6 +766,28 @@ local function silphRocketsLeave(game, ow, onlyMap)
end
end
-- SilphCo11FGiovanniAfterBattleScript (scripts/SilphCo11F.asm) is the whole
-- aftermath: DisplayTextID TEXT_SILPHCO11F_GIOVANNI_YOU_RUINED_OUR_PLANS,
-- GBFadeOutToBlack, SilphCo11FTeamRocketLeavesScript, Delay3,
-- GBFadeInFromBlack, then SetEvent. The port had only the hide pass, so the
-- speech never played and every rocket blinked out in front of the player
-- (#722). Same hide list as silphRocketsLeave, spelled as script rows so the
-- fade can hold over it.
local function silphAftermathRows()
local rows = {
{ "show_text", "_SilphCo11FGiovanniYouRuinedOurPlansText" },
{ "fade", "out" },
}
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
for _, name in ipairs(floor[2]) do
rows[#rows + 1] = { "hide_object", floor[1], name }
end
end
rows[#rows + 1] = { "wait", 3 } -- Delay3
rows[#rows + 1] = { "fade", "in" }
return rows
end
M.SILPH_CO_11F = {
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
@@ -792,11 +814,15 @@ M.SILPH_CO_11F = {
ow:scriptMove(gio, "down", 3, function()
gio:facePlayer(ow.player)
ow:engageTrainer(gio, function()
-- SilphCo11FTeamRocketLeavesScript: every Silph rocket leaves
-- after the loss (the street rockets are handled by
-- M.SAFFRON_CITY.onEnter in story4.lua).
-- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!" speech,
-- then SilphCo11FTeamRocketLeavesScript behind a fade so every Silph
-- rocket leaves off-screen (the street rockets are handled by
-- M.SAFFRON_CITY.onEnter in story4.lua). Queued, not run here: the
-- battle's own callbacks are still unwinding, so queueScript starts
-- it on the first idle overworld frame -- after the end-battle
-- "Arrgh!!" box victories.lua OPP_GIOVANNI#2 pushes (#722).
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
silphRocketsLeave(game, ow)
ow:queueScript(silphAftermathRows())
end
end)
end)
@@ -981,7 +1007,11 @@ M.VICTORY_ROAD_3F = {
local championsRoomRivalScript = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
{ "jump_if_true", 26 }, -- 3 past end
-- "end" rather than a row number past the tail: this script grew by a row
-- when the follow-Oak walk landed (#704), which silently turned the old
-- numeric 26 into a jump ONTO the closing HALL_OF_FAME warp instead of past
-- it, so a returning champion warped straight into the induction.
{ "jump_if_true", "end" }, -- 3
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
-- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after
-- the intro text, before the battle itself (#706); pushBattle's wipe-time
@@ -989,37 +1019,50 @@ local championsRoomRivalScript = {
-- continuous into the fight
{ "play_music", "Music_FinalBattle" }, -- 5
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 6
{ "jump_if_false", 26 }, -- 6 past end
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
-- losing halts here; the numeric target this replaced pointed at the
-- closing warp, which inducted a player who had just lost the fight (#704)
{ "jump_if_false", "end" }, -- 7
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 8
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 9
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 10
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in
{ "play_music", "Music_Cities1", { keep = true } }, -- 10
{ "show_text", "_ChampionsRoomOakText" }, -- 11
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 12
{ "move_npc", 2, "up", 5 }, -- 13 OakEntranceAfterVictoryMovement
{ "play_music", "Music_Cities1", { keep = true } }, -- 11
{ "show_text", "_ChampionsRoomOakText" }, -- 12
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 13
{ "move_npc", 2, "up", 5 }, -- 14 OakEntranceAfterVictoryMovement
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 14
{ "face_object", 2, "down" }, -- 15
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 16
{ "face_object", 1, "left" }, -- 15
{ "face_object", 2, "down" }, -- 16
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 17
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 17
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 18
{ "face_object", 2, "right" }, -- 18
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 19
-- OakComeWithMeScript: Oak faces down again, then exits up
{ "face_object", 2, "down" }, -- 19
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 20
{ "move_npc", 2, "up", 2 }, -- 21 OakExitChampionsRoomMovement
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 22
{ "face_object", 2, "down" }, -- 20
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 21
{ "move_npc", 2, "up", 2 }, -- 22 OakExitChampionsRoomMovement
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 23
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement
-- (PAD_UP 4, PAD_LEFT 1): the player walks out after Oak instead of the
-- screen just fading on the spot (#704). The entrance walk leaves the
-- player at (4,3) and both north-wall warps sit on row 0, so the original
-- only ever spends three of those simulated steps -- CheckWarpsNoCollision
-- takes the HALL_OF_FAME warp the moment the walk lands on (4,0) and the
-- trailing UP/LEFT are dropped. Scripted steps ignore collision here just
-- as they do in the original (CollisionCheckOnLand skips its checks while
-- wSimulatedJoypadStatesIndex is non-zero), so stepping through the
-- rival's cell at (4,2) is the ported behavior, not a clip.
{ "move_player", "up", 3 }, -- 24
-- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
{ "set_field", "pendingHallOfFame", true }, -- 23
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 24
{ "set_field", "pendingHallOfFame", true }, -- 25
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 26
}
M.CHAMPIONS_ROOM = {
+206 -80
View File
@@ -549,21 +549,57 @@ M.GAME_CORNER = {
-- handler is bound to both text ids just below (#552).
TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local t = game.data.text
local function line(suffix, fallback)
return t["_GameCornerClerk1" .. suffix]
or t["_GameCornerClerk" .. suffix]
or fallback
end
-- GameCornerDrawCoinBox (scripts/GameCorner.asm; pokeyellow's copy is
-- identical): TextBoxBorder at hlcoord 11,0 with b=5 c=7, a 9x7-tile
-- window in the top right holding MONEY at (12,2) over the amount on
-- row 3 and COIN at (12,4) over the count on row 5. Both
-- PrintBCDNumber calls pass LEADING_ZEROES, whose bit 7 SUPPRESSES
-- leading zeroes (home/print_bcd.asm), and neither passes LEFT_ALIGN,
-- so both numbers read plain and right-aligned against the inner edge
-- at column 18. The asm draws the box before the offer and redraws it
-- after the purchase, so it stands for the whole exchange: a draw-only
-- state under the dialogue gets that lifetime, since StateStack draws
-- every state above the last opaque one and updates only the top
-- (src/core/StateStack.lua), and reading save each frame is the
-- redraw (#624).
local coinBox = { draw = function()
Font.drawBox(11, 0, 9, 7)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("MONEY"), 96, 16)
local money = ("¥%d"):format(game.save.money or 0)
Font.draw(money, 152 - Font.width(money), 24)
Font.draw(Strings("COIN"), 96, 32)
local coins = ("%d"):format(game.save.coins or 0)
Font.draw(coins, 152 - Font.width(coins), 40)
love.graphics.setColor(1, 1, 1, 1)
end }
game.stack:push(coinBox)
-- Every branch below finishes here. A TextBox pops itself before its
-- onDone runs, so the coin box is top of the stack again by then and
-- this pop takes it down, never someone else's state.
local function finish()
game.stack:pop()
done()
end
-- YesNoChoice is called with the offer still printed, so the prompt
-- has to ride the open text box (opts.choice) instead of being pushed
-- after it closes, which is what made the question vanish (#624).
game.stack:push(TextBox.new(game,
line("DoYouNeedSomeGameCoinsText",
"Do you need some\ngame coins?\f¥1000 for 50."), function()
game.stack:push(ChoiceBox.new(game, function(yes)
"Do you need some\ngame coins?\f¥1000 for 50."),
nil, { choice = function(yes)
if not yes then
game.stack:push(TextBox.new(game,
line("PleaseComePlaySometimeText",
"No? Please come\nplay sometime!"), done))
"No? Please come\nplay sometime!"), finish))
return
end
-- scripts/GameCorner.asm GameCornerClerk1Text: coins need
@@ -571,29 +607,30 @@ M.GAME_CORNER = {
if not game.save.inventory.COIN_CASE then
game.stack:push(TextBox.new(game,
line("DontHaveCoinCaseText",
"You don't have a\nCOIN CASE!"), done))
"You don't have a\nCOIN CASE!"), finish))
return
end
if (game.save.coins or 0) >= 9990 then
game.stack:push(TextBox.new(game,
line("CoinCaseIsFullText",
"Oops! Your COIN\nCASE is full."), done))
"Oops! Your COIN\nCASE is full."), finish))
return
end
if game.save.money < 1000 then
game.stack:push(TextBox.new(game,
line("CantAffordTheCoinsText",
"You can't afford\nthe coins!"), done))
"You can't afford\nthe coins!"), finish))
return
end
game.save.money = game.save.money - 1000
game.save.coins = math.min(9999, (game.save.coins or 0) + 50)
-- the thanks text is the plain _GameCornerClerk1ThanksHereAre50-
-- CoinsText; the new count belongs in the coin box the asm
-- redraws here, not appended to the line (#624)
game.stack:push(TextBox.new(game,
line("ThanksHereAre50CoinsText",
"Thanks! Here are\nyour 50 coins!")
.. ("\fCOINS: %d"):format(game.save.coins), done))
end))
end))
"Thanks! Here are\nyour 50 coins!"), finish))
end }))
end,
},
}
@@ -606,99 +643,188 @@ M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK =
M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK1
-- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm).
-- The six mon prizes differ between Red and Blue; the three TM prizes are
-- identical, so they are shared and appended to each version's mon list.
-- Each counter owns ONE window of three prizes, not the whole catalogue:
-- GetPrizeMenuId (engine/events/prize_menu.asm) subtracts
-- TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 from hTextID and indexes
-- PrizeDifferentMenuPtrs with the result, so vendor 1 sells
-- PrizeMenuMon1Entries, vendor 2 PrizeMenuMon2Entries and vendor 3
-- PrizeMenuTMsEntries (#623). The mon windows and their levels differ per
-- version; the TM window is identical in all three, so it is shared.
local PRIZE_TMS = {
{ kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 },
{ kind = "item", item = "TM_HYPER_BEAM", cost = 5500 },
{ kind = "item", item = "TM_SUBSTITUTE", cost = 7700 },
}
local RED_PRIZES = {
{ kind = "mon", species = "ABRA", level = 9, cost = 180 },
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
local RED_PRIZE_WINDOWS = {
{
{ kind = "mon", species = "ABRA", level = 9, cost = 180 },
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
},
{
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
},
PRIZE_TMS,
}
local BLUE_PRIZES = {
{ kind = "mon", species = "ABRA", level = 6, cost = 120 },
{ kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 },
{ kind = "mon", species = "NIDORINO", level = 17, cost = 1200 },
{ kind = "mon", species = "PINSIR", level = 20, cost = 2500 },
{ kind = "mon", species = "DRATINI", level = 24, cost = 4600 },
{ kind = "mon", species = "PORYGON", level = 18, cost = 6500 },
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
local BLUE_PRIZE_WINDOWS = {
{
{ kind = "mon", species = "ABRA", level = 6, cost = 120 },
{ kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 },
{ kind = "mon", species = "NIDORINO", level = 17, cost = 1200 },
},
{
{ kind = "mon", species = "PINSIR", level = 20, cost = 2500 },
{ kind = "mon", species = "DRATINI", level = 24, cost = 4600 },
{ kind = "mon", species = "PORYGON", level = 18, cost = 6500 },
},
PRIZE_TMS,
}
-- Yellow keeps the three windows but restocks both mon counters
-- (pokeyellow/data/events/prizes.asm, prize_mon_levels.asm)
local YELLOW_PRIZE_WINDOWS = {
{
{ kind = "mon", species = "ABRA", level = 15, cost = 230 },
{ kind = "mon", species = "VULPIX", level = 18, cost = 1000 },
{ kind = "mon", species = "WIGGLYTUFF", level = 22, cost = 2680 },
},
{
{ kind = "mon", species = "SCYTHER", level = 30, cost = 6500 },
{ kind = "mon", species = "PINSIR", level = 30, cost = 6500 },
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
},
PRIZE_TMS,
}
local function activePrizes()
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES
local function prizeWindow(n)
local GameVersion = require("src.core.GameVersion")
local windows = RED_PRIZE_WINDOWS
if GameVersion.isBlue() then
windows = BLUE_PRIZE_WINDOWS
elseif GameVersion.isYellow() then
windows = YELLOW_PRIZE_WINDOWS
end
return windows[n]
end
-- Prize counters (engine/menus/prize_menu.asm CeladonPrizeMenu; the prize
-- Prize counters (engine/events/prize_menu.asm CeladonPrizeMenu; the prize
-- list itself is data/events/prizes.asm, prize_mon_levels.asm). Gen1 gates
-- the prize window on the COIN CASE: it does IsItemInBag COIN_CASE first, and
-- with no case prints RequireCoinCaseText and returns without ever opening a
-- window; only with the case does it print ExchangeCoinsForPrizesText and then
-- show the prizes. #194: the port used to open the window unconditionally and
-- skip both text boxes.
local function prizeCounter(game, ow, npc, done)
local ListMenu = require("src.ui.ListMenu")
local Commands = require("src.script.Commands")
local TextBox = require("src.render.TextBox")
local t = game.data.text
-- IsItemInBag COIN_CASE: without the case, deny and open no window
-- (COIN_CASE is a numeric count in save.inventory, nil when absent).
if not game.save.inventory.COIN_CASE then
-- skip both text boxes. wMaxMenuItem is 3, i.e. this window's three prizes
-- plus the NO THANKS row, and HandlePrizeChoice confirms the pick with
-- SoYouWantPrizeText + YesNoChoice before any coins move; every branch then
-- rets out of CeladonPrizeMenu, so one transaction ends the conversation and
-- buying again means talking to the counter again (#623).
local function prizeCounter(window)
return function(game, ow, npc, done)
local ListMenu = require("src.ui.ListMenu")
local Commands = require("src.script.Commands")
local TextBox = require("src.render.TextBox")
local t = game.data.text
-- IsItemInBag COIN_CASE: without the case, deny and open no window
-- (COIN_CASE is a numeric count in save.inventory, nil when absent).
if not game.save.inventory.COIN_CASE then
game.stack:push(TextBox.new(game,
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
return
end
-- ExchangeCoinsForPrizesText plays before the prize window opens.
game.stack:push(TextBox.new(game,
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
return
end
-- ExchangeCoinsForPrizesText plays before the prize window opens.
game.stack:push(TextBox.new(game,
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
function()
local items = {}
for _, p in ipairs(activePrizes()) do
local label
if p.kind == "mon" then
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
else
label = game.data.items[p.item].name
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
function()
local items = {}
for _, p in ipairs(prizeWindow(window)) do
local label
if p.kind == "mon" then
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
else
label = game.data.items[p.item].name
end
table.insert(items,
{ label = label, right = tostring(p.cost), value = p })
end
table.insert(items,
{ label = label, right = tostring(p.cost), value = p })
end
local list
list = ListMenu.new(game, "PRIZES (COINS)", items, {
footer = ("COINS %d"):format(game.save.coins or 0),
onChoose = function(item)
local p = item.value
-- NoThanksText (data/events/prizes.asm) sits under the three prizes
table.insert(items, { label = "NO THANKS" })
local list
-- close the window first: every ending in HandlePrizeChoice leaves
-- the menu for good, and the closing line belongs over the map
local function finish(msg)
list:close()
game.stack:push(TextBox.new(game, msg, done))
end
local function buy(p)
if (game.save.coins or 0) < p.cost then
list.footer = "Not enough coins!"
finish(t._SorryNeedMoreCoinsText or "Sorry, you need\nmore coins.")
return
end
-- HasEnoughCoins passed, so hand the prize over first and only
-- subtract once it landed: the asm rets before .subtractCoins when
-- the bag is full, or when both the party and every box are full
local roomless = t._OopsYouDontHaveEnoughRoomText
or "Oops! You don't\nhave enough room."
if p.kind == "mon" then
-- no runner here, so give_pokemon reports through ctx.lastCheck
-- and skips the AskName prompt (Commands.give_pokemon)
local ctx = { save = game.save, game = game }
Commands.give_pokemon(ctx, p.species, p.level)
if not ctx.lastCheck then
finish(roomless)
return
end
elseif not require("src.inventory.Bag").add(
game.save, p.item, 1, game.data) then
finish(roomless)
return
end
game.save.coins = game.save.coins - p.cost
if p.kind == "mon" then
Commands.give_pokemon({ save = game.save, game = game },
p.species, p.level)
else
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
end
list.footer = ("Got it! COINS %d"):format(game.save.coins)
end,
onCancel = done,
})
game.stack:push(list)
end))
-- no thank-you line: HereYouGoText is unreferenced in the asm,
-- which just redraws the coin box (PrintPrizePrice) and returns
list:close()
done()
end
list = ListMenu.new(game, "PRIZES (COINS)", items, {
footer = ("COINS %d"):format(game.save.coins or 0),
onChoose = function(item)
local p = item.value
if not p then -- NO THANKS is the B exit (cp 3 -> .noChoice)
list:close()
done()
return
end
local name = (p.kind == "mon")
and game.data.pokemon[p.species].name
or game.data.items[p.item].name
-- SoYouWantPrizeText names the prize out of wNameBuffer, which
-- is not one of TextBox's RAM tokens, so fill it in here
local ask = (t._SoYouWantPrizeText
or "So, you want\n{RAM:wNameBuffer}?")
:gsub("{RAM:wNameBuffer}", name)
game.stack:push(TextBox.new(game, ask, nil, {
choice = function(yes)
if not yes then
finish(t._OhFineThenText or "Oh, fine then.")
return
end
buy(p)
end,
}))
end,
onCancel = done,
})
game.stack:push(list)
end))
end
end
M.GAME_CORNER_PRIZE_ROOM = {
talk = { -- the three prize counters are bg events
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 = prizeCounter,
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_2 = prizeCounter,
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_3 = prizeCounter,
talk = { -- the three prize counters are bg events, one window each
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 = prizeCounter(1),
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_2 = prizeCounter(2),
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_3 = prizeCounter(3),
},
}
+12 -2
View File
@@ -49,7 +49,7 @@ local function gift(opts)
end
end)
end
if opts.pre then say(opts.pre, "", give) else give() end
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
end
end
@@ -118,11 +118,21 @@ M.CINNABAR_LAB_METRONOME_ROOM = {
},
}
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher; no pre text)
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher). The fisher's
-- YouCanHaveThisText prints before GiveItem, so this gift needs a pre
-- text (#775). Like the SilphCo2F worker (#393) that label carries no
-- leading underscore, and on Red it sits outside the extractor's symbol
-- set, so the literal from text/ViridianCity.asm rides along as the
-- fallback; Yellow resolves the ROM string instead.
M.VIRIDIAN_CITY = {
talk = {
TEXT_VIRIDIANCITY_FISHER = gift({
flag = "EVENT_GOT_TM42", item = "TM_DREAM_EATER",
pre = "ViridianCityFisherYouCanHaveThisText",
preFallback = "Yawn!\nI must have dozed\voff in the sun."
.. "\fI had this dream\nabout a DROWZEE\veating my dream."
.. "\vWhat's this?\vWhere did this TM\vcome from?"
.. "\fThis is spooky!\nHere, you can\vhave this TM.",
received = "_ViridianCityFisherReceivedTM42Text",
explain = "_ViridianCityFisherTM42ExplanationText",
noRoom = "_ViridianCityFisherTM42NoRoomText",
+10 -2
View File
@@ -108,8 +108,16 @@ return {
"_ViridianGymGiovanniTM27ExplanationText",
} },
-- Silph Co. Giovanni: unlocks the president's Master Ball gift
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
-- Silph Co. Giovanni: unlocks the president's Master Ball gift.
-- SilphCo11FGiovanniStartBattleScript (scripts/SilphCo11F.asm) hands the
-- battle SilphCo10FGiovanniILostAgainText through SaveEndBattleTextPointers,
-- but he has no def_trainers header on 11F, so engageTrainer finds no
-- header.won to give it -- this chain is the port's stand-in for that loss
-- line (#722). The "Blast it all!" speech, the fade and the rockets
-- leaving are SilphCo11FGiovanniAfterBattleScript, ported in M.SILPH_CO_11F
-- (data/scripts/story.lua).
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI",
dialogue = { "_SilphCo10FGiovanniILostAgainText" } },
-- Fighting Dojo Karate Master (scripts/FightingDojo.asm
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
+8 -2
View File
@@ -24,7 +24,8 @@
-- ViridianCityPostInitialCatchTraining): stepping into (19,9) -- the gap
-- east of the sleeper's cell -- faces the old man right and the player
-- left, prints the apology, and without any choice runs the demo battle
-- (BATTLE_TYPE_OLD_MAN, RATTATA lvl 5). After it, the same text pointer
-- (BATTLE_TYPE_OLD_MAN, RATTATA lvl 5), which he FAILS -- the ball shakes
-- three times and breaks open. After it, the same text pointer
-- now prints _ViridianCityOldManLosingMyTouchText ("That didn't work!
-- I must be losing my touch."), the old man walks off (down 6 with the
-- player on (19,9), right 1 otherwise, Pikachu nudged out of the way
@@ -63,7 +64,12 @@ end
local function oldMan2Rows(game, ow, npc)
local rows = {
{ "show_text", "_ViridianCityOldManHadMyCoffeeNowText" },
{ "old_man_demo" },
-- ViridianCityOldManInitialCatchTrainingScript sets
-- EVENT_INITIAL_CATCH_TRAINING before the battle runs, and
-- ItemUseBall's .oldManBattle branch turns that event into anim data
-- $63: three shakes, then the ball breaks open. The losing-my-touch
-- line below only follows a throw that failed (#636).
{ "old_man_demo", "fail" },
{ "set_flag", "EVENT_COMPLETED_CATCH_TRAINING" },
{ "show_text", "_ViridianCityOldManLosingMyTouchText" },
}
+37 -8
View File
@@ -45,6 +45,9 @@ same system picker and install the chosen archive on return.
draws one chip per game plus a MODS chip and rebuilds `self.tabRects` every
frame so `mousepressed` can dispatch clicks; switching tabs mid-import is
allowed (a dropped ROM still routes by SHA-1 regardless of which tab shows).
On **NX**, **Scan again** is stricter: it only starts an import whose SHA-1
matches the open game tab, so a shared `imports/` folder with Red+Yellow
cannot jump Yellow → Red.
- A game tab (`_drawGamePanel`) shows the ROM card, the SAVE FILES card, the
Play button, and the SAVE SLOT card in a responsive two-column grid (see
@@ -150,6 +153,17 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
- `LauncherMods.uninstall(id)` removes `mods/<id>/` and clears
`options.mods[id]` so a later reinstall starts from the loader's default
(enabled). The mods panel Delete control calls this and re-derives the list.
- A mod that declares `github` shows its total GitHub downloads (every
release's summed asset `download_count`, from the same cached release
fetch the update check uses) as a highlighted body line like "12,345
downloads across all releases - Released 2024-05-31 - Updated 2026-07-01"
(first and latest `published_at`). Old cache entries written before the
counts existed show no line rather than a wrong zero; a manual check
refreshes them.
- The MODS panel sorts its rows by Name, Popularity (downloads),
Release date (first release), or Last updated, chosen by chips under the
header and persisted in `options.modSort`. Mods without release data
(no `github` field, or a stale cache) sink to the bottom of data sorts.
## Import / Export save
@@ -157,9 +171,14 @@ The SAVE FILES card wires a raw Gen1 `.sav` battery image to the save slots
through `src/import/SaveFileIO.lua`, which sits on top of
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
- **Import save** is live once the game's ROM is imported (playable). It opens
a native `.sav` picker (`chooseSav` on desktop; on Android,
`love.system.pickFile("sav")``picked_save.sav`, same SAF path as ROMs).
- **Import save** is live once the game's ROM is imported (playable).
On desktop it opens a native `.sav` picker (`chooseSav`); on Android,
`love.system.pickFile("sav")``picked_save.sav`, same SAF path as ROMs.
On **NX (Switch)** there is no picker: copy a `.sav` into
`getSaveDirectory()/imports/saves/<red|blue|yellow>/` via MTP / SD / FTP
(one folder per game), then press **Import save** on that games tab to
ensure the inbox and rescan (same pattern as the ROM `imports/` and mod
`imports/mods/` inboxes). Hidden `._*.sav` AppleDouble sidecars are skipped.
`SaveFileIO.importToSlot` reads the bytes (an absolute path, a save-dir
relative name, a dropped LOVE file, or raw bytes),
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
@@ -167,19 +186,29 @@ through `src/import/SaveFileIO.lua`, which sits on top of
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
The meta stamp is re-stamped off `gen1_import` to the current numeric format
so `SaveData.load`'s migration pass accepts the slot. On success the SAVE SLOT
panel is refreshed with the new slot selected.
panel is refreshed with the new slot selected. On **NX**, a successful inbox
import retires the file to `*.sav.imported` and records a content hash in
`imports/saves/<game>/.imported-sha1` so a second **Import save** (or the same
bytes under a new name) does not clone slots; failures leave the original
`.sav`. Only that games folder is scanned.
- **Export save** is live only when the active slot actually holds a save
(checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
`rawImport`, so this is a zero-filled template export, which is valid), and
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
(`love.filesystem.createDirectory("exports")`). On desktop it returns the
absolute path (`love.filesystem.getSaveDirectory()`), which the notice line
shows with an "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
writes `exports/<version>/gen1recomp-<version>-<slotId>.sav` under the same
root `persistFs` writes slots to: the portable game folder when `portable.txt`
marks the install, otherwise the save directory (`exports/` and
`exports/<version>/` are created as needed; #752). On desktop it returns the
absolute path (`SaveData.portableBaseDir()` when portable, else
`love.filesystem.getSaveDirectory()`), which the notice line shows with an
"Open folder" affordance (`love.system.openURL("file://" .. dir)`).
On Android the bytes are also staged as `pending_export.sav` and
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
player can save to Downloads / Drive / etc.; on return `export_done.flag`
makes focus show "Save exported."
On **NX**, export success sets a notice with the `exports/<game>/` path and an
MTP-oriented hint — no `openURL` / Open folder (pull the file via MTP /
SD / FTP instead).
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
currently active game tab; when a non-game tab (mods, or the locked yellow
placeholder) is showing it defaults to red, the always-present first game
+26
View File
@@ -174,6 +174,32 @@ It runs immediately before queued button edges are promoted, so input added by
the wrapper is visible during that same fixed step. The callback receives
`(next, game, dt)` and must call `next(game, dt)`.
`input.pointer` delivers uncaptured gameplay pointer events -- touches and
real mouse input alike. The callback receives `(next, game, ev)` where `ev`
is `{ phase, source, id, x, y, dx, dy, pressure, button }`: `phase` is
`"pressed"`, `"moved"`, `"released"` or `"cancelled"`; `source` is `"touch"`
or `"mouse"`; `id` is the LÖVE touch id or `"mouse"`; and the coordinates
are LOVE window units, the same space `render.hud`'s viewport and the touch
overlay lay out in. The on-screen touch controls keep first refusal: a
pointer that begins on a virtual control belongs to the pad for its whole
lifecycle and never reaches the hook, while one that begins outside stays
visible even if it later crosses a control. A real mouse reaches the hook
without `POKEPORT_TOUCH` (synthesized `istouch` mouse twins are dropped, so
a mobile touch fires once), and focus or visibility loss and input recovery
deliver a `"cancelled"` for every pointer the hook saw pressed but not yet
released. Return `true` without calling `next` to consume the event.
`mod.input` presses GB buttons source-safely. `mod.input:tap(game, btn)`
queues exactly one `wasPressed` edge for the next fixed step and holds
nothing; `local token = mod.input:press(game, btn)` holds the button until
`mod.input:release(token)`. Buttons are `up`, `down`, `left`, `right`, `a`,
`b`, `start` and `select`. Every press is its own input source inside the
engine's multi-source bookkeeping, so releasing a token never clears a hold
the keyboard, a controller, the touch overlay or another mod still owns;
`release` is idempotent and refuses tokens taken by another mod.
Outstanding tokens are released automatically on entry-chunk rollback, hot
reload and input recovery.
`ui.title_menu.items` receives `(next, game, items)` and follows the same
decorate-after-`next` convention as `ui.start_menu.items`. It is the safe place
for a tool to offer a fresh-session action before gameplay begins.
+103 -4
View File
@@ -1,10 +1,10 @@
# New features (deliberate additions beyond the original)
Intentional enhancements this port adds on top of faithful Pokémon Red
behavior. They have no Game Boy equivalent and are kept by design.
Intentional enhancements this port adds on top of faithful Pokémon Red, Blue,
and Yellow behavior. They have no Game Boy equivalent and are kept by design.
Genuine divergences from the original (things still missing, wrong, or
approximated) live in docs/known-differences.md; faithfully-ported
behavior is in docs/behavior-porting-notes.md.
approximated) live in docs/known-differences.md; faithfully-ported behavior is
in docs/behavior-porting-notes.md.
## Survey zoom
@@ -332,6 +332,17 @@ one used sideways. An `options.lua` from before this split keeps its single
layout in both orientations until one of them is edited. In-game, Options →
**TOUCH PAD** toggles the same on/off flag without leaving a play session.
## Screen orientation lock (Android)
Options → **ORIENTATION** (also in the launcher's gear menu) locks the
screen to **PORTRAIT**, **LANDSCAPE** (either landscape, following the
device), or **REVERSE LANDSCAPE**, or leaves it on **AUTO** (#592). AUTO
allows every orientation but defers to the system: with auto-rotate turned
off in Android's quick settings, the game stays put instead of following
the sensor (#716). Changes apply immediately -- the screen rotates as the
row is stepped -- and persist in `options.lua`. Android only: iOS follows
the app's fixed orientation list, and desktop windows rotate nothing.
## Translation support
Every string the player can read is now reachable from a mod, so a
@@ -365,6 +376,28 @@ plus a glyph-page and charmap stub, a naming-grid stub, and a
be packed). `--refresh` re-harvests after an engine update, keeping
existing translations and parking orphaned keys rather than dropping them.
A translation can also skip glyph pages entirely: scaffolding with
`--pixel-font` (or registering `mod.content.font:register("ttf", {})` in
an existing mod) renders text through a bundled TTF covering Latin with
diacritics, Cyrillic, kana and CJK, while box borders and `<PK>`-style
macro glyphs keep their tiles. The font is "Plain Pixel Font" by Douglas
Vautour (Burpy Fresh), licensed under CC-BY 4.0 (5x11 base characters,
11x11 double-width; see `assets/fonts/plainpixel/README.md`). Options on
the registry entry: `file` for a mod-shipped TTF, `size` (the font's
design em; Plain Pixel rasterizes cleanly only at multiples of 15),
`spacing` added to every advance, `yOffset` for vertical alignment
against the 8px cell grid, `bold`, which double-prints at a 1px
offset for fonts whose strokes read too light, and `tiles`, the
characters that keep their ROM tile instead of coming from the TTF.
`tiles` matters for a CJK translation. Sizing the font so a kana fills
the 8px cell leaves Latin narrower than the tile font it replaces, which
pulls the numeric columns out of line: the party menu's `:L12` stops
sitting over `34/ 34`. Naming `"0123456789/:"` keeps those on the
vanilla tiles, so numbers render exactly as they do in English while
kana still come from the font. It takes a string of characters, or a
list when a multi-character charmap sequence is meant.
See the wiki's Translations guide.
## Save editor (bundled, reachable from the launcher)
@@ -447,6 +480,16 @@ automatically, so the tab opens on an "Add an index" prompt until you name
one; paste an index URL or its `owner/repo` and it is remembered in
`options.lua`. More than one index can be added, and the listings merge.
A feed author can publish per-mod release stats by adding three optional
fields to an entry -- `downloads` (total across every release), and
`first_release` / `last_release` (ISO days) -- which the listing shows in
the same gold line the MODS tab uses. When a feed does not carry them,
the row fetches the mod's own GitHub releases instead -- the same cached
`ModUpdate` fetch the MODS tab uses, one entry per frame -- so the stats
appear for any mod with a `github` field regardless of feed maintenance.
The fields are additive: feeds that carry them stay readable by every
build that predates them, and feeds that do not render exactly as before.
## Soft reset (all versions)
Holding A, B, START and SELECT together restarts the game the way flicking
@@ -508,3 +551,59 @@ file pickers are ordinary desktop dialogs and still appear normally, and a
run started from a terminal (`lovec.exe`, what `scripts\run.ps1` prefers)
keeps its terminal and its printed output. Set `POKEPORT_CONSOLE=1` to opt
out.
## A faster, higher-contrast launcher
The launcher and the save editor were rebuilt on one small immediate-mode UI
kit (`src/ui/kit/`), replacing the vendored FlexLove layout engine. The
visible result is that the launcher is quick: building and drawing a frame
went from about 9 ms to under 1 ms on the same machine and the same data, at
every window size, so the window keeps up with the pointer instead of
trailing it. Measure it yourself with `POKEPORT_LAUNCHER_PROF=200 love .`.
**Nothing blocks the window any more.** Fetching a mod index, checking a mod
for updates, listing versions, downloading an install and pulling thumbnails
all run on background threads. Opening FIND MODS on a cold cache used to
freeze the launcher for as long as the server took -- often minutes, with no
indication anything was happening. Mod indexes are now also fetched at boot,
so the tab is usually already populated by the time you reach it.
**Anything you wait on says so.** Every operation that takes time raises a
loading panel with a spinner or a progress bar that cannot be clicked around
or dismissed, so a half-finished install can never be interrupted by a stray
click. Work that only affects one row (a mod's update check) shows a small
spinner on that row instead and leaves the rest of the list usable.
**Lists page instead of scrolling.** Mods, Find Mods, save slots, settings,
release notes and the version list all show a fixed number of rows with a
pager underneath, and the number of rows comes from the window height -- a
tall window shows more, a phone shows fewer. A long list costs exactly what a
short one does. The mouse wheel turns pages.
**Updates live in the top right.** The in-app updater moved next to the
settings gear and pulses when an update is waiting, instead of sitting in a
banner at the bottom of the page that you had to scroll to notice. Checking
for updates from there shows a loader like everything else.
**The look.** Black background, white outlines, no gradients or glows, and
buttons that are solid colour-coded keys: green commits, blue navigates, red
destroys, yellow wants attention. The three game tabs keep their red, blue
and gold cartridge colours. Everything is about a third larger than before.
The save editor follows the same theme, and adding an item there is now a
searchable pop-up like adding a Pokemon, rather than a cramped list wedged
into the tab.
**Reset rebinds.** Input rebinds are additive, so there was no in-game way to
undo one. A RESET REBINDS row in Settings, and a matching button under Touch
Controls on each game tab, restore the stock keyboard, gamepad and touch
layout. Both ask twice.
## Launch options: boot straight into a game
`love . --game red` skips the launcher and starts that game; `--slot <id or
number>` picks the save slot to load, and `--launcher` forces the launcher
anyway. `POKEPORT_GAME` / `POKEPORT_SLOT` do the same for shortcuts that can
only pass environment variables. This is for one-click entries: a desktop
shortcut per game, a Steam entry, or a handheld frontend. Asking for a game
whose ROM has not been imported opens the launcher on that game's tab rather
than failing.
+8 -7
View File
@@ -1,12 +1,11 @@
# What This Port Requires
The packaged desktop app requires one user-supplied input on first boot: a
canonical 1 MiB US Pokemon Red ROM.
canonical 1 MiB US Pokemon Red, Blue, or Yellow ROM.
The importer verifies SHA-1
`ea9bcae617fdf159b045185467ae58b2e4a48b9a`. Other revisions, Virtual
Console releases, and Pokemon Blue are rejected rather than decoded with
incorrect addresses.
The importer verifies the SHA-1 for the game (see `src/core/GameVersion.lua`
for specific hashes). Other revisions and Virtual Console releases are rejected
rather than decoded with incorrect addresses.
After verification, the app generates its private cache in the LÖVE save
directory. It does not keep a copy of the ROM. Later boots use the cache.
@@ -15,9 +14,11 @@ Python and Pillow are not required by the packaged app.
## Bundled Metadata
Assembly removes high-level names and some relationships that the Lua port
needs. `tools/rom_manifest.json` therefore contains:
needs. The version-specific files `tools/rom_manifest.json`,
`tools/rom_manifest_blue.json`, and `tools/rom_manifest_yellow.json` therefore
contain:
- the 3,268 ROM symbol addresses actually read by the extractor
- the ROM symbol addresses actually read by the extractor
- symbolic IDs and ordering for maps, species, moves, items, and trainers
- source-erased dimensions, image names, and map object integration names
- hand-ported field/script integration tables
+204
View File
@@ -0,0 +1,204 @@
# Build the Nintendo Switch NRO — contributor guide
Want to play a release build instead? Download the SD-ready zip and extract it
at your microSD root — see [switch-install.md](switch-install.md).
This guide is for contributors who build Gen1Recomp for Switch from source.
Hardware evidence, MTP operator loops, and deeper notes live in
[switch-development.md](switch-development.md).
> Releases ship `gen1recomp-*-switch.zip` (SD tree under `switch/gen1recomp/`;
> issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Hardware
> evidence: **OLED** (author) and **V1 boot** (community). See
> [switch-development.md](switch-development.md) for known limitations.
---
## Prerequisites by OS
All packaging entrypoints are **bash**. On Windows, use Git Bash, MSYS2, or
WSL — not cmd.exe or PowerShell (AD-008).
### macOS / Linux
1. Install [devkitPro pacman](https://devkitpro.org/wiki/devkitPro_pacman).
2. Install Switch tools:
```sh
sudo dkp-pacman -S switch-dev
```
3. Ensure `nacptool` and `elf2nro` are on `PATH` (or under
`$DEVKITPRO/tools/bin` — the fused script prepends that when set).
**Optional:** Install [Docker](https://docs.docker.com/get-docker/) so fused
builds can fall back to the pinned image when native tools are missing.
### Windows (Git Bash / MSYS2 / WSL)
1. Use a bash environment:
- **MSYS2** with the [devkitPro](https://devkitpro.org/wiki/devkitPro_pacman)
packages (preferred for native `nacptool`/`elf2nro`), or
- **WSL** (Ubuntu/etc.) with the Linux pacman flow above, or
- **Git Bash** for `--fetch` / `--loose`; for `--fused` prefer MSYS2 or
WSL if Docker bind-mounts from Git Bash paths misbehave.
2. Install `switch-dev` (or rely on Docker fallback — see below).
3. Do **not** expect `scripts/build_switch.sh` to run under cmd/PowerShell.
### What you must install yourself
| You install | Script does **not** install |
| ----------- | --------------------------- |
| bash, git, zip tooling the repo already expects | — |
| `dkp-pacman` + `switch-dev` (native fused) | `dkp-pacman -S …` |
| Docker (optional fused fallback) | Docker Engine |
| A legal `.gb` ROM (to play) | Any ROM or game data |
---
## Mode glossary
`scripts/build_switch.sh` supports three modes (combinable as noted):
| Mode | What it does |
| ---- | ------------ |
| `--fetch` | Downloads pinned **love.nro** + **love.elf** into `.bazinga/love-nx/11.5-nx1/` and verifies SHA-256 against `scripts/switch/love-nx-11.5-nx1.sha256`. |
| `--loose` | Packs `game.love`, copies pinned `love.nro``dist/switch/loose/` as `gen1recomp.nro` + `game.love` side by side. Needs the pin. |
| `--fused` | Builds `dist/switch/gen1recomp-<ver>-switch.nro` (game in romfs) via `nacptool` + `elf2nro`, then packs `dist/switch/gen1recomp-<ver>-switch.zip` (SD-ready tree). Needs the pin + toolchain (native or Docker). GitHub Releases publish the **zip only**. |
Rules:
- `--fetch` alone is fine; combine as `--fetch --loose` or `--fetch --fused`.
- `--loose` and `--fused` are **XOR** — pick one packaging path per run.
- `--version X.Y.Z` sets the NACP / filename version (defaults to short git SHA).
### What `--fetch` downloads
Only the two pinned love-nx release assets (`love.nro`, `love.elf`). It does
**not** install:
- devkitPro / `dkp-pacman` / `switch-dev`
- Docker
- ROMs, saves, or mods
---
## Native tools, then Docker
Fused packaging (`scripts/switch/build_fused.sh`):
1. Prefer native `nacptool` + `elf2nro` on `PATH` (or `$DEVKITPRO/tools/bin`).
2. Else fall back to Docker using:
- `GEN1_DKP_IMAGE` if set, otherwise
- the image named in `scripts/switch/dkp-docker.image` (default
`devkitpro/devkita64:latest`).
If neither native tools nor Docker work, the script exits non-zero with
macOS / Linux / Windows / Docker hints and a pointer to this doc.
---
## Example commands
From the repo root:
```sh
# Download pinned love-nx only
scripts/build_switch.sh --fetch
# Loose pair for iteration (fetch + assemble)
scripts/build_switch.sh --fetch --loose
# Single fused NRO + SD-ready zip for a release-like artifact
scripts/build_switch.sh --fetch --fused --version 0.2.0
```
Outputs land under `dist/switch/` (and `dist/switch/loose/` for loose mode).
The fused path also writes `gen1recomp-<ver>-switch.nro.sha256` and
`gen1recomp-<ver>-switch.zip` (+ `.sha256` sidecar for the zip).
Offline packaging smoke (no network, no nacptool required):
```sh
bash scripts/switch/selftest_build_switch.sh
bash scripts/switch/verify_payload.sh --self-test
```
---
## CI and release
Switch packaging has three automated surfaces (same policy as AD-010):
### Path-gated PR / push CI (`.github/workflows/ci.yml`)
When a change touches Switch packaging / Switch docs / NX runtime paths
(`scripts/build_switch.sh`, `scripts/switch/**`, `docs/switch-*.md`,
`tests/switch_ci_workflows_test.lua`, `tests/switch_transfer_docs_test.lua`,
the NX runtime modules `src/core/NxAssetOverlay.lua`, `src/core/Platform.lua`,
`src/core/GameVersion.lua`, `src/import/CacheFs.lua`, the NX engine suites
`tests/engine/assets_version_fallback_test.lua`,
`tests/engine/nx_generated_guard_test.lua`,
`tests/engine/nx_yellow_boot_test.lua`,
`tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`,
or the Switch-related workflow YAML), CI runs:
1. **Offline selftest** on `ubuntu-latest` (forks **and** the canonical repo):
`scripts/switch/selftest_build_switch.sh`,
`scripts/switch/verify_payload.sh --self-test`,
`luajit tests/switch_ci_workflows_test.lua`,
`luajit tests/switch_transfer_docs_test.lua`, and the NX engine suites
headlessly (`luajit tests/engine/assets_version_fallback_test.lua`,
`luajit tests/engine/nx_generated_guard_test.lua`,
`luajit tests/engine/nx_yellow_boot_test.lua`).
2. **Fused NRO build** only on the **canonical** repository
(`bryanthaboi/gen1recomp`), on the self-hosted Mac runner
(`scripts/build_switch.sh --fetch --fused`), and only when the workflow
head is that repo (same-repo push/PR). **Fork repository** CI never runs
fused. **Fork → canonical PRs** also skip Switch fused (offline selftest
still runs) so untrusted head code is not executed on the self-hosted Mac;
iOS device build eligibility is unchanged. Fused also waits for a successful
offline selftest before starting on the Mac runner.
3. On successful PR fused builds, a follow-up workflow posts a PR comment
linking the Actions artifact named `gen1recomp-switch-nro`
(comment tag `switch-build-result`; see
`.github/workflows/switch-artifact-comment.yml`).
Unrelated PRs do not burn the self-hosted Mac on Switch packaging.
### Release hard-fail (`.github/workflows/release.yml`)
GitHub Releases always build Switch on the same self-hosted Mac runner as the
other platforms — this is a **hard gate** (no `continue-on-error`):
```sh
scripts/build_switch.sh --fetch --fused --version "<release version>"
```
A Switch packaging failure fails the entire release job. The release asset is
`gen1recomp-<ver>-switch.zip` (SD-ready); the versioned `.nro` stays under
`dist/switch/` for the packer and for PR CI artifacts.
### Runner provisioning
The self-hosted Mac runner must have **native switch-tools** (`nacptool` /
`elf2nro`) **and/or Docker** available. CI and release do not silently run
`dkp-pacman -S`; keep the runner image/host provisioned per this guide.
---
## Limitations / non-goals
These scripts and this guide do **not**:
- Push files to the console (no automated MTP / FTP / SD scripting)
- Bundle or download any Pokémon ROM
- Install `dkp-pacman` / `switch-dev` for you
- Provide `nxlink` / netloader deploy (deferred — see [switch-transfer.md](switch-transfer.md))
- Validate **Applet Mode** — use title override (hold **R**) for full memory
Player install steps: [switch-install.md](switch-install.md).
Manual transfer (MTP / SD / FTP, macOS / Linux / Windows):
[switch-transfer.md](switch-transfer.md).
Hardware depth and evidence: [switch-development.md](switch-development.md),
[switch-hardware-evidence.md](switch-hardware-evidence.md).
+528
View File
@@ -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 Dusklights **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 games** 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 ports 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 consoles `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 operators 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-0af | love-nx pin, probe, MTP, title override | **pass** | Phase 0 checklist above; T4 |
| P0-1ad | 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`)
+187
View File
@@ -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
```
+164
View File
@@ -0,0 +1,164 @@
# Install Gen1Recomp on Nintendo Switch
Every GitHub Release that includes Switch support ships an SD-ready zip:
`gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install
**or** update — same steps), launch with **title override**, then import your
own legal `.gb` ROM.
> You need a console that can run Switch homebrew (custom firmware / hbmenu).
> This project does not help you set that up. Tracks issue
> [#531](https://github.com/bryanthaboi/gen1recomp/issues/531).
> Hardware: **OLED** validated by the porter; **V1 / Erista** boot confirmed
> by the community. Lite and other setups welcome more reports.
> See [switch-development.md](switch-development.md) for limitations.
Prefer building from source? See [switch-build.md](switch-build.md).
Port by [andrewqsantos](https://github.com/andrewqsantos). Community testing
help from [booshankles](https://github.com/booshankles).
## 1. Download the zip
1. Open
[Releases](https://github.com/bryanthaboi/gen1recomp/releases).
2. Download `gen1recomp-*-switch.zip` for the version you want.
(Optional: verify against `sha256sums.txt` in the same release.)
## 2. Extract onto the microSD
Extract the zip at the **root** of the microSD so you get:
```text
sdmc:/switch/gen1recomp/gen1recomp.nro
sdmc:/switch/gen1recomp/pokemon-love2d/imports/
sdmc:/switch/gen1recomp/pokemon-love2d/imports/mods/
sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/...
```
Merge folders if your OS asks. Any method works: **MTP** (DBI → Run MTP
responder + a client), **direct SD** (Hekate UMS or a card reader), or **FTP**.
Exit MTP / unmount / stop FTP cleanly before launching. Step-by-step for
macOS, Linux, and Windows: [switch-transfer.md](switch-transfer.md).
### Updating
Use the **same** extract/merge. It replaces `gen1recomp.nro` (and the small
help `README.txt` / `INSTALL.txt` files). Saves, imported ROMs, mods, and
options live under `pokemon-love2d/`**do not delete that folder** when
updating, or you will lose progress.
## 3. Launch with title override
**Applet Mode is not supported** for this game (not enough memory).
1. On the Switch HOME menu, highlight any installed title.
2. Hold **R** and launch that title — this opens hbmenu with full memory
(title override).
3. From hbmenu, open `gen1recomp`.
Do **not** launch from the Album applet path for normal play.
## 4. Import your ROM
This project ships **no** game data. On first launch:
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), or Yellow
(`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
launcher also shows the live save-dir path). All three can sit in the
same folder.
2. Use **Scan again** on that games tab (Red / Blue / Yellow). Rescan
matches by ROM SHA-1 for the open tab only — a Red dump never imports
from the Yellow tab (and vice versa).
## 5. Import / Export a raw `.sav`
Continue a cart or PC battery save (or pull a slot off-console) via MTP /
SD / FTP — same transfer methods as ROMs. Paths are **per game**:
| Game | Import inbox | Export folder |
| ---- | ------------ | ------------- |
| Red | `imports/saves/red/` | `exports/red/` |
| Blue | `imports/saves/blue/` | `exports/blue/` |
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
(Under the save dir `pokemon-love2d/` — the zip already creates these folders.)
1. Copy a Gen1 `.sav` (32 KB) into that games inbox under the save dir
([switch-transfer.md](switch-transfer.md)).
2. With the games ROM already imported, open **that games tab**
**SAVE FILES****Import save**. Only that folder is scanned.
3. A successful import retires the file to `*.sav.imported` and records its
content hash so pressing **Import save** again does not clone slots.
Failed imports leave the original `.sav` in place.
4. To pull a slot off the console, use **Export save**, then copy the file
from that games **`exports/<game>/`** folder via MTP / SD / FTP.
Do not put `.sav` files into git. Prefer clean copies — some MTP clients
create `._*.sav` AppleDouble sidecars that are not real saves.
## Controls
### Gameplay
| Control | Action |
| ------- | ------ |
| D-pad / left stick | Move |
| **A** | Confirm |
| **B** | Cancel |
| **+** (Start) | Start |
| **** (Select) | Select |
| **R** (no Select held) | Cycle game speed up |
| **L** (no Select held) | Cycle game speed down |
### Launcher
| Control | Action |
| ------- | ------ |
| D-pad / left stick | Move virtual cursor |
| **A** | Click at cursor |
| **L** / **R** | Previous / next tab |
| **Start** / **Select** | Play if a ROM is ready; otherwise Choose ROM |
### System
| Control | Action |
| ------- | ------ |
| Hold **R** on HOME, then open from hbmenu | Title override (full memory) |
## Community mods
Mods install from a zip inbox (same transfer methods as ROMs):
1. Copy a release `.zip` into the save-dir **`imports/mods/`** path the
launcher shows (MTP / SD / FTP — [switch-transfer.md](switch-transfer.md)).
2. In the launcher, open **MODS****Scan again** → enable the mod →
**Play**.
Remote **FIND MODS** / GitHub download stays **off** on Switch. Do not put
mod zips into git. Community mods ship their own OPTIONS / rebinds — this port
does not document third-party control tables.
### Joy-Con shortcuts (Select + face)
Hold **Select** () and press a face/shoulder button. Without Select, A/B stay
normal gameplay confirm/cancel. These chords are the stock engine display
hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs).
| Chord | Same as PC key | Stock engine effect |
| ----- | -------------- | ------------------- |
| Select + **A** | `2` | COLORS |
| Select + **B** | `3` | TILT |
| Select + **Y** | `5` | GBC FX |
| Select + **X** | `6` | Mod pipeline hotkey (if a mod registers `6`) |
| Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) |
If the handheld stutters with extras on, try **OPTIONS → PERFORMANCE**
`LOW` or `BALANCED`. Full chord notes for contributors:
[switch-development.md](switch-development.md#joy-con-display-chords-select--face).
## Prefer building it yourself?
Building the fused NRO (and SD-ready zip) from source is covered in
[switch-build.md](switch-build.md). Copying artifacts and inbox files
(MTP / SD / FTP on macOS, Linux, Windows): [switch-transfer.md](switch-transfer.md).
Status, limitations, and how we tested: [switch-development.md](switch-development.md).
+168
View File
@@ -0,0 +1,168 @@
# Switch file transfer (MTP / SD / FTP)
Canonical ways to put Gen1Recomp artifacts and inbox files onto a Nintendo
Switch. **Any method is valid** if the bytes land in the destinations below.
This is the home runbook for contributors on **macOS, Linux, and Windows**.
Player install (what to download, title override) stays in
[switch-install.md](switch-install.md). Packaging stays in
[switch-build.md](switch-build.md). Hardware evidence lives in
[switch-hardware-evidence.md](switch-hardware-evidence.md).
> **Not supported yet:** `nxlink` / hbmenu netloader automation. Useful later
> for a fast contrib rebuild loop; deferred on purpose (AD-009). Do not treat
> netloader as the release or ROM/mod install path.
---
## Destinations (shared by every method)
| What | Where on the console |
| ---- | -------------------- |
| SD-ready release zip | Extract at microSD **root**`sdmc:/switch/gen1recomp/gen1recomp.nro` plus `pokemon-love2d/` inbox folders. Install and update use the same merge; do **not** delete `pokemon-love2d/` |
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that games SAVE FILES → **Import save** |
| Save exports | Same save dir → `exports/red\|blue\|yellow/` (pull after **Export save**; MTP / SD / FTP) |
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
| Lua error log | `lua-error.log` in the save dir |
Saves persist across zip re-extract / NRO replacements as long as
`pokemon-love2d/` is left in place. Never commit ROM dumps, `.sav`
files, or third-party mod zips to git.
---
## Canonical methods
### 1. MTP (DBI responder + host client)
On the Switch: close Gen1Recomp → open **DBI****Run MTP responder** (often
**X** on the main screen) → keep that screen up → USB-C data cable to the host.
On the host: open **one** MTP client, navigate to **`1: SD Card`**, then the
paths above. Wait for the transfer queue; refresh; exit MTP on the Switch
before launching.
#### macOS (example: OpenMTP)
[OpenMTP](https://github.com/ganeshrvel/openmtp) is the loop used for OLED
hardware evidence — **one contributor example**, not a Mac-only product rule.
1. Quit other MTP clients.
2. Open OpenMTP → select the DBI device → **`1: SD Card`**.
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
(or copy NRO / `game.love` for loose).
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
`imports/saves/<red|blue|yellow>/`, or `exports/<red|blue|yellow>/` path the
launcher prints.
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
macOS clients often create AppleDouble sidecars (`._Something.zip`,
`._cart.gb`, `._foo.sav`). Those are not real archives or saves — the
launcher skips hidden `.*` names. Delete `._*` junk if a zip/ROM/`.sav`
fails to open.
#### Linux
1. Install desktop MTP support if needed (e.g. `gvfs-mtp` on GNOME/GTK
desktops, or your distros KDE MTP stack).
2. With DBI MTP active, open **Files** / **Dolphin** / **Thunar** and select
the Switch / DBI device → **`1: SD Card`**.
3. Extract the release zip at SD root (merge), or copy into `switch/gen1recomp/`
and the save-dir inboxes as above.
4. Use **only one** MTP accessor at a time. If `mtp-tools` / `mtpfs` reports
“device is busy”, close the file managers MTP mount (or the CLI mount)
and retry with a single client.
5. Eject/unmount cleanly; exit MTP on the Switch; title-override launch.
If MTP is unavailable or flaky on Linux, use **direct SD** (Hekate UMS or a
card reader) or **FTP** instead — same destinations in the table above.
#### Windows
1. With DBI MTP active, open **This PC** / **File Explorer** and look under
**Portable Devices** for the Switch / DBI MTP volume → **`1: SD Card`**.
2. Copy / extract into `switch\gen1recomp\` and the save-dir inboxes.
3. Optional: [OpenMTP](https://github.com/ganeshrvel/openmtp) on Windows if
Explorer is flaky.
4. If Windows does not show an MTP device: Device Manager → find DBI / Switch
→ Update driver → **MTP USB Device** (or Standard MTP Device). Prefer a
data-capable USB-C cable and a direct port.
5. Safely disconnect; exit MTP on the Switch; title-override launch.
If MTP is unavailable or flaky on Windows, use **direct SD** (Hekate UMS or a
card reader) or **FTP** instead — same destinations in the table above.
### 2. Direct SD (Hekate UMS or card reader)
Same destinations; no MTP client required.
- **Hekate UMS** (preferred when available): expose the microSD to the host
while the card stays in the console; mount the volume; copy files; **cleanly
unmount** before leaving UMS.
- **Physical reader**: power off / remove the microSD, copy on the host,
**eject safely**, reinsert, boot CFW, title-override launch.
Do not yank the card or unplug UMS mid-write.
### 3. FTP (any SD-exposing Switch FTP)
Any homebrew FTP server that can write the microSD is fine — for example
**DBIs own FTP**, **sys-ftpd-light**, or **Sphaira** (names are illustrations
only; pick what your CFW setup already uses).
1. Start the FTP server on the Switch; note IP/port/credentials from that app.
2. From the host, connect with any FTP client and upload to the same
`switch/gen1recomp/`, `imports/`, `imports/mods/`, `imports/saves/<game>/`,
and `exports/<game>/` paths.
3. Stop the FTP server cleanly before launching Gen1Recomp.
If credentials or chroots differ by app, trust the **destination paths**, not
a single vendor tutorial.
---
## After every transfer
1. Exit MTP / unmount SD / stop FTP cleanly.
2. Launch via **title override** (hold **R** on a title → hbmenu). **Applet
Mode is not supported** (not enough memory).
3. For ROMs: open the matching game tab → **Scan again** if the file was
added after boot (SHA-1 must match that tab; other dumps in `imports/`
stay for their own tabs). For mods: MODS → **Scan again** → enable →
Play. For saves: SAVE FILES → **Import save** (rescans
`imports/saves/<game>/`). Pull exported `.sav` files from
`exports/<game>/`. Joy-Con display chords (stock engine):
[switch-install.md](switch-install.md#joy-con-shortcuts-select--face).
### Optional NRO integrity check
For the first deploy of a given artifact (or after a flaky cable):
```bash
shasum -a 256 path/to/gen1recomp.nro # or sha256sum
```
Copy the file back from the SD and compare hashes. Round-trip must match.
---
## Failure modes (quick)
| Symptom | What to try |
| ------- | ----------- |
| Device busy / no MTP volume | One client only; different cable/port; Windows MTP USB Device driver; alternate method (SD or FTP) |
| Zip/ROM/`.sav` “could not be opened” | Delete `._*` sidecars (including `._*.sav`); confirm real zip starts with `PK` |
| Half-copied NRO / crash on boot | Re-copy; verify SHA-256; exit transfer mode before launch |
| App opens in Applet Mode | Use title override (hold **R**), not Album |
---
## Related
- Players: [switch-install.md](switch-install.md)
- Builders: [switch-build.md](switch-build.md)
- Status / hardware matrix: [switch-development.md](switch-development.md)
- Evidence log: [switch-hardware-evidence.md](switch-hardware-evidence.md)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-188
View File
@@ -1,188 +0,0 @@
-- modules/Behavior.lua
--
-- Base module for the pluggable behavior system that drives the Behavior &
-- Mode Unification refactor.
--
-- A *behavior* is a small, stateless table produced by `Behavior.new(spec)`
-- that implements a fixed lifecycle hook set. Concrete behaviors (Clickable,
-- Scrollable, TextEditable, Selectable, ...) each live in their own module and
-- are attached to an Element. The Element's `update`/`draw`/save-restore paths
-- iterate `element.behaviors` and dispatch to the appropriate hooks, replacing
-- the swarm of `if self.scrollable` / immediate-mode-branch checks previously
-- hard-coded in Element.lua.
--
-- Element.new iterates a registry of behavior prototypes and auto-attaches
-- whichever return true from `shouldAttach(props)`. Element therefore never
-- needs to know what an individual behavior does — only that it conforms to
-- this interface.
--
-- Design constraints (locked — tasks 02-13 depend on this API):
-- * Pure Lua — NO `love` import, NO dependency on utils/Color/Units/ErrorHandler.
-- Stays fully stub-testable standalone (see testing/__tests__/behavior_test.lua).
-- * Minimal interface — exactly 6 lifecycle hooks + a `shouldAttach` predicate.
-- Do NOT add hooks "just in case"; new capabilities become new behaviors,
-- not new hooks. Extending HOOK_NAMES is an architectural decision that must
-- be mirrored by every concrete behavior.
-- * Immutable instances — behavior tables are produced once and treated as
-- read-only. Per-element runtime state lives on the element (or a subsystem
-- the behavior attaches), NEVER on the behavior instance itself, so a single
-- behavior instance can be shared across many elements.
--
-- Lifecycle hook contract (each receives the owning element as first argument):
-- onAttach(element) — called once when the behavior is attached
-- (element fully constructed). Allocate
-- subsystems / register listeners here.
-- onDetach(element) — called once when the behavior is detached
-- (element destroyed / mode switch). Tear
-- down anything onAttach created.
-- onUpdate(element, dt) — called every frame from Element:update.
-- onDraw(element, ctx) — called every frame from Element:draw; `ctx`
-- is the draw context (viewport transform,
-- scissor state, theme renderer, ...).
-- saveState(element) -> state — called during Element save-state; returns
-- a serializable snapshot (or nil) so the
-- behavior's runtime state survives the
-- immediate-mode recreation cycle.
-- restoreState(element, state) — called after reconstruction with the
-- snapshot previously returned by saveState.
--
-- shouldAttach(props) -> boolean — class-level predicate (not a hook): given
-- an element's props table, return true if
-- this behavior should be auto-attached.
-- Defaults to false (opt-in).
--- A behavior instance: a frozen table of lifecycle hooks + a shouldAttach
--- predicate. All hooks are always present (custom override or no-op default).
---@class Behavior
---@field onAttach fun(element:table)
---@field onDetach fun(element:table)
---@field onUpdate fun(element:table, dt:number)
---@field onDraw fun(element:table, ctx:table)
---@field saveState fun(element:table):any
---@field restoreState fun(element:table, state:any)
---@field shouldAttach fun(props:table):boolean
local Behavior = {}
-- The fixed, ordered lifecycle hook set. Order is preserved so downstream tasks
-- (Element behavior iteration) can rely on a deterministic dispatch sequence.
-- HOOK_NAMES is intentionally NOT extended casually — see file header.
Behavior.HOOK_NAMES = {
"onAttach",
"onDetach",
"onUpdate",
"onDraw",
"saveState",
"restoreState",
}
-- Allowlist of spec keys accepted by Behavior.new. Anything else is rejected so
-- a typo (e.g. `onUpdat`) surfaces immediately instead of silently no-op'ing.
-- Hook keys (HOOK_NAMES + shouldAttach) MUST be functions; metadata keys
-- (drawLayer) may hold any value.
local ALLOWED_KEYS = {
onAttach = true,
onDetach = true,
onUpdate = true,
onDraw = true,
saveState = true,
restoreState = true,
shouldAttach = true,
drawLayer = true,
}
-- Spec keys whose values are NOT required to be functions (passive metadata
-- consumed by dispatch sites, e.g. Element:draw's pre/post-children split).
local NON_FUNCTION_KEYS = {
drawLayer = true,
}
-- Default no-op hook. Behaviors override only the hooks they need; every other
-- hook resolves to this so dispatch sites never have to nil-check.
local function noop() end
-- Default shouldAttach predicate: never auto-attach unless the behavior opts in
-- by providing its own predicate. This is the safe default — a behavior with no
-- opinion about which elements it applies to stays inert in the auto-attach
-- pass (it can still be attached explicitly by name in a later task).
local function defaultShouldAttach()
return false
end
-- Module-level default predicate exposed for callers/tests that want to
-- reference the base default directly without constructing an instance.
Behavior.shouldAttach = defaultShouldAttach
--- Factory: create a frozen behavior instance from a spec table.
---
--- `spec` is a table whose keys may be any subset of the 6 lifecycle hook names
--- plus `shouldAttach`; each value (when present) must be a function. The
--- returned table contains every lifecycle hook (custom override OR no-op) and
--- a `shouldAttach` predicate (custom OR always-false default), so dispatch
--- sites can call any hook unconditionally without nil-checking.
---
--- Unknown spec keys and non-function values raise an error immediately so
--- mistakes fail fast at construction rather than as silent no-ops later.
---
---@param spec table|nil spec table overriding select hooks / shouldAttach
---@return Behavior
function Behavior.new(spec)
spec = spec or {}
-- Validate spec keys up front so typos surface here, not as silent no-ops.
for key, value in pairs(spec) do
if not ALLOWED_KEYS[key] then
error(string.format("Behavior.new: unknown spec key '%s'", tostring(key)), 2)
end
if not NON_FUNCTION_KEYS[key] and type(value) ~= "function" then
error(string.format("Behavior.new: spec key '%s' must be a function, got %s", tostring(key), type(value)), 2)
end
end
local instance = {}
-- Populate every lifecycle hook: custom override when provided, no-op default
-- otherwise. Guarantees `instance.hook` is always callable.
for _, hook in ipairs(Behavior.HOOK_NAMES) do
instance[hook] = spec[hook] or noop
end
-- shouldAttach defaults to always-false; behaviors opt in by supplying one.
instance.shouldAttach = spec.shouldAttach or defaultShouldAttach
-- drawLayer: optional metadata field (default nil = "background"/pre-children).
-- Dispatch sites (Element:draw) use it to split rendering into pre-children
-- (background layers) and post-children (overlay layers, e.g. scrollbars).
instance.drawLayer = spec.drawLayer
-- Freeze: prevent adding new fields. Behavior instances are shared, stateless
-- objects; runtime state belongs on the element, never on the behavior.
-- (Reassigning an existing hook is still possible via direct index write —
-- Lua metatables cannot intercept that — but the freeze communicates intent
-- and catches accidental field additions.)
local mt = {
__newindex = function(_, key)
error(string.format("Behavior: behavior instances are immutable (cannot set '%s')", tostring(key)), 2)
end,
--- Mark the metatable so consumers can detect a Behavior instance.
---@return string
__tostring = function()
return "Behavior"
end,
__metatable = "Behavior",
}
setmetatable(instance, mt)
return instance
end
--- Type guard: returns true if `value` is a Behavior instance produced by
--- `Behavior.new`. Used by Element's attach path to validate registry entries
--- without depending on identity.
---@param value any
---@return boolean
function Behavior.isBehavior(value)
return type(value) == "table" and getmetatable(value) == "Behavior"
end
return Behavior
-686
View File
@@ -1,686 +0,0 @@
-- Lua 5.2+ compatibility for unpack
local unpack = table.unpack or unpack
-- Warning cache to prevent duplicate warnings for the same element
local warningCache = {}
local Cache = {
canvases = {},
quads = {},
blurInstances = {}, -- Cache blur instances by quality
blurredCanvases = {}, -- Cache pre-blurred canvases for immediate mode
MAX_CANVAS_SIZE = 20,
MAX_QUAD_SIZE = 20,
MAX_BLURRED_CANVAS_CACHE = 50, -- Maximum cached blurred canvases
RADIUS_THRESHOLD = 0.5, -- Skip blur below this radius
LARGE_BLUR_THRESHOLD = 250 * 250, -- Warn if blur area exceeds this (250x250px)
}
--- Round canvas size to nearest bucket for better reuse
---@param size number Size to bucket
---@return number bucketSize Bucketed size
local function bucketSize(size)
if size <= 128 then
return math.ceil(size / 32) * 32
elseif size <= 512 then
return math.ceil(size / 64) * 64
elseif size <= 1024 then
return math.ceil(size / 128) * 128
else
return math.ceil(size / 256) * 256
end
end
--- Get or create a canvas from cache
---@param width number Canvas width
---@param height number Canvas height
---@return love.Canvas canvas The cached or new canvas
function Cache.getCanvas(width, height)
-- Use bucketed sizes for better cache reuse
local bucketedWidth = bucketSize(width)
local bucketedHeight = bucketSize(height)
local key = string.format("%dx%d", bucketedWidth, bucketedHeight)
if not Cache.canvases[key] then
Cache.canvases[key] = {}
end
local cache = Cache.canvases[key]
for i, entry in ipairs(cache) do
if not entry.inUse then
entry.inUse = true
return entry.canvas
end
end
local canvas = love.graphics.newCanvas(bucketedWidth, bucketedHeight)
table.insert(cache, { canvas = canvas, inUse = true })
if #cache > Cache.MAX_CANVAS_SIZE then
local removed = table.remove(cache, 1)
if removed and removed.canvas then
removed.canvas:release()
end
end
return canvas
end
--- Release a canvas back to the cache
---@param canvas love.Canvas Canvas to release
function Cache.releaseCanvas(canvas)
for _, sizeCache in pairs(Cache.canvases) do
for _, entry in ipairs(sizeCache) do
if entry.canvas == canvas then
entry.inUse = false
return
end
end
end
end
--- Get or create a quad from cache
---@param x number X position
---@param y number Y position
---@param width number Quad width
---@param height number Quad height
---@param sw number Source width
---@param sh number Source height
---@return love.Quad quad The cached or new quad
function Cache.getQuad(x, y, width, height, sw, sh)
local key = string.format("%d,%d,%d,%d,%d,%d", x, y, width, height, sw, sh)
if not Cache.quads[key] then
Cache.quads[key] = {}
end
local cache = Cache.quads[key]
for i, entry in ipairs(cache) do
if not entry.inUse then
entry.inUse = true
return entry.quad
end
end
local quad = love.graphics.newQuad(x, y, width, height, sw, sh)
table.insert(cache, { quad = quad, inUse = true })
if #cache > Cache.MAX_QUAD_SIZE then
table.remove(cache, 1)
end
return quad
end
--- Release a quad back to the cache
---@param quad love.Quad Quad to release
function Cache.releaseQuad(quad)
for _, keyCache in pairs(Cache.quads) do
for _, entry in ipairs(keyCache) do
if entry.quad == quad then
entry.inUse = false
return
end
end
end
end
--- Generate cache key for blurred canvas
---@param elementId string Element ID
---@param x number X position
---@param y number Y position
---@param width number Width
---@param height number Height
---@param radius number Blur radius
---@param quality number Blur quality
---@param isBackdrop boolean Whether this is backdrop blur
---@return string key Cache key
function Cache.generateBlurCacheKey(elementId, x, y, width, height, radius, quality, isBackdrop)
return string.format(
"%s:%d:%d:%d:%d:%.1f:%d:%s",
elementId,
x,
y,
width,
height,
radius,
quality,
tostring(isBackdrop)
)
end
--- Get cached blurred canvas
---@param key string Cache key
---@return love.Canvas|nil canvas Cached canvas or nil
function Cache.getBlurredCanvas(key)
local entry = Cache.blurredCanvases[key]
if entry then
entry.lastUsed = os.time()
return entry.canvas
end
return nil
end
--- Store blurred canvas in cache
---@param key string Cache key
---@param canvas love.Canvas Canvas to cache
function Cache.setBlurredCanvas(key, canvas)
-- Limit cache size
local count = 0
for _ in pairs(Cache.blurredCanvases) do
count = count + 1
end
if count >= Cache.MAX_BLURRED_CANVAS_CACHE then
-- Remove oldest entry
local oldestKey = nil
local oldestTime = math.huge
for k, v in pairs(Cache.blurredCanvases) do
if v.lastUsed < oldestTime then
oldestTime = v.lastUsed
oldestKey = k
end
end
if oldestKey then
if Cache.blurredCanvases[oldestKey].canvas then
Cache.blurredCanvases[oldestKey].canvas:release()
end
Cache.blurredCanvases[oldestKey] = nil
end
end
Cache.blurredCanvases[key] = {
canvas = canvas,
lastUsed = os.time(),
}
end
--- Clear blurred canvas cache for specific element
---@param elementId string Element ID to clear cache for
function Cache.clearBlurredCanvasesForElement(elementId)
for key, entry in pairs(Cache.blurredCanvases) do
if key:match("^" .. elementId .. ":") then
if entry.canvas then
entry.canvas:release()
end
Cache.blurredCanvases[key] = nil
end
end
end
--- Clear all caches
function Cache.clear()
-- Release all blurred canvases
for _, entry in pairs(Cache.blurredCanvases) do
if entry.canvas then
entry.canvas:release()
end
end
Cache.canvases = {}
Cache.quads = {}
Cache.blurInstances = {}
Cache.blurredCanvases = {}
warningCache = {} -- Clear warning cache on cache clear
end
-- ============================================================================
-- SHADER BUILDER
-- ============================================================================
local ShaderBuilder = {}
--- Build Gaussian blur shader with given parameters
---@param taps number Number of samples (must be odd, >= 3)
---@param offset number Offset value
---@param offsetType string "weighted" or "center"
---@param sigma number Sigma value for Gaussian distribution
---@return love.Shader shader The compiled blur shader
function ShaderBuilder.build(taps, offset, offsetType, sigma)
taps = math.floor(taps)
sigma = sigma >= 1 and sigma or (taps - 1) * offset / 6
sigma = math.max(sigma, 1)
local steps = (taps + 1) / 2
local gOffsets = {}
local gWeights = {}
for i = 1, steps do
gOffsets[i] = offset * (i - 1)
gWeights[i] = math.exp(-0.5 * (gOffsets[i] - 0) ^ 2 * 1 / sigma ^ 2)
end
local offsets = {}
local weights = {}
for i = #gWeights, 2, -2 do
local oA, oB = gOffsets[i], gOffsets[i - 1]
local wA, wB = gWeights[i], gWeights[i - 1]
wB = oB == 0 and wB / 2 or wB
local weight = wA + wB
offsets[#offsets + 1] = offsetType == "center" and (oA + oB) / 2 or (oA * wA + oB * wB) / weight
weights[#weights + 1] = weight
end
local code = {
[[
extern vec2 direction;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {]],
}
local norm = 0
if #gWeights % 2 == 0 then
code[#code + 1] = "vec4 c = vec4( 0.0 );"
else
local weight = gWeights[1]
norm = norm + weight
code[#code + 1] = string.format("vec4 c = %f * texture2D(tex, tc);", weight)
end
local template = "c += %f * ( texture2D(tex, tc + %f * direction)+ texture2D(tex, tc - %f * direction));\n"
for i = 1, #offsets do
local offset = offsets[i]
local weight = weights[i]
norm = norm + weight * 2
code[#code + 1] = string.format(template, weight, offset, offset)
end
code[#code + 1] = string.format("return c * vec4(%f) * color; }", 1 / norm)
local shaderCode = table.concat(code)
return love.graphics.newShader(shaderCode)
end
--- Get or create a blur instance from cache
---@param quality number Quality level (1-10)
---@return table blurData Cached blur data {shader, taps}
function Cache.getBlurInstance(quality)
if not Cache.blurInstances[quality] then
local taps = 3 + (quality - 1) * 1.5
taps = math.floor(taps)
if taps % 2 == 0 then
taps = taps + 1
end
local shader = ShaderBuilder.build(taps, 1.0, "weighted", -1)
Cache.blurInstances[quality] = {
shader = shader,
taps = taps,
}
end
return Cache.blurInstances[quality]
end
---@class BlurProps
---@field quality number? Quality level (1-10, default: 5)
---@class Blur
---@field shader love.Shader The blur shader
---@field quality number Quality level (1-10)
---@field taps number Number of shader taps
---@field _ErrorHandler table? Reference to ErrorHandler module
local Blur = {}
Blur.__index = Blur
--- Check if we should warn about large blur area in immediate mode
---@param elementId string|nil Element ID for caching warnings
---@param width number Blur area width
---@param height number Blur area height
---@param blurType string "content" or "backdrop"
local function checkLargeBlurWarning(elementId, width, height, blurType)
-- Skip if no ErrorHandler available
if not Blur._ErrorHandler then
return
end
-- Skip if not in immediate mode
if not Blur._blurOptimizations then
return
end
-- Calculate blur area
local area = width * height
-- Skip if area is below threshold
if area <= Cache.LARGE_BLUR_THRESHOLD then
return
end
-- Generate warning key (use elementId if available, otherwise use dimensions)
local warningKey = elementId or string.format("%dx%d:%s", width, height, blurType)
-- Skip if already warned for this element/area
if warningCache[warningKey] then
return
end
-- Mark as warned
warningCache[warningKey] = true
-- Issue warning
local message =
string.format("Large %s blur area detected (%dx%d = %d pixels) in immediate mode", blurType, width, height, area)
local suggestion =
"Consider using retained mode for this component to avoid recreating blur effects every frame. Large blur operations are expensive and can cause performance issues in immediate mode."
Blur._ErrorHandler:warn("Blur", "PERF_003", {
area = string.format("%.0fx%.0f", width or 0, height or 0),
})
end
--- Create a new blur effect instance
---@param props BlurProps? Blur configuration
---@return Blur blur The new blur instance
function Blur.new(props)
props = props or {}
local quality = props.quality or 5
quality = math.max(1, math.min(10, quality))
-- Get cached blur instance for this quality level
local blurData = Cache.getBlurInstance(quality)
local self = setmetatable({}, Blur)
self.shader = blurData.shader
self.quality = quality
self.taps = blurData.taps
return self
end
--- Apply blur to a region of the screen
---@param radius number Blur radius in pixels
---@param x number X position
---@param y number Y position
---@param width number Width of region
---@param height number Height of region
---@param drawFunc function Function to draw content to be blurred
function Blur:applyToRegion(radius, x, y, width, height, drawFunc)
if type(drawFunc) ~= "function" then
if Blur._ErrorHandler then
Blur._ErrorHandler:warn("Blur", "BLUR_001")
end
return
end
if radius <= 0 or width <= 0 or height <= 0 then
drawFunc()
return
end
-- Early exit for very low radius (optimization)
if radius < Cache.RADIUS_THRESHOLD then
drawFunc()
return
end
-- Check for large blur area in immediate mode
checkLargeBlurWarning(nil, width, height, "content")
-- Calculate offset multiplier based on radius and quality
-- Higher quality = more samples = smaller steps for same radius
local offsetMultiplier = radius / self.quality
local canvas1 = Cache.getCanvas(width, height)
local canvas2 = Cache.getCanvas(width, height)
local prevCanvas = love.graphics.getCanvas()
local prevShader = love.graphics.getShader()
local prevColor = { love.graphics.getColor() }
local prevBlendMode = love.graphics.getBlendMode()
love.graphics.setCanvas(canvas1)
love.graphics.clear()
love.graphics.push()
love.graphics.origin()
love.graphics.translate(-x, -y)
drawFunc()
love.graphics.pop()
love.graphics.setShader(self.shader)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setBlendMode("alpha", "premultiplied")
-- Single pass with radius-controlled offset
love.graphics.setCanvas(canvas2)
love.graphics.clear()
self.shader:send("direction", { offsetMultiplier / width, 0 })
love.graphics.draw(canvas1, 0, 0)
love.graphics.setCanvas(canvas1)
love.graphics.clear()
self.shader:send("direction", { 0, offsetMultiplier / height })
love.graphics.draw(canvas2, 0, 0)
love.graphics.setCanvas(prevCanvas)
love.graphics.setShader()
love.graphics.setBlendMode(prevBlendMode)
love.graphics.draw(canvas1, x, y)
love.graphics.setShader(prevShader)
love.graphics.setColor(unpack(prevColor))
Cache.releaseCanvas(canvas1)
Cache.releaseCanvas(canvas2)
end
--- Apply backdrop blur effect (blur content behind a region)
---@param radius number Blur radius in pixels
---@param x number X position
---@param y number Y position
---@param width number Width of region
---@param height number Height of region
---@param backdropCanvas love.Canvas Canvas containing the backdrop content
function Blur:applyBackdrop(radius, x, y, width, height, backdropCanvas)
if not backdropCanvas then
if Blur._ErrorHandler then
Blur._ErrorHandler:warn("Blur", "BLUR_002")
end
return
end
if radius <= 0 or width <= 0 or height <= 0 then
return
end
-- Early exit for very low radius (optimization)
if radius < Cache.RADIUS_THRESHOLD then
return
end
-- Calculate offset multiplier based on radius and quality
local offsetMultiplier = radius / self.quality
local canvas1 = Cache.getCanvas(width, height)
local canvas2 = Cache.getCanvas(width, height)
local prevCanvas = love.graphics.getCanvas()
local prevShader = love.graphics.getShader()
local prevColor = { love.graphics.getColor() }
local prevBlendMode = love.graphics.getBlendMode()
love.graphics.setCanvas(canvas1)
love.graphics.clear()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setBlendMode("alpha", "premultiplied")
local backdropWidth, backdropHeight = backdropCanvas:getDimensions()
local quad = Cache.getQuad(x, y, width, height, backdropWidth, backdropHeight)
love.graphics.draw(backdropCanvas, quad, 0, 0)
love.graphics.setShader(self.shader)
-- Single pass with radius-controlled offset
love.graphics.setCanvas(canvas2)
love.graphics.clear()
self.shader:send("direction", { offsetMultiplier / width, 0 })
love.graphics.draw(canvas1, 0, 0)
love.graphics.setCanvas(canvas1)
love.graphics.clear()
self.shader:send("direction", { 0, offsetMultiplier / height })
love.graphics.draw(canvas2, 0, 0)
love.graphics.setCanvas(prevCanvas)
love.graphics.setShader()
love.graphics.setBlendMode(prevBlendMode)
love.graphics.draw(canvas1, x, y)
love.graphics.setShader(prevShader)
love.graphics.setColor(unpack(prevColor))
Cache.releaseCanvas(canvas1)
Cache.releaseCanvas(canvas2)
Cache.releaseQuad(quad)
end
--- Get the current quality level
---@return number quality Quality level (1-10)
function Blur:getQuality()
return self.quality
end
--- Get the number of shader taps
---@return number taps Number of shader taps
function Blur:getTaps()
return self.taps
end
--- Clear all caches (call on window resize or memory cleanup)
function Blur.clearCache()
Cache.clear()
end
--- Apply backdrop blur with caching support
---@param radius number Blur radius in pixels
---@param x number X position
---@param y number Y position
---@param width number Width of region
---@param height number Height of region
---@param backdropCanvas love.Canvas Canvas containing the backdrop content
---@param elementId string|nil Element ID for caching (nil disables caching)
function Blur:applyBackdropCached(radius, x, y, width, height, backdropCanvas, elementId)
-- If caching is disabled or no element ID, fall back to regular apply
if not Blur._blurOptimizations or not elementId then
return self:applyBackdrop(radius, x, y, width, height, backdropCanvas)
end
-- Generate cache key
local cacheKey = Cache.generateBlurCacheKey(elementId, x, y, width, height, radius, self.quality, true)
-- Check cache
local cachedCanvas = Cache.getBlurredCanvas(cacheKey)
if cachedCanvas then
-- Draw cached blur
local prevCanvas = love.graphics.getCanvas()
local prevShader = love.graphics.getShader()
local prevColor = { love.graphics.getColor() }
local prevBlendMode = love.graphics.getBlendMode()
love.graphics.setCanvas(prevCanvas)
love.graphics.setShader()
love.graphics.setBlendMode(prevBlendMode)
love.graphics.draw(cachedCanvas, x, y)
love.graphics.setShader(prevShader)
love.graphics.setColor(unpack(prevColor))
return
end
-- Not cached, render and cache
if not backdropCanvas then
if Blur._ErrorHandler then
Blur._ErrorHandler:warn("Blur", "BLUR_002")
end
return
end
if radius <= 0 or width <= 0 or height <= 0 then
return
end
-- Early exit for very low radius (optimization)
if radius < Cache.RADIUS_THRESHOLD then
return
end
-- Check for large blur area in immediate mode
checkLargeBlurWarning(elementId, width, height, "backdrop")
-- Calculate offset multiplier based on radius and quality
local offsetMultiplier = radius / self.quality
local canvas1 = Cache.getCanvas(width, height)
local canvas2 = Cache.getCanvas(width, height)
local prevCanvas = love.graphics.getCanvas()
local prevShader = love.graphics.getShader()
local prevColor = { love.graphics.getColor() }
local prevBlendMode = love.graphics.getBlendMode()
love.graphics.setCanvas(canvas1)
love.graphics.clear()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setBlendMode("alpha", "premultiplied")
local backdropWidth, backdropHeight = backdropCanvas:getDimensions()
local quad = Cache.getQuad(x, y, width, height, backdropWidth, backdropHeight)
love.graphics.draw(backdropCanvas, quad, 0, 0)
love.graphics.setShader(self.shader)
-- Single pass with radius-controlled offset
love.graphics.setCanvas(canvas2)
love.graphics.clear()
self.shader:send("direction", { offsetMultiplier / width, 0 })
love.graphics.draw(canvas1, 0, 0)
love.graphics.setCanvas(canvas1)
love.graphics.clear()
self.shader:send("direction", { 0, offsetMultiplier / height })
love.graphics.draw(canvas2, 0, 0)
-- Cache the result
local cachedResult = love.graphics.newCanvas(width, height)
love.graphics.setCanvas(cachedResult)
love.graphics.clear()
love.graphics.setShader()
love.graphics.setBlendMode("alpha", "premultiplied")
love.graphics.draw(canvas1, 0, 0)
Cache.setBlurredCanvas(cacheKey, cachedResult)
love.graphics.setCanvas(prevCanvas)
love.graphics.setShader()
love.graphics.setBlendMode(prevBlendMode)
love.graphics.draw(canvas1, x, y)
love.graphics.setShader(prevShader)
love.graphics.setColor(unpack(prevColor))
Cache.releaseCanvas(canvas1)
Cache.releaseCanvas(canvas2)
Cache.releaseQuad(quad)
end
--- Clear blur cache for specific element
---@param elementId string Element ID
function Blur.clearElementCache(elementId)
Cache.clearBlurredCanvasesForElement(elementId)
end
--- Initialize Blur module with dependencies
---@param deps table Dependencies: { ErrorHandler = ErrorHandler?, immediateModeOptimizations = boolean? }
function Blur.init(deps)
if type(deps) == "table" then
Blur._ErrorHandler = deps.ErrorHandler
Blur._blurOptimizations = deps.immediateModeOptimizations or false
end
end
Blur.Cache = Cache
Blur.ShaderBuilder = ShaderBuilder
return Blur
-385
View File
@@ -1,385 +0,0 @@
--- Utility module for parsing and evaluating CSS-like calc() expressions
--- Supports arithmetic operations (+, -, *, /) with mixed units (px, %, vw, vh)
---@class Calc
local Calc = {}
--- Initialize Calc module with dependencies
---@param deps CalcDependencies Dependencies: { ErrorHandler = ErrorHandler? }
function Calc.init(deps)
Calc._ErrorHandler = deps.ErrorHandler
end
--- Token types for lexical analysis
local TokenType = {
NUMBER = "NUMBER",
UNIT = "UNIT",
PLUS = "PLUS",
MINUS = "MINUS",
MULTIPLY = "MULTIPLY",
DIVIDE = "DIVIDE",
LPAREN = "LPAREN",
RPAREN = "RPAREN",
EOF = "EOF",
}
--- Tokenize a calc expression string into tokens
---@param expr string The expression to tokenize (e.g., "50% - 10vw")
---@return CalcToken[]? tokens Array of tokens with type, value, unit
---@return string? error Error message if tokenization fails
local function tokenize(expr)
local tokens = {}
local i = 1
local len = #expr
while i <= len do
local char = expr:sub(i, i)
-- Skip whitespace
if char:match("%s") then
i = i + 1
-- Number (including decimals, but NOT negative - handled separately below)
elseif char:match("%d") or (char == "." and expr:sub(i + 1, i + 1):match("%d")) then
local numStr = ""
-- Parse integer and decimal parts
while i <= len and (expr:sub(i, i):match("%d") or expr:sub(i, i) == ".") do
numStr = numStr .. expr:sub(i, i)
i = i + 1
end
local num = tonumber(numStr)
if not num then
return nil, "Invalid number: " .. numStr
end
-- Check for unit following the number
local unitStr = ""
while i <= len and expr:sub(i, i):match("[%a%%]") do
unitStr = unitStr .. expr:sub(i, i)
i = i + 1
end
-- Default to px if no unit
if unitStr == "" then
unitStr = "px"
end
-- Validate unit
local validUnits = { px = true, ["%"] = true, vw = true, vh = true }
if not validUnits[unitStr] then
return nil, "Invalid unit: " .. unitStr
end
table.insert(tokens, {
type = TokenType.NUMBER,
value = num,
unit = unitStr,
})
-- Operators
elseif char == "+" then
table.insert(tokens, { type = TokenType.PLUS })
i = i + 1
elseif char == "-" then
-- Check if this is a negative number or subtraction
-- It's a negative number if previous token is an operator or opening paren
local prevToken = tokens[#tokens]
if
not prevToken
or prevToken.type == TokenType.PLUS
or prevToken.type == TokenType.MINUS
or prevToken.type == TokenType.MULTIPLY
or prevToken.type == TokenType.DIVIDE
or prevToken.type == TokenType.LPAREN
then
-- This is a negative number, continue to number parsing
local numStr = "-"
i = i + 1
-- Parse integer and decimal parts
while i <= len and (expr:sub(i, i):match("%d") or expr:sub(i, i) == ".") do
numStr = numStr .. expr:sub(i, i)
i = i + 1
end
local num = tonumber(numStr)
if not num then
return nil, "Invalid number: " .. numStr
end
-- Check for unit following the number
local unitStr = ""
while i <= len and expr:sub(i, i):match("[%a%%]") do
unitStr = unitStr .. expr:sub(i, i)
i = i + 1
end
-- Default to px if no unit
if unitStr == "" then
unitStr = "px"
end
-- Validate unit
local validUnits = { px = true, ["%"] = true, vw = true, vh = true }
if not validUnits[unitStr] then
return nil, "Invalid unit: " .. unitStr
end
table.insert(tokens, {
type = TokenType.NUMBER,
value = num,
unit = unitStr,
})
else
-- This is subtraction operator
table.insert(tokens, { type = TokenType.MINUS })
i = i + 1
end
elseif char == "*" then
table.insert(tokens, { type = TokenType.MULTIPLY })
i = i + 1
elseif char == "/" then
table.insert(tokens, { type = TokenType.DIVIDE })
i = i + 1
elseif char == "(" then
table.insert(tokens, { type = TokenType.LPAREN })
i = i + 1
elseif char == ")" then
table.insert(tokens, { type = TokenType.RPAREN })
i = i + 1
else
return nil, "Unexpected character: " .. char
end
end
table.insert(tokens, { type = TokenType.EOF })
return tokens
end
--- Parser for calc expressions using recursive descent
---@class Parser
---@field tokens CalcToken[] Array of tokens
---@field pos number Current token position
local Parser = {}
Parser.__index = Parser
--- Create a new parser
---@param tokens CalcToken[] Array of tokens
---@return Parser
function Parser.new(tokens)
local self = setmetatable({}, Parser)
self.tokens = tokens
self.pos = 1
return self
end
--- Get current token
---@return CalcToken token Current token
function Parser:current()
return self.tokens[self.pos]
end
--- Advance to next token
function Parser:advance()
self.pos = self.pos + 1
end
--- Parse expression (handles + and -)
---@return CalcASTNode ast Abstract syntax tree node
function Parser:parseExpression()
local left = self:parseTerm()
while self:current().type == TokenType.PLUS or self:current().type == TokenType.MINUS do
local op = self:current().type
self:advance()
local right = self:parseTerm()
left = {
type = op == TokenType.PLUS and "add" or "subtract",
left = left,
right = right,
}
end
return left
end
--- Parse term (handles * and /)
---@return CalcASTNode ast Abstract syntax tree node
function Parser:parseTerm()
local left = self:parseFactor()
while self:current().type == TokenType.MULTIPLY or self:current().type == TokenType.DIVIDE do
local op = self:current().type
self:advance()
local right = self:parseFactor()
left = {
type = op == TokenType.MULTIPLY and "multiply" or "divide",
left = left,
right = right,
}
end
return left
end
--- Parse factor (handles numbers and parentheses)
---@return CalcASTNode ast Abstract syntax tree node
function Parser:parseFactor()
local token = self:current()
if token.type == TokenType.NUMBER then
self:advance()
return {
type = "number",
value = token.value,
unit = token.unit,
}
elseif token.type == TokenType.LPAREN then
self:advance()
local expr = self:parseExpression()
if self:current().type ~= TokenType.RPAREN then
error("Expected closing parenthesis")
end
self:advance()
return expr
else
error("Unexpected token: " .. token.type)
end
end
--- Parse the tokens into an AST
---@return CalcASTNode ast Abstract syntax tree
function Parser:parse()
local ast = self:parseExpression()
if self:current().type ~= TokenType.EOF then
error("Unexpected tokens after expression")
end
return ast
end
--- Create a calc expression object that can be resolved later
--- This is the main API function that users call
---@param expr string The calc expression (e.g., "50% - 10vw")
---@return CalcObject calcObject A calc expression object with AST
function Calc.new(expr)
-- Tokenize
local tokens, err = tokenize(expr)
if not tokens then
if Calc._ErrorHandler then
Calc._ErrorHandler:warn("Calc", "VAL_006", {
expression = expr,
error = err,
})
end
-- Return a fallback calc object that resolves to 0
return {
_isCalc = true,
_expr = expr,
_ast = nil,
_error = err,
}
end
-- Parse
local parser = Parser.new(tokens)
local success, ast = pcall(function()
return parser:parse()
end)
if not success then
if Calc._ErrorHandler then
Calc._ErrorHandler:warn("Calc", "VAL_006", {
expression = expr,
error = ast, -- ast contains error message on failure
})
end
-- Return a fallback calc object that resolves to 0
return {
_isCalc = true,
_expr = expr,
_ast = nil,
_error = ast,
}
end
return {
_isCalc = true,
_expr = expr,
_ast = ast,
}
end
--- Check if a value is a calc expression
---@param value any The value to check
---@return boolean isCalc True if value is a calc expression
function Calc.isCalc(value)
return type(value) == "table" and value._isCalc == true
end
--- Resolve a calc expression to pixel value
---@param calcObj CalcObject The calc expression object
---@param viewportWidth number Viewport width in pixels
---@param viewportHeight number Viewport height in pixels
---@param parentSize number? Parent dimension for percentage units
---@return number resolvedValue Resolved pixel value
function Calc.resolve(calcObj, viewportWidth, viewportHeight, parentSize)
if not calcObj._ast then
-- Error during parsing, return 0
return 0
end
--- Evaluate AST node recursively
---@param node table AST node
---@return number value Evaluated value in pixels
local function evaluate(node)
if node.type == "number" then
-- Convert unit to pixels
local value = node.value
local unit = node.unit
if unit == "px" then
return value
elseif unit == "%" then
if not parentSize then
if Calc._ErrorHandler then
Calc._ErrorHandler:warn("Calc", "LAY_003", {
unit = "%",
issue = "parent dimension not available",
})
end
return 0
end
return (value / 100) * parentSize
elseif unit == "vw" then
return (value / 100) * viewportWidth
elseif unit == "vh" then
return (value / 100) * viewportHeight
else
return 0
end
elseif node.type == "add" then
return evaluate(node.left) + evaluate(node.right)
elseif node.type == "subtract" then
return evaluate(node.left) - evaluate(node.right)
elseif node.type == "multiply" then
return evaluate(node.left) * evaluate(node.right)
elseif node.type == "divide" then
local divisor = evaluate(node.right)
if divisor == 0 then
if Calc._ErrorHandler then
Calc._ErrorHandler:warn("Calc", "VAL_006", {
expression = calcObj._expr,
error = "Division by zero",
})
end
return 0
end
return evaluate(node.left) / divisor
else
return 0
end
end
return evaluate(calcObj._ast)
end
return Calc
-346
View File
@@ -1,346 +0,0 @@
---@class Color
local Color = {}
Color.__index = Color
--- Initialize module with shared dependencies
---@param deps table Dependencies {ErrorHandler}
function Color.init(deps)
if type(deps) == "table" then
Color._ErrorHandler = deps.ErrorHandler
end
end
--- Build type-safe color objects with automatic validation and clamping
--- Use this to avoid invalid color values and ensure consistent LÖVE-compatible colors (0-1 range)
---@param r number? Red component (0-1), defaults to 0
---@param g number? Green component (0-1), defaults to 0
---@param b number? Blue component (0-1), defaults to 0
---@param a number? Alpha component (0-1), defaults to 1
---@return Color color The new color instance
function Color.new(r, g, b, a)
-- Sanitize and clamp color components
local _, sanitizedR = Color.validateColorChannel(r or 0, 1)
local _, sanitizedG = Color.validateColorChannel(g or 0, 1)
local _, sanitizedB = Color.validateColorChannel(b or 0, 1)
local _, sanitizedA = Color.validateColorChannel(a or 1, 1)
-- FFI structs don't support metatables/methods without wrapping
-- The wrapping overhead negates the FFI benefits
local self = setmetatable({}, Color)
self.r = sanitizedR or 0
self.g = sanitizedG or 0
self.b = sanitizedB or 0
self.a = sanitizedA or 1
return self
end
--- Extract individual color channels for use with love.graphics.setColor()
--- Use this to pass colors to LÖVE's rendering functions
---@return number r Red component (0-1)
---@return number g Green component (0-1)
---@return number b Blue component (0-1)
---@return number a Alpha component (0-1)
function Color:toRGBA()
return self.r, self.g, self.b, self.a
end
--- Parse CSS-style hex colors into Color objects for designer-friendly workflows
--- Use this to work with colors from design tools that export hex values
---@param hexWithTag string Hex color string (e.g. "#RRGGBB" or "#RRGGBBAA")
---@return Color color The parsed color (returns white on error with warning)
function Color.fromHex(hexWithTag)
-- Validate input type
if type(hexWithTag) ~= "string" then
Color._ErrorHandler:warn("Color", "VAL_004", {
input = tostring(hexWithTag),
issue = "not a string",
fallback = "white (#FFFFFF)",
})
return Color.new(1, 1, 1, 1)
end
local hex = hexWithTag:gsub("#", "")
if #hex == 6 then
local r = tonumber("0x" .. hex:sub(1, 2))
local g = tonumber("0x" .. hex:sub(3, 4))
local b = tonumber("0x" .. hex:sub(5, 6))
if not r or not g or not b then
Color._ErrorHandler:warn("Color", "VAL_004", {
input = hexWithTag,
issue = "invalid hex digits",
fallback = "white (#FFFFFF)",
})
return Color.new(1, 1, 1, 1) -- Return white as fallback
end
return Color.new(r / 255, g / 255, b / 255, 1)
elseif #hex == 8 then
local r = tonumber("0x" .. hex:sub(1, 2))
local g = tonumber("0x" .. hex:sub(3, 4))
local b = tonumber("0x" .. hex:sub(5, 6))
local a = tonumber("0x" .. hex:sub(7, 8))
if not r or not g or not b or not a then
Color._ErrorHandler:warn("Color", "VAL_004", {
input = hexWithTag,
issue = "invalid hex digits",
fallback = "white (#FFFFFFFF)",
})
return Color.new(1, 1, 1, 1) -- Return white as fallback
end
return Color.new(r / 255, g / 255, b / 255, a / 255)
else
Color._ErrorHandler:warn("Color", "VAL_004", {
input = hexWithTag,
expected = "#RRGGBB or #RRGGBBAA",
hexLength = #hex,
fallback = "white (#FFFFFF)",
})
return Color.new(1, 1, 1, 1) -- Return white as fallback
end
end
--- Verify and sanitize individual color components to prevent rendering errors
--- Use this to safely process user input or external color data
---@param value any Value to validate
---@param max number? Maximum value (255 for 0-255 range, 1 for 0-1 range), defaults to 1
---@return boolean valid True if valid
---@return number? clamped Clamped value in 0-1 range, nil if invalid
function Color.validateColorChannel(value, max)
max = max or 1
if type(value) ~= "number" then
return false, nil
end
-- Check for NaN
if value ~= value then
return false, nil
end
-- Check for Infinity
if value == math.huge or value == -math.huge then
return false, nil
end
-- Normalize to 0-1 range
local normalized = value
if max == 255 then
normalized = value / 255
end
-- Clamp to valid range
normalized = math.max(0, math.min(1, normalized))
return true, normalized
end
--- Validate hex color format
---@param hex string Hex color string (with or without #)
---@return boolean valid True if valid format
---@return string? error Error message if invalid, nil if valid
function Color.validateHexColor(hex)
if type(hex) ~= "string" then
return false, "Hex color must be a string"
end
-- Remove # prefix
local cleanHex = hex:gsub("^#", "")
-- Check length (3, 6, or 8 characters)
if #cleanHex ~= 3 and #cleanHex ~= 6 and #cleanHex ~= 8 then
return false, string.format("Invalid hex length: %d. Expected 3, 6, or 8 characters", #cleanHex)
end
-- Check for valid hex characters
if not cleanHex:match("^[0-9A-Fa-f]+$") then
return false, "Invalid hex characters. Use only 0-9, A-F"
end
return true, nil
end
--- Validate RGB/RGBA color values
---@param r number Red component
---@param g number Green component
---@param b number Blue component
---@param a number? Alpha component (optional, defaults to max)
---@param max number? Maximum value (255 or 1), defaults to 1
---@return boolean valid True if valid
---@return string? error Error message if invalid, nil if valid
function Color.validateRGBColor(r, g, b, a, max)
max = max or 1
a = a or max
local rValid = Color.validateColorChannel(r, max)
local gValid = Color.validateColorChannel(g, max)
local bValid = Color.validateColorChannel(b, max)
local aValid = Color.validateColorChannel(a, max)
if not rValid then
return false, string.format("Invalid red channel: %s", tostring(r))
end
if not gValid then
return false, string.format("Invalid green channel: %s", tostring(g))
end
if not bValid then
return false, string.format("Invalid blue channel: %s", tostring(b))
end
if not aValid then
return false, string.format("Invalid alpha channel: %s", tostring(a))
end
return true, nil
end
--- Check if a value is a valid color format
---@param value any Value to check
---@return string? format Format type ("hex", "named", "table"), nil if invalid
function Color.isValidColorFormat(value)
local valueType = type(value)
-- Check for hex string
if valueType == "string" then
if value:match("^#?[0-9A-Fa-f]+$") then
local valid = Color.validateHexColor(value)
if valid then
return "hex"
end
end
return nil
end
-- Check for table format
if valueType == "table" then
-- Check for Color instance
if getmetatable(value) == Color then
return "table"
end
-- Check for array format {r, g, b, a}
if value[1] and value[2] and value[3] then
local valid = Color.validateRGBColor(value[1], value[2], value[3], value[4])
if valid then
return "table"
end
end
-- Check for named format {r=, g=, b=, a=}
if value.r and value.g and value.b then
local valid = Color.validateRGBColor(value.r, value.g, value.b, value.a)
if valid then
return "table"
end
end
return nil
end
return nil
end
--- Convert any color format to a valid Color object with graceful fallbacks
--- Use this to robustly handle colors from any source without crashes
---@param value any Color value to sanitize (hex, named, table, or Color instance)
---@param default Color? Default color if invalid (defaults to black)
---@return Color color Sanitized color instance (guaranteed non-nil)
function Color.sanitizeColor(value, default)
default = default or Color.new(0, 0, 0, 1)
local format = Color.isValidColorFormat(value)
if not format then
return default
end
-- Handle hex format
if format == "hex" then
local cleanHex = value:gsub("^#", "")
-- Expand 3-digit hex to 6-digit
if #cleanHex == 3 then
cleanHex = cleanHex:gsub("(.)", "%1%1")
end
-- Try to parse
local success, result = pcall(Color.fromHex, "#" .. cleanHex)
if success then
return result
else
return default
end
end
if format == "table" then
-- Color instance
if getmetatable(value) == Color then
return value
end
-- Array format
if value[1] then
local _, r = Color.validateColorChannel(value[1], 1)
local _, g = Color.validateColorChannel(value[2], 1)
local _, b = Color.validateColorChannel(value[3], 1)
local _, a = Color.validateColorChannel(value[4] or 1, 1)
if r and g and b and a then
return Color.new(r, g, b, a)
end
end
-- Named format
if value.r then
local _, r = Color.validateColorChannel(value.r, 1)
local _, g = Color.validateColorChannel(value.g, 1)
local _, b = Color.validateColorChannel(value.b, 1)
local _, a = Color.validateColorChannel(value.a or 1, 1)
if r and g and b and a then
return Color.new(r, g, b, a)
end
end
end
return default
end
--- Universally convert any color format (hex, named, table) into a Color object
--- Use this as your main color input handler to accept flexible color specifications
---@param value any Color value (hex string, named color, table, or Color instance)
---@return Color color Parsed color instance (defaults to black on error)
function Color.parse(value)
return Color.sanitizeColor(value, Color.new(0, 0, 0, 1))
end
--- Smoothly transition between two colors for animations and gradients
--- Use this to create color-based animations without manual channel calculations
---@param colorA Color Starting color
---@param colorB Color Ending color
---@param t number Interpolation factor (0-1)
---@return Color color Interpolated color
function Color.lerp(colorA, colorB, t)
-- Sanitize inputs
if type(colorA) ~= "table" or getmetatable(colorA) ~= Color then
colorA = Color.new(0, 0, 0, 1)
end
if type(colorB) ~= "table" or getmetatable(colorB) ~= Color then
colorB = Color.new(0, 0, 0, 1)
end
if type(t) ~= "number" or t ~= t or t == math.huge or t == -math.huge then
t = 0
end
-- Clamp t to 0-1 range
t = math.max(0, math.min(1, t))
-- Linear interpolation for each channel
local oneMinusT = 1 - t
local r = colorA.r * oneMinusT + colorB.r * t
local g = colorA.g * oneMinusT + colorB.g * t
local b = colorA.b * oneMinusT + colorB.b * t
local a = colorA.a * oneMinusT + colorB.a * t
return Color.new(r, g, b, a)
end
return Color
-596
View File
@@ -1,596 +0,0 @@
---@class Context
local modulePath = (...):match("(.-)[^%.]+$")
local ZIndex = require(modulePath .. "ZIndex")
local Element = require(modulePath .. "Element")
local Context = {
topElements = {},
-- Base scale configuration
baseScale = nil, -- {width: number, height: number}
-- Current scale factors
scaleFactors = { x = 1.0, y = 1.0 },
defaultTheme = nil,
_focusedElement = nil,
_focusedElementId = nil, -- Stable id used to rehydrate focus across immediate-mode frames
_activeEventElement = nil,
_cachedViewport = { width = 0, height = 0 },
-- Immediate mode state
_immediateMode = false,
_frameNumber = 0,
_currentFrameElements = {},
_immediateModeState = nil, -- Will be initialized if immediate mode is enabled
_frameStarted = false,
_autoBeganFrame = false,
-- Z-index ordered element tracking for immediate mode
_zIndexOrderedElements = {}, -- Array of elements sorted by z-index (lowest to highest)
-- Focus management guard
_settingFocus = false,
-- Hook called whenever focus changes: function(element) or nil
_onFocusChanged = nil,
-- Navigation state
_navigationContext = {
lastFocusedElement = nil, -- For returning from modals
navigationMode = "sequential", -- "sequential" or "directional"
containerElement = nil, -- Current navigation container
},
initialized = false,
-- Expose internal hit-testing helpers for unit testing only.
-- These are populated below after their local definitions. They are NOT part
-- of the public API and must not be relied on by callers; they exist so the
-- shared hit-test core (the single place display:none guarding lives) can be
-- exercised directly by the test suite. Subsequent unified-event-routing
-- tasks consume these locals through the mode-agnostic query functions.
_test = {
pointHitsElement = nil,
elementHasScrollableOverflow = nil,
},
-- Debug draw overlay
_debugDraw = false,
_debugDrawKey = nil,
-- Initialization state tracking
---@type "uninitialized"|"initializing"|"ready"
_initState = "uninitialized",
---@type table[] Queue of {props: ElementProps, callback: function(element)|nil}
_initQueue = {},
-- Per-frame cache for findInteractiveAtPosition so Clickable.onUpdate's
-- per-element call (unified-event-routing task 05) doesn't re-walk the tree
-- + realloc + sort for every interactive element sharing the same cursor.
-- Invalidated explicitly by Context.clearInteractiveCache() at the start of
-- each flexlove.update (both modes) and in clearFrameElements (immediate
-- mid-frame rebuild). It also self-invalidates when the topElements table
-- reference changes (tests replace it per-case; immediate-mode beginFrame
-- reassigns it each frame), so direct callers that never go through
-- flexlove.update still see fresh results across tree swaps.
_interactiveLookupCache = {
valid = false,
x = nil,
y = nil,
result = nil,
topElementsRef = nil,
frameNumber = -1,
},
}
--- Check if a point hits an element, accounting for scroll offsets and display:none.
--- All mode-agnostic query functions use this as their single hit-test entry point,
--- ensuring fixes like display:none guarding apply everywhere.
---
--- This is the single canonical place where `element.display == false` short-
--- circuits hit testing. Parent-chain clipping/scroll-offset accumulation is
--- the caller's responsibility: callers walk the parent chain (using
--- `elementHasScrollableOverflow` to decide which ancestors clip) and pass the
--- accumulated scroll offset in here. Keeping the parent walk outside this core
--- lets retained-mode (recursive tree descent) and immediate-mode (flat
--- z-index list) callers share the exact same primitive bounds/display logic.
---@param element Element
---@param mx number Screen X coordinate
---@param my number Screen Y coordinate
---@param scrollOffsetX number? Accumulated scroll offset from parent chain
---@param scrollOffsetY number? Accumulated scroll offset from parent chain
---@return boolean hits
local function pointHitsElement(element, mx, my, scrollOffsetX, scrollOffsetY)
scrollOffsetX = scrollOffsetX or 0
scrollOffsetY = scrollOffsetY or 0
-- Skip display:none elements entirely
if element.display == false then
return false
end
local bx = element.x
local by = element.y
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
local adjustedX = mx + scrollOffsetX
local adjustedY = my + scrollOffsetY
return adjustedX >= bx and adjustedX <= bx + bw and adjustedY >= by and adjustedY <= by + bh
end
--- Check if an element has scrollable/clipped overflow (for scroll offset accumulation).
--- Returns true for `scroll`, `auto`, and `hidden` on either axis. These are the
--- overflow values that clip/translate descendant content and therefore require
--- scroll-offset compensation when hit testing descendants.
---@param element Element
---@return boolean
local function elementHasScrollableOverflow(element)
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
return overflowX == "scroll"
or overflowX == "auto"
or overflowY == "scroll"
or overflowY == "auto"
or overflowX == "hidden"
or overflowY == "hidden"
end
-- Expose the two core helpers for unit testing only (see Context._test above).
Context._test.pointHitsElement = pointHitsElement
Context._test.elementHasScrollableOverflow = elementHasScrollableOverflow
-- Public exposure of the canonical hit-test primitive so other modules
-- (e.g. FlexLove's `getElementAtPosition` / `_getTouchElementAtPosition`
-- tree walks) can share the single implementation of bounds + display:none
-- guarding instead of duplicating the `display == false` check inline.
-- This keeps "display == false" in exactly one place for hit-testing.
Context.pointHitsElement = pointHitsElement
Context.elementHasScrollableOverflow = elementHasScrollableOverflow
--- Find the first scrollable element at a screen position, regardless of mode.
--- This is the mode-agnostic successor to the two duplicated scrollable lookups
--- that previously lived inline in `flexlove.wheelmoved`:
--- * immediate mode — walked `Context._zIndexOrderedElements` in reverse and
--- re-implemented bounds + parent-chain clipping + scroll-offset math; and
--- * retained mode — recursed through `Context.topElements` with a private
--- `findScrollableAtPosition(elements, x, y)` helper.
--- Both paths now collapse into this single function, which routes every
--- hit test through `pointHitsElement` (the single place `display == false`
--- is guarded) and every scroll-offset decision through
--- `elementHasScrollableOverflow`. As a result display:none elements are never
--- returned in either mode, fixing the latent bug where the immediate-mode
--- path's `isPointInElement` did not skip display:none elements.
---
--- The retained-mode branch intentionally mirrors the original
--- `findScrollableAtPosition` helper's tree walk (deepest scrollable wins,
--- children checked before self) but is upgraded to thread accumulated scroll
--- offsets through `pointHitsElement` so nested scrolled containers are tested
--- against their visible position. The original helper is removed once
--- `flexlove.wheelmoved` is rerouted onto this function in task 04.
---@param x number Screen X coordinate
---@param y number Screen Y coordinate
---@return Element|nil The scrollable element, or nil
function Context.findScrollableAtPosition(x, y)
if Context.isImmediateMode() then
-- Immediate mode: iterate the z-index ordered list (reverse order =
-- topmost first). pointHitsElement supplies the bounds + display guard.
for i = #Context._zIndexOrderedElements, 1, -1 do
local element = Context._zIndexOrderedElements[i]
if pointHitsElement(element, x, y) then
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
if
(overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto")
and (element._overflowX or element._overflowY)
then
return element
end
end
end
return nil
else
-- Retained mode: recursive tree walk from topElements. Children are
-- checked before self (deepest scrollable wins); accumulated scroll
-- offsets are threaded through pointHitsElement so descendants of
-- scrolled containers are hit-tested against their translated position.
local function findInTree(elements, scrollOffsetX, scrollOffsetY)
scrollOffsetX = scrollOffsetX or 0
scrollOffsetY = scrollOffsetY or 0
for i = #elements, 1, -1 do
local element = elements[i]
if pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then
if #element.children > 0 then
local childScrollOffsetX = scrollOffsetX
local childScrollOffsetY = scrollOffsetY
if elementHasScrollableOverflow(element) then
childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0)
childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0)
end
local childResult = findInTree(element.children, childScrollOffsetX, childScrollOffsetY)
if childResult then
return childResult
end
end
-- No descendant was scrollable — check self.
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
if
(overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto")
and (element._overflowX or element._overflowY)
then
return element
end
end
end
return nil
end
return findInTree(Context.topElements)
end
end
--- Check whether immediate mode is active.
--- This is the single canonical accessor for the mode flag consumed throughout
--- the framework. Mode-aware branches elsewhere call this instead of reading
--- `Context._immediateMode` directly, so the literal mode flag only appears
--- here (its definition) and in StateManager (its mirrored storage) — never
--- scattered across Element / behaviors / managers (behavior-mode-unification
--- task 11).
---@return boolean
function Context.isImmediateMode()
return Context._immediateMode
end
---@return number, number -- scaleX, scaleY
function Context.getScaleFactors()
return Context.scaleFactors.x, Context.scaleFactors.y
end
--- Register an element in the z-index ordered tree (for immediate mode)
---@param element Element The element to register
function Context.registerElement(element)
if not Context.isImmediateMode() then
return
end
table.insert(Context._zIndexOrderedElements, element)
end
function Context.clearFrameElements()
Context._zIndexOrderedElements = {}
Context.clearInteractiveCache()
end
--- Compute the composite z-index key for an element.
--- rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ
---
--- ROOT_WEIGHT (10^10) gives the top-level ancestor's z-index 10 digits of significance.
--- DEPTH_WEIGHT (10^3) gives nesting depth 3 digits, ensuring children always sort above
--- their ancestors. The element's own z (capped to ±999 by ZIndex.clamp) fits within the
--- remaining 3 digits without interfering with the depth component.
---
--- These weights assume |z| <= ZIndex.MAX_Z and practical tree depths (< 10^7), which
--- keeps the composite key well within Lua's exact integer range (2^53 ≈ 9 × 10^15).
---
--- This is the SINGLE canonical z-index ordering function, used by both
--- sortElementsByZIndex (the immediate-mode flat list sort) and
--- findInteractiveAtPosition (the mode-agnostic occlusion sort). Keeping them
--- on the same key ensures the interactive topmost element matches the visual
--- draw order — a button in a z=50 MainMenu window must occlude a button in a
--- z=0 BottomBar even when both buttons default to own z=0.
local function getEffectiveZIndex(elem)
local ownZ = elem.z or 0
local rootZ = ownZ
local depth = 0
local current = elem.parent
while current do
rootZ = current.z or 0
depth = depth + 1
current = current.parent
end
return rootZ * ZIndex.ROOT_WEIGHT + depth * ZIndex.DEPTH_WEIGHT + ownZ
end
-- Public exposure so FlexLove.getElementAtPosition shares the single
-- implementation instead of duplicating the parent-chain walk as a closure.
Context.getEffectiveZIndex = getEffectiveZIndex
--- Sort elements by z-index (called after all elements are registered)
function Context.sortElementsByZIndex()
-- Precompute the composite key ONCE per element so the sort comparator is a
-- pure table lookup (O(1)) instead of re-walking the parent chain on every
-- O(N log N) comparison. This function runs every frame in immediate mode.
local elements = Context._zIndexOrderedElements
local zIndices = {}
for i = 1, #elements do
zIndices[elements[i]] = getEffectiveZIndex(elements[i])
end
table.sort(elements, function(a, b)
return zIndices[a] < zIndices[b]
end)
end
--- Find the topmost interactive element at a screen position, regardless of mode.
--- Replaces the former immediate-mode-only `Context.getTopElementAt()` (removed
--- in unified-event-routing task 05) and the retained-mode `_activeEventElement`
--- mechanism — both are now funneled through this single entry point.
---
--- In immediate mode this replaces Context.getTopElementAt() (which only worked
--- in immediate mode). In retained mode this provides the same role as the
--- _activeEventElement set by flexlove.getElementAtPosition().
---
--- An element is "interactive" if it has an onEvent handler, themeComponent, or is editable.
---@param x number Screen X coordinate
---@param y number Screen Y coordinate
---@return Element|nil The topmost interactive element, or nil
function Context.findInteractiveAtPosition(x, y)
-- Per-frame cache: Clickable.onUpdate runs this for every interactive
-- element under the same cursor, but the result for a given (x,y) is
-- identical across all of them within a single update pass. Returning a
-- cached element restores the old 1x/frame cost of the _activeEventElement
-- mechanism that task 05 replaced. Cache auto-invalidates when the
-- topElements table reference changes (so tests and mid-frame rebuilds get
-- fresh results) and is cleared explicitly per-frame in flexlove.update.
local cache = Context._interactiveLookupCache
if
cache.valid
and cache.x == x
and cache.y == y
and cache.topElementsRef == Context.topElements
and cache.frameNumber == Context._frameNumber
then
return cache.result
end
local interactiveCandidates = {}
local function collectInteractive(element, scrollOffsetX, scrollOffsetY)
scrollOffsetX = scrollOffsetX or 0
scrollOffsetY = scrollOffsetY or 0
if not pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then
return
end
-- Check if this element is interactive
if element.onEvent or element.themeComponent or element.editable then
table.insert(interactiveCandidates, element)
end
-- Recurse into children with accumulated scroll offset
local childScrollOffsetX = scrollOffsetX
local childScrollOffsetY = scrollOffsetY
if elementHasScrollableOverflow(element) then
childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0)
childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0)
end
for _, child in ipairs(element.children) do
collectInteractive(child, childScrollOffsetX, childScrollOffsetY)
end
end
-- Always traverse the tree (works in both modes — topElements exists always)
for _, element in ipairs(Context.topElements) do
collectInteractive(element)
end
-- Sort by composite z-index descending — topmost wins. The composite key
-- (rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ) matches the ordering
-- used by sortElementsByZIndex / _zIndexOrderedElements, so the interactive
-- topmost element matches the visual draw order. This is critical for the
-- game's multi-window layout: a button inside a z=50 MainMenu window must
-- occlude a button inside a z=0 BottomBar even when both buttons default to
-- own z=0. Sorting by own-z alone (the original implementation) couldn't
-- distinguish them, so the wrong window's button could win, leaving the
-- visible button's isActiveElement=false and clicks/hover dead.
local zIndices = {}
for _, el in ipairs(interactiveCandidates) do
zIndices[el] = getEffectiveZIndex(el)
end
table.sort(interactiveCandidates, function(a, b)
return zIndices[a] > zIndices[b]
end)
local result = interactiveCandidates[1]
cache.x = x
cache.y = y
cache.result = result
cache.topElementsRef = Context.topElements
cache.frameNumber = Context._frameNumber
cache.valid = true
return result
end
--- Invalidate the per-frame `findInteractiveAtPosition` cache.
--- Called once at the top of `flexlove.update` (the natural per-frame boundary
--- in both modes) and from `clearFrameElements` (immediate-mode mid-frame
--- rebuild). After invalidation the next lookup recomputes fresh.
function Context.clearInteractiveCache()
local cache = Context._interactiveLookupCache
cache.valid = false
cache.x = nil
cache.y = nil
cache.result = nil
cache.topElementsRef = nil
cache.frameNumber = -1
end
--- Set the focused element (centralizes focus management)
--- Automatically blurs the previously focused element if different
---@param element Element|nil The element to focus (nil to clear focus)
function Context.setFocused(element)
if Context._focusedElement == element then
return -- Already focused
end
-- Prevent re-entry during focus change
if Context._settingFocus then
return
end
Context._settingFocus = true
-- Save reference to previously focused element before updating
local oldFocusedElement = Context._focusedElement
-- Blur previously focused element
if oldFocusedElement and oldFocusedElement ~= element then
if oldFocusedElement._textEditor then
oldFocusedElement._textEditor:blur(oldFocusedElement)
end
end
-- Set new focused element and persist its id for immediate-mode rehydration
Context._focusedElement = element
Context._focusedElementId = element and (element.id ~= "" and element.id or nil) or nil
-- Notify any registered focus change hook (e.g. FocusIndicator)
if Context._onFocusChanged then
Context._onFocusChanged(element)
end
-- Focus the new element's text editor if it has one
if element and element._textEditor then
element._textEditor._focused = true
end
Context._settingFocus = false
end
--- Recursively search for an element by id in an element tree
---@param root Element The root element to start searching from
---@param targetId string The id to search for
---@return Element|nil The element with the matching id, or nil if not found
local function findElementById(root, targetId)
if root.id == targetId then
return root
end
for _, child in ipairs(root.children or {}) do
local found = findElementById(child, targetId)
if found then
return found
end
end
return nil
end
--- Rehydrate _focusedElement from _focusedElementId by scanning live elements.
--- Called at the start of getFocused() in immediate mode so stale references
--- are always replaced with the current-frame object before use.
function Context._rehydrateFocus()
if not Context._focusedElementId then
Context._focusedElement = nil
return
end
-- First, try a fast linear search through all registered elements
for _, elem in ipairs(Context._zIndexOrderedElements) do
if elem.id == Context._focusedElementId then
Context._focusedElement = elem
return
end
end
-- If not found, recursively search from top-level elements
-- This handles cases where elements may not be in _zIndexOrderedElements
for _, topLevel in ipairs(Context.topElements or {}) do
local found = findElementById(topLevel, Context._focusedElementId)
if found then
Context._focusedElement = found
return
end
end
-- Element with that id is not present this frame (e.g. screen changed)
Context._focusedElement = nil
end
--- Get the currently focused element
---@return Element|nil The focused element, or nil if none
function Context.getFocused()
if Context.isImmediateMode() then
Context._rehydrateFocus()
end
return Context._focusedElement
end
--- Clear focus from any element
function Context.clearFocus()
Context._focusedElementId = nil
Context.setFocused(nil)
end
--- Get all focusable elements in tab order, regardless of mode.
--- In immediate mode this extracts from _zIndexOrderedElements (flat, z-sorted).
--- In retained mode it walks the element tree (DOM order).
--- In both modes, display:none elements are excluded.
---@return table<Element> List of focusable elements in tab order
function Context.getFocusableElements()
local focusable = {}
local function isFocusable(elem)
if elem.display == false then
return false
end
-- Use Element:isFocusable() for consistent behavior
return Element.isFocusable(elem)
end
local function collectFromTree(elements)
for _, elem in ipairs(elements) do
if isFocusable(elem) then
table.insert(focusable, elem)
end
if #elem.children > 0 then
collectFromTree(elem.children)
end
end
end
if Context._immediateMode then
-- Immediate mode: _zIndexOrderedElements is already in z-index order (lowest first),
-- which approximates tab order for most UIs.
for _, elem in ipairs(Context._zIndexOrderedElements) do
if isFocusable(elem) then
table.insert(focusable, elem)
end
end
else
-- Retained mode: walk the top element trees in DOM order
collectFromTree(Context.topElements)
end
return focusable
end
-- ====================
-- Navigation Context
-- ====================
--- Push current focus onto stack (for modals/dialogs)
---@param element Element?
function Context.pushFocusStack(element)
Context._navigationContext.lastFocusedElement = Context._focusedElement
if element then
Context.setFocused(element)
end
end
--- Pop focus from stack (return from modal)
---@return Element?
function Context.popFocusStack()
local previous = Context._navigationContext.lastFocusedElement
Context._navigationContext.lastFocusedElement = nil
Context.setFocused(previous)
return previous
end
--- Set navigation container (scope for tab navigation)
---@param element Element?
function Context.setNavigationContainer(element)
Context._navigationContext.containerElement = element
end
--- Get navigation container
---@return Element?
function Context.getNavigationContainer()
return Context._navigationContext.containerElement
end
return Context
File diff suppressed because it is too large Load Diff
-171
View File
@@ -1,171 +0,0 @@
-- Layout, flex, text, image, and ARIA enums used across FlexLove.
-- Extracted from utils so utils stays under its LOC budget; re-exported as
-- `utils.enums` for backward compatibility.
local enums = {
---@enum TextAlign
TextAlign = { START = "start", CENTER = "center", END = "end", JUSTIFY = "justify" },
---@enum TextAlignVertical
TextAlignVertical = { START = "start", CENTER = "center", END = "end" },
---@enum Positioning
Positioning = { ABSOLUTE = "absolute", RELATIVE = "relative", FLEX = "flex", GRID = "grid" },
---@enum FlexDirection
FlexDirection = {
HORIZONTAL = "horizontal",
VERTICAL = "vertical",
ROW = "row",
COLUMN = "column",
HORIZONTAL_REVERSE = "horizontal-reverse",
VERTICAL_REVERSE = "vertical-reverse",
ROW_REVERSE = "row-reverse",
COLUMN_REVERSE = "column-reverse",
},
---@enum JustifyContent
JustifyContent = {
FLEX_START = "flex-start",
CENTER = "center",
SPACE_AROUND = "space-around",
FLEX_END = "flex-end",
SPACE_EVENLY = "space-evenly",
SPACE_BETWEEN = "space-between",
},
---@enum JustifySelf
JustifySelf = {
AUTO = "auto",
FLEX_START = "flex-start",
CENTER = "center",
FLEX_END = "flex-end",
SPACE_AROUND = "space-around",
SPACE_EVENLY = "space-evenly",
SPACE_BETWEEN = "space-between",
},
---@enum AlignItems
AlignItems = {
STRETCH = "stretch",
FLEX_START = "flex-start",
FLEX_END = "flex-end",
CENTER = "center",
BASELINE = "baseline",
},
---@enum AlignSelf
AlignSelf = {
AUTO = "auto",
STRETCH = "stretch",
FLEX_START = "flex-start",
FLEX_END = "flex-end",
CENTER = "center",
BASELINE = "baseline",
},
---@enum AlignContent
AlignContent = {
STRETCH = "stretch",
FLEX_START = "flex-start",
FLEX_END = "flex-end",
CENTER = "center",
SPACE_BETWEEN = "space-between",
SPACE_AROUND = "space-around",
},
---@enum FlexWrap
FlexWrap = { NOWRAP = "nowrap", WRAP = "wrap", WRAP_REVERSE = "wrap-reverse" },
---@enum TextSize
TextSize = {
XXS = "xxs",
XS = "xs",
SM = "sm",
MD = "md",
LG = "lg",
XL = "xl",
XXL = "xxl",
XL3 = "3xl",
XL4 = "4xl",
},
---@enum ImageRepeat
ImageRepeat = {
NO_REPEAT = "no-repeat",
REPEAT = "repeat",
REPEAT_X = "repeat-x",
REPEAT_Y = "repeat-y",
SPACE = "space",
ROUND = "round",
},
---@enum ARIA Role (accessibility roles for screen readers)
ARIA = {
-- Widget roles
BUTTON = "button",
CHECKBOX = "checkbox",
LINK = "link",
MENUITEM = "menuitem",
MENUITEMCHECKBOX = "menuitemcheckbox",
MENUITEMRADIO = "menuitemradio",
PROGRESSBAR = "progressbar",
RADIO = "radio",
SCROLLBAR = "scrollbar",
SLIDER = "slider",
SPINBUTTON = "spinbutton",
SWITCH = "switch",
TAB = "tab",
TABLIST = "tablist",
TABPANEL = "tabpanel",
TEXTBOX = "textbox",
TOOLTIP = "tooltip",
TREEITEM = "treeitem",
COMBOBOX = "combobox",
GRID = "grid",
GRIDCELL = "gridcell",
LISTBOX = "listbox",
LISTITEM = "listitem",
MENU = "menu",
MENUBAR = "menubar",
TREE = "tree",
TREEGRID = "treegrid",
WINDOW = "window",
DIALOG = "dialog",
ALERTDIALOG = "alertdialog",
-- Landmark roles
BANNER = "banner",
COMPLEMENTARY = "complementary",
CONTENTINFO = "contentinfo",
FORM = "form",
MAIN = "main",
NAVIGATION = "navigation",
REGION = "region",
SEARCH = "search",
-- Live region roles
ALERT = "alert",
LOG = "log",
MARQUEE = "marquee",
STATUS = "status",
TIMERTIME = "timer",
-- Document structure roles
ARTICLE = "article",
BLOCKQUOTEBLOCKQUOTE = "blockquote",
CAPTION = "caption",
CODE = "code",
DEFINITION = "definition",
DELETED = "deletion",
DIRECTORY = "directory",
DIVISION = "division",
EMphasis = "emphasis",
HEADING = "heading",
INSERTED = "insertion",
LIST = "list",
MARK = "mark",
MATH = "math",
NONE = "none",
PARAGRAPH = "paragraph",
PRESENTATION = "presentation",
SEPARATOR = "separator",
STRONG = "strong",
SUBSCRIPT = "subscript",
SUPERSCRIPT = "superscript",
TERM = "term",
TIME = "time",
VARIABLE = "variable",
},
}
return { enums = enums }
File diff suppressed because it is too large Load Diff
-843
View File
@@ -1,843 +0,0 @@
---@class EventHandler
---@field onEvent fun(element:Element, event:InputEvent)?
---@field onEventDeferred boolean?
---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Touch-specific callback
---@field onTouchEventDeferred boolean? -- Whether onTouchEvent is deferred
---@field onGesture fun(element:Element, gesture:table)? -- Gesture callback
---@field onGestureDeferred boolean? -- Whether onGesture is deferred
---@field touchEnabled boolean -- Whether touch events are processed (default: true)
---@field multiTouchEnabled boolean -- Whether multi-touch is supported (default: false)
---@field _pressed table<number, boolean>
---@field _lastClickTime number?
---@field _lastClickButton number?
---@field _clickCount number
---@field _dragStartX table<number, number>
---@field _dragStartY table<number, number>
---@field _lastMouseX table<number, number>
---@field _lastMouseY table<number, number>
---@field _touches table<string, table> -- Multi-touch state per touch ID
---@field _touchStartPositions table<string, table> -- Touch start positions
---@field _lastTouchPositions table<string, table> -- Last touch positions for delta
---@field _touchHistory table<string, table> -- Touch position history for gestures (last 5)
---@field _hovered boolean
---@field _scrollbarPressHandled boolean
---@field _InputEvent table
---@field _utils table
---@field _Performance Performance? Performance module dependency
---@field _ErrorHandler ErrorHandler
local EventHandler = {}
EventHandler.__index = EventHandler
--- Initialize module with shared dependencies
---@param deps table Dependencies {Performance, ErrorHandler, InputEvent, Context, utils}
function EventHandler.init(deps)
EventHandler._Performance = deps.Performance
EventHandler._ErrorHandler = deps.ErrorHandler
EventHandler._InputEvent = deps.InputEvent
EventHandler._utils = deps.utils
EventHandler._Context = deps.Context
end
---@param config table Configuration options
---@return EventHandler
function EventHandler.new(config)
config = config or {}
local self = setmetatable({}, EventHandler)
self.onEvent = config.onEvent
self.onEventDeferred = config.onEventDeferred
self.onTouchEvent = config.onTouchEvent
self.onTouchEventDeferred = config.onTouchEventDeferred or false
self.onGesture = config.onGesture
self.onGestureDeferred = config.onGestureDeferred or false
self.touchEnabled = config.touchEnabled ~= false -- Default true
self.multiTouchEnabled = config.multiTouchEnabled or false -- Default false
self._pressed = config._pressed or {}
self._lastClickTime = config._lastClickTime
self._lastClickButton = config._lastClickButton
self._clickCount = config._clickCount or 0
-- FocusIndicator reference (set after initialization)
self._FocusIndicator = nil
self._dragStartX = config._dragStartX or {}
self._dragStartY = config._dragStartY or {}
self._lastMouseX = config._lastMouseX or {}
self._lastMouseY = config._lastMouseY or {}
-- Multi-touch tracking
self._touches = config._touches or {}
self._touchStartPositions = config._touchStartPositions or {}
self._lastTouchPositions = config._lastTouchPositions or {}
self._touchHistory = config._touchHistory or {}
self._hovered = config._hovered or false
self._scrollbarPressHandled = false
return self
end
--- Get state for persistence (for immediate mode)
---@return table State data
function EventHandler:getState()
return {
_pressed = self._pressed,
_lastClickTime = self._lastClickTime,
_lastClickButton = self._lastClickButton,
_clickCount = self._clickCount,
_dragStartX = self._dragStartX,
_dragStartY = self._dragStartY,
_lastMouseX = self._lastMouseX,
_lastMouseY = self._lastMouseY,
_touches = self._touches,
_touchStartPositions = self._touchStartPositions,
_lastTouchPositions = self._lastTouchPositions,
_touchHistory = self._touchHistory,
_hovered = self._hovered,
}
end
--- Restore state from persistence (for immediate mode)
---@param state table State data
function EventHandler:setState(state)
if not state then
return
end
self._pressed = state._pressed or {}
self._lastClickTime = state._lastClickTime
self._lastClickButton = state._lastClickButton
self._clickCount = state._clickCount or 0
self._dragStartX = state._dragStartX or {}
self._dragStartY = state._dragStartY or {}
self._lastMouseX = state._lastMouseX or {}
self._lastMouseY = state._lastMouseY or {}
self._touches = state._touches or {}
self._touchStartPositions = state._touchStartPositions or {}
self._lastTouchPositions = state._lastTouchPositions or {}
self._touchHistory = state._touchHistory or {}
self._hovered = state._hovered or false
end
--- Process mouse button events in the update cycle
---@param element Element The parent element
---@param mx number Mouse X position
---@param my number Mouse Y position
---@param isHovering boolean Whether mouse is over element
---@param isActiveElement boolean Whether this is the top element at mouse position
function EventHandler:processMouseEvents(element, mx, my, isHovering, isActiveElement)
-- Start performance timing
-- Performance accessed via EventHandler._Performance
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:startTimer("event_mouse")
end
-- Check if currently dragging (allows drag continuation even if occluded)
local isDragging = false
for _, button in ipairs({ 1, 2, 3 }) do
if self._pressed[button] and love.mouse.isDown(button) then
isDragging = true
break
end
end
-- Check if any button is currently pressed (tracked state)
local hasTrackedPress = false
for _, button in ipairs({ 1, 2, 3 }) do
if self._pressed[button] then
hasTrackedPress = true
break
end
end
-- Can only process events if we have handler, element is enabled, and is active or dragging or has tracked press
-- Read onEvent from element (source of truth), fallback to handler cache for backwards compat
local canProcessEvents = (
element.onEvent
or self.onEvent
or element.editable
or element._selectState
or element.selectOption
)
and element.visibility ~= "hidden"
and not element.disabled
and (isActiveElement or isDragging or hasTrackedPress)
if not canProcessEvents then
-- If not hovering and no buttons are physically pressed, reset all pressed states
-- This ensures the pressed state is cleared when mouse leaves without button held
if not isHovering and not isDragging then
for _, button in ipairs({ 1, 2, 3 }) do
if self._pressed[button] and not love.mouse.isDown(button) then
self._pressed[button] = false
self._dragStartX[button] = nil
self._dragStartY[button] = nil
end
end
end
-- Track hover state changes even when events can't be processed
-- Fire synthetic unhover when element becomes disabled while hovered
if element.disabled and self._hovered then
self._hovered = false
if element.onEvent or self.onEvent then
local modifiers = EventHandler._utils.getModifiers()
local unhoverEvent = EventHandler._InputEvent.new({
type = "unhover",
button = 0,
x = mx,
y = my,
modifiers = modifiers,
clickCount = 0,
})
self:_invokeCallback(element, unhoverEvent)
end
elseif self._hovered and not isHovering then
self._hovered = false
if element.onEvent or self.onEvent then
local modifiers = EventHandler._utils.getModifiers()
local unhoverEvent = EventHandler._InputEvent.new({
type = "unhover",
button = 0,
x = mx,
y = my,
modifiers = modifiers,
clickCount = 0,
})
self:_invokeCallback(element, unhoverEvent)
end
end
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:stopTimer("event_mouse")
end
return
end
-- Track hover state changes and fire hover/unhover events BEFORE button processing
-- This ensures hover fires before press when mouse first enters element
local wasHovered = self._hovered
local isHoveringAndActive = isHovering and isActiveElement
if isHoveringAndActive and not wasHovered then
-- Just started hovering - fire hover event
self._hovered = true
local modifiers = EventHandler._utils.getModifiers()
local hoverEvent = EventHandler._InputEvent.new({
type = "hover",
button = 0,
x = mx,
y = my,
modifiers = modifiers,
clickCount = 0,
})
self:_invokeCallback(element, hoverEvent)
elseif not isHoveringAndActive and wasHovered then
-- Just stopped hovering - fire unhover event
self._hovered = false
local modifiers = EventHandler._utils.getModifiers()
local unhoverEvent = EventHandler._InputEvent.new({
type = "unhover",
button = 0,
x = mx,
y = my,
modifiers = modifiers,
clickCount = 0,
})
self:_invokeCallback(element, unhoverEvent)
end
-- Process all three mouse buttons
local buttons = { 1, 2, 3 } -- left, right, middle
for _, button in ipairs(buttons) do
-- Check if this button was tracked as pressed
local wasPressed = self._pressed[button]
local isPhysicallyPressed = love.mouse.isDown(button)
if isHovering or isDragging or wasPressed then
if isPhysicallyPressed then
-- Button is pressed down
if not wasPressed then
-- Just pressed - fire press event (only if hovering)
if isHovering then
self:_handleMousePress(element, mx, my, button)
end
else
-- Button is still pressed - check for drag
self:_handleMouseDrag(element, mx, my, button, isHovering)
end
elseif wasPressed then
-- Button was just released
-- Only fire click and release events if mouse is still hovering AND element is active
-- (not occluded by another element)
if isHovering and isActiveElement then
self:_handleMouseRelease(element, mx, my, button)
else
-- Mouse left before release OR element is occluded - just clear the pressed state without firing events
self._pressed[button] = false
self._dragStartX[button] = nil
self._dragStartY[button] = nil
end
end
end
end
-- After processing events, reset pressed states for buttons that are no longer held
-- This handles the case where mouse leaves while button is held, then released
if not isHovering and not isDragging then
for _, button in ipairs({ 1, 2, 3 }) do
if self._pressed[button] and not love.mouse.isDown(button) then
self._pressed[button] = false
self._dragStartX[button] = nil
self._dragStartY[button] = nil
end
end
end
-- Stop performance timing
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:stopTimer("event_mouse")
end
end
--- Handle mouse button press
---@param element Element The parent element
---@param mx number Mouse X position
---@param my number Mouse Y position
---@param button number Mouse button (1=left, 2=right, 3=middle)
function EventHandler:_handleMousePress(element, mx, my, button)
-- Check if press is on scrollbar first (skip if already handled)
if button == 1 and not self._scrollbarPressHandled and element._handleScrollbarPress then
if element:_handleScrollbarPress(mx, my, button) then
-- Scrollbar consumed the event, mark as pressed to prevent onEvent
self._pressed[button] = true
self._scrollbarPressHandled = true
return
end
end
-- Fire press event
local modifiers = EventHandler._utils.getModifiers()
local pressEvent = EventHandler._InputEvent.new({
type = "press",
button = button,
x = mx,
y = my,
modifiers = modifiers,
clickCount = 1,
})
self:_invokeCallback(element, pressEvent)
self._pressed[button] = true
-- On left click, set keyboard focus to any focusable element (not just editable).
-- Clear the focus indicator since mouse navigation doesn't use it.
local isFocusable
if type(element.isFocusable) == "function" then
isFocusable = element:isFocusable()
else
isFocusable = (element.editable == true)
or (type(element.onEvent) == "function")
or element._selectState ~= nil
or element.selectOption ~= nil
end
if button == 1 and EventHandler._Context and isFocusable then
EventHandler._Context.setFocused(element)
-- Hide focus indicator - it's only for keyboard navigation
if EventHandler._FocusIndicator then
EventHandler._FocusIndicator.setFocused(nil)
end
end
-- Set mouse down position for text selection on left click
if button == 1 and element._textEditor then
element._mouseDownPosition = element._textEditor:mouseToTextPosition(element, mx, my)
element._textDragOccurred = false -- Reset drag flag on press
end
-- Record drag start position per button
self._dragStartX[button] = mx
self._dragStartY[button] = my
self._lastMouseX[button] = mx
self._lastMouseY[button] = my
end
--- Handle mouse drag (while button is pressed and mouse moves)
---@param element Element The parent element
---@param mx number Mouse X position
---@param my number Mouse Y position
---@param button number Mouse button
---@param isHovering boolean Whether mouse is over element
function EventHandler:_handleMouseDrag(element, mx, my, button, isHovering)
local lastX = self._lastMouseX[button] or mx
local lastY = self._lastMouseY[button] or my
if lastX ~= mx or lastY ~= my then
-- Handle scrollbar drag if scrollbar was pressed
if button == 1 and self._scrollbarPressHandled and element._handleScrollbarDrag then
element:_handleScrollbarDrag(mx, my)
self._lastMouseX[button] = mx
self._lastMouseY[button] = my
return -- Don't process other drag events while dragging scrollbar
end
-- Mouse has moved - fire drag event only if still hovering
if isHovering then
local modifiers = EventHandler._utils.getModifiers()
local dx = mx - self._dragStartX[button]
local dy = my - self._dragStartY[button]
local dragEvent = EventHandler._InputEvent.new({
type = "drag",
button = button,
x = mx,
y = my,
dx = dx,
dy = dy,
modifiers = modifiers,
clickCount = 1,
})
self:_invokeCallback(element, dragEvent)
end
-- Handle text selection drag for editable elements
if button == 1 and element.editable and element._focused and element._handleTextDrag then
element:_handleTextDrag(mx, my)
end
-- Update last known position for this button
self._lastMouseX[button] = mx
self._lastMouseY[button] = my
end
end
--- Handle mouse button release
---@param mx number Mouse X position
---@param my number Mouse Y position
---@param button number Mouse button
function EventHandler:_handleMouseRelease(element, mx, my, button)
local currentTime = love.timer.getTime()
local modifiers = EventHandler._utils.getModifiers()
-- Handle scrollbar release if scrollbar was pressed
if button == 1 and self._scrollbarPressHandled and element._handleScrollbarRelease then
element:_handleScrollbarRelease(button)
self._scrollbarPressHandled = false -- Reset flag
self._pressed[button] = false
self._dragStartX[button] = nil
self._dragStartY[button] = nil
return -- Don't process click events for scrollbar release
end
-- Determine click count (double-click detection)
local clickCount
local doubleClickThreshold = 0.3 -- 300ms for double-click
if
self._lastClickTime
and self._lastClickButton == button
and (currentTime - self._lastClickTime) < doubleClickThreshold
then
clickCount = self._clickCount + 1
else
clickCount = 1
end
self._clickCount = clickCount
self._lastClickTime = currentTime
self._lastClickButton = button
-- Determine event type based on button
local eventType = "click"
if button == 2 then
eventType = "rightclick"
elseif button == 3 then
eventType = "middleclick"
end
-- Fire click event
local clickEvent = EventHandler._InputEvent.new({
type = eventType,
button = button,
x = mx,
y = my,
modifiers = modifiers,
clickCount = clickCount,
})
self:_invokeCallback(element, clickEvent)
self._pressed[button] = false
-- Clean up drag tracking
self._dragStartX[button] = nil
self._dragStartY[button] = nil
-- Clean up text selection drag tracking
if button == 1 then
element._mouseDownPosition = nil
end
-- Focus editable elements on left click
if button == 1 and element.editable then
-- Only focus if not already focused (to avoid moving cursor to end)
local wasFocused = element:isFocused()
if not wasFocused then
element:focus()
end
-- Handle text click for cursor positioning and word selection
-- Only process click if no text drag occurred (to preserve drag selection)
if element._handleTextClick and not element._textDragOccurred then
element:_handleTextClick(mx, my, clickCount)
end
-- Reset drag flag after release
element._textDragOccurred = false
end
-- Fire release event
local releaseEvent = EventHandler._InputEvent.new({
type = "release",
button = button,
x = mx,
y = my,
modifiers = modifiers,
clickCount = clickCount,
})
self:_invokeCallback(element, releaseEvent)
if button == 1 and element._handleSelectRelease then
element:_handleSelectRelease()
end
end
--- Process touch events in the update cycle
---@param element Element The parent element
function EventHandler:processTouchEvents(element)
-- Start performance timing
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:startTimer("event_touch")
end
-- Check if element can process events
local canProcessEvents = (
element.onEvent
or self.onEvent
or element.onTouchEvent
or self.onTouchEvent
or element.editable
)
and not element.disabled
and self.touchEnabled
if not canProcessEvents then
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:stopTimer("event_touch")
end
return
end
local bx = element.x
local by = element.y
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
-- Get current active touches from LÖVE
local activeTouches = {}
local touches = love.touch.getTouches()
for _, id in ipairs(touches) do
activeTouches[tostring(id)] = true
end
-- Count active tracked touches for multi-touch filtering
local trackedTouchCount = 0
for _ in pairs(self._touches) do
trackedTouchCount = trackedTouchCount + 1
end
-- Process active touches
for _, id in ipairs(touches) do
local touchId = tostring(id)
local tx, ty = love.touch.getPosition(id)
local pressure = 1.0 -- LÖVE doesn't provide pressure by default
-- Check if touch is within element bounds
local isInside = tx >= bx and tx <= bx + bw and ty >= by and ty <= by + bh
if isInside then
if not self._touches[touchId] then
-- Multi-touch filtering: reject new touches when multiTouchEnabled=false
-- and we already have an active touch
if self.multiTouchEnabled or trackedTouchCount == 0 then
-- New touch began
self:_handleTouchBegan(element, touchId, tx, ty, pressure)
trackedTouchCount = trackedTouchCount + 1
end
else
-- Touch moved
self:_handleTouchMoved(element, touchId, tx, ty, pressure)
end
elseif self._touches[touchId] then
-- Touch moved outside or ended
if activeTouches[touchId] then
-- Still active but outside - fire moved event
self:_handleTouchMoved(element, touchId, tx, ty, pressure)
else
-- Touch ended
self:_handleTouchEnded(element, touchId, tx, ty, pressure)
end
end
end
-- Check for ended touches (touches that were tracked but are no longer active)
for touchId, _ in pairs(self._touches) do
if not activeTouches[touchId] then
-- Touch ended or cancelled
local lastPos = self._lastTouchPositions[touchId]
if lastPos then
self:_handleTouchEnded(element, touchId, lastPos.x, lastPos.y, 1.0)
else
-- Cleanup orphaned touch
self:_cleanupTouch(touchId)
end
end
end
-- Stop performance timing
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:stopTimer("event_touch")
end
end
--- Handle touch began event
---@param element Element The parent element
---@param touchId string Touch identifier
---@param x number Touch X position
---@param y number Touch Y position
---@param pressure number Touch pressure (0-1)
function EventHandler:_handleTouchBegan(element, touchId, x, y, pressure)
-- Create touch state
self._touches[touchId] = {
x = x,
y = y,
pressure = pressure,
timestamp = love.timer.getTime(),
phase = "began",
}
-- Record start position
self._touchStartPositions[touchId] = { x = x, y = y }
self._lastTouchPositions[touchId] = { x = x, y = y }
-- Initialize touch history
self._touchHistory[touchId] = { { x = x, y = y, timestamp = love.timer.getTime() } }
-- Create and fire touch press event
local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "began", pressure)
touchEvent.type = "touchpress"
touchEvent.dx = 0
touchEvent.dy = 0
self:_invokeCallback(element, touchEvent)
self:_invokeTouchCallback(element, touchEvent)
end
--- Handle touch moved event
---@param element Element The parent element
---@param touchId string Touch identifier
---@param x number Touch X position
---@param y number Touch Y position
---@param pressure number Touch pressure (0-1)
function EventHandler:_handleTouchMoved(element, touchId, x, y, pressure)
local touchState = self._touches[touchId]
if not touchState then
-- Touch not tracked, ignore
return
end
local lastPos = self._lastTouchPositions[touchId]
if not lastPos or lastPos.x ~= x or lastPos.y ~= y then
-- Touch position changed
local startPos = self._touchStartPositions[touchId]
local dx = x - startPos.x
local dy = y - startPos.y
-- Update touch state
touchState.x = x
touchState.y = y
touchState.pressure = pressure
touchState.phase = "moved"
-- Update last position
self._lastTouchPositions[touchId] = { x = x, y = y }
-- Add to touch history (keep last 5 positions)
local history = self._touchHistory[touchId] or {}
table.insert(history, { x = x, y = y, timestamp = love.timer.getTime() })
if #history > 5 then
table.remove(history, 1)
end
self._touchHistory[touchId] = history
-- Create and fire touch move event
local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "moved", pressure)
touchEvent.type = "touchmove"
touchEvent.dx = dx
touchEvent.dy = dy
self:_invokeCallback(element, touchEvent)
self:_invokeTouchCallback(element, touchEvent)
end
end
--- Handle touch ended event
---@param element Element The parent element
---@param touchId string Touch identifier
---@param x number Touch X position
---@param y number Touch Y position
---@param pressure number Touch pressure (0-1)
function EventHandler:_handleTouchEnded(element, touchId, x, y, pressure)
local touchState = self._touches[touchId]
if not touchState then
-- Touch not tracked, ignore
return
end
local startPos = self._touchStartPositions[touchId]
local dx = x - startPos.x
local dy = y - startPos.y
-- Create and fire touch release event
local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "ended", pressure)
touchEvent.type = "touchrelease"
touchEvent.dx = dx
touchEvent.dy = dy
self:_invokeCallback(element, touchEvent)
self:_invokeTouchCallback(element, touchEvent)
-- Cleanup touch state
self:_cleanupTouch(touchId)
end
--- Cleanup touch state
---@param touchId string Touch ID
function EventHandler:_cleanupTouch(touchId)
self._touches[touchId] = nil
self._touchStartPositions[touchId] = nil
self._lastTouchPositions[touchId] = nil
self._touchHistory[touchId] = nil
end
--- Get active touches on this element
---@return table<string, table> Active touches
function EventHandler:getActiveTouches()
return self._touches
end
--- Reset scrollbar press flag (called each frame)
function EventHandler:resetScrollbarPressFlag()
self._scrollbarPressHandled = false
end
--- Check if any mouse button is pressed
---@return boolean True if any button is pressed
function EventHandler:isAnyButtonPressed()
for _, pressed in pairs(self._pressed) do
if pressed then
return true
end
end
return false
end
--- Check if a specific button is pressed
---@param button number Mouse button (1=left, 2=right, 3=middle)
---@return boolean True if button is pressed
function EventHandler:isButtonPressed(button)
return self._pressed[button] == true
end
--- Invoke the onEvent callback, optionally deferring it if onEventDeferred is true
---@param element Element The element that triggered the event
---@param event InputEvent The event data
function EventHandler:_invokeCallback(element, event)
-- Read onEvent from element (source of truth), fallback to handler cache for backwards compat
local callback = element.onEvent or self.onEvent
if not callback then
return
end
if self.onEventDeferred then
-- Get FlexLove module to defer the callback
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
if FlexLove and FlexLove.deferCallback then
FlexLove.deferCallback(function()
callback(element, event)
end)
else
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
eventType = event.type,
})
end
else
callback(element, event)
end
end
--- Invoke the onTouchEvent callback, optionally deferring it
---@param element Element The element that triggered the event
---@param event InputEvent The touch event data
function EventHandler:_invokeTouchCallback(element, event)
-- Read onTouchEvent from element (source of truth), fallback to handler cache for backwards compat
local callback = element.onTouchEvent or self.onTouchEvent
if not callback then
return
end
if self.onTouchEventDeferred then
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
if FlexLove and FlexLove.deferCallback then
FlexLove.deferCallback(function()
callback(element, event)
end)
else
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
eventType = event.type,
})
end
else
callback(element, event)
end
end
--- Invoke the onGesture callback, optionally deferring it
---@param element Element The element that triggered the event
---@param gesture table The gesture data from GestureRecognizer
function EventHandler:_invokeGestureCallback(element, gesture)
-- Read onGesture from element (source of truth), fallback to handler cache for backwards compat
local callback = element.onGesture or self.onGesture
if not callback then
return
end
if self.onGestureDeferred then
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
if FlexLove and FlexLove.deferCallback then
FlexLove.deferCallback(function()
callback(element, gesture)
end)
else
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
gestureType = gesture.type,
})
end
else
callback(element, gesture)
end
end
return EventHandler
-232
View File
@@ -1,232 +0,0 @@
local packageName = ... or "FocusIndicator"
local modulePath = packageName:match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
local FocusIndicator = {}
--- Configuration
---@type KeyboardNavigationFocusIndicatorConfig
FocusIndicator.config = {
enabled = true,
--- Custom draw function to override default rendering
---@type function|nil
--- Called with: element, bounds, style - return true to skip default drawing
draw = nil,
-- Appearance
color = { 0.2, 0.6, 1.0, 0.8 }, -- Blue with 80% opacity
lineWidth = 2,
inset = -3, -- Negative value extends beyond element
borderRadius = 4,
-- Animation
animationDuration = 0.15, -- Seconds for focus animation
pulseEnabled = false, -- Enable pulsing animation
pulseDuration = 1.0, -- Seconds per pulse cycle
pulseScaleMin = 0.95, -- Minimum scale during pulse
pulseScaleMax = 1.05, -- Maximum scale during pulse
}
--- State
FocusIndicator._focusedElement = nil
FocusIndicator._animationProgress = 0
FocusIndicator._pulsePhase = 0
FocusIndicator._hidden = true
FocusIndicator._deps = nil
--- Initialize FocusIndicator module
---@param deps table Dependencies table containing Context and Color modules
---@field deps.Context table Context module for getting focused element
---@field deps.Color table Color module for color manipulation
function FocusIndicator.init(deps)
FocusIndicator._deps = deps
FocusIndicator._Context = deps.Context
FocusIndicator._Color = deps.Color
end
--- Update animation state for entrance and pulse effects
---@param dt number Delta time in seconds since last frame
function FocusIndicator:update(dt)
if not FocusIndicator.config.enabled then
return
end
-- Update focus entrance animation
if FocusIndicator._animationProgress < 1 then
FocusIndicator._animationProgress =
math.min(1, FocusIndicator._animationProgress + (dt / FocusIndicator.config.animationDuration))
end
-- Update pulse animation
if FocusIndicator.config.pulseEnabled then
FocusIndicator._pulsePhase = (FocusIndicator._pulsePhase + dt) % FocusIndicator.config.pulseDuration
end
end
--- Set the focused element to render indicator around
---@param element Element? The element to show focus indicator around, or nil to hide
function FocusIndicator.setFocused(element)
FocusIndicator._focusedElement = element
FocusIndicator._hidden = element == nil
-- Reset animation when focus changes
if element then
FocusIndicator._animationProgress = 0
end
end
--- Get the current scale factor for animations
--- Combines entrance scale (0.8 to 1.0) with optional pulse scale
---@return number Scale factor (typically 0.8-1.05 range)
function FocusIndicator:getScale()
local scale = 1
-- Apply entrance animation (scale up from 0.8)
local entranceScale = 0.8 + (0.2 * FocusIndicator._animationProgress)
scale = scale * entranceScale
-- Apply pulse animation
if FocusIndicator.config.pulseEnabled then
local pulseProgress = FocusIndicator._pulsePhase / FocusIndicator.config.pulseDuration
-- Smooth sine wave pulse
local pulseScale = FocusIndicator.config.pulseScaleMin
+ (FocusIndicator.config.pulseScaleMax - FocusIndicator.config.pulseScaleMin)
* (0.5 + 0.5 * math.sin(2 * math.pi * pulseProgress))
scale = scale * pulseScale
end
return scale
end
--- Get the current opacity for the indicator
--- Applies entrance animation fade-in to the configured alpha
---@return number Alpha value (0-1 range)
function FocusIndicator:getOpacity()
-- Fade in on focus
return FocusIndicator.config.color[4] * FocusIndicator._animationProgress
end
--- Draw the focus indicator around the focused element
--- Renders a rounded rectangle border, or calls custom draw function if configured
--- Should be called from within love.draw() after all elements are drawn
function FocusIndicator:draw()
if not FocusIndicator.config.enabled then
return
end
if FocusIndicator._hidden then
return
end
-- In immediate mode the stored element reference is stale (recreated every frame).
-- Always resolve through Context so we get the live object with up-to-date positions.
local element
if FocusIndicator._Context then
element = FocusIndicator._Context.getFocused()
else
element = FocusIndicator._focusedElement
end
if not element then
return
end
-- Get element dimensions (use border-box size which includes padding)
local x = element.x or 0
local y = element.y or 0
local w = element._borderBoxWidth
or (element.width + (element.padding and (element.padding.left + element.padding.right) or 0))
local h = element._borderBoxHeight
or (element.height + (element.padding and (element.padding.top + element.padding.bottom) or 0))
if w == 0 or h == 0 then
return
end
-- Calculate indicator dimensions with inset and scale
local inset = FocusIndicator.config.inset
local scale = self:getScale()
local indicatorX = x + inset
local indicatorY = y + inset
local indicatorW = w - 2 * inset
local indicatorH = h - 2 * inset
-- Center the scale around the element
local offsetX = (indicatorW * (1 - scale)) / 2
local offsetY = (indicatorH * (1 - scale)) / 2
indicatorX = indicatorX + offsetX
indicatorY = indicatorY + offsetY
indicatorW = indicatorW * scale
indicatorH = indicatorH * scale
-- Get color with animated opacity
local r, g, b = FocusIndicator.config.color[1], FocusIndicator.config.color[2], FocusIndicator.config.color[3]
local a = self:getOpacity()
-- Build style table for custom draw callback
local bounds = {
x = indicatorX,
y = indicatorY,
width = indicatorW,
height = indicatorH,
}
local style = {
color = { r = r, g = g, b = b, a = a },
lineWidth = FocusIndicator.config.lineWidth,
borderRadius = FocusIndicator.config.borderRadius,
scale = scale,
opacity = a,
}
-- Check for custom draw callback
if FocusIndicator.config.draw then
local skipDefault = FocusIndicator.config.draw(element, bounds, style)
if skipDefault then
return
end
end
-- Save current love.graphics state
local prevBlend, prevAlphaMode = love.graphics.getBlendMode()
local prevR, prevG, prevB, prevA = love.graphics.getColor()
local prevLineWidth = love.graphics.getLineWidth()
-- Set blend mode for transparency
love.graphics.setBlendMode("alpha")
-- Draw rounded rectangle border
love.graphics.setColor(r, g, b, a)
love.graphics.setLineWidth(FocusIndicator.config.lineWidth)
-- Draw the rounded rectangle border
local borderRadius = FocusIndicator.config.borderRadius
love.graphics.rectangle("line", indicatorX, indicatorY, indicatorW, indicatorH, borderRadius)
-- Restore love.graphics state
love.graphics.setBlendMode(prevBlend, prevAlphaMode)
love.graphics.setColor(prevR, prevG, prevB, prevA)
love.graphics.setLineWidth(prevLineWidth)
end
--- Set the indicator color
---@param r number Red component (0-1 range)
---@param g number Green component (0-1 range)
---@param b number Blue component (0-1 range)
---@param a number|nil Alpha component (0-1 range), defaults to current alpha if omitted
function FocusIndicator.setColor(r, g, b, a)
FocusIndicator.config.color = { r, g, b, a or FocusIndicator.config.color[4] }
end
--- Set the stroke width for the indicator border
---@param width number Line width in pixels
function FocusIndicator.setLineWidth(width)
FocusIndicator.config.lineWidth = width
end
return FocusIndicator
-269
View File
@@ -1,269 +0,0 @@
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
-- Font cache with LRU eviction, font resolution, and cache management.
-- `ErrorHandler` and `resolveImagePath` are injected via init() to avoid
-- a cross-import into utils (utils re-exports the cache via aliases).
-- Font cache with LRU eviction
local FONT_CACHE = {}
local FONT_CACHE_MAX_SIZE = 50
local FONT_CACHE_STATS = {
hits = 0,
misses = 0,
evictions = 0,
size = 0,
}
local ErrorHandler = nil
local resolveImagePath = nil
--- Initialize dependencies
---@param deps table Dependencies: { ErrorHandler = ErrorHandler, resolveImagePath = function }
local function init(deps)
if type(deps) == "table" then
ErrorHandler = deps.ErrorHandler
resolveImagePath = deps.resolveImagePath
end
end
-- LRU tracking: each entry has {font, lastUsed, accessCount}
local function updateCacheAccess(cacheKey)
local entry = FONT_CACHE[cacheKey]
if entry then
entry.lastUsed = love.timer.getTime()
entry.accessCount = entry.accessCount + 1
end
end
local function evictLRU()
local oldestKey = nil
local oldestTime = math.huge
for key, entry in pairs(FONT_CACHE) do
-- Skip methods (get, getFont) - only evict cache entries (tables with lastUsed)
if type(entry) == "table" and entry.lastUsed then
if entry.lastUsed < oldestTime then
oldestTime = entry.lastUsed
oldestKey = key
end
end
end
if oldestKey then
FONT_CACHE[oldestKey] = nil
FONT_CACHE_STATS.evictions = FONT_CACHE_STATS.evictions + 1
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size - 1
end
end
--- Create or get a font from cache
---@param size number
---@param fontPath string?
---@return love.Font
function FONT_CACHE.get(size, fontPath)
-- Bucket font sizes for better cache reuse (reduces unique cache entries)
-- Small sizes (< 20): round to nearest 2
-- Medium sizes (20-40): round to nearest 4
-- Large sizes (> 40): round to nearest 8
if size < 20 then
size = math.floor((size + 1) / 2) * 2
elseif size < 40 then
size = math.floor((size + 2) / 4) * 4
else
size = math.floor((size + 4) / 8) * 8
end
local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size))
if FONT_CACHE[cacheKey] then
-- Cache hit
FONT_CACHE_STATS.hits = FONT_CACHE_STATS.hits + 1
updateCacheAccess(cacheKey)
return FONT_CACHE[cacheKey].font
end
-- Cache miss
FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1
local font
if fontPath then
local resolvedPath = resolveImagePath(fontPath)
local success, result = pcall(love.graphics.newFont, resolvedPath, size)
if success then
font = result
else
if ErrorHandler then
ErrorHandler:warn("utils", "RES_004", {
resourceType = "font",
path = fontPath,
})
end
font = love.graphics.newFont(size)
end
else
font = love.graphics.newFont(size)
end
-- Add to cache with LRU metadata
FONT_CACHE[cacheKey] = {
font = font,
lastUsed = love.timer.getTime(),
accessCount = 1,
}
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size + 1
-- Evict if cache is full
if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then
evictLRU()
end
return font
end
--- Get font for text size (cached)
---@param textSize number?
---@param fontPath string?
---@return love.Font
function FONT_CACHE.getFont(textSize, fontPath)
if textSize then
return FONT_CACHE.get(textSize, fontPath)
else
return love.graphics.getFont()
end
end
-- Font resolution utilities
--- Resolve font path from fontFamily and theme
---@param fontFamily string? Font family name or direct path
---@param themeComponent string? Theme component name
---@param themeManager table? ThemeManager instance
---@return string? Resolved font path or nil
local function resolveFontPath(fontFamily, themeComponent, themeManager)
if fontFamily then
-- Check if fontFamily is a theme font name
local themeToUse = themeManager and themeManager:getTheme()
if themeToUse and themeToUse.fonts and themeToUse.fonts[fontFamily] then
return themeToUse.fonts[fontFamily]
else
-- Treat as direct path to font file
return fontFamily
end
elseif themeComponent and themeManager then
-- If using themeComponent but no fontFamily specified, check for default font in theme
return themeManager:getDefaultFontFamily()
end
return nil
end
--- Get font for element (resolves from theme or fontFamily)
---@param textSize number? Text size in pixels
---@param fontFamily string? Font family name or direct path
---@param themeComponent string? Theme component name
---@param themeManager table? ThemeManager instance
---@return love.Font
local function getFont(textSize, fontFamily, themeComponent, themeManager)
local fontPath = resolveFontPath(fontFamily, themeComponent, themeManager)
return FONT_CACHE.getFont(textSize, fontPath)
end
-- Font cache management
--- Get font cache statistics
---@return table stats {hits, misses, evictions, size, hitRate}
local function getFontCacheStats()
local total = FONT_CACHE_STATS.hits + FONT_CACHE_STATS.misses
local hitRate = total > 0 and (FONT_CACHE_STATS.hits / total) or 0
return {
hits = FONT_CACHE_STATS.hits,
misses = FONT_CACHE_STATS.misses,
evictions = FONT_CACHE_STATS.evictions,
size = FONT_CACHE_STATS.size,
hitRate = hitRate,
}
end
--- Set maximum font cache size
---@param maxSize number Maximum number of fonts to cache
local function setFontCacheSize(maxSize)
FONT_CACHE_MAX_SIZE = math.max(1, maxSize)
-- Evict entries if cache is now over limit
while FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE do
evictLRU()
end
end
--- Clear font cache
local function clearFontCache()
-- Clear cache entries but preserve methods (get, getFont)
for key, entry in pairs(FONT_CACHE) do
if type(entry) == "table" and entry.lastUsed then
FONT_CACHE[key] = nil
end
end
FONT_CACHE_STATS.size = 0
FONT_CACHE_STATS.evictions = 0
end
--- Preload font at multiple sizes
---@param fontPath string? Path to font file (nil for default font)
---@param sizes table Array of font sizes to preload
local function preloadFont(fontPath, sizes)
for _, size in ipairs(sizes) do
-- Round size to reduce cache entries
size = math.floor(size + 0.5)
local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size))
if not FONT_CACHE[cacheKey] then
local font
if fontPath then
local resolvedPath = resolveImagePath(fontPath)
local success, result = pcall(love.graphics.newFont, resolvedPath, size)
if success then
font = result
else
font = love.graphics.newFont(size)
end
else
font = love.graphics.newFont(size)
end
FONT_CACHE[cacheKey] = {
font = font,
lastUsed = love.timer.getTime(),
accessCount = 1,
}
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size + 1
FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1
-- Evict if cache is full
if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then
evictLRU()
end
end
end
end
--- Reset font cache statistics
local function resetFontCacheStats()
FONT_CACHE_STATS.hits = 0
FONT_CACHE_STATS.misses = 0
FONT_CACHE_STATS.evictions = 0
end
return {
FONT_CACHE = FONT_CACHE,
init = init,
resolveFontPath = resolveFontPath,
getFont = getFont,
getFontCacheStats = getFontCacheStats,
setFontCacheSize = setFontCacheSize,
clearFontCache = clearFontCache,
preloadFont = preloadFont,
resetFontCacheStats = resetFontCacheStats,
}
-583
View File
@@ -1,583 +0,0 @@
---@class GestureRecognizer
---@field _touches table<string, table> -- Current touch states
---@field _gestureStates table -- Active gesture states
---@field _config table -- Gesture configuration (thresholds, etc.)
---@field _InputEvent table
---@field _utils table
local GestureRecognizer = {}
GestureRecognizer.__index = GestureRecognizer
-- Gesture types enum
local GestureType = {
TAP = "tap",
DOUBLE_TAP = "double_tap",
LONG_PRESS = "long_press",
SWIPE = "swipe",
PAN = "pan",
PINCH = "pinch",
ROTATE = "rotate",
}
-- Gesture states
local GestureState = {
POSSIBLE = "possible",
BEGAN = "began",
CHANGED = "changed",
ENDED = "ended",
CANCELLED = "cancelled",
FAILED = "failed",
}
-- Default configuration
local defaultConfig = {
-- Tap gesture
tapMaxDuration = 0.3, -- seconds
tapMaxMovement = 10, -- pixels
-- Double-tap gesture
doubleTapInterval = 0.3, -- seconds between taps
-- Long-press gesture
longPressMinDuration = 0.5, -- seconds
longPressMaxMovement = 10, -- pixels
-- Swipe gesture
swipeMinDistance = 50, -- pixels
swipeMaxDuration = 0.2, -- seconds
swipeMinVelocity = 200, -- pixels per second
-- Pan gesture
panMinMovement = 5, -- pixels to start pan
-- Pinch gesture
pinchMinScaleChange = 0.1, -- 10% scale change
-- Rotate gesture
rotateMinAngleChange = 5, -- degrees
}
--- Create a new GestureRecognizer instance
---@param config table? Optional configuration options
---@param deps table Dependencies {InputEvent, utils}
---@return GestureRecognizer
function GestureRecognizer.new(config, deps)
config = config or {}
local self = setmetatable({}, GestureRecognizer)
self._InputEvent = deps.InputEvent
self._utils = deps.utils
-- Merge configuration with defaults
self._config = {}
for key, value in pairs(defaultConfig) do
self._config[key] = config[key] or value
end
self._touches = {}
self._gestureStates = {
tap = nil,
doubleTap = { lastTapTime = 0, tapCount = 0 },
longPress = {},
swipe = {},
pan = {},
pinch = {},
rotate = {},
}
return self
end
--- Update gesture recognizer with touch event
---@param event InputEvent Touch event
function GestureRecognizer:processTouchEvent(event)
if not event.touchId then
return nil
end
local touchId = event.touchId
local gestures = {}
-- Update touch state
if event.type == "touchpress" then
self._touches[touchId] = {
startX = event.x,
startY = event.y,
x = event.x,
y = event.y,
startTime = event.timestamp,
lastTime = event.timestamp,
phase = "began",
}
-- Initialize gesture detection
self:_detectTapBegan(touchId, event)
self:_detectLongPressBegan(touchId, event)
elseif event.type == "touchmove" then
local touch = self._touches[touchId]
if touch then
touch.x = event.x
touch.y = event.y
touch.lastTime = event.timestamp
touch.phase = "moved"
-- Update gesture detection
local panGesture = self:_detectPan(touchId, event)
if panGesture then
table.insert(gestures, panGesture)
end
local swipeGesture = self:_detectSwipe(touchId, event)
if swipeGesture then
table.insert(gestures, swipeGesture)
end
-- Multi-touch gestures
if self:_getTouchCount() >= 2 then
local pinchGesture = self:_detectPinch(event)
if pinchGesture then
table.insert(gestures, pinchGesture)
end
local rotateGesture = self:_detectRotate(event)
if rotateGesture then
table.insert(gestures, rotateGesture)
end
end
end
elseif event.type == "touchrelease" then
local touch = self._touches[touchId]
if touch then
touch.phase = "ended"
-- Finalize gesture detection
local tapGesture = self:_detectTapEnded(touchId, event)
if tapGesture then
table.insert(gestures, tapGesture)
end
local swipeGesture = self:_detectSwipeEnded(touchId, event)
if swipeGesture then
table.insert(gestures, swipeGesture)
end
local panGesture = self:_detectPanEnded(touchId, event)
if panGesture then
table.insert(gestures, panGesture)
end
-- Cleanup touch
self._touches[touchId] = nil
end
elseif event.type == "touchcancel" then
-- Cancel all active gestures for this touch
self._touches[touchId] = nil
self:_cancelAllGestures()
end
return #gestures > 0 and gestures or nil
end
--- Get number of active touches
---@return number
function GestureRecognizer:_getTouchCount()
local count = 0
for _ in pairs(self._touches) do
count = count + 1
end
return count
end
--- Detect tap gesture began
---@param touchId string
---@param event InputEvent
function GestureRecognizer:_detectTapBegan(touchId, event)
-- Tap detection happens on touch end
-- Just record the touch for now
end
--- Detect tap gesture ended
---@param touchId string
---@param event InputEvent
function GestureRecognizer:_detectTapEnded(touchId, event)
local touch = self._touches[touchId]
if not touch then
return
end
local duration = event.timestamp - touch.startTime
local dx = event.x - touch.startX
local dy = event.y - touch.startY
local distance = math.sqrt(dx * dx + dy * dy)
-- Check if it's a valid tap
if duration < self._config.tapMaxDuration and distance < self._config.tapMaxMovement then
local currentTime = event.timestamp
local doubleTapState = self._gestureStates.doubleTap
-- Check for double-tap
if currentTime - doubleTapState.lastTapTime < self._config.doubleTapInterval then
doubleTapState.tapCount = doubleTapState.tapCount + 1
if doubleTapState.tapCount >= 2 then
-- Fire double-tap gesture
return {
type = GestureType.DOUBLE_TAP,
state = GestureState.ENDED,
x = event.x,
y = event.y,
timestamp = event.timestamp,
}
end
else
doubleTapState.tapCount = 1
end
doubleTapState.lastTapTime = currentTime
-- Fire tap gesture
return {
type = GestureType.TAP,
state = GestureState.ENDED,
x = event.x,
y = event.y,
timestamp = event.timestamp,
}
end
end
--- Detect long-press gesture began
---@param touchId string
---@param event InputEvent
function GestureRecognizer:_detectLongPressBegan(touchId, event)
-- Long-press detection happens continuously during touch
self._gestureStates.longPress[touchId] = {
startX = event.x,
startY = event.y,
startTime = event.timestamp,
triggered = false,
}
end
--- Detect pan gesture
---@param touchId string
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectPan(touchId, event)
local touch = self._touches[touchId]
if not touch then
return nil
end
local dx = event.x - touch.startX
local dy = event.y - touch.startY
local distance = math.sqrt(dx * dx + dy * dy)
local panState = self._gestureStates.pan[touchId]
if not panState then
-- Check if pan should begin
if distance >= self._config.panMinMovement then
self._gestureStates.pan[touchId] = {
active = true,
lastX = touch.startX,
lastY = touch.startY,
}
panState = self._gestureStates.pan[touchId]
return {
type = GestureType.PAN,
state = GestureState.BEGAN,
x = event.x,
y = event.y,
dx = dx,
dy = dy,
timestamp = event.timestamp,
}
end
else
-- Pan is active, fire changed event
local panDx = event.x - panState.lastX
local panDy = event.y - panState.lastY
panState.lastX = event.x
panState.lastY = event.y
return {
type = GestureType.PAN,
state = GestureState.CHANGED,
x = event.x,
y = event.y,
dx = panDx,
dy = panDy,
totalDx = dx,
totalDy = dy,
timestamp = event.timestamp,
}
end
return nil
end
--- Detect pan ended
---@param touchId string
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectPanEnded(touchId, event)
local panState = self._gestureStates.pan[touchId]
if panState and panState.active then
self._gestureStates.pan[touchId] = nil
local touch = self._touches[touchId]
local dx = event.x - touch.startX
local dy = event.y - touch.startY
return {
type = GestureType.PAN,
state = GestureState.ENDED,
x = event.x,
y = event.y,
dx = dx,
dy = dy,
timestamp = event.timestamp,
}
end
return nil
end
--- Detect swipe gesture
---@param touchId string
---@param event InputEvent
function GestureRecognizer:_detectSwipe(touchId, event)
-- Swipe detection happens on touch end
end
--- Detect swipe ended
---@param touchId string
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectSwipeEnded(touchId, event)
local touch = self._touches[touchId]
if not touch then
return nil
end
local duration = event.timestamp - touch.startTime
local dx = event.x - touch.startX
local dy = event.y - touch.startY
local distance = math.sqrt(dx * dx + dy * dy)
-- Check if it's a valid swipe
if distance >= self._config.swipeMinDistance and duration <= self._config.swipeMaxDuration then
local velocity = distance / duration
if velocity >= self._config.swipeMinVelocity then
-- Determine swipe direction
local angle = math.atan2(dy, dx)
local direction = "right"
if angle >= -math.pi / 4 and angle < math.pi / 4 then
direction = "right"
elseif angle >= math.pi / 4 and angle < 3 * math.pi / 4 then
direction = "down"
elseif angle >= -3 * math.pi / 4 and angle < -math.pi / 4 then
direction = "up"
else
direction = "left"
end
return {
type = GestureType.SWIPE,
state = GestureState.ENDED,
x = event.x,
y = event.y,
dx = dx,
dy = dy,
direction = direction,
velocity = velocity,
timestamp = event.timestamp,
}
end
end
return nil
end
--- Detect pinch gesture
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectPinch(event)
-- Get two touches for pinch
local touches = {}
for touchId, touch in pairs(self._touches) do
table.insert(touches, { id = touchId, touch = touch })
if #touches >= 2 then
break
end
end
if #touches < 2 then
return nil
end
local t1 = touches[1].touch
local t2 = touches[2].touch
-- Calculate current distance
local currentDx = t2.x - t1.x
local currentDy = t2.y - t1.y
local currentDistance = math.sqrt(currentDx * currentDx + currentDy * currentDy)
-- Calculate initial distance
local initialDx = t2.startX - t1.startX
local initialDy = t2.startY - t1.startY
local initialDistance = math.sqrt(initialDx * initialDx + initialDy * initialDy)
if initialDistance == 0 then
return nil
end
-- Calculate scale
local scale = currentDistance / initialDistance
local pinchState = self._gestureStates.pinch
if not pinchState.active then
-- Check if pinch should begin
if math.abs(scale - 1.0) >= self._config.pinchMinScaleChange then
pinchState.active = true
pinchState.initialScale = scale
pinchState.lastScale = scale
-- Calculate center point
local centerX = (t1.x + t2.x) / 2
local centerY = (t1.y + t2.y) / 2
return {
type = GestureType.PINCH,
state = GestureState.BEGAN,
scale = scale,
centerX = centerX,
centerY = centerY,
timestamp = event.timestamp,
}
end
else
-- Pinch is active, fire changed event
local centerX = (t1.x + t2.x) / 2
local centerY = (t1.y + t2.y) / 2
local scaleChange = scale - pinchState.lastScale
pinchState.lastScale = scale
return {
type = GestureType.PINCH,
state = GestureState.CHANGED,
scale = scale,
scaleChange = scaleChange,
centerX = centerX,
centerY = centerY,
timestamp = event.timestamp,
}
end
return nil
end
--- Detect rotate gesture
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectRotate(event)
-- Get two touches for rotation
local touches = {}
for touchId, touch in pairs(self._touches) do
table.insert(touches, { id = touchId, touch = touch })
if #touches >= 2 then
break
end
end
if #touches < 2 then
return nil
end
local t1 = touches[1].touch
local t2 = touches[2].touch
-- Calculate current angle
local currentAngle = math.atan2(t2.y - t1.y, t2.x - t1.x)
-- Calculate initial angle
local initialAngle = math.atan2(t2.startY - t1.startY, t2.startX - t1.startX)
-- Calculate rotation (in degrees)
local rotation = (currentAngle - initialAngle) * 180 / math.pi
local rotateState = self._gestureStates.rotate
if not rotateState.active then
-- Check if rotation should begin
if math.abs(rotation) >= self._config.rotateMinAngleChange then
rotateState.active = true
rotateState.initialRotation = rotation
rotateState.lastRotation = rotation
-- Calculate center point
local centerX = (t1.x + t2.x) / 2
local centerY = (t1.y + t2.y) / 2
return {
type = GestureType.ROTATE,
state = GestureState.BEGAN,
rotation = rotation,
centerX = centerX,
centerY = centerY,
timestamp = event.timestamp,
}
end
else
-- Rotation is active, fire changed event
local centerX = (t1.x + t2.x) / 2
local centerY = (t1.y + t2.y) / 2
local rotationChange = rotation - rotateState.lastRotation
rotateState.lastRotation = rotation
return {
type = GestureType.ROTATE,
state = GestureState.CHANGED,
rotation = rotation,
rotationChange = rotationChange,
centerX = centerX,
centerY = centerY,
timestamp = event.timestamp,
}
end
return nil
end
--- Cancel all active gestures
function GestureRecognizer:_cancelAllGestures()
for gestureType, state in pairs(self._gestureStates) do
if type(state) == "table" and state.active then
state.active = false
end
end
end
--- Reset gesture recognizer state
function GestureRecognizer:reset()
self._touches = {}
self._gestureStates = {
tap = nil,
doubleTap = { lastTapTime = 0, tapCount = 0 },
longPress = {},
swipe = {},
pan = {},
pinch = { active = false },
rotate = { active = false },
}
end
-- Export gesture types and states
GestureRecognizer.GestureType = GestureType
GestureRecognizer.GestureState = GestureState
return GestureRecognizer
-336
View File
@@ -1,336 +0,0 @@
local modulePath = (...):match("(.-)[^%.]+$")
local utils = require(modulePath .. "utils")
local enums = utils.enums
local Units = require(modulePath .. "Units")
local Positioning = enums.Positioning
local AlignItems = enums.AlignItems
--- Grid layout with variable column widths / row heights
--- Supports px, %, fr, auto, vw, vh, and calc track sizes
local Grid = {}
--- Parse a single track spec into {type, value}
--- Uses the Units pipeline for standard CSS units (px, %, vw, vh, calc).
--- Grid-specific types (fr, auto) are handled directly.
---@param spec number|string Track specification: number (px), string ("100px", "50%", "10vw", "1fr", "auto")
---@param availableSize number Container size for % resolution
---@param viewportWidth number Viewport width for vw resolution
---@param viewportHeight number Viewport height for vh resolution
---@return table {type: "px"|"fr"|"auto", value: number}
function Grid._parseTrack(spec, availableSize, viewportWidth, viewportHeight)
-- Handle calc objects (tables with _isCalc flag from FlexLove.calc())
if type(spec) == "table" then
local resolved = Units.resolve(spec, "calc", viewportWidth, viewportHeight, availableSize)
return { type = "px", value = resolved }
end
if type(spec) == "number" then
return { type = "px", value = spec }
end
if type(spec) == "string" then
if spec == "auto" then
return { type = "auto", value = 0 }
end
-- Check for fr unit (grid-specific, not in Units pipeline)
local numStr, unit = spec:match("^([%-]?[%d%.]+)(.*)$")
if numStr and unit == "fr" then
local num = tonumber(numStr)
if num then
return { type = "fr", value = num }
end
end
-- Delegate all other units to the Units pipeline (px, %, vw, vh, calc)
local parsedVal, parsedUnit = Units.parse(spec)
local resolved = Units.resolve(parsedVal, parsedUnit, viewportWidth, viewportHeight, availableSize)
return { type = "px", value = resolved }
end
-- Default: 1fr
return { type = "fr", value = 1 }
end
--- Build track list from gridColumns/gridRows or fall back to equal 1fr tracks
---@param spec number|table? Track count (number = equal 1fr tracks) or array of track specs (e.g., {"1fr", "2fr", "100px"})
---@param availableSize number Container size for % resolution
---@param viewportWidth number Viewport width for vw resolution
---@param viewportHeight number Viewport height for vh resolution
---@return table Array of {type, value} track descriptors
function Grid._buildTracks(spec, availableSize, viewportWidth, viewportHeight)
if type(spec) == "table" and #spec > 0 then
local tracks = {}
for i, s in ipairs(spec) do
tracks[i] = Grid._parseTrack(s, availableSize, viewportWidth, viewportHeight)
end
return tracks
end
-- Fallback: equal 1fr tracks
local count = (type(spec) == "number" and spec > 0) and spec or 1
local tracks = {}
for i = 1, count do
tracks[i] = { type = "fr", value = 1 }
end
return tracks
end
--- Measure intrinsic content sizes for auto tracks
--- Maps children to their tracks and computes each child's max-content contribution.
--- For children with explicit dimensions (units unit ~= "auto"), uses the original
--- explicit size. For auto-sized children, uses calculated content size.
--- Stores the max per auto track. Matches CSS Grid auto sizing where tracks size
--- to the max-content contribution of their grid items.
---@param tracks table Array of {type, value} track descriptors
---@param children table Array of grid child elements
---@param axis "width"|"height" Dimension axis to measure
function Grid._measureAutoTracks(tracks, children, axis)
local trackSizes = {}
local numTracks = #tracks
for i, child in ipairs(children) do
local index = i - 1
local trackIdx = (index % numTracks) + 1
local intrinsicSize
if axis == "width" then
local unit = child.units and child.units.width and child.units.width.unit
if unit and unit ~= "auto" then
-- Explicit width: use original value + padding (not stretched border-box)
intrinsicSize = (child.units.width.value or 0) + child.padding.left + child.padding.right
else
-- Auto-sized: use calculated content size
intrinsicSize = child:calculateAutoWidth()
end
else
local unit = child.units and child.units.height and child.units.height.unit
if unit and unit ~= "auto" then
intrinsicSize = (child.units.height.value or 0) + child.padding.top + child.padding.bottom
else
intrinsicSize = child:calculateAutoHeight()
end
end
if intrinsicSize > 0 then
trackSizes[trackIdx] = math.max(trackSizes[trackIdx] or 0, intrinsicSize)
end
end
-- Apply measured sizes to auto tracks
for i, track in ipairs(tracks) do
if track.type == "auto" and trackSizes[i] then
track.value = trackSizes[i]
end
end
end
--- Resolve track sizes: auto (content) first, then px (fixed), then fr (remaining)
--- CSS Grid algorithm:
--- 1. auto tracks size to their content (max-content) — measured by _measureAutoTracks
--- 2. px tracks consume their fixed size
--- 3. fr tracks consume remaining free space proportionally
--- 4. If no fr tracks exist, auto tracks share remaining space equally
--- Mutates tracks in-place, converting all to {type="px", value=number}
---@param tracks table Array of {type, value} track descriptors
---@param availableSize number Total space available for tracks
---@param gap number Gap between tracks
function Grid._resolveTracks(tracks, availableSize, gap)
local count = #tracks
local totalGaps = (count > 1 and (count - 1) * gap) or 0
local remaining = math.max(0, availableSize - totalGaps)
-- Pass 1: Treat auto tracks as fixed (content-measured) and subtract
for _, track in ipairs(tracks) do
if track.type == "px" then
remaining = remaining - track.value
elseif track.type == "auto" then
remaining = remaining - math.max(0, track.value)
end
end
remaining = math.max(0, remaining)
-- Pass 2: Count fr shares
local totalFr = 0
local autoCount = 0
for _, track in ipairs(tracks) do
if track.type == "fr" then
totalFr = totalFr + track.value
elseif track.type == "auto" then
autoCount = autoCount + 1
end
end
-- Pass 3: Distribute remaining space
if totalFr > 0 then
-- fr tracks consume all remaining free space
local frUnit = remaining / totalFr
for _, track in ipairs(tracks) do
if track.type == "fr" then
track.value = frUnit * track.value
track.type = "px"
end
end
elseif autoCount > 0 then
-- No fr tracks: auto tracks share remaining space equally (grow beyond content)
local extraPerAuto = math.max(0, remaining) / autoCount
for _, track in ipairs(tracks) do
if track.type == "auto" then
track.value = track.value + extraPerAuto
track.type = "px"
end
end
end
end
--- Layout grid items within a grid container
--- Supports variable column widths and row heights via gridColumns/gridRows (number or track specs)
--- Falls back to equal-sized 1fr tracks when nil
---@param element Element -- Grid container element
function Grid.layoutGridItems(element)
-- Calculate space reserved by absolutely positioned siblings
local reservedLeft = 0
local reservedRight = 0
local reservedTop = 0
local reservedBottom = 0
for _, child in ipairs(element.children) do
-- Only consider absolutely positioned children with explicit positioning and display != false
if child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute and child.display ~= false then
-- BORDER-BOX MODEL: Use border-box dimensions for space calculations
local childBorderBoxWidth = child:getBorderBoxWidth()
local childBorderBoxHeight = child:getBorderBoxHeight()
if child.left then
reservedLeft = math.max(reservedLeft, child.left + childBorderBoxWidth)
end
if child.right then
reservedRight = math.max(reservedRight, child.right + childBorderBoxWidth)
end
if child.top then
reservedTop = math.max(reservedTop, child.top + childBorderBoxHeight)
end
if child.bottom then
reservedBottom = math.max(reservedBottom, child.bottom + childBorderBoxHeight)
end
end
end
-- Calculate available space (accounting for padding and reserved space)
-- BORDER-BOX MODEL: element.width and element.height are already content dimensions
local availableWidth = math.max(0, element.width - reservedLeft - reservedRight)
local availableHeight = math.max(0, element.height - reservedTop - reservedBottom)
-- Get gaps
local columnGap = element.columnGap or 0
local rowGap = element.rowGap or 0
-- Collect grid children (exclude explicitly absolute and display=false)
local gridChildren = {}
for _, child in ipairs(element.children) do
if not (child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute) and child.display ~= false then
table.insert(gridChildren, child)
end
end
-- Get viewport dimensions for unit resolution (vw, vh, %)
local vpw, vph = Units.getViewport()
-- Build tracks, measure auto tracks by content, then resolve sizes
local colTracks = Grid._buildTracks(element.gridColumns, availableWidth, vpw, vph)
local rowTracks = Grid._buildTracks(element.gridRows, availableHeight, vpw, vph)
Grid._measureAutoTracks(colTracks, gridChildren, "width")
Grid._measureAutoTracks(rowTracks, gridChildren, "height")
Grid._resolveTracks(colTracks, availableWidth, columnGap)
Grid._resolveTracks(rowTracks, availableHeight, rowGap)
-- Compute column start positions (for positioning)
local colStarts = {}
local currentX = element.x + element.padding.left + reservedLeft
for col = 1, #colTracks do
colStarts[col] = currentX
currentX = currentX + colTracks[col].value + columnGap
end
local rowStarts = {}
local currentY = element.y + element.padding.top + reservedTop
for row = 1, #rowTracks do
rowStarts[row] = currentY
currentY = currentY + rowTracks[row].value + rowGap
end
local effectiveAlignItems = element.alignItems or AlignItems.STRETCH
for i, child in ipairs(gridChildren) do
-- Calculate row and column (0-indexed for calculation)
local index = i - 1
local col = index % #colTracks
local row = math.floor(index / #colTracks)
if row >= #rowTracks then
break
end
-- Get resolved cell position and size
local colIdx = col + 1
local rowIdx = row + 1
local cellX = colStarts[colIdx]
local cellY = rowStarts[rowIdx]
local cellWidth = colTracks[colIdx].value
local cellHeight = rowTracks[rowIdx].value
-- Apply alignment within grid cell (default to stretch)
-- BORDER-BOX MODEL: Set border-box dimensions, content area adjusts automatically
if effectiveAlignItems == AlignItems.STRETCH or effectiveAlignItems == "stretch" then
child.x = cellX
child.y = cellY
child._borderBoxWidth = cellWidth
child._borderBoxHeight = cellHeight
child.width = math.max(0, cellWidth - child.padding.left - child.padding.right)
child.height = math.max(0, cellHeight - child.padding.top - child.padding.bottom)
-- Disable auto-sizing when stretched by grid
child.autosizing.width = false
child.autosizing.height = false
elseif effectiveAlignItems == AlignItems.CENTER or effectiveAlignItems == "center" then
local childBorderBoxWidth = child:getBorderBoxWidth()
local childBorderBoxHeight = child:getBorderBoxHeight()
child.x = cellX + (cellWidth - childBorderBoxWidth) / 2
child.y = cellY + (cellHeight - childBorderBoxHeight) / 2
elseif
effectiveAlignItems == AlignItems.FLEX_START
or effectiveAlignItems == "flex-start"
or effectiveAlignItems == "start"
then
child.x = cellX
child.y = cellY
elseif
effectiveAlignItems == AlignItems.FLEX_END
or effectiveAlignItems == "flex-end"
or effectiveAlignItems == "end"
then
local childBorderBoxWidth = child:getBorderBoxWidth()
local childBorderBoxHeight = child:getBorderBoxHeight()
child.x = cellX + cellWidth - childBorderBoxWidth
child.y = cellY + cellHeight - childBorderBoxHeight
else
child.x = cellX
child.y = cellY
child._borderBoxWidth = cellWidth
child._borderBoxHeight = cellHeight
child.width = math.max(0, cellWidth - child.padding.left - child.padding.right)
child.height = math.max(0, cellHeight - child.padding.top - child.padding.bottom)
-- Disable auto-sizing when stretched by grid
child.autosizing.width = false
child.autosizing.height = false
end
if #child.children > 0 then
child:layoutChildren()
end
end
end
return Grid
-160
View File
@@ -1,160 +0,0 @@
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
local utils = req("utils")
-- ErrorHandler will be injected via init
local ErrorHandler = nil
---@class ImageCache
---@field _cache table<string, {image: love.Image, imageData: love.ImageData?}>
local ImageCache = {}
ImageCache._cache = {}
--- Initialize ImageCache with dependencies
---@param deps table Dependencies table with ErrorHandler
function ImageCache.init(deps)
if deps and deps.ErrorHandler then
ErrorHandler = deps.ErrorHandler
end
end
--- Load an image from file path with caching
--- Returns cached image if already loaded, otherwise loads and caches it
---@param imagePath string -- Path to image file
---@param loadImageData boolean? -- Optional: also load ImageData for pixel access (default: false)
---@return love.Image|nil -- Image object or nil on error
---@return string|nil -- Error message if loading failed
function ImageCache.load(imagePath, loadImageData)
if not imagePath or type(imagePath) ~= "string" or imagePath == "" then
return nil, "Invalid image path: path must be a non-empty string"
end
local normalizedPath = utils.normalizePath(imagePath)
if ImageCache._cache[normalizedPath] then
return ImageCache._cache[normalizedPath].image, nil
end
local success, imageOrError = pcall(love.graphics.newImage, normalizedPath)
if not success then
if ErrorHandler then
ErrorHandler:warn("ImageCache", "RES_004", {
resourceType = "image",
path = imagePath,
error = tostring(imageOrError),
})
end
return nil, string.format("Failed to load image '%s': %s", imagePath, tostring(imageOrError))
end
local image = imageOrError
local imgData = nil
if loadImageData then
local dataSuccess, dataOrError = pcall(love.image.newImageData, normalizedPath)
if dataSuccess then
imgData = dataOrError
elseif ErrorHandler then
ErrorHandler:warn("ImageCache", "RES_004", {
resourceType = "image data",
path = imagePath,
error = tostring(dataOrError),
})
end
end
ImageCache._cache[normalizedPath] = {
image = image,
imageData = imgData,
}
return image, nil
end
--- Get a cached image without loading
---@param imagePath string -- Path to image file
---@return love.Image|nil -- Cached image or nil if not found
function ImageCache.get(imagePath)
if not imagePath or type(imagePath) ~= "string" then
return nil
end
local normalizedPath = utils.normalizePath(imagePath)
local cached = ImageCache._cache[normalizedPath]
return cached and cached.image or nil
end
--- Get cached ImageData for an image
---@param imagePath string -- Path to image file
---@return love.ImageData|nil -- Cached ImageData or nil if not found
function ImageCache.getImageData(imagePath)
if not imagePath or type(imagePath) ~= "string" then
return nil
end
local normalizedPath = utils.normalizePath(imagePath)
local cached = ImageCache._cache[normalizedPath]
return cached and cached.imageData or nil
end
--- Remove a specific image from cache
---@param imagePath string -- Path to image file to remove
---@return boolean -- True if image was removed, false if not found
function ImageCache.remove(imagePath)
if not imagePath or type(imagePath) ~= "string" then
return false
end
local normalizedPath = utils.normalizePath(imagePath)
if ImageCache._cache[normalizedPath] then
local cached = ImageCache._cache[normalizedPath]
if cached.image then
cached.image:release()
end
if cached.imageData then
cached.imageData:release()
end
ImageCache._cache[normalizedPath] = nil
return true
end
return false
end
--- Clear all cached images
function ImageCache.clear()
for path, cached in pairs(ImageCache._cache) do
if cached.image then
cached.image:release()
end
if cached.imageData then
cached.imageData:release()
end
end
ImageCache._cache = {}
end
--- Get cache statistics
---@return {count: number, memoryEstimate: number} -- Cache stats
function ImageCache.getStats()
local count = 0
local memoryEstimate = 0
for path, cached in pairs(ImageCache._cache) do
count = count + 1
if cached.image then
local w, h = cached.image:getDimensions()
-- Estimate: 4 bytes per pixel (RGBA)
memoryEstimate = memoryEstimate + (w * h * 4)
end
end
return {
count = count,
memoryEstimate = memoryEstimate,
}
end
return ImageCache
-380
View File
@@ -1,380 +0,0 @@
---@class ImageRenderer
local ImageRenderer = {}
-- ErrorHandler and utils will be injected via init
local ErrorHandler = nil
local utils = nil
--- Initialize ImageRenderer with dependencies
---@param deps table Dependencies table with ErrorHandler and utils
function ImageRenderer.init(deps)
if deps and deps.ErrorHandler then
ErrorHandler = deps.ErrorHandler
end
if deps and deps.utils then
utils = deps.utils
end
end
--- Calculate rendering parameters for object-fit modes
--- Returns source and destination rectangles for rendering
---@param imageWidth number -- Natural width of the image
---@param imageHeight number -- Natural height of the image
---@param boundsWidth number -- Width of the bounds to fit within
---@param boundsHeight number -- Height of the bounds to fit within
---@param fitMode string? -- One of: "fill", "contain", "cover", "scale-down", "none" (default: "fill")
---@param objectPosition string? -- Position like "center center", "top left", "50% 50%" (default: "center center")
---@return {sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number, scaleX: number, scaleY: number}
function ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, fitMode, objectPosition)
fitMode = fitMode or "fill"
objectPosition = objectPosition or "center center"
if imageWidth <= 0 or imageHeight <= 0 or boundsWidth <= 0 or boundsHeight <= 0 then
ErrorHandler:error("ImageRenderer", "VAL_002", {
imageWidth = imageWidth,
imageHeight = imageHeight,
boundsWidth = boundsWidth,
boundsHeight = boundsHeight,
})
end
local result = {
sx = 0, -- Source X
sy = 0, -- Source Y
sw = imageWidth, -- Source width
sh = imageHeight, -- Source height
dx = 0, -- Destination X
dy = 0, -- Destination Y
dw = boundsWidth, -- Destination width
dh = boundsHeight, -- Destination height
scaleX = 1, -- Scale factor X
scaleY = 1, -- Scale factor Y
}
if fitMode == "fill" then
-- Stretch to fill bounds (may distort)
result.scaleX = boundsWidth / imageWidth
result.scaleY = boundsHeight / imageHeight
result.dw = boundsWidth
result.dh = boundsHeight
elseif fitMode == "contain" then
-- Scale to fit within bounds (preserves aspect ratio)
local scale = math.min(boundsWidth / imageWidth, boundsHeight / imageHeight)
result.scaleX = scale
result.scaleY = scale
result.dw = imageWidth * scale
result.dh = imageHeight * scale
-- Apply object-position for letterbox alignment
local posX, posY = ImageRenderer._parsePosition(objectPosition)
result.dx = (boundsWidth - result.dw) * posX
result.dy = (boundsHeight - result.dh) * posY
elseif fitMode == "cover" then
-- Scale to cover bounds (preserves aspect ratio, may crop)
local scale = math.max(boundsWidth / imageWidth, boundsHeight / imageHeight)
result.scaleX = scale
result.scaleY = scale
local scaledWidth = imageWidth * scale
local scaledHeight = imageHeight * scale
-- Apply object-position for crop alignment
local posX, posY = ImageRenderer._parsePosition(objectPosition)
-- Calculate which part of the scaled image to show
local cropX = (scaledWidth - boundsWidth) * posX
local cropY = (scaledHeight - boundsHeight) * posY
-- Convert back to source coordinates
result.sx = cropX / scale
result.sy = cropY / scale
result.sw = boundsWidth / scale
result.sh = boundsHeight / scale
result.dx = 0
result.dy = 0
result.dw = boundsWidth
result.dh = boundsHeight
elseif fitMode == "none" then
-- Use natural size (no scaling)
result.scaleX = 1
result.scaleY = 1
result.dw = imageWidth
result.dh = imageHeight
-- Apply object-position
local posX, posY = ImageRenderer._parsePosition(objectPosition)
result.dx = (boundsWidth - imageWidth) * posX
result.dy = (boundsHeight - imageHeight) * posY
elseif fitMode == "scale-down" then
-- Use none or contain, whichever is smaller
if imageWidth <= boundsWidth and imageHeight <= boundsHeight then
-- Image fits naturally, use "none"
return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "none", objectPosition)
else
-- Image too large, use "contain"
return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "contain", objectPosition)
end
else
ErrorHandler:warn("ImageRenderer", "VAL_007", {
fitMode = fitMode,
fallback = "fill",
})
-- Use 'fill' as fallback
return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "fill", objectPosition)
end
return result
end
--- Parse object-position string into normalized coordinates (0-1)
--- Supports keywords (center, top, bottom, left, right) and percentages
---@param position string -- Position string like "center center", "top left", "50% 50%"
---@return number, number -- Normalized X and Y positions (0-1)
function ImageRenderer._parsePosition(position)
if not position or type(position) ~= "string" then
return 0.5, 0.5 -- Default to center
end
-- Split into X and Y components
local parts = {}
for part in position:gmatch("%S+") do
table.insert(parts, part:lower())
end
-- If only one value, use it for both axes (with special handling)
if #parts == 1 then
local val = parts[1]
if val == "left" or val == "right" then
parts = { val, "center" }
elseif val == "top" or val == "bottom" then
parts = { "center", val }
else
parts = { val, val }
end
elseif #parts == 0 then
return 0.5, 0.5 -- Default to center
end
local function parseValue(val)
-- Handle keywords
if val == "center" then
return 0.5
elseif val == "left" or val == "top" then
return 0
elseif val == "right" or val == "bottom" then
return 1
end
-- Handle percentages
local percent = val:match("^([%d%.]+)%%$")
if percent then
return tonumber(percent) / 100
end
-- Handle plain numbers (treat as percentage)
local num = tonumber(val)
if num then
return num / 100
end
-- Invalid value, default to center
return 0.5
end
local x = parseValue(parts[1])
local y = parseValue(parts[2] or parts[1])
-- Clamp to 0-1 range
x = math.max(0, math.min(1, x))
y = math.max(0, math.min(1, y))
return x, y
end
--- Draw an image with specified object-fit mode
---@param image love.Image -- Image to draw
---@param x number -- X position of bounds
---@param y number -- Y position of bounds
---@param width number -- Width of bounds
---@param height number -- Height of bounds
---@param fitMode string? -- Object-fit mode (default: "fill")
---@param objectPosition string? -- Object-position (default: "center center")
---@param opacity number? -- Opacity 0-1 (default: 1)
---@param tintColor Color? -- Color to tint the image (default: white/no tint)
function ImageRenderer.draw(image, x, y, width, height, fitMode, objectPosition, opacity, tintColor)
if not image then
return -- Nothing to draw
end
opacity = opacity or 1
fitMode = fitMode or "fill"
objectPosition = objectPosition or "center center"
local imgWidth, imgHeight = image:getDimensions()
local params = ImageRenderer.calculateFit(imgWidth, imgHeight, width, height, fitMode, objectPosition)
-- Save current color
local r, g, b, a = love.graphics.getColor()
-- Apply opacity and tint
if tintColor then
love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity)
else
love.graphics.setColor(1, 1, 1, opacity)
end
-- Draw image
if params.sx ~= 0 or params.sy ~= 0 or params.sw ~= imgWidth or params.sh ~= imgHeight then
-- Need to use a quad for cropping
local quad = love.graphics.newQuad(params.sx, params.sy, params.sw, params.sh, imgWidth, imgHeight)
love.graphics.draw(image, quad, x + params.dx, y + params.dy, 0, params.dw / params.sw, params.dh / params.sh)
else
-- Simple draw with scaling
love.graphics.draw(image, x + params.dx, y + params.dy, 0, params.scaleX, params.scaleY)
end
-- Restore color
love.graphics.setColor(r, g, b, a)
end
--- Draw an image with tiling/repeat mode
---@param image love.Image -- Image to draw
---@param x number -- X position of bounds
---@param y number -- Y position of bounds
---@param width number -- Width of bounds
---@param height number -- Height of bounds
---@param repeatMode string? -- Repeat mode: "repeat", "repeat-x", "repeat-y", "no-repeat", "space", "round" (default: "no-repeat")
---@param opacity number? -- Opacity 0-1 (default: 1)
---@param tintColor Color? -- Color to tint the image (default: white/no tint)
function ImageRenderer.drawTiled(image, x, y, width, height, repeatMode, opacity, tintColor)
if not image then
return -- Nothing to draw
end
opacity = opacity or 1
repeatMode = repeatMode or "no-repeat"
local imgWidth, imgHeight = image:getDimensions()
-- Save current color
local r, g, b, a = love.graphics.getColor()
-- Apply opacity and tint
if tintColor then
love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity)
else
love.graphics.setColor(1, 1, 1, opacity)
end
if repeatMode == "no-repeat" then
-- Just draw once, no tiling
love.graphics.draw(image, x, y)
elseif repeatMode == "repeat" then
-- Tile in both directions
local tilesX = math.ceil(width / imgWidth)
local tilesY = math.ceil(height / imgHeight)
for tileY = 0, tilesY - 1 do
for tileX = 0, tilesX - 1 do
local drawX = x + (tileX * imgWidth)
local drawY = y + (tileY * imgHeight)
-- Calculate how much of the tile to draw (for partial tiles at edges)
local drawWidth = math.min(imgWidth, width - (tileX * imgWidth))
local drawHeight = math.min(imgHeight, height - (tileY * imgHeight))
if drawWidth < imgWidth or drawHeight < imgHeight then
-- Use quad for partial tile
local quad = love.graphics.newQuad(0, 0, drawWidth, drawHeight, imgWidth, imgHeight)
love.graphics.draw(image, quad, drawX, drawY)
else
-- Draw full tile
love.graphics.draw(image, drawX, drawY)
end
end
end
elseif repeatMode == "repeat-x" then
-- Tile horizontally only
local tilesX = math.ceil(width / imgWidth)
for tileX = 0, tilesX - 1 do
local drawX = x + (tileX * imgWidth)
local drawWidth = math.min(imgWidth, width - (tileX * imgWidth))
if drawWidth < imgWidth then
-- Use quad for partial tile
local quad = love.graphics.newQuad(0, 0, drawWidth, imgHeight, imgWidth, imgHeight)
love.graphics.draw(image, quad, drawX, y)
else
-- Draw full tile
love.graphics.draw(image, drawX, y)
end
end
elseif repeatMode == "repeat-y" then
-- Tile vertically only
local tilesY = math.ceil(height / imgHeight)
for tileY = 0, tilesY - 1 do
local drawY = y + (tileY * imgHeight)
local drawHeight = math.min(imgHeight, height - (tileY * imgHeight))
if drawHeight < imgHeight then
-- Use quad for partial tile
local quad = love.graphics.newQuad(0, 0, imgWidth, drawHeight, imgWidth, imgHeight)
love.graphics.draw(image, quad, x, drawY)
else
-- Draw full tile
love.graphics.draw(image, x, drawY)
end
end
elseif repeatMode == "space" then
-- Distribute tiles with even spacing
local tilesX = math.floor(width / imgWidth)
local tilesY = math.floor(height / imgHeight)
if tilesX < 1 then
tilesX = 1
end
if tilesY < 1 then
tilesY = 1
end
local spaceX = tilesX > 1 and (width - (tilesX * imgWidth)) / (tilesX - 1) or 0
local spaceY = tilesY > 1 and (height - (tilesY * imgHeight)) / (tilesY - 1) or 0
for tileY = 0, tilesY - 1 do
for tileX = 0, tilesX - 1 do
local drawX = x + (tileX * (imgWidth + spaceX))
local drawY = y + (tileY * (imgHeight + spaceY))
love.graphics.draw(image, drawX, drawY)
end
end
elseif repeatMode == "round" then
-- Scale tiles to fit bounds exactly
local tilesX = math.max(1, utils.round(width / imgWidth))
local tilesY = math.max(1, utils.round(height / imgHeight))
local scaleX = width / (tilesX * imgWidth)
local scaleY = height / (tilesY * imgHeight)
for tileY = 0, tilesY - 1 do
for tileX = 0, tilesX - 1 do
local drawX = x + (tileX * imgWidth * scaleX)
local drawY = y + (tileY * imgHeight * scaleY)
love.graphics.draw(image, drawX, drawY, 0, scaleX, scaleY)
end
end
else
ErrorHandler:warn("ImageRenderer", "VAL_007", {
repeatMode = repeatMode,
fallback = "no-repeat",
})
love.graphics.draw(image, x, y)
end
-- Restore color
love.graphics.setColor(r, g, b, a)
end
return ImageRenderer
-174
View File
@@ -1,174 +0,0 @@
-- ====================
-- ImageScaler
-- ====================
local ImageScaler = {}
-- ErrorHandler will be injected via init
local ErrorHandler = nil
--- Initialize ImageScaler with dependencies
---@param deps table Dependencies table with ErrorHandler
function ImageScaler.init(deps)
if deps and deps.ErrorHandler then
ErrorHandler = deps.ErrorHandler
end
end
--- Scale an ImageData region using nearest-neighbor sampling
--- Produces sharp, pixelated scaling - ideal for pixel art
---@param sourceImageData love.ImageData -- Source image data
---@param srcX number -- Source region X (0-based)
---@param srcY number -- Source region Y (0-based)
---@param srcW number -- Source region width
---@param srcH number -- Source region height
---@param destW number -- Destination width
---@param destH number -- Destination height
---@return love.ImageData -- Scaled image data
function ImageScaler.scaleNearest(sourceImageData, srcX, srcY, srcW, srcH, destW, destH)
if not sourceImageData then
ErrorHandler:error("ImageScaler", "VAL_001", {
parameter = "sourceImageData",
})
end
if srcW <= 0 or srcH <= 0 or destW <= 0 or destH <= 0 then
ErrorHandler:warn("ImageScaler", "VAL_002", {
srcW = srcW,
srcH = srcH,
destW = destW,
destH = destH,
fallback = "1x1 transparent image",
})
-- Return a minimal 1x1 transparent image as fallback
local fallbackImageData = love.image.newImageData(1, 1)
fallbackImageData:setPixel(0, 0, 0, 0, 0, 0)
return fallbackImageData
end
-- Create destination ImageData
local destImageData = love.image.newImageData(destW, destH)
-- Calculate scale ratios (cached outside loops for performance)
local scaleX = srcW / destW
local scaleY = srcH / destH
-- Nearest-neighbor sampling
for destY = 0, destH - 1 do
for destX = 0, destW - 1 do
-- Calculate source pixel coordinates using floor (nearest-neighbor)
local srcPixelX = math.floor(destX * scaleX) + srcX
local srcPixelY = math.floor(destY * scaleY) + srcY
-- Clamp to source bounds (safety check)
srcPixelX = math.min(srcPixelX, srcX + srcW - 1)
srcPixelY = math.min(srcPixelY, srcY + srcH - 1)
-- Sample source pixel
local r, g, b, a = sourceImageData:getPixel(srcPixelX, srcPixelY)
-- Write to destination
destImageData:setPixel(destX, destY, r, g, b, a)
end
end
return destImageData
end
--- Linear interpolation helper
--- Blends between two values based on interpolation factor
---@param a number -- Start value
---@param b number -- End value
---@param t number -- Interpolation factor [0, 1]
---@return number -- Interpolated value
local function lerp(a, b, t)
return a + (b - a) * t
end
--- Scale an ImageData region using bilinear interpolation
--- Produces smooth, filtered scaling - ideal for high-quality upscaling
---@param sourceImageData love.ImageData -- Source image data
---@param srcX number -- Source region X (0-based)
---@param srcY number -- Source region Y (0-based)
---@param srcW number -- Source region width
---@param srcH number -- Source region height
---@param destW number -- Destination width
---@param destH number -- Destination height
---@return love.ImageData -- Scaled image data
function ImageScaler.scaleBilinear(sourceImageData, srcX, srcY, srcW, srcH, destW, destH)
if not sourceImageData then
ErrorHandler:error("ImageScaler", "VAL_001", {
parameter = "sourceImageData",
})
end
if srcW <= 0 or srcH <= 0 or destW <= 0 or destH <= 0 then
ErrorHandler:warn("ImageScaler", "VAL_002", {
srcW = srcW,
srcH = srcH,
destW = destW,
destH = destH,
fallback = "1x1 transparent image",
})
-- Return a minimal 1x1 transparent image as fallback
local fallbackImageData = love.image.newImageData(1, 1)
fallbackImageData:setPixel(0, 0, 0, 0, 0, 0)
return fallbackImageData
end
-- Create destination ImageData
local destImageData = love.image.newImageData(destW, destH)
-- Calculate scale ratios
local scaleX = srcW / destW
local scaleY = srcH / destH
-- Bilinear interpolation
for destY = 0, destH - 1 do
for destX = 0, destW - 1 do
-- Calculate fractional source position
local srcXf = destX * scaleX
local srcYf = destY * scaleY
-- Get integer coordinates for 2x2 sampling grid
local x0 = math.floor(srcXf)
local y0 = math.floor(srcYf)
local x1 = math.min(x0 + 1, srcW - 1)
local y1 = math.min(y0 + 1, srcH - 1)
-- Get fractional parts for interpolation
local fx = srcXf - x0
local fy = srcYf - y0
-- Sample 4 neighboring pixels (with source offset)
local r00, g00, b00, a00 = sourceImageData:getPixel(srcX + x0, srcY + y0)
local r10, g10, b10, a10 = sourceImageData:getPixel(srcX + x1, srcY + y0)
local r01, g01, b01, a01 = sourceImageData:getPixel(srcX + x0, srcY + y1)
local r11, g11, b11, a11 = sourceImageData:getPixel(srcX + x1, srcY + y1)
-- Interpolate horizontally (top and bottom rows)
local rTop = lerp(r00, r10, fx)
local gTop = lerp(g00, g10, fx)
local bTop = lerp(b00, b10, fx)
local aTop = lerp(a00, a10, fx)
local rBottom = lerp(r01, r11, fx)
local gBottom = lerp(g01, g11, fx)
local bBottom = lerp(b01, b11, fx)
local aBottom = lerp(a01, a11, fx)
-- Interpolate vertically (final result)
local r = lerp(rTop, rBottom, fy)
local g = lerp(gTop, gBottom, fy)
local b = lerp(bTop, bBottom, fy)
local a = lerp(aTop, aBottom, fy)
-- Write to destination
destImageData:setPixel(destX, destY, r, g, b, a)
end
end
return destImageData
end
return ImageScaler
-88
View File
@@ -1,88 +0,0 @@
---@class InputEvent
---@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag"|"hover"|"unhover"|"touchpress"|"touchmove"|"touchrelease"|"touchcancel"
---@field button number -- Mouse button: 1 (left), 2 (right), 3 (middle)
---@field x number -- Mouse/Touch X position
---@field y number -- Mouse/Touch Y position
---@field dx number? -- Delta X from drag/touch start (only for drag/touch events)
---@field dy number? -- Delta Y from drag/touch start (only for drag/touch events)
---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean}
---@field clickCount number -- Number of clicks (for double/triple click detection)
---@field timestamp number -- Time when event occurred
---@field touchId string? -- Touch identifier (for multi-touch)
---@field pressure number? -- Touch pressure (0-1, defaults to 1.0)
---@field phase string? -- Touch phase: "began", "moved", "ended", "cancelled"
local InputEvent = {}
InputEvent.__index = InputEvent
---@class InputEventProps
---@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag"|"hover"|"unhover"|"touchpress"|"touchmove"|"touchrelease"|"touchcancel"
---@field button number
---@field x number
---@field y number
---@field dx number?
---@field dy number?
---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean}
---@field clickCount number?
---@field timestamp number?
---@field touchId string?
---@field pressure number?
---@field phase string?
--- Create a new input event
---@param props InputEventProps
---@return InputEvent
function InputEvent.new(props)
local self = setmetatable({}, InputEvent)
self.type = props.type
self.button = props.button
self.x = props.x
self.y = props.y
self.dx = props.dx
self.dy = props.dy
self.modifiers = props.modifiers
self.clickCount = props.clickCount or 1
self.timestamp = props.timestamp or love.timer.getTime()
-- Touch-specific properties
self.touchId = props.touchId
self.pressure = props.pressure or 1.0
self.phase = props.phase
return self
end
--- Create an InputEvent from LÖVE touch data
---@param id userdata Touch ID from LÖVE
---@param x number Touch X position
---@param y number Touch Y position
---@param phase string Touch phase: "began", "moved", "ended", "cancelled"
---@param pressure number? Touch pressure (0-1, defaults to 1.0)
---@return InputEvent
function InputEvent.fromTouch(id, x, y, phase, pressure)
local touchIdStr = tostring(id)
local eventType = "touchpress"
if phase == "moved" then
eventType = "touchmove"
elseif phase == "ended" then
eventType = "touchrelease"
elseif phase == "cancelled" then
eventType = "touchcancel"
end
return InputEvent.new({
type = eventType,
button = 1, -- Treat touch as left button
x = x,
y = y,
dx = 0,
dy = 0,
modifiers = { shift = false, ctrl = false, alt = false, super = false },
clickCount = 1,
timestamp = love.timer.getTime(),
touchId = touchIdStr,
pressure = pressure or 1.0,
phase = phase,
})
end
return InputEvent
@@ -1,748 +0,0 @@
local packageName = ... or "KeyboardNavigation"
local modulePath = packageName:match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
---@class KeyboardNavigation
---@field config KeyboardNavigationConfig
local KeyboardNavigation = {
config = {
-- Global settings
enabled = true,
debugMode = false,
-- Key bindings
keys = {
next = "tab",
previous = "shifttab",
up = "up",
down = "down",
left = "left",
right = "right",
activate = { "return", "space" },
dismiss = "escape",
toggleDebug = "f12",
inspect = "i",
},
-- Navigation behavior
wrapAround = true,
directionalNavigation = true,
focusVisible = true,
autofocusOnCreate = false,
--- Drop focus after pressing Enter/Space to activate an element
--- When false, focus remains on the element after activation
dropFocusOnSelection = true,
-- Developer tools
developerTools = {
enabled = true,
showProperties = true,
highlightColor = { 1, 0.8, 0, 0.5 },
},
-- Focus indicator style
focusIndicator = {
color = { 0.2, 0.6, 1.0, 0.8 },
lineWidth = 2,
inset = -3,
borderRadius = 4,
animationDuration = 0.15,
},
},
-- State
_navigationStack = {},
_lastNavigationTime = 0,
_inspectMode = false,
_deps = nil,
-- Spatial index for directional navigation (performance optimization)
_spatialIndex = {
enabled = false,
cellSize = 100, -- Grid cell size in pixels
grid = {}, -- Grid storing element references
elementPositions = {}, -- Cache of element positions {element = {x, y, w, h}}
lastUpdateFrame = 0,
},
}
--- Initialize KeyboardNavigation module
---@param deps table {Context, Element, ErrorHandler, utils, InputEvent}
function KeyboardNavigation.init(deps)
-- Validate required dependencies
local required = { Context = true, Element = true, ErrorHandler = true, utils = true, InputEvent = true }
for depName, _ in pairs(required) do
if not deps[depName] then
error(string.format("KeyboardNavigation.init: Missing required dependency: %s", depName))
end
end
KeyboardNavigation._deps = deps
KeyboardNavigation._ErrorHandler = deps.ErrorHandler
KeyboardNavigation._InputEvent = deps.InputEvent
KeyboardNavigation._Context = deps.Context
KeyboardNavigation._Element = deps.Element
KeyboardNavigation._utils = deps.utils
end
--- Handle keyboard press for navigation
---@param key string
---@param scancode string
---@param isrepeat boolean
---@return boolean handled
function KeyboardNavigation:handleKeyPress(key, scancode, isrepeat)
if not KeyboardNavigation._Context then
return false
end
-- Debug logging
if KeyboardNavigation.config.debugMode then
print(
string.format(
"[KeyboardNavigation] Key pressed: %s (scancode: %s, repeat: %s)",
key,
scancode,
tostring(isrepeat)
)
)
print(string.format("[KeyboardNavigation] Enabled: %s", tostring(KeyboardNavigation.config.enabled)))
end
local config = KeyboardNavigation.config
local keys = config.keys
-- Check for activation keys
for _, activateKey in ipairs(keys.activate) do
if key == activateKey then
return self:activateElement()
end
end
-- Check for dismiss key
if key == keys.dismiss then
return self:dismissElement()
end
-- Check for next/previous navigation
-- Tab with shift held = previous; Tab without shift = next
if key == keys.next then
if love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift") then
return self:previousFocusable()
end
return self:nextFocusable()
end
if key == keys.previous then
return self:previousFocusable()
end
-- Check for directional navigation
if config.directionalNavigation then
if key == keys.up then
return self:navigateDirectional("up")
elseif key == keys.down then
return self:navigateDirectional("down")
elseif key == keys.left then
return self:navigateDirectional("left")
elseif key == keys.right then
return self:navigateDirectional("right")
end
end
return false
end
--- Find next focusable element in the focusable list
---@param focusableList table<Element> List of focusable elements in tab order
---@param current Element? Currently focused element
---@return Element?
function KeyboardNavigation:_findNextInList(focusableList, current)
local currentIndex = 0
if current then
for i, elem in ipairs(focusableList) do
if elem.id == current.id then
currentIndex = i
break
end
end
end
-- Search forward
if currentIndex < #focusableList then
return focusableList[currentIndex + 1]
end
-- Wrap around if enabled
if KeyboardNavigation.config.wrapAround and #focusableList > 0 then
return focusableList[1]
end
return nil
end
--- Get the focusable element list scoped to the navigation container
---@return Element[]
function KeyboardNavigation:_getScopedFocusableList()
local Context = KeyboardNavigation._Context
local container = Context.getNavigationContainer()
if container then
return container:getFocusableChildren()
end
return Context.getFocusableElements()
end
--- Navigate to next focusable element (Tab)
---@return boolean success
function KeyboardNavigation:nextFocusable()
local Context = KeyboardNavigation._Context
local current = Context.getFocused()
if KeyboardNavigation.config.debugMode then
print(
string.format("[KeyboardNavigation] Tab pressed - Current focus: %s", tostring(current and current.id or "nil"))
)
end
local focusableList = self:_getScopedFocusableList()
local nextElem = self:_findNextInList(focusableList, current)
if nextElem then
self:_focusElement(nextElem)
return true
end
return false
end
--- Find previous focusable element in the focusable list
---@param focusableList table<Element> List of focusable elements in tab order
---@param current Element? Currently focused element
---@return Element?
function KeyboardNavigation:_findPreviousInList(focusableList, current)
local currentIndex = #focusableList + 1
if current then
for i, elem in ipairs(focusableList) do
if elem.id == current.id then
currentIndex = i
break
end
end
end
-- Search backward
if currentIndex - 1 >= 1 then
return focusableList[currentIndex - 1]
end
-- Wrap around if enabled
if KeyboardNavigation.config.wrapAround and #focusableList > 0 then
return focusableList[#focusableList]
end
return nil
end
--- Navigate to previous focusable element (Shift+Tab)
---@return boolean success
function KeyboardNavigation:previousFocusable()
local Context = KeyboardNavigation._Context
local current = Context.getFocused()
local focusableList = self:_getScopedFocusableList()
local prevElem = self:_findPreviousInList(focusableList, current)
if prevElem then
self:_focusElement(prevElem)
return true
end
return false
end
--- Navigate using arrow keys
---@param direction "up"|"down"|"left"|"right"
---@return boolean success
function KeyboardNavigation:navigateDirectional(direction)
local Context = KeyboardNavigation._Context
local current = Context.getFocused()
if not current then
return false
end
local nextElem = KeyboardNavigation:_findDirectionalNeighbor(current, direction)
if nextElem then
self:_focusElement(nextElem)
return true
end
return false
end
--- Find closest focusable element in the given direction
---@param current Element
---@param direction "up"|"down"|"left"|"right"
---@return Element?
function KeyboardNavigation:_findDirectionalNeighbor(current, direction)
-- Try spatial index first if enabled
if KeyboardNavigation._spatialIndex.enabled then
local spatialResult = self:_findDirectionalNeighborSpatial(current, direction)
if spatialResult then
return spatialResult
end
end
-- Collect all focusable elements visible this frame
local Context = KeyboardNavigation._Context
local focusable = {}
local function collectFocusable(elem)
if elem:isFocusable() and elem ~= current then
table.insert(focusable, elem)
end
for _, child in ipairs(elem.children) do
collectFocusable(child)
end
end
-- Mode-agnostic: collect from Context's focusable list
local allFocusable = Context.getFocusableElements()
for _, elem in ipairs(allFocusable) do
if elem ~= current then
table.insert(focusable, elem)
end
end
if #focusable == 0 then
return nil
end
local currentRect = {
x = current.x,
y = current.y,
width = current.width or 0,
height = current.height or 0,
}
local closest = nil
local closestDistance = math.huge
for _, elem in ipairs(focusable) do
local elemRect = {
x = elem.x,
y = elem.y,
width = elem.width or 0,
height = elem.height or 0,
}
local distance, isInDirection = self:_calculateDirectionalDistance(currentRect, elemRect, direction)
if isInDirection and distance < closestDistance then
closest = elem
closestDistance = distance
end
end
-- If no element found in exact direction, try with looser criteria
if not closest then
closest = self:_findClosestInDirection(current, focusable, direction)
end
return closest
end
--- Calculate distance and direction between elements
---@param from table {x, y, width, height}
---@param to table {x, y, width, height}
---@param direction string
---@return number distance, boolean isInDirection
function KeyboardNavigation:_calculateDirectionalDistance(from, to, direction)
-- Calculate bounding box edges
local fromLeft = from.x
local fromRight = from.x + from.width
local fromTop = from.y
local fromBottom = from.y + from.height
local toLeft = to.x
local toRight = to.x + to.width
local toTop = to.y
local toBottom = to.y + to.height
local distance = math.huge
local isInDirection = false
if direction == "up" then
if toBottom < fromTop then
isInDirection = true
distance = fromTop - toBottom
end
elseif direction == "down" then
if toTop > fromBottom then
isInDirection = true
distance = toTop - fromBottom
end
elseif direction == "left" then
if toRight < fromLeft then
isInDirection = true
distance = fromLeft - toRight
end
elseif direction == "right" then
if toLeft > fromRight then
isInDirection = true
distance = toLeft - fromRight
end
end
return distance, isInDirection
end
--- Find closest element in direction using center-to-center distance
---@param current Element
---@param focusable Element[]
---@param direction string
---@return Element?
function KeyboardNavigation:_findClosestInDirection(current, focusable, direction)
local currentCenterX = current.x + (current.width or 0) / 2
local currentCenterY = current.y + (current.height or 0) / 2
local closest = nil
local closestDistance = math.huge
for _, elem in ipairs(focusable) do
if elem ~= current then
local elemCenterX = elem.x + (elem.width or 0) / 2
local elemCenterY = elem.y + (elem.height or 0) / 2
local dx = elemCenterX - currentCenterX
local dy = elemCenterY - currentCenterY
-- Check if element is generally in the right direction
local isInDirection = false
if direction == "up" and dy < 0 then
isInDirection = true
elseif direction == "down" and dy > 0 then
isInDirection = true
elseif direction == "left" and dx < 0 then
isInDirection = true
elseif direction == "right" and dx > 0 then
isInDirection = true
end
if isInDirection then
local distance = math.sqrt(dx * dx + dy * dy)
if distance < closestDistance then
closest = elem
closestDistance = distance
end
end
end
end
return closest
end
--- Focus an element
---@param element Element
function KeyboardNavigation:_focusElement(element)
local Context = KeyboardNavigation._Context
if element and element:isFocusable() then
if KeyboardNavigation.config.debugMode then
print(
string.format(
"[KeyboardNavigation] Focusing element: %s (id: %s)",
element.themeComponent or "unknown",
tostring(element.id)
)
)
end
Context.setFocused(element)
-- Update focus indicator
if KeyboardNavigation.FocusIndicator then
KeyboardNavigation.FocusIndicator.setFocused(element)
end
-- Call onFocus callback if it exists
if element.onFocus then
local success, err = pcall(function()
if element.onFocusDeferred then
table.insert(Context._deferredCallbacks or {}, function()
element:onFocus(element)
end)
else
element:onFocus(element)
end
end)
if not success then
KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_001", {
elementId = element.id or "unknown",
error = tostring(err),
})
end
end
end
end
---@param element Element
---@return boolean
function KeyboardNavigation:_shouldDropFocusOnSelection(element)
if element and element.dropFocusOnSelection ~= nil then
return element.dropFocusOnSelection == true
end
return KeyboardNavigation.config.dropFocusOnSelection == true
end
--- Activate currently focused element
---@return boolean success
function KeyboardNavigation:activateElement()
local Context = KeyboardNavigation._Context
local focused = Context.getFocused()
if not focused then
return false
end
if focused.disabled then
return false
end
-- Fire press and release events
if focused.onEvent then
local modifiers = KeyboardNavigation._utils.getModifiers()
local pressEvent = KeyboardNavigation._InputEvent.new({
type = "press",
button = 1,
x = focused.x,
y = focused.y,
modifiers = modifiers,
clickCount = 1,
})
local releaseEvent = KeyboardNavigation._InputEvent.new({
type = "release",
button = 1,
x = focused.x,
y = focused.y,
modifiers = modifiers,
clickCount = 1,
})
local success, err = pcall(function()
focused.onEvent(focused, pressEvent)
focused.onEvent(focused, releaseEvent)
end)
if not success then
KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_002", {
elementId = focused.id or "unknown",
error = tostring(err),
})
end
-- Drop focus after selection based on per-element override or global config.
if KeyboardNavigation:_shouldDropFocusOnSelection(focused) then
Context.clearFocus()
if KeyboardNavigation.FocusIndicator then
KeyboardNavigation.FocusIndicator.setFocused(nil)
end
end
return true
end
return false
end
--- Dismiss currently focused element
---@return boolean success
function KeyboardNavigation:dismissElement()
local Context = KeyboardNavigation._Context
local focused = Context.getFocused()
if not focused then
return false
end
-- Check if element has a dismiss handler
if focused.onDismiss then
local success, err = pcall(function()
if focused.onDismissDeferred then
table.insert(Context._deferredCallbacks or {}, function()
focused:onDismiss(focused)
end)
else
focused:onDismiss(focused)
end
end)
if not success then
KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_003", {
elementId = focused.id or "unknown",
error = tostring(err),
})
end
return true -- Handler took care of dismissal
end
-- Default behavior: blur the element (only if no onDismiss handler)
Context.clearFocus()
return true
end
--- Update keyboard navigation (for animations, etc.)
---@param dt number
function KeyboardNavigation:update(dt)
-- Update focus indicator if it exists
if KeyboardNavigation.FocusIndicator then
KeyboardNavigation.FocusIndicator:update(dt)
end
end
--- Push current focus onto stack (for modals/dialogs)
--- Saves current focus and sets new focus to the given element
---@param element Element? The element to focus (e.g., modal dialog)
function KeyboardNavigation:pushFocus(element)
local Context = KeyboardNavigation._Context
table.insert(KeyboardNavigation._navigationStack, Context.getFocused())
Context.pushFocusStack(element)
end
--- Pop focus from stack (return from modal)
--- Restores previously focused element from the stack
---@return Element? The previously focused element, or nil if stack was empty
function KeyboardNavigation:popFocus()
local Context = KeyboardNavigation._Context
local previous = Context.popFocusStack()
if #KeyboardNavigation._navigationStack > 0 then
previous = table.remove(KeyboardNavigation._navigationStack)
end
return previous
end
-- ====================
-- Spatial Index (Performance Optimization)
-- ====================
--- Enable spatial index for faster directional navigation
---@param enabled boolean
function KeyboardNavigation.enableSpatialIndex(enabled)
KeyboardNavigation._spatialIndex.enabled = enabled
if not enabled then
KeyboardNavigation:_clearSpatialIndex()
end
end
--- Clear spatial index
function KeyboardNavigation:_clearSpatialIndex()
KeyboardNavigation._spatialIndex.grid = {}
KeyboardNavigation._spatialIndex.elementPositions = {}
end
--- Find directional neighbor using spatial index
---@param current Element
---@param direction "up"|"down"|"left"|"right"
---@return Element?
function KeyboardNavigation:_findDirectionalNeighborSpatial(current, direction)
local index = KeyboardNavigation._spatialIndex
local cellSize = index.cellSize
-- Get current element's grid position
local currentPos = index.elementPositions[current]
if not currentPos then
return nil
end
local centerX = currentPos.x + currentPos.w / 2
local centerY = currentPos.y + currentPos.h / 2
local currentCellX = math.floor(centerX / cellSize)
local currentCellY = math.floor(centerY / cellSize)
-- Search in direction, expanding outward
local maxSearchRadius = 20 -- Maximum cells to search
local visited = {}
for radius = 1, maxSearchRadius do
local candidates = {}
-- Get cells in the search ring
if direction == "up" then
table.insert(candidates, { currentCellX, currentCellY - radius })
if radius > 1 then
table.insert(candidates, { currentCellX - 1, currentCellY - radius })
table.insert(candidates, { currentCellX + 1, currentCellY - radius })
end
elseif direction == "down" then
table.insert(candidates, { currentCellX, currentCellY + radius })
if radius > 1 then
table.insert(candidates, { currentCellX - 1, currentCellY + radius })
table.insert(candidates, { currentCellX + 1, currentCellY + radius })
end
elseif direction == "left" then
table.insert(candidates, { currentCellX - radius, currentCellY })
if radius > 1 then
table.insert(candidates, { currentCellX - radius, currentCellY - 1 })
table.insert(candidates, { currentCellX - radius, currentCellY + 1 })
end
elseif direction == "right" then
table.insert(candidates, { currentCellX + radius, currentCellY })
if radius > 1 then
table.insert(candidates, { currentCellX + radius, currentCellY - 1 })
table.insert(candidates, { currentCellX + radius, currentCellY + 1 })
end
end
-- Check each candidate cell
for _, cell in ipairs(candidates) do
local cellKey = string.format("%d,%d", cell[1], cell[2])
local cellElements = index.grid[cellKey]
if cellElements then
for _, elem in ipairs(cellElements) do
if elem ~= current and not visited[elem] then
visited[elem] = true
local elemPos = index.elementPositions[elem]
if elemPos then
local elemCenterX = elemPos.x + elemPos.w / 2
local elemCenterY = elemPos.y + elemPos.h / 2
-- Check if element is in the correct direction
local isInDirection = false
if direction == "up" and elemCenterY < centerY then
isInDirection = true
elseif direction == "down" and elemCenterY > centerY then
isInDirection = true
elseif direction == "left" and elemCenterX < centerX then
isInDirection = true
elseif direction == "right" and elemCenterX > centerX then
isInDirection = true
end
if isInDirection then
return elem
end
end
end
end
end
end
end
return nil
end
return KeyboardNavigation
File diff suppressed because it is too large Load Diff
-697
View File
@@ -1,697 +0,0 @@
---@class MemoryScanner
---@field _StateManager table
---@field _Context table
---@field _ImageCache table
---@field _ErrorHandler table
local MemoryScanner = {}
---Initialize MemoryScanner with dependencies
---@param deps {StateManager: table, Context: table, ImageCache: table, ErrorHandler: table}
function MemoryScanner.init(deps)
MemoryScanner._StateManager = deps.StateManager
MemoryScanner._Context = deps.Context
MemoryScanner._ImageCache = deps.ImageCache
MemoryScanner._ErrorHandler = deps.ErrorHandler
end
---Count items in a table
---@param tbl table
---@return number
local function countTable(tbl)
local count = 0
for _ in pairs(tbl) do
count = count + 1
end
return count
end
---Calculate memory size estimate for a table (recursive)
---@param tbl table
---@param visited table? Tracking table to prevent circular references
---@param depth number? Current recursion depth
---@return number bytes Estimated memory usage in bytes
local function estimateTableSize(tbl, visited, depth)
if type(tbl) ~= "table" then
return 0
end
visited = visited or {}
depth = depth or 0
-- Limit recursion depth to prevent stack overflow
if depth > 10 then
return 0
end
-- Check for circular references
if visited[tbl] then
return 0
end
visited[tbl] = true
local size = 40 -- Base table overhead (approximate)
for k, v in pairs(tbl) do
-- Key size
if type(k) == "string" then
size = size + #k + 24 -- String overhead
elseif type(k) == "number" then
size = size + 8
else
size = size + 8 -- Reference
end
-- Value size
if type(v) == "string" then
size = size + #v + 24
elseif type(v) == "number" then
size = size + 8
elseif type(v) == "boolean" then
size = size + 4
elseif type(v) == "table" then
size = size + estimateTableSize(v, visited, depth + 1)
elseif type(v) == "function" then
size = size + 16 -- Function reference
else
size = size + 8 -- Other references
end
end
return size
end
---Scan StateManager for memory issues
---@return table report Detailed report of StateManager memory usage
function MemoryScanner.scanStateManager()
local report = {
stateCount = 0,
stateStoreSize = 0,
metadataSize = 0,
callSiteCounterSize = 0,
orphanedStates = {},
staleStates = {},
largeStates = {},
issues = {},
}
if not MemoryScanner._StateManager then
table.insert(report.issues, {
severity = "error",
message = "StateManager not initialized",
})
return report
end
local internal = MemoryScanner._StateManager._getInternalState()
local stateStore = internal.stateStore
local stateMetadata = internal.stateMetadata
local callSiteCounters = internal.callSiteCounters
local currentFrame = MemoryScanner._StateManager.getFrameNumber()
-- Count states
report.stateCount = countTable(stateStore)
-- Estimate sizes
report.stateStoreSize = estimateTableSize(stateStore)
report.metadataSize = estimateTableSize(stateMetadata)
report.callSiteCounterSize = estimateTableSize(callSiteCounters)
-- Check for orphaned states (metadata without state)
for id, _ in pairs(stateMetadata) do
if not stateStore[id] then
table.insert(report.orphanedStates, id)
end
end
-- Check for stale states (not accessed in many frames)
local staleThreshold = 120 -- 2 seconds at 60fps
for id, meta in pairs(stateMetadata) do
local framesSinceAccess = currentFrame - meta.lastFrame
if framesSinceAccess > staleThreshold then
table.insert(report.staleStates, {
id = id,
framesSinceAccess = framesSinceAccess,
createdFrame = meta.createdFrame,
accessCount = meta.accessCount,
})
end
end
-- Check for large states (may indicate memory bloat)
for id, state in pairs(stateStore) do
local stateSize = estimateTableSize(state)
if stateSize > 1024 then -- More than 1KB
table.insert(report.largeStates, {
id = id,
size = stateSize,
keyCount = countTable(state),
})
end
end
-- Check callSiteCounters (should be near 0 after frame cleanup)
local callSiteCount = countTable(callSiteCounters)
if callSiteCount > 100 then
table.insert(report.issues, {
severity = "warning",
message = string.format("callSiteCounters has %d entries (expected near 0)", callSiteCount),
suggestion = "incrementFrame() may not be called properly, or counters aren't being reset",
})
end
-- Check for excessive state count
if report.stateCount > 500 then
table.insert(report.issues, {
severity = "warning",
message = string.format("High state count: %d states", report.stateCount),
suggestion = "Consider reducing element count or implementing more aggressive cleanup",
})
end
-- Check for orphaned states
if #report.orphanedStates > 0 then
table.insert(report.issues, {
severity = "error",
message = string.format("Found %d orphaned states (metadata without state)", #report.orphanedStates),
suggestion = "This indicates a bug in state management - metadata should be cleaned up with state",
})
end
-- Check for stale states
if #report.staleStates > 10 then
table.insert(report.issues, {
severity = "warning",
message = string.format("Found %d stale states (not accessed in 2+ seconds)", #report.staleStates),
suggestion = "Cleanup may not be aggressive enough - consider reducing stateRetentionFrames",
})
end
return report
end
---Scan Context for memory issues
---@return table report Detailed report of Context memory usage
function MemoryScanner.scanContext()
local report = {
topElementCount = 0,
zIndexElementCount = 0,
frameElementCount = 0,
issues = {},
}
if not MemoryScanner._Context then
table.insert(report.issues, {
severity = "error",
message = "Context not initialized",
})
return report
end
-- Count elements
report.topElementCount = #MemoryScanner._Context.topElements
report.zIndexElementCount = #MemoryScanner._Context._zIndexOrderedElements
report.frameElementCount = #MemoryScanner._Context._currentFrameElements
-- Check for stale z-index elements (should be cleared each frame)
if MemoryScanner._Context.isImmediateMode() then
-- In immediate mode, _zIndexOrderedElements should be cleared at frame start
-- If it has elements outside of frame rendering, that's a leak
if not MemoryScanner._Context._frameStarted and report.zIndexElementCount > 0 then
table.insert(report.issues, {
severity = "warning",
message = string.format("Z-index array has %d elements outside of frame", report.zIndexElementCount),
suggestion = "clearFrameElements() may not be called properly in beginFrame()",
})
end
end
-- Check for excessive element count
if report.topElementCount > 100 then
table.insert(report.issues, {
severity = "info",
message = string.format("High top-level element count: %d", report.topElementCount),
suggestion = "Consider consolidating elements or using fewer top-level containers",
})
end
return report
end
---Scan ImageCache for memory issues
---@return table report Detailed report of ImageCache memory usage
function MemoryScanner.scanImageCache()
local report = {
imageCount = 0,
estimatedMemory = 0,
issues = {},
}
if not MemoryScanner._ImageCache then
table.insert(report.issues, {
severity = "error",
message = "ImageCache not initialized",
})
return report
end
local stats = MemoryScanner._ImageCache.getStats()
report.imageCount = stats.count
report.estimatedMemory = stats.memoryEstimate
-- Check for excessive memory usage (>100MB)
if report.estimatedMemory > 100 * 1024 * 1024 then
table.insert(report.issues, {
severity = "warning",
message = string.format("ImageCache using ~%.2f MB", report.estimatedMemory / 1024 / 1024),
suggestion = "Consider implementing cache eviction or clearing unused images",
})
end
-- Check for excessive image count
if report.imageCount > 50 then
table.insert(report.issues, {
severity = "info",
message = string.format("ImageCache has %d images", report.imageCount),
suggestion = "Review if all cached images are necessary",
})
end
return report
end
---Check if a circular reference is intentional (parent-child, module, or metatable)
---@param path string The current path where circular ref was detected
---@param originalPath string The original path where the table was first seen
---@return boolean True if this is an intentional circular reference
local function isIntentionalCircularReference(path, originalPath)
-- Pattern 1: child.parent points back to parent
-- Example: "topElements.1.children.1.parent" -> "topElements.1"
if path:match("%.parent$") then
local parentPath = path:match("^(.+)%.children%.[^.]+%.parent$")
if parentPath == originalPath then
return true
end
end
-- Pattern 2: parent.children[n] points to child, child points back somewhere in parent tree
-- Example: "topElements.1" -> "topElements.1.children.1.parent"
if originalPath:match("%.parent$") then
local childParentPath = originalPath:match("^(.+)%.children%.[^.]+%.parent$")
if childParentPath == path then
return true
end
end
-- Pattern 3: Check for nested parent-child cycles
-- child.children[n].parent -> child
local segments = {}
for segment in path:gmatch("[^.]+") do
table.insert(segments, segment)
end
-- Look for .children.N.parent pattern
for i = 1, #segments - 2 do
if segments[i] == "children" and segments[i + 2] == "parent" then
-- Reconstruct path without the .children.N.parent suffix
local reconstructedPath = table.concat(segments, ".", 1, i - 1)
if reconstructedPath == originalPath then
return true
end
end
end
-- Pattern 4: Metatable __index self-references (modules)
-- Example: "element._renderer._Theme.__index" -> "element._renderer._Theme"
if path:match("%.__index$") then
local basePath = path:match("^(.+)%.__index$")
if basePath == originalPath then
return true
end
end
-- Pattern 5: Shared module references (elements sharing same module instances)
-- Example: Multiple elements referencing _utils, _Theme, _Blur, etc.
-- These start with _ and are typically modules
local pathModuleName = path:match("%.(_[%w]+)%.")
local originalModuleName = originalPath:match("%.(_[%w]+)%.")
if pathModuleName and originalModuleName then
-- If both paths reference the same internal module (starting with _), it's intentional
if pathModuleName == originalModuleName then
return true
end
end
-- Pattern 6: Shared Color/Transform objects between elements
-- These are value objects that can be safely shared
if path:match("Color") and originalPath:match("Color") then
return true
end
if path:match("Transform") and originalPath:match("Transform") then
return true
end
-- Pattern 7: LayoutEngine holding reference to its element
-- Example: "element._layoutEngine.element" -> "element"
if path:match("%._layoutEngine%.element$") then
local elementPath = path:match("^(.+)%._layoutEngine%.element$")
if elementPath == originalPath then
return true
end
end
-- Pattern 8: Renderer holding references to element properties
-- Example: "element._renderer.cornerRadius" -> "element.cornerRadius"
if path:match("%._renderer%.") then
local rendererBasePath = path:match("^(.+)%._renderer%.")
local originalBasePath = originalPath:match("^(.+)%.")
if rendererBasePath == originalBasePath then
return true
end
end
-- Pattern 9: Context reference from layout engine (shared singleton)
-- Example: "element._layoutEngine._Context.topElements" -> "topElements"
if path:match("%._layoutEngine%._Context%.") and originalPath == "topElements" then
return true
end
return false
end
---Detect circular references in a table
---@param tbl table Table to check
---@param path string? Current path (for reporting)
---@param visited table? Tracking table
---@return table[] circularRefs Array of circular reference paths
---@return table[] intentionalRefs Array of intentional parent-child refs
local function detectCircularReferences(tbl, path, visited)
if type(tbl) ~= "table" then
return {}, {}
end
path = path or "root"
visited = visited or {}
local circularRefs = {}
local intentionalRefs = {}
-- Check if we've seen this table before
if visited[tbl] then
local ref = {
path = path,
originalPath = visited[tbl],
}
-- Determine if this is an intentional circular reference
if isIntentionalCircularReference(path, visited[tbl]) then
table.insert(intentionalRefs, ref)
else
table.insert(circularRefs, ref)
end
return circularRefs, intentionalRefs
end
-- Mark as visited
visited[tbl] = path
-- Recursively check children
for k, v in pairs(tbl) do
if type(v) == "table" then
local childPath = path .. "." .. tostring(k)
local childRefs, childIntentionalRefs = detectCircularReferences(v, childPath, visited)
for _, ref in ipairs(childRefs) do
table.insert(circularRefs, ref)
end
for _, ref in ipairs(childIntentionalRefs) do
table.insert(intentionalRefs, ref)
end
end
end
return circularRefs, intentionalRefs
end
---Scan for circular references in immediate mode
---@return table report Detailed report of circular references
function MemoryScanner.scanCircularReferences()
local report = {
stateStoreCircularRefs = {},
stateStoreIntentionalRefs = {},
contextCircularRefs = {},
contextIntentionalRefs = {},
issues = {},
}
if MemoryScanner._StateManager then
local internal = MemoryScanner._StateManager._getInternalState()
report.stateStoreCircularRefs, report.stateStoreIntentionalRefs =
detectCircularReferences(internal.stateStore, "stateStore")
end
if MemoryScanner._Context then
report.contextCircularRefs, report.contextIntentionalRefs =
detectCircularReferences(MemoryScanner._Context.topElements, "topElements")
end
-- Report issues only for cross-module circular references
if #report.stateStoreCircularRefs > 0 then
table.insert(report.issues, {
severity = "info",
message = string.format(
"Found %d cross-module circular references in StateManager",
#report.stateStoreCircularRefs
),
suggestion = "These are typically architectural dependencies between modules, not memory leaks",
})
end
if #report.contextCircularRefs > 0 then
table.insert(report.issues, {
severity = "info",
message = string.format("Found %d cross-module circular references in Context", #report.contextCircularRefs),
suggestion = "These are typically architectural dependencies (e.g., layout engine ↔ renderer), not memory leaks",
})
end
return report
end
---Run comprehensive memory scan
---@return table report Complete memory analysis report
function MemoryScanner.scan()
local startMemory = collectgarbage("count")
local report = {
timestamp = os.time(),
startMemory = startMemory / 1024, -- MB
stateManager = MemoryScanner.scanStateManager(),
context = MemoryScanner.scanContext(),
imageCache = MemoryScanner.scanImageCache(),
circularRefs = MemoryScanner.scanCircularReferences(),
summary = {
totalIssues = 0,
criticalIssues = 0,
warnings = 0,
info = 0,
},
}
-- Count issues by severity
local function countIssues(subReport)
for _, issue in ipairs(subReport.issues or {}) do
report.summary.totalIssues = report.summary.totalIssues + 1
if issue.severity == "error" then
report.summary.criticalIssues = report.summary.criticalIssues + 1
elseif issue.severity == "warning" then
report.summary.warnings = report.summary.warnings + 1
elseif issue.severity == "info" then
report.summary.info = report.summary.info + 1
end
end
end
countIssues(report.stateManager)
countIssues(report.context)
countIssues(report.imageCache)
countIssues(report.circularRefs)
-- Force GC and measure freed memory
local beforeGC = collectgarbage("count")
collectgarbage("collect")
collectgarbage("collect")
local afterGC = collectgarbage("count")
report.gcAnalysis = {
beforeGC = beforeGC / 1024, -- MB
afterGC = afterGC / 1024, -- MB
freed = (beforeGC - afterGC) / 1024, -- MB
freedPercent = ((beforeGC - afterGC) / beforeGC) * 100,
}
-- Analyze GC effectiveness
if report.gcAnalysis.freedPercent < 5 then
table.insert(report.stateManager.issues, {
severity = "info",
message = string.format("GC freed only %.1f%% of memory", report.gcAnalysis.freedPercent),
suggestion = "Most memory is still referenced - this is normal if UI is active",
})
elseif report.gcAnalysis.freedPercent > 30 then
table.insert(report.stateManager.issues, {
severity = "warning",
message = string.format("GC freed %.1f%% of memory", report.gcAnalysis.freedPercent),
suggestion = "Significant memory was unreferenced - may indicate cleanup issues",
})
end
return report
end
---Format report as human-readable string
---@param report table Memory scan report
---@return string formatted Formatted report
function MemoryScanner.formatReport(report)
local lines = {}
table.insert(lines, "=== FlexLöve Memory Scanner Report ===")
table.insert(lines, string.format("Timestamp: %s", os.date("%Y-%m-%d %H:%M:%S", report.timestamp)))
table.insert(lines, string.format("Memory: %.2f MB", report.startMemory))
table.insert(lines, "")
-- Summary
table.insert(lines, "--- Summary ---")
table.insert(lines, string.format("Total Issues: %d", report.summary.totalIssues))
table.insert(lines, string.format(" Critical: %d", report.summary.criticalIssues))
table.insert(lines, string.format(" Warnings: %d", report.summary.warnings))
table.insert(lines, string.format(" Info: %d", report.summary.info))
table.insert(lines, "")
-- StateManager
table.insert(lines, "--- StateManager ---")
table.insert(lines, string.format("State Count: %d", report.stateManager.stateCount))
table.insert(lines, string.format("State Store Size: %.2f KB", report.stateManager.stateStoreSize / 1024))
table.insert(lines, string.format("Metadata Size: %.2f KB", report.stateManager.metadataSize / 1024))
table.insert(lines, string.format("CallSite Counters: %.2f KB", report.stateManager.callSiteCounterSize / 1024))
table.insert(lines, string.format("Orphaned States: %d", #report.stateManager.orphanedStates))
table.insert(lines, string.format("Stale States: %d", #report.stateManager.staleStates))
table.insert(lines, string.format("Large States: %d", #report.stateManager.largeStates))
if #report.stateManager.issues > 0 then
table.insert(lines, "Issues:")
for _, issue in ipairs(report.stateManager.issues) do
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
if issue.suggestion then
table.insert(lines, string.format(" → %s", issue.suggestion))
end
end
end
table.insert(lines, "")
-- Context
table.insert(lines, "--- Context ---")
table.insert(lines, string.format("Top Elements: %d", report.context.topElementCount))
table.insert(lines, string.format("Z-Index Elements: %d", report.context.zIndexElementCount))
table.insert(lines, string.format("Frame Elements: %d", report.context.frameElementCount))
if #report.context.issues > 0 then
table.insert(lines, "Issues:")
for _, issue in ipairs(report.context.issues) do
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
if issue.suggestion then
table.insert(lines, string.format(" → %s", issue.suggestion))
end
end
end
table.insert(lines, "")
-- ImageCache
table.insert(lines, "--- ImageCache ---")
table.insert(lines, string.format("Image Count: %d", report.imageCache.imageCount))
table.insert(lines, string.format("Estimated Memory: %.2f MB", report.imageCache.estimatedMemory / 1024 / 1024))
if #report.imageCache.issues > 0 then
table.insert(lines, "Issues:")
for _, issue in ipairs(report.imageCache.issues) do
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
if issue.suggestion then
table.insert(lines, string.format(" → %s", issue.suggestion))
end
end
end
table.insert(lines, "")
-- Circular References
table.insert(lines, "--- Circular References ---")
table.insert(lines, string.format("StateStore (Cross-module refs): %d", #report.circularRefs.stateStoreCircularRefs))
table.insert(
lines,
string.format(
"StateStore (Intentional - parent-child, modules, metatables): %d",
#report.circularRefs.stateStoreIntentionalRefs
)
)
table.insert(lines, string.format("Context (Cross-module refs): %d", #report.circularRefs.contextCircularRefs))
table.insert(
lines,
string.format(
"Context (Intentional - parent-child, modules, metatables): %d",
#report.circularRefs.contextIntentionalRefs
)
)
if #report.circularRefs.issues > 0 then
table.insert(lines, "Issues:")
for _, issue in ipairs(report.circularRefs.issues) do
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
if issue.suggestion then
table.insert(lines, string.format(" → %s", issue.suggestion))
end
end
else
table.insert(lines, " ✓ No unexpected circular references detected")
end
table.insert(lines, " Note: Cross-module refs are typically architectural dependencies, not memory leaks")
table.insert(lines, "")
-- GC Analysis
table.insert(lines, "--- Garbage Collection Analysis ---")
table.insert(lines, string.format("Before GC: %.2f MB", report.gcAnalysis.beforeGC))
table.insert(lines, string.format("After GC: %.2f MB", report.gcAnalysis.afterGC))
table.insert(lines, string.format("Freed: %.2f MB (%.1f%%)", report.gcAnalysis.freed, report.gcAnalysis.freedPercent))
table.insert(lines, "")
table.insert(lines, "=== End Report ===")
return table.concat(lines, "\n")
end
---Save report to file
---@param report table Memory scan report
---@param filename string? Output filename (default: memory_report.txt)
function MemoryScanner.saveReport(report, filename)
filename = filename or "memory_report.txt"
local formatted = MemoryScanner.formatReport(report)
local file = io.open(filename, "w")
if file then
file:write(formatted)
file:close()
if MemoryScanner._ErrorHandler then
MemoryScanner._ErrorHandler:warn("MemoryScanner", "RES_004", {
resourceType = "report",
path = filename,
status = "saved",
})
end
else
if MemoryScanner._ErrorHandler then
MemoryScanner._ErrorHandler:warn("MemoryScanner", "RES_004", {
resourceType = "report",
path = filename,
status = "failed to save",
})
end
end
end
return MemoryScanner
-202
View File
@@ -1,202 +0,0 @@
---@class ModuleLoader
local ModuleLoader = {}
-- Module registry to track loaded vs. stub modules
ModuleLoader._registry = {}
ModuleLoader._ErrorHandler = nil
--- Initialize ModuleLoader with dependencies
---@param deps table
function ModuleLoader.init(deps)
ModuleLoader._ErrorHandler = deps.ErrorHandler
end
--- Create a null-object stub for a missing optional module
--- Provides safe defaults that won't cause runtime errors
---@param moduleName string
---@return table
local function createNullObject(moduleName)
local stub = {
_isStub = true,
_moduleName = moduleName,
}
-- Common method stubs that return safe defaults
local metatable = {
__index = function(_, key)
-- Common initialization method
if key == "init" then
return function()
return stub
end
end
-- Common constructor method
if key == "new" then
return function()
return stub
end
end
-- Common draw method
if key == "draw" then
return function() end
end
-- Common update method
if key == "update" then
return function() end
end
-- Common render method
if key == "render" then
return function() end
end
-- Common cleanup method
if key == "destroy" then
return function() end
end
-- Common cleanup method
if key == "cleanup" then
return function() end
end
-- Common clear method
if key == "clear" then
return function() end
end
-- Common reset method
if key == "reset" then
return function() end
end
-- Common get method
if key == "get" then
return function()
return nil
end
end
-- Common set method
if key == "set" then
return function() end
end
-- Common load method
if key == "load" then
return function()
return stub
end
end
-- Common cache-related methods
if key == "cache" or key == "getCache" or key == "clearCache" then
return function()
return {}
end
end
-- For any unknown method, return a no-op function that accepts any arguments
-- This allows safe method calls on stub objects (e.g., Performance:startFrame())
return function()
return stub
end
end,
-- Make function calls safe (in case the stub itself is called)
__call = function()
return stub
end,
}
setmetatable(stub, metatable)
return stub
end
--- Safely require a module with graceful fallback for optional modules
--- Returns the module if it exists, or a null-object stub if it's optional and missing
--- Throws an error if a required module is missing
---@param modulePath string Full path to the module (e.g., "modules.Performance")
---@param isOptional boolean If true, returns null-object on failure; if false, throws error
---@return table module The loaded module or a null-object stub
function ModuleLoader.safeRequire(modulePath, isOptional)
-- Check if already loaded
if ModuleLoader._registry[modulePath] then
return ModuleLoader._registry[modulePath]
end
-- Attempt to load the module
local success, result = pcall(require, modulePath)
if success then
-- Module loaded successfully
ModuleLoader._registry[modulePath] = result
return result
else
-- Module failed to load
if isOptional then
-- Create null-object stub for optional module
local stub = createNullObject(modulePath)
ModuleLoader._registry[modulePath] = stub
-- Log warning about missing optional module
if ModuleLoader._ErrorHandler then
ModuleLoader._ErrorHandler:warn("ModuleLoader", "MOD_001", {
modulePath = modulePath,
})
end
return stub
else
-- Required module is missing - throw error
error(string.format("Required module '%s' not found: %s", modulePath, tostring(result)))
end
end
end
--- Check if a module is actually loaded (not a stub)
---@param modulePath string Full path to the module
---@return boolean isLoaded True if module is loaded, false if it's a stub or not loaded
function ModuleLoader.isModuleLoaded(modulePath)
local module = ModuleLoader._registry[modulePath]
if not module then
return false
end
-- Check if it's a stub
return not module._isStub
end
--- Get list of all loaded modules
---@return table modules List of module paths that are actually loaded (not stubs)
function ModuleLoader.getLoadedModules()
local loaded = {}
for path, module in pairs(ModuleLoader._registry) do
if not module._isStub then
table.insert(loaded, path)
end
end
return loaded
end
--- Get list of all stub modules
---@return table stubs List of module paths that are stubs
function ModuleLoader.getStubModules()
local stubs = {}
for path, module in pairs(ModuleLoader._registry) do
if module._isStub then
table.insert(stubs, path)
end
end
return stubs
end
--- Clear the module registry (useful for testing)
function ModuleLoader._clearRegistry()
ModuleLoader._registry = {}
end
return ModuleLoader
-217
View File
@@ -1,217 +0,0 @@
local modulePath = (...):match("(.-)[^%.]+$")
local ImageScaler = require(modulePath .. "ImageScaler")
local NinePatch = {}
-- ErrorHandler will be injected via init
local ErrorHandler = nil
--- Initialize NinePatch with dependencies
---@param deps table Dependencies table with ErrorHandler
function NinePatch.init(deps)
if deps and deps.ErrorHandler then
ErrorHandler = deps.ErrorHandler
end
-- Also initialize ImageScaler since it's a dependency
if ImageScaler.init then
ImageScaler.init(deps)
end
end
--- Draw a 9-patch component using Android-style rendering
--- Corners are scaled by scaleCorners multiplier, edges stretch in one dimension only
---@param component ThemeComponent
---@param atlas love.Image
---@param x number -- X position (top-left corner)
---@param y number -- Y position (top-left corner)
---@param width number -- Total width (border-box)
---@param height number -- Total height (border-box)
---@param opacity number?
---@param elementScaleCorners number? -- Element-level override for scaleCorners (scale multiplier)
---@param elementScalingAlgorithm "nearest"|"bilinear"? -- Element-level override for scalingAlgorithm
function NinePatch.draw(component, atlas, x, y, width, height, opacity, elementScaleCorners, elementScalingAlgorithm)
if not component or not atlas then
return
end
opacity = opacity or 1
love.graphics.setColor(1, 1, 1, opacity)
local regions = component.regions
-- Extract border dimensions from regions (in pixels)
local left = regions.topLeft.w
local right = regions.topRight.w
local top = regions.topLeft.h
local bottom = regions.bottomLeft.h
local centerW = regions.middleCenter.w
local centerH = regions.middleCenter.h
-- Calculate content area (space remaining after borders)
local contentWidth = width - left - right
local contentHeight = height - top - bottom
-- Clamp to prevent negative dimensions
contentWidth = math.max(0, contentWidth)
contentHeight = math.max(0, contentHeight)
-- Calculate stretch scales for edges and center
local scaleX = contentWidth / centerW
local scaleY = contentHeight / centerH
-- Create quads for each region
local atlasWidth, atlasHeight = atlas:getDimensions()
local function makeQuad(region)
return love.graphics.newQuad(region.x, region.y, region.w, region.h, atlasWidth, atlasHeight)
end
-- Get corner scale multiplier
-- Priority: element-level override > component setting > default (nil = no scaling)
local scaleCorners = elementScaleCorners
if scaleCorners == nil then
scaleCorners = component.scaleCorners
end
-- Priority: element-level override > component setting > default ("bilinear")
local scalingAlgorithm = elementScalingAlgorithm
if scalingAlgorithm == nil then
scalingAlgorithm = component.scalingAlgorithm or "bilinear"
end
if scaleCorners and type(scaleCorners) == "number" and scaleCorners > 0 then
-- Initialize cache if needed
if not component._scaledRegionCache then
component._scaledRegionCache = {}
end
-- Use the numeric scale multiplier directly
local scaleFactor = scaleCorners
-- Helper to get or create scaled region
local function getScaledRegion(regionName, region, targetWidth, targetHeight)
local cacheKey = string.format("%s_%.2f_%s", regionName, scaleFactor, scalingAlgorithm)
if component._scaledRegionCache[cacheKey] then
return component._scaledRegionCache[cacheKey]
end
-- Get ImageData from component (stored during theme loading)
local atlasData = component._loadedAtlasData
if not atlasData then
ErrorHandler.error(
"NinePatch",
"REN_007",
"No ImageData available for atlas. Image must be loaded with safeLoadImage.",
{
componentType = component.type,
}
)
end
local scaledData
if scalingAlgorithm == "nearest" then
scaledData =
ImageScaler.scaleNearest(atlasData, region.x, region.y, region.w, region.h, targetWidth, targetHeight)
else
scaledData =
ImageScaler.scaleBilinear(atlasData, region.x, region.y, region.w, region.h, targetWidth, targetHeight)
end
-- Convert to image and cache
local scaledImage = love.graphics.newImage(scaledData)
component._scaledRegionCache[cacheKey] = scaledImage
return scaledImage
end
-- Calculate scaled dimensions for corners
local scaledLeft = math.floor(left * scaleFactor + 0.5)
local scaledRight = math.floor(right * scaleFactor + 0.5)
local scaledTop = math.floor(top * scaleFactor + 0.5)
local scaledBottom = math.floor(bottom * scaleFactor + 0.5)
-- CORNERS (scaled using algorithm)
local topLeftScaled = getScaledRegion("topLeft", regions.topLeft, scaledLeft, scaledTop)
local topRightScaled = getScaledRegion("topRight", regions.topRight, scaledRight, scaledTop)
local bottomLeftScaled = getScaledRegion("bottomLeft", regions.bottomLeft, scaledLeft, scaledBottom)
local bottomRightScaled = getScaledRegion("bottomRight", regions.bottomRight, scaledRight, scaledBottom)
love.graphics.draw(topLeftScaled, x, y)
love.graphics.draw(topRightScaled, x + width - scaledRight, y)
love.graphics.draw(bottomLeftScaled, x, y + height - scaledBottom)
love.graphics.draw(bottomRightScaled, x + width - scaledRight, y + height - scaledBottom)
-- Update content dimensions to account for scaled borders
local adjustedContentWidth = width - scaledLeft - scaledRight
local adjustedContentHeight = height - scaledTop - scaledBottom
adjustedContentWidth = math.max(0, adjustedContentWidth)
adjustedContentHeight = math.max(0, adjustedContentHeight)
-- Recalculate stretch scales
local adjustedScaleX = adjustedContentWidth / centerW
local adjustedScaleY = adjustedContentHeight / centerH
-- TOP/BOTTOM EDGES (stretch horizontally, scale vertically)
if adjustedContentWidth > 0 then
local topCenterScaled = getScaledRegion("topCenter", regions.topCenter, regions.topCenter.w, scaledTop)
local bottomCenterScaled =
getScaledRegion("bottomCenter", regions.bottomCenter, regions.bottomCenter.w, scaledBottom)
love.graphics.draw(topCenterScaled, x + scaledLeft, y, 0, adjustedScaleX, 1)
love.graphics.draw(bottomCenterScaled, x + scaledLeft, y + height - scaledBottom, 0, adjustedScaleX, 1)
end
-- LEFT/RIGHT EDGES (stretch vertically, scale horizontally)
if adjustedContentHeight > 0 then
local middleLeftScaled = getScaledRegion("middleLeft", regions.middleLeft, scaledLeft, regions.middleLeft.h)
local middleRightScaled = getScaledRegion("middleRight", regions.middleRight, scaledRight, regions.middleRight.h)
love.graphics.draw(middleLeftScaled, x, y + scaledTop, 0, 1, adjustedScaleY)
love.graphics.draw(middleRightScaled, x + width - scaledRight, y + scaledTop, 0, 1, adjustedScaleY)
end
-- CENTER (stretch both dimensions, no scaling)
if adjustedContentWidth > 0 and adjustedContentHeight > 0 then
love.graphics.draw(
atlas,
makeQuad(regions.middleCenter),
x + scaledLeft,
y + scaledTop,
0,
adjustedScaleX,
adjustedScaleY
)
end
else
-- Original rendering logic (no scaling)
-- CORNERS (no scaling - 1:1 pixel perfect)
love.graphics.draw(atlas, makeQuad(regions.topLeft), x, y)
love.graphics.draw(atlas, makeQuad(regions.topRight), x + left + contentWidth, y)
love.graphics.draw(atlas, makeQuad(regions.bottomLeft), x, y + top + contentHeight)
love.graphics.draw(atlas, makeQuad(regions.bottomRight), x + left + contentWidth, y + top + contentHeight)
-- TOP/BOTTOM EDGES (stretch horizontally only)
if contentWidth > 0 then
love.graphics.draw(atlas, makeQuad(regions.topCenter), x + left, y, 0, scaleX, 1)
love.graphics.draw(atlas, makeQuad(regions.bottomCenter), x + left, y + top + contentHeight, 0, scaleX, 1)
end
-- LEFT/RIGHT EDGES (stretch vertically only)
if contentHeight > 0 then
love.graphics.draw(atlas, makeQuad(regions.middleLeft), x, y + top, 0, 1, scaleY)
love.graphics.draw(atlas, makeQuad(regions.middleRight), x + left + contentWidth, y + top, 0, 1, scaleY)
end
-- CENTER (stretch both dimensions)
if contentWidth > 0 and contentHeight > 0 then
love.graphics.draw(atlas, makeQuad(regions.middleCenter), x + left, y + top, 0, scaleX, scaleY)
end
end
-- Reset color
love.graphics.setColor(1, 1, 1, 1)
end
return NinePatch
-351
View File
@@ -1,351 +0,0 @@
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
-- All numeric, range, type, and enum validation lives here.
-- `clamp` is injected via init() to avoid a cross-import into utils.
-- `ErrorHandler` is injected via init() so error reporting routes through
-- the shared handler (matching the pre-split behavior of utils.validate*).
local ErrorHandler = nil
local clamp = nil
--- Initialize dependencies
---@param deps table Dependencies: { ErrorHandler = table, clamp = function }
local function init(deps)
if type(deps) == "table" then
ErrorHandler = deps.ErrorHandler or ErrorHandler
clamp = deps.clamp or clamp
end
end
-- Numeric validation utilities
--- Check if a value is NaN (not-a-number)
--- @param value any Value to check
--- @return boolean
local function isNaN(value)
return type(value) == "number" and value ~= value
end
--- Check if a value is Infinity
--- @param value any Value to check
--- @return boolean
local function isInfinity(value)
return type(value) == "number" and (value == math.huge or value == -math.huge)
end
--- Validate a numeric value with comprehensive checks
--- @param value any Value to validate
--- @param options table? Validation options
--- @return boolean, string?, number? Returns valid, errorMessage, sanitizedValue
local function validateNumber(value, options)
options = options or {}
-- Check if value is a number type
if type(value) ~= "number" then
if options.default ~= nil then
return true, nil, options.default
end
return false, string.format("Value must be a number, got %s", type(value)), nil
end
-- Check for NaN
if isNaN(value) then
if not options.allowNaN then
if options.default ~= nil then
return true, nil, options.default
end
return false, "Value is NaN (not-a-number)", nil
end
end
-- Check for Infinity
if isInfinity(value) then
if not options.allowInfinity then
if options.default ~= nil then
return true, nil, options.default
end
return false, "Value is Infinity", nil
end
end
-- Check for integer requirement
if options.integer and math.floor(value) ~= value then
return false, string.format("Value must be an integer, got %s", value), nil
end
-- Check for positive requirement
if options.positive and value <= 0 then
return false, string.format("Value must be positive, got %s", value), nil
end
-- Check bounds
if options.min and value < options.min then
return false, string.format("Value %s is below minimum %s", value, options.min), nil
end
if options.max and value > options.max then
return false, string.format("Value %s is above maximum %s", value, options.max), nil
end
return true, nil, value
end
--- Sanitize a numeric value (never errors, always returns valid number)
--- @param value any Value to sanitize
--- @param min number? Minimum value
--- @param max number? Maximum value
--- @param default number? Default value for invalid inputs
--- @return number Sanitized value
local function sanitizeNumber(value, min, max, default)
default = default or 0
min = min or -math.huge
max = max or math.huge
-- Convert to number if possible
if type(value) == "string" then
value = tonumber(value)
end
-- Handle non-numeric
if type(value) ~= "number" then
return default
end
-- Handle NaN
if isNaN(value) then
return default
end
-- Handle Infinity
if value == math.huge then
return max
end
if value == -math.huge then
return min
end
-- Clamp to range
return clamp(value, min, max)
end
--- Validate and convert to integer
--- @param value any Value to validate
--- @param min number? Minimum value
--- @param max number? Maximum value
--- @return boolean, string?, number? Returns valid, errorMessage, integerValue
local function validateInteger(value, min, max)
local valid, err, sanitized = validateNumber(value, {
min = min,
max = max,
integer = true,
})
if not valid then
return false, err, nil
end
return true, nil, math.floor(sanitized or value)
end
--- Validate and normalize percentage value
--- @param value any Value to validate (can be "50%", 0.5, or 50)
--- @return boolean, string?, number? Returns valid, errorMessage, normalizedValue (0-1)
local function validatePercentage(value)
-- Handle string percentage
if type(value) == "string" then
local num = value:match("^(%d+%.?%d*)%%$")
if num then
value = tonumber(num)
if value then
value = value / 100
end
else
value = tonumber(value)
end
end
if type(value) ~= "number" then
return false, "Percentage must be a number", nil
end
if isNaN(value) or isInfinity(value) then
return false, "Percentage cannot be NaN or Infinity", nil
end
-- If value is > 1, assume it's 0-100 range
if value > 1 then
value = value / 100
end
-- Clamp to 0-1
value = clamp(value, 0, 1)
return true, nil, value
end
--- Validate opacity value (0-1)
--- @param value any Value to validate
--- @return boolean, string?, number? Returns valid, errorMessage, opacityValue
local function validateOpacity(value)
return validateNumber(value, { min = 0, max = 1, default = 1 })
end
--- Validate degree value (0-360)
--- @param value any Value to validate
--- @return boolean, string?, number? Returns valid, errorMessage, degreeValue
local function validateDegrees(value)
local valid, err, sanitized = validateNumber(value)
if not valid then
return false, err, nil
end
-- Normalize to 0-360 range
local degrees = sanitized or value
degrees = degrees % 360
if degrees < 0 then
degrees = degrees + 360
end
return true, nil, degrees
end
--- Validate coordinate value (pixel position)
--- @param value any Value to validate
--- @return boolean, string?, number? Returns valid, errorMessage, coordinateValue
local function validateCoordinate(value)
return validateNumber(value, {
allowNaN = false,
allowInfinity = false,
})
end
--- Validate dimension value (width/height, must be non-negative)
--- @param value any Value to validate
--- @return boolean, string?, number? Returns valid, errorMessage, dimensionValue
local function validateDimension(value)
return validateNumber(value, {
min = 0,
allowNaN = false,
allowInfinity = false,
})
end
--- Validate that a value is in an enum table
---@param value any Value to validate
---@param enumTable table Enum table with valid values
---@param propName string Property name for error messages
---@param moduleName string? Module name for error messages (default: "Element")
---@return boolean True if valid
local function validateEnum(value, enumTable, propName, moduleName)
if value == nil then
return true
end
for _, validValue in pairs(enumTable) do
if value == validValue then
return true
end
end
-- Build list of valid options
local validOptions = {}
for _, v in pairs(enumTable) do
table.insert(validOptions, "'" .. v .. "'")
end
table.sort(validOptions)
if ErrorHandler then
ErrorHandler:error(moduleName or "Element", "VAL_007", {
property = propName,
expected = table.concat(validOptions, ", "),
got = tostring(value),
})
else
error(
string.format("%s must be one of: %s. Got: '%s'", propName, table.concat(validOptions, ", "), tostring(value))
)
end
end
--- Validate that a numeric value is within a range
---@param value any Value to validate
---@param min number Minimum allowed value
---@param max number Maximum allowed value
---@param propName string Property name for error messages
---@param moduleName string? Module name for error messages (default: "Element")
---@return boolean True if valid
local function validateRange(value, min, max, propName, moduleName)
if value == nil then
return true
end
if type(value) ~= "number" then
if ErrorHandler then
ErrorHandler:error(moduleName or "Element", "VAL_001", {
property = propName,
expected = "number",
got = type(value),
})
else
error(string.format("%s must be a number, got %s", propName, type(value)))
end
elseif value < min or value > max then
if ErrorHandler then
ErrorHandler:error(moduleName or "Element", "VAL_002", {
property = propName,
min = tostring(min),
max = tostring(max),
value = tostring(value),
})
else
error(
string.format("%s must be between %s and %s, got %s", propName, tostring(min), tostring(max), tostring(value))
)
end
end
return true
end
--- Validate that a value is of the expected type
---@param value any Value to validate
---@param expectedType string Expected type name
---@param propName string Property name for error messages
---@param moduleName string? Module name for error messages (default: "Element")
---@return boolean True if valid
local function validateType(value, expectedType, propName, moduleName)
if value == nil then
return true
end
local actualType = type(value)
if actualType ~= expectedType then
if ErrorHandler then
ErrorHandler:error(moduleName or "Element", "VAL_001", {
property = propName,
expected = expectedType,
got = actualType,
})
else
error(string.format("%s must be %s, got %s", propName, expectedType, actualType))
end
end
return true
end
return {
init = init,
isNaN = isNaN,
isInfinity = isInfinity,
validateNumber = validateNumber,
sanitizeNumber = sanitizeNumber,
validateInteger = validateInteger,
validatePercentage = validatePercentage,
validateOpacity = validateOpacity,
validateDegrees = validateDegrees,
validateCoordinate = validateCoordinate,
validateDimension = validateDimension,
validateEnum = validateEnum,
validateRange = validateRange,
validateType = validateType,
}
-198
View File
@@ -1,198 +0,0 @@
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
-- Path sanitization, validation, and file-extension helpers.
-- Uses love.filesystem when available (optional) for existence checks.
--- Normalize a file path for consistent cache keys
---@param path string File path to normalize
---@return string Normalized path
local function normalizePath(path)
path = path:match("^%s*(.-)%s*$")
path = path:gsub("\\", "/")
path = path:gsub("/+", "/")
return path
end
--- Sanitize a file path
--- @param path string Path to sanitize
--- @return string Sanitized path
local function sanitizePath(path)
if path == nil then
return ""
end
path = tostring(path)
-- Trim whitespace
path = path:match("^%s*(.-)%s*$") or ""
-- Normalize separators to forward slash
path = path:gsub("\\", "/")
-- Remove duplicate slashes
path = path:gsub("/+", "/")
-- Remove trailing slash (except for root)
if #path > 1 and path:sub(-1) == "/" then
path = path:sub(1, -2)
end
return path
end
--- Check if a path is safe (no traversal attacks)
--- @param path string Path to check
--- @param baseDir string? Base directory to check against (optional)
--- @return boolean, string? Returns true if safe, or false with reason
local function isPathSafe(path, baseDir)
if path == nil or path == "" then
return false, "Path is empty"
end
-- Sanitize the path
path = sanitizePath(path)
-- Check for suspicious patterns
if path:match("%.%.") then
return false, "Path contains '..' (parent directory reference)"
end
-- Check for null bytes
if path:match("%z") then
return false, "Path contains null bytes"
end
-- Check for encoded traversal attempts (including double-encoding)
local lowerPath = path:lower()
if
lowerPath:match("%%2e")
or lowerPath:match("%%2f")
or lowerPath:match("%%5c")
or lowerPath:match("%%252e")
or lowerPath:match("%%252f")
or lowerPath:match("%%255c")
then
return false, "Path contains URL-encoded directory separators"
end
-- If baseDir is provided, ensure path is within it
if baseDir then
baseDir = sanitizePath(baseDir)
-- For relative paths, prepend baseDir
local fullPath = path
if not path:match("^/") and not path:match("^%a:") then
fullPath = baseDir .. "/" .. path
end
fullPath = sanitizePath(fullPath)
-- Check if fullPath starts with baseDir
if not fullPath:match("^" .. baseDir:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%1")) then
return false, "Path is outside allowed directory"
end
end
return true, nil
end
--- Validate a file path with comprehensive checks
--- @param path string Path to validate
--- @param options table? Validation options
--- @return boolean, string? Returns true if valid, or false with error message
local function validatePath(path, options)
options = options or {}
-- Check path is not nil/empty
if path == nil or path == "" then
return false, "Path is empty"
end
path = tostring(path)
-- Check maximum length
local maxLength = options.maxLength or 4096
if #path > maxLength then
return false, string.format("Path exceeds maximum length of %d characters", maxLength)
end
-- Sanitize path
path = sanitizePath(path)
-- Check for safety (traversal attacks)
local safe, reason = isPathSafe(path, options.baseDir)
if not safe then
return false, reason
end
-- Check allowed extensions
if options.allowedExtensions then
local ext = path:match("%.([^%.]+)$")
if not ext then
return false, "Path has no file extension"
end
ext = ext:lower()
local allowed = false
for _, allowedExt in ipairs(options.allowedExtensions) do
if ext == allowedExt:lower() then
allowed = true
break
end
end
if not allowed then
return false, string.format("File extension '%s' is not allowed", ext)
end
end
-- Check if file must exist
if options.mustExist and love and love.filesystem then
local info = love.filesystem.getInfo(path)
if not info then
return false, "File does not exist"
end
end
return true, nil
end
--- Get file extension from path
--- @param path string File path
--- @return string? extension File extension (lowercase) or nil
local function getFileExtension(path)
if not path then
return nil
end
local ext = path:match("%.([^%.]+)$")
return ext and ext:lower() or nil
end
--- Check if path has allowed extension
--- @param path string File path
--- @param allowedExtensions table Array of allowed extensions
--- @return boolean
local function hasAllowedExtension(path, allowedExtensions)
local ext = getFileExtension(path)
if not ext then
return false
end
for _, allowedExt in ipairs(allowedExtensions) do
if ext == allowedExt:lower() then
return true
end
end
return false
end
return {
normalizePath = normalizePath,
sanitizePath = sanitizePath,
isPathSafe = isPathSafe,
validatePath = validatePath,
getFileExtension = getFileExtension,
hasAllowedExtension = hasAllowedExtension,
}
-560
View File
@@ -1,560 +0,0 @@
---@class Performance
---@field enabled boolean
---@field hudEnabled boolean
---@field hudToggleKey string
---@field hudPosition {x: number, y: number}
---@field warningThresholdMs number
---@field criticalThresholdMs number
---@field logToConsole boolean
---@field logWarnings boolean
---@field warningsEnabled boolean
---@field _ErrorHandler table?
---@field _timers table
---@field _metrics table
---@field _lastMetricsCleanup number
---@field _frameMetrics table
---@field _memoryMetrics table
---@field _warnings table
---@field _lastFrameStart number?
---@field _shownWarnings table
---@field _memoryProfiler table
local Performance = {}
Performance.__index = Performance
---@type Performance|nil
local instance = nil
local METRICS_CLEANUP_INTERVAL = 30
local METRICS_RETENTION_TIME = 10
local MAX_METRICS_COUNT = 500
local CORE_METRICS = { frame = true, layout = true, render = true }
---@param config {enabled?: boolean, hudEnabled?: boolean, hudToggleKey?: string, hudPosition?: {x: number, y: number}, warningThresholdMs?: number, criticalThresholdMs?: number, logToConsole?: boolean, logWarnings?: boolean, warningsEnabled?: boolean, memoryProfiling?: boolean}?
---@param deps {ErrorHandler: ErrorHandler}
---@return Performance
function Performance.init(config, deps)
if instance == nil then
local self = setmetatable({}, Performance)
-- Configuration
self.enabled = config and config.enabled or false
self.hudEnabled = config and config.hudEnabled or false
self.hudToggleKey = config and config.hudToggleKey or "f3"
self.hudPosition = config and config.hudPosition or { x = 10, y = 10 }
self.warningThresholdMs = config and config.warningThresholdMs or 13.0
self.criticalThresholdMs = config and config.criticalThresholdMs or 16.67
self.logToConsole = config and config.logToConsole or false
self.logWarnings = config and config.logWarnings or true
self.warningsEnabled = config and config.warningsEnabled or true
self._timers = {}
self._metrics = {}
self._lastMetricsCleanup = 0
self._frameMetrics = {
frameCount = 0,
totalTime = 0,
lastFrameTime = 0,
minFrameTime = math.huge,
maxFrameTime = 0,
fps = 0,
lastFpsUpdate = 0,
fpsUpdateInterval = 0.5,
}
self._memoryMetrics = {
current = 0,
peak = 0,
gcCount = 0,
lastGcCheck = 0,
}
self._warnings = {}
self._lastFrameStart = nil
self._shownWarnings = {}
self._memoryProfiler = {
enabled = config and config.memoryProfiling or false,
sampleInterval = 60,
framesSinceLastSample = 0,
samples = {},
maxSamples = 20,
monitoredTables = {},
}
self._ErrorHandler = deps and deps.ErrorHandler
instance = self
end
return instance
end
--- Toggle HUD visibility
function Performance:toggleHUD()
self.hudEnabled = not self.hudEnabled
end
function Performance:startTimer(name)
if not self.enabled then
return
end
self._timers[name] = love.timer.getTime()
end
function Performance:stopTimer(name)
if not self.enabled then
return nil
end
local startTime = self._timers[name]
if not startTime then
-- Silently return nil if timer wasn't started
-- This can happen legitimately when Performance is toggled mid-frame
-- or when layout functions have early returns
return nil
end
local elapsed = (love.timer.getTime() - startTime) * 1000
self._timers[name] = nil
-- Update metrics
if not self._metrics[name] then
self._metrics[name] = {
total = 0,
count = 0,
min = math.huge,
max = 0,
average = 0,
lastUsed = love.timer.getTime(),
}
end
local m = self._metrics[name]
m.total = m.total + elapsed
m.count = m.count + 1
m.min = math.min(m.min, elapsed)
m.max = math.max(m.max, elapsed)
m.average = m.total / m.count
m.lastUsed = love.timer.getTime()
-- Check for warnings
if elapsed > self.criticalThresholdMs then
self:_addWarning(name, elapsed, "critical")
elseif elapsed > self.warningThresholdMs then
self:_addWarning(name, elapsed, "warning")
end
if self.logToConsole then
-- Use ErrorHandler if available, otherwise fall back to print
if self._ErrorHandler and self._ErrorHandler.warn then
self._ErrorHandler:warn("Performance", "PERF_001", {
metric = name,
elapsed = string.format("%.3fms", elapsed),
})
else
print(string.format("[Performance] %s: %.3fms", name, elapsed))
end
end
return elapsed
end
--- Update with actual delta time from LÖVE (call from love.update)
---@param dt number Delta time in seconds
function Performance:updateDeltaTime(dt)
if not self.enabled then
return
end
local now = love.timer.getTime()
if now - self._frameMetrics.lastFpsUpdate >= self._frameMetrics.fpsUpdateInterval then
if dt > 0 then
self._frameMetrics.fps = math.floor(1 / dt + 0.5)
end
self._frameMetrics.lastFpsUpdate = now
end
end
--- Start frame timing (call at beginning of frame)
function Performance:startFrame()
if not self.enabled then
return
end
self._lastFrameStart = love.timer.getTime()
self:_updateMemory()
end
function Performance:endFrame()
if not self.enabled or not self._lastFrameStart then
return
end
local now = love.timer.getTime()
local frameTime = (now - self._lastFrameStart) * 1000
self._frameMetrics.lastFrameTime = frameTime
self._frameMetrics.totalTime = self._frameMetrics.totalTime + frameTime
self._frameMetrics.frameCount = self._frameMetrics.frameCount + 1
self._frameMetrics.minFrameTime = math.min(self._frameMetrics.minFrameTime, frameTime)
self._frameMetrics.maxFrameTime = math.max(self._frameMetrics.maxFrameTime, frameTime)
if frameTime > self.criticalThresholdMs then
self:_addWarning("frame", frameTime, "critical")
end
self:updateMemoryProfiling()
-- Periodic metrics cleanup
if now - self._lastMetricsCleanup >= METRICS_CLEANUP_INTERVAL then
local cleanupTime = now - METRICS_RETENTION_TIME
for name, data in pairs(self._metrics) do
if not CORE_METRICS[name] and data.lastUsed and data.lastUsed < cleanupTime then
self._metrics[name] = nil
end
end
self._lastMetricsCleanup = now
end
-- Enforce max metrics limit
local metricsCount = 0
for _ in pairs(self._metrics) do
metricsCount = metricsCount + 1
end
if metricsCount > MAX_METRICS_COUNT then
local sortedMetrics = {}
for name, data in pairs(self._metrics) do
if not CORE_METRICS[name] then
table.insert(sortedMetrics, { name = name, lastUsed = data.lastUsed or 0 })
end
end
table.sort(sortedMetrics, function(a, b)
return a.lastUsed < b.lastUsed
end)
local toRemove = metricsCount - MAX_METRICS_COUNT
for i = 1, math.min(toRemove, #sortedMetrics) do
self._metrics[sortedMetrics[i].name] = nil
end
end
end
--- Update memory metrics
function Performance:_updateMemory()
if not self.enabled then
return
end
local memKb = collectgarbage("count")
self._memoryMetrics.current = memKb
self._memoryMetrics.peak = math.max(self._memoryMetrics.peak, memKb)
local now = love.timer.getTime()
if now - self._memoryMetrics.lastGcCheck >= 1.0 then
self._memoryMetrics.gcCount = self._memoryMetrics.gcCount + 1
self._memoryMetrics.lastGcCheck = now
end
end
--- Add a performance warning (private)
--- @param name string Metric name
--- @param value number Metric value
--- @param level "warning"|"critical" Warning level
function Performance:_addWarning(name, value, level)
if not self.logWarnings then
return
end
local warning = {
name = name,
value = value,
level = level,
time = love.timer.getTime(),
}
table.insert(self._warnings, warning)
if #self._warnings > 100 then
table.remove(self._warnings, 1)
end
if self.logToConsole or self.warningsEnabled then
local warningKey = name .. "_" .. level
local lastWarningTime = self._shownWarnings[warningKey] or 0
local now = love.timer.getTime()
if now - lastWarningTime >= 60 then
if self._ErrorHandler and self._ErrorHandler.warn then
local code = level == "critical" and "PERF_002" or "PERF_001"
self._ErrorHandler:warn("Performance", code, {
metric = name,
value = string.format("%.2fms", value),
threshold = level == "critical" and self.criticalThresholdMs or self.warningThresholdMs,
})
end
self._shownWarnings[warningKey] = now
end
end
end
--- Render performance HUD
--- @param x number? X position (default: 10)
--- @param y number? Y position (default: 10)
function Performance:renderHUD(x, y)
if not self.hudEnabled then
return
end
x = x or self.hudPosition.x
y = y or self.hudPosition.y
self:_updateMemory()
local fm = self._frameMetrics
local mm = self._memoryMetrics
love.graphics.setColor(0, 0, 0, 0.8)
love.graphics.rectangle("fill", x, y, 300, 220)
love.graphics.setColor(1, 1, 1, 1)
local lineHeight = 18
local currentY = y + 10
-- FPS
local fpsColor = { 1, 1, 1 }
if fm.lastFrameTime > self.criticalThresholdMs then
fpsColor = { 1, 0, 0 }
elseif fm.lastFrameTime > self.warningThresholdMs then
fpsColor = { 1, 1, 0 }
end
love.graphics.setColor(fpsColor)
love.graphics.print(string.format("FPS: %d (%.2fms)", fm.fps, fm.lastFrameTime), x + 10, currentY)
currentY = currentY + lineHeight
love.graphics.setColor(1, 1, 1, 1)
local avgFrame = fm.frameCount > 0 and fm.totalTime / fm.frameCount or 0
love.graphics.print(string.format("Avg Frame: %.2fms", avgFrame), x + 10, currentY)
currentY = currentY + lineHeight
love.graphics.print(string.format("Min/Max: %.2f/%.2fms", fm.minFrameTime, fm.maxFrameTime), x + 10, currentY)
currentY = currentY + lineHeight
local currentMb = mm.current / 1024
local peakMb = mm.peak / 1024
love.graphics.print(string.format("Memory: %.2f MB (peak: %.2f MB)", currentMb, peakMb), x + 10, currentY)
currentY = currentY + lineHeight
local metricsCount = 0
for _ in pairs(self._metrics) do
metricsCount = metricsCount + 1
end
local metricsColor = metricsCount > MAX_METRICS_COUNT * 0.8 and { 1, 0.5, 0 } or { 1, 1, 1 }
love.graphics.setColor(metricsColor)
love.graphics.print(string.format("Metrics: %d/%d", metricsCount, MAX_METRICS_COUNT), x + 10, currentY)
currentY = currentY + lineHeight + 5
-- Top timings
love.graphics.setColor(1, 1, 1, 1)
local sortedMetrics = {}
for name, data in pairs(self._metrics) do
table.insert(sortedMetrics, { name = name, average = data.average })
end
table.sort(sortedMetrics, function(a, b)
return a.average > b.average
end)
love.graphics.print("Top Timings:", x + 10, currentY)
currentY = currentY + lineHeight
for i = 1, math.min(5, #sortedMetrics) do
local m = sortedMetrics[i]
love.graphics.print(string.format(" %s: %.3fms", m.name, m.average), x + 10, currentY)
currentY = currentY + lineHeight
end
if #self._warnings > 0 then
love.graphics.setColor(1, 0.5, 0, 1)
love.graphics.print(string.format("Warnings: %d", #self._warnings), x + 10, currentY)
end
end
--- Handle keyboard input for HUD toggle
--- @param key string Key pressed
function Performance:keypressed(key)
if key == self.hudToggleKey then
self:toggleHUD()
end
end
--- Log a performance warning (only once per warning key)
--- @param warningKey string Unique key for this warning type
--- @param module string Module name (e.g., "LayoutEngine", "Element")
--- @param message string Warning message
--- @param details table? Additional details
--- @param suggestion string? Optimization suggestion
function Performance:logWarning(warningKey, module, message, details, suggestion)
if not self.warningsEnabled then
return
end
if self._shownWarnings[warningKey] then
return
end
self._shownWarnings[warningKey] = true
local count = 0
for _ in pairs(self._shownWarnings) do
count = count + 1
end
if count > 1000 then
self._shownWarnings = { [warningKey] = true }
end
if self._ErrorHandler and self._ErrorHandler.warn then
self._ErrorHandler:warn(module, "PERF_001", details or {})
end
end
--- Track a counter metric (increments per frame)
--- @param name string Counter name
--- @param value number? Value to add (default: 1)
function Performance:incrementCounter(name, value)
if not self.enabled then
return
end
value = value or 1
if not self._metrics[name] then
self._metrics[name] = {
total = 0,
count = 0,
min = math.huge,
max = 0,
average = 0,
frameValue = 0,
lastUsed = love.timer.getTime(),
}
end
local m = self._metrics[name]
m.frameValue = (m.frameValue or 0) + value
m.lastUsed = love.timer.getTime()
end
--- Reset frame counters (call at end of frame)
function Performance:resetFrameCounters()
if not self.enabled then
return
end
local now = love.timer.getTime()
local toRemove = {}
for name, data in pairs(self._metrics) do
if data.frameValue then
if data.frameValue > 0 then
data.total = data.total + data.frameValue
data.count = data.count + 1
data.min = math.min(data.min, data.frameValue)
data.max = math.max(data.max, data.frameValue)
data.average = data.total / data.count
data.lastUsed = now
end
data.frameValue = 0
if data.count == 0 and not CORE_METRICS[name] then
table.insert(toRemove, name)
end
end
end
for _, name in ipairs(toRemove) do
self._metrics[name] = nil
end
end
--- Register a table for memory leak monitoring
--- @param name string Friendly name for the table
--- @param tableRef table Reference to the table to monitor
function Performance:registerTableForMonitoring(name, tableRef)
self._memoryProfiler.monitoredTables[name] = tableRef
end
function Performance:_sampleMemory()
local sample = {
time = love.timer.getTime(),
memory = collectgarbage("count") / 1024, -- MB
tableSizes = {},
}
local function getTableSize(tbl)
local count = 0
for _ in pairs(tbl) do
count = count + 1
end
return count
end
for name, tableRef in pairs(self._memoryProfiler.monitoredTables) do
sample.tableSizes[name] = getTableSize(tableRef)
end
table.insert(self._memoryProfiler.samples, sample)
-- Keep only maxSamples
if #self._memoryProfiler.samples > self._memoryProfiler.maxSamples then
table.remove(self._memoryProfiler.samples, 1)
end
-- Check for memory leaks (consistent growth)
if #self._memoryProfiler.samples >= 5 then
for name, _ in pairs(self._memoryProfiler.monitoredTables) do
local sizes = {}
for i = math.max(1, #self._memoryProfiler.samples - 4), #self._memoryProfiler.samples do
table.insert(sizes, self._memoryProfiler.samples[i].tableSizes[name])
end
-- Check if table is consistently growing
local growing = true
for i = 2, #sizes do
if sizes[i] <= sizes[i - 1] then
growing = false
break
end
end
if growing and sizes[#sizes] > sizes[1] * 1.5 then
self:_addWarning("memory_leak", sizes[#sizes], "warning")
if not self._shownWarnings[name] then
local message = string.format("Table '%s' growing consistently", name)
if self._ErrorHandler and self._ErrorHandler.warn then
self._ErrorHandler:warn("Performance", "MEM_001", {
table = name,
initialSize = sizes[1],
currentSize = sizes[#sizes],
growthPercent = math.floor(((sizes[#sizes] / sizes[1]) - 1) * 100),
})
end
self._shownWarnings[name] = true
end
elseif not growing then
self._shownWarnings[name] = nil
end
end
end
end
--- Update memory profiling (call from endFrame)
function Performance:updateMemoryProfiling()
if not self._memoryProfiler.enabled then
return
end
self._memoryProfiler.framesSinceLastSample = self._memoryProfiler.framesSinceLastSample + 1
if self._memoryProfiler.framesSinceLastSample >= self._memoryProfiler.sampleInterval then
self:_sampleMemory()
self._memoryProfiler.framesSinceLastSample = 0
end
end
return Performance
-505
View File
@@ -1,505 +0,0 @@
-- modules/PropertySchema.lua
--
-- Declarative source of truth for every Element prop.
--
-- Each entry describes one prop that Element.new / Element:setProperty currently
-- handles inline. Downstream tasks (03 data-driven prop binding, 05 registry-driven
-- setProperty dispatch) read this metadata instead of hardcoding property names.
--
-- Design constraints (locked — tasks 03/05 depend on this API):
-- * Pure Lua — NO `love` import, NO dependency on utils/Color/Units/ErrorHandler.
-- Normalizers/validators are small, dependency-free closures so the module is
-- unit-testable standalone. Color/^/unit/enum *defaults* that require those
-- modules are left as `nil` here and applied by construction-time special
-- handlers in Task 03; only defaults expressible as literals are stored.
-- * O(1) lookup — `get(name)` is a single table index into a pre-built registry;
-- no per-call construction.
-- * Additive — `define(specs)` merges entries by name so build profiles can
-- extend/override without rebuilding the whole table.
--
-- Metadata shape per prop (all fields present, false/nil when not applicable):
-- type string — type tag for tooling ("number"|"string"|"boolean"|
-- "table"|"function"|"color"|"any")
-- default any|nil — literal default value applied when prop is absent
-- normalizer fn|nil — pure fn(value) -> value; transforms input before
-- storage (e.g. single-value padding -> 4-side table)
-- validator fn|nil — pure fn(value) -> bool; returns false for invalid
-- input (Task 03 warns + falls back on false)
-- isDimension boolean — true for width/height: setProperty routes these
-- through _resolveDimensionProperty (unit-string
-- resolution + border-box sync). Other unit-accepting
-- props (x/y/gap/padding/etc.) are resolved at
-- construction via special handlers, NOT via this flag.
-- affectsLayout boolean — true for props in the legacy setProperty
-- `layoutProperties` table; setting one invalidates
-- layout (matches baseline behavior exactly).
-- syncsTheme boolean — true for props whose setProperty path must reach
-- ThemeManager/Renderer (disabled/active/themeComponent)
-- hasDeferred boolean — true for callbacks that have an `on<Name>Deferred`
-- boolean companion prop (auto-wired by Task 03)
-- storageKey string|nil— when set, the prop is stored on the element under
-- this key instead of its own name (prop aliases, e.g.
-- isDisabled -> stored as `disabled`)
local PropertySchema = {}
---@type table<string, table>
local registry = {}
-- ---------------------------------------------------------------------------
-- Pure normalizers (small + dependency-free; hot-pathed during construction)
-- ---------------------------------------------------------------------------
--- Expand a single value to a 4-side table. Leaves tables unchanged. nil passthrough.
--- Used by padding/margin: `padding = 5` -> `{top=5,right=5,bottom=5,left=5}`.
local function expandSides(value)
if value == nil then
return nil
end
if type(value) == "table" then
return value
end
return { top = value, right = value, bottom = value, left = value }
end
--- Normalize flex direction aliases to internal enum names.
--- "row" -> "horizontal", "column" -> "vertical",
--- "row-reverse" -> "horizontal-reverse", "column-reverse" -> "vertical-reverse";
--- everything else passes through.
local function normalizeFlexDirection(value)
if value == "row" then
return "horizontal"
elseif value == "column" then
return "vertical"
elseif value == "row-reverse" then
return "horizontal-reverse"
elseif value == "column-reverse" then
return "vertical-reverse"
end
return value
end
--- Replicate Element.new's border-shape normalization (pure).
--- * table with sides: true -> 1, number -> value, false/nil -> false; nil if no
--- truthy side remains.
--- * number / other truthy scalar: kept as-is.
--- * nil / false: nil.
local function normalizeBorder(value)
if value == nil or value == false then
return nil
end
if type(value) == "table" then
local function side(v)
if v == true then
return 1
elseif type(v) == "number" then
return v
else
return false
end
end
local t = side(value.top)
local r = side(value.right)
local b = side(value.bottom)
local l = side(value.left)
if not (t or r or b or l) then
return nil
end
return { top = t, right = r, bottom = b, left = l }
end
return value
end
--- Replicate Element.new's cornerRadius-shape normalization (pure).
--- * number: 0 -> nil, else the number.
--- * table: nil if all four sides are zero/absent, else fill zeros for absent sides.
--- * nil -> nil.
local function normalizeCornerRadius(value)
if value == nil then
return nil
end
if type(value) == "number" then
if value == 0 then
return nil
end
return value
end
if type(value) == "table" then
-- Mirrors Element.new: `or` truthiness (0 is truthy in Lua). Only an all-
-- nil/false table collapses to nil; any present side — including 0 — yields
-- the 4-side table with zero-filled absent sides.
local hasAny = value.topLeft or value.topRight or value.bottomLeft or value.bottomRight
if not hasAny then
return nil
end
return {
topLeft = value.topLeft or 0,
topRight = value.topRight or 0,
bottomLeft = value.bottomLeft or 0,
bottomRight = value.bottomRight or 0,
}
end
return value
end
-- ---------------------------------------------------------------------------
-- Pure validators (dependency-free; return boolean)
-- ---------------------------------------------------------------------------
--- Range validator factory: returns fn(v) -> bool. nil is treated as valid
--- (absence handling is the default mechanism's job).
local function rangeValidator(min, max)
return function(v)
if v == nil then
return true
end
return type(v) == "number" and v >= min and v <= max
end
end
--- Enum validator factory: returns fn(v) -> bool for membership in `set` (set may
--- be an array or a map of value->truthy).
local function enumValidator(set)
local lookup = {}
if type(set) == "table" then
for k, v in pairs(set) do
if type(k) == "number" then
lookup[v] = true
else
lookup[k] = true
end
end
end
return function(v)
if v == nil then
return true
end
return lookup[v] == true
end
end
--- Boolean validator: nil is valid (absence); otherwise must be a boolean.
local function booleanValidator(v)
return v == nil or type(v) == "boolean"
end
-- ---------------------------------------------------------------------------
-- Registry construction
-- ---------------------------------------------------------------------------
--- Build a fully-populated metadata entry, filling omitted fields with defaults.
local function entry(spec)
return {
type = spec.type or "any",
default = spec.default,
normalizer = spec.normalizer,
validator = spec.validator,
isDimension = spec.isDimension == true,
affectsLayout = spec.affectsLayout == true,
syncsTheme = spec.syncsTheme == true,
hasDeferred = spec.hasDeferred == true,
storageKey = spec.storageKey,
}
end
--- Merge prop specs into the registry (additive; later entries override earlier).
---@param specs table<string, table> map of prop-name -> spec
---@return table registry the live registry table (for chaining/inspection)
function PropertySchema.define(specs)
for name, spec in pairs(specs) do
registry[name] = entry(spec)
end
return registry
end
--- O(1) metadata lookup.
---@param name string prop name
---@return table|nil metadata nil for unknown props (no error)
function PropertySchema.get(name)
return registry[name]
end
--- Return the live registry (for inspection / coverage assertions only — not for
--- per-call construction).
---@return table
function PropertySchema.all()
return registry
end
--- True if a prop is registered.
---@param name string
---@return boolean
function PropertySchema.has(name)
return registry[name] ~= nil
end
--- True if setting this prop invalidates layout (legacy `layoutProperties` set).
--- O(1) registry lookup — no per-call table construction. Unknown props return false,
--- matching the legacy `layoutProperties[name]` nil-lookup behavior exactly.
---@param name string prop name
---@return boolean
function PropertySchema.affectsLayout(name)
local meta = registry[name]
return meta ~= nil and meta.affectsLayout == true
end
--- True for dimension props (width/height) that `setProperty` routes through
--- `_resolveDimensionProperty` (unit-string resolution + border-box sync).
--- O(1) registry lookup — no per-call table construction. Unknown props return false,
--- matching the legacy `dimensionProperties[name]` nil-lookup behavior exactly.
---@param name string prop name
---@return boolean
function PropertySchema.isDimension(name)
local meta = registry[name]
return meta ~= nil and meta.isDimension == true
end
--- True for props whose setProperty path must reach ThemeManager/Renderer
--- (disabled/active/themeComponent). O(1) registry lookup — no per-call table
--- construction. Unknown props return false, matching a legacy nil-lookup exactly.
---@param name string prop name
---@return boolean
function PropertySchema.syncsTheme(name)
local meta = registry[name]
return meta ~= nil and meta.syncsTheme == true
end
-- ---------------------------------------------------------------------------
-- Default schema (covers every prop handled in Element.new lines 259-1909 and
-- Element:setProperty lines 4291-4417 of the Task-01 baseline).
-- ---------------------------------------------------------------------------
local function defineDefaults()
PropertySchema.define({
-- ------------------------------------------------------------------ identity
id = { type = "string" },
userdata = { type = "any" },
parent = { type = "table", affectsLayout = true },
children = { type = "table" },
-- ------------------------------------------------------------------ callbacks
onEvent = { type = "function", hasDeferred = true },
onFocus = { type = "function", hasDeferred = true },
onBlur = { type = "function", hasDeferred = true },
onTextInput = { type = "function", hasDeferred = true },
onTextChange = { type = "function", hasDeferred = true },
onEnter = { type = "function", hasDeferred = true },
onCreate = { type = "function", hasDeferred = true },
onTouchEvent = { type = "function", hasDeferred = true },
onGesture = { type = "function", hasDeferred = true },
onImageLoad = { type = "function", hasDeferred = true },
onImageError = { type = "function", hasDeferred = true },
-- Deferred companion flags (stored directly; no further Deferred companion)
onEventDeferred = { type = "boolean", default = false },
onFocusDeferred = { type = "boolean", default = false },
onBlurDeferred = { type = "boolean", default = false },
onTextInputDeferred = { type = "boolean", default = false },
onTextChangeDeferred = { type = "boolean", default = false },
onEnterDeferred = { type = "boolean", default = false },
onCreateDeferred = { type = "boolean", default = false },
onTouchEventDeferred = { type = "boolean", default = false },
onGestureDeferred = { type = "boolean", default = false },
onImageLoadDeferred = { type = "boolean", default = false },
onImageErrorDeferred = { type = "boolean", default = false },
-- focus / touch behavior
dropFocusOnSelection = { type = "boolean" },
customDraw = { type = "function" },
touchEnabled = { type = "boolean", default = true },
multiTouchEnabled = { type = "boolean", default = false },
-- ------------------------------------------------------------------ theme
theme = { type = "table" },
themeComponent = { type = "string", syncsTheme = true },
disabled = { type = "boolean", default = false, syncsTheme = true },
isDisabled = {
type = "boolean",
default = false,
syncsTheme = true,
storageKey = "disabled",
},
active = { type = "boolean", default = false, syncsTheme = true },
disableHighlight = { type = "boolean" },
themeStateLock = { type = "boolean" },
themeComponentDisabledStates = { type = "table" },
scaleCorners = { type = "boolean" },
scalingAlgorithm = { type = "string" },
contentAutoSizingMultiplier = { type = "table" },
contentBlur = { type = "table" },
backdropBlur = { type = "table" },
-- ------------------------------------------------------------------ text editing
editable = { type = "boolean", default = false },
multiline = { type = "boolean", default = false },
passwordMode = { type = "boolean", default = false },
textWrap = { type = "string" }, -- default computed from multiline
maxLines = { type = "number" },
maxLength = { type = "number" },
placeholder = { type = "string" },
inputType = { type = "string", default = "text" },
textOverflow = { type = "string", default = "clip" },
scrollable = { type = "boolean" }, -- default = multiline
autoGrow = { type = "boolean" }, -- default = multiline
selectOnFocus = { type = "boolean", default = false },
cursorColor = { type = "color" },
selectionColor = { type = "color" },
cursorBlinkRate = { type = "number", default = 0.5 },
text = { type = "string" },
textAlign = {
type = "string",
default = "start",
validator = enumValidator({ "start", "center", "end", "justify" }),
},
-- textAlignVertical is a derived storage field split out from textAlign
-- (bindVisualState resolves table/compound-string input into H + V). Its
-- validator is exposed for bindVisualState to validate the V component; the
-- prop itself stays in SPECIAL_PROPS because compound parsing needs
-- ErrorHandler warnings (schema is pure-Lua, cannot warn).
textAlignVertical = {
type = "string",
default = "start",
validator = enumValidator({ "start", "center", "end" }),
},
textColor = { type = "color" },
fontFamily = { type = "string" },
textSize = { type = "any" }, -- number | preset string; resolved by special handler
minTextSize = { type = "number" },
maxTextSize = { type = "number" },
autoScaleText = { type = "boolean", default = true },
-- ------------------------------------------------------------------ dimensions / box model
width = { type = "any", isDimension = true, affectsLayout = true },
height = { type = "any", isDimension = true, affectsLayout = true },
x = { type = "any", affectsLayout = false },
y = { type = "any", affectsLayout = false },
minWidth = { type = "any" },
maxWidth = { type = "any" },
minHeight = { type = "any" },
maxHeight = { type = "any" },
gap = { type = "any", affectsLayout = true },
padding = {
type = "any",
affectsLayout = true,
normalizer = expandSides,
},
margin = {
type = "any",
affectsLayout = true,
normalizer = expandSides,
},
flexDirection = {
type = "string",
default = "horizontal",
affectsLayout = true,
normalizer = normalizeFlexDirection,
},
flexWrap = { type = "string", default = "nowrap", affectsLayout = true },
justifyContent = { type = "string", default = "flex-start", affectsLayout = true },
alignItems = { type = "string", default = "stretch", affectsLayout = true },
alignContent = { type = "string", default = "stretch", affectsLayout = true },
positioning = { type = "string", default = "relative", affectsLayout = true },
gridRows = { type = "number", affectsLayout = true },
gridColumns = { type = "number", affectsLayout = true },
top = { type = "any", affectsLayout = true },
right = { type = "any", affectsLayout = true },
bottom = { type = "any", affectsLayout = true },
left = { type = "any", affectsLayout = true },
columnGap = { type = "any" },
rowGap = { type = "any" },
flex = { type = "any" }, -- shorthand: expands to flexGrow/flexShrink/flexBasis
flexGrow = { type = "number", default = 0, validator = rangeValidator(0, math.huge) },
flexShrink = { type = "number", default = 1, validator = rangeValidator(0, math.huge) },
flexBasis = { type = "any", default = "auto" },
alignSelf = { type = "string", default = "auto" },
justifySelf = { type = "string" },
z = { type = "number", default = 0 },
tabIndex = { type = "number" },
-- ------------------------------------------------------------------ border / background / visual
border = { type = "any", normalizer = normalizeBorder },
borderColor = { type = "color" }, -- default Color.new(0,0,0,1) via special handler
backgroundColor = { type = "color" }, -- default transparent via special handler
opacity = {
type = "number",
default = 1,
validator = rangeValidator(0, 1),
},
visibility = { type = "string", default = "visible" },
display = {
type = "boolean",
default = true,
validator = booleanValidator,
},
transform = { type = "table" },
cornerRadius = { type = "any", normalizer = normalizeCornerRadius },
-- ------------------------------------------------------------------ image
imagePath = { type = "string" },
image = { type = "table" },
objectFit = {
type = "string",
default = "fill",
validator = enumValidator({ "fill", "contain", "cover", "scale-down", "none" }),
},
objectPosition = { type = "string", default = "center center" },
imageOpacity = {
type = "number",
default = 1,
validator = rangeValidator(0, 1),
},
imageRepeat = {
type = "string",
default = "no-repeat",
validator = enumValidator({
"no-repeat",
"repeat",
"repeat-x",
"repeat-y",
"space",
"round",
}),
},
imageTint = { type = "color" },
-- ------------------------------------------------------------------ scroll / scrollbar
overflow = { type = "string" },
overflowX = { type = "string" },
overflowY = { type = "string" },
scrollbarWidth = { type = "number" },
scrollbarColor = { type = "color" },
scrollbarTrackColor = { type = "color" },
scrollbarRadius = { type = "number" },
scrollbarPadding = { type = "number" },
scrollSpeed = { type = "number" },
invertScroll = { type = "boolean" },
smoothScrollEnabled = { type = "boolean" },
scrollBarStyle = { type = "string" },
scrollbarKnobOffset = { type = "number" },
hideScrollbars = { type = "boolean" },
scrollbarPlacement = { type = "string" },
scrollbarBalance = { type = "number" },
_scrollX = { type = "number", storageKey = "_scrollX" },
_scrollY = { type = "number", storageKey = "_scrollY" },
-- ------------------------------------------------------------------ select
selectParent = { type = "table" },
selectOption = { type = "table" },
-- ------------------------------------------------------------------ transition
transition = { type = "table", default = {} },
})
end
--- (Re)populate the default schema. Idempotent: safe to call from Element.init
--- for build profiles that re-require the module. Returns the live registry.
---@return table registry
function PropertySchema.populate()
defineDefaults()
return registry
end
-- Auto-populate on require so the registry is ready without an explicit init call
-- (pure module, no external deps — safe at load time).
PropertySchema.populate()
return PropertySchema
File diff suppressed because it is too large Load Diff
-124
View File
@@ -1,124 +0,0 @@
local RoundedRect = {}
--- Generate points for a rounded rectangle
---@param x number
---@param y number
---@param width number
---@param height number
---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number
---@param segments number? -- Number of segments per corner arc (default: 10)
---@return table -- Array of vertices for love.graphics.polygon
function RoundedRect.getPoints(x, y, width, height, cornerRadius, segments)
segments = segments or 10
local points = {}
-- Helper to add arc points
local function addArc(cx, cy, radius, startAngle, endAngle)
if radius <= 0 then
table.insert(points, cx)
table.insert(points, cy)
return
end
for i = 0, segments do
local angle = startAngle + (endAngle - startAngle) * (i / segments)
table.insert(points, cx + math.cos(angle) * radius)
table.insert(points, cy + math.sin(angle) * radius)
end
end
-- Handle uniform corner radius (number)
if type(cornerRadius) == "number" then
cornerRadius = {
topLeft = cornerRadius,
topRight = cornerRadius,
bottomLeft = cornerRadius,
bottomRight = cornerRadius,
}
end
local r1 = math.min(cornerRadius.topLeft, width / 2, height / 2)
local r2 = math.min(cornerRadius.topRight, width / 2, height / 2)
local r3 = math.min(cornerRadius.bottomRight, width / 2, height / 2)
local r4 = math.min(cornerRadius.bottomLeft, width / 2, height / 2)
-- Top-right corner
addArc(x + width - r2, y + r2, r2, -math.pi / 2, 0)
-- Bottom-right corner
addArc(x + width - r3, y + height - r3, r3, 0, math.pi / 2)
-- Bottom-left corner
addArc(x + r4, y + height - r4, r4, math.pi / 2, math.pi)
-- Top-left corner
addArc(x + r1, y + r1, r1, math.pi, math.pi * 1.5)
return points
end
--- Draw a filled rounded rectangle
---@param mode string -- "fill" or "line"
---@param x number
---@param y number
---@param width number
---@param height number
---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number|nil
function RoundedRect.draw(mode, x, y, width, height, cornerRadius)
-- OPTIMIZATION: Handle nil cornerRadius (no rounding)
if not cornerRadius then
love.graphics.rectangle(mode, x, y, width, height)
return
end
-- Handle uniform corner radius (number)
if type(cornerRadius) == "number" then
if cornerRadius <= 0 then
love.graphics.rectangle(mode, x, y, width, height)
return
end
-- Convert to table format for processing
cornerRadius = {
topLeft = cornerRadius,
topRight = cornerRadius,
bottomLeft = cornerRadius,
bottomRight = cornerRadius,
}
end
-- Check if any corners are rounded
local hasRoundedCorners = cornerRadius.topLeft > 0
or cornerRadius.topRight > 0
or cornerRadius.bottomLeft > 0
or cornerRadius.bottomRight > 0
if not hasRoundedCorners then
-- No rounded corners, use regular rectangle
love.graphics.rectangle(mode, x, y, width, height)
return
end
local points = RoundedRect.getPoints(x, y, width, height, cornerRadius)
if mode == "fill" then
love.graphics.polygon("fill", points)
else
-- For line mode, draw the outline
love.graphics.polygon("line", points)
end
end
--- Create a stencil function for rounded rectangle clipping
---@param x number
---@param y number
---@param width number
---@param height number
---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number|nil
---@return function
function RoundedRect.stencilFunction(x, y, width, height, cornerRadius)
return function()
RoundedRect.draw("fill", x, y, width, height, cornerRadius)
end
end
return RoundedRect
File diff suppressed because it is too large Load Diff
-719
View File
@@ -1,719 +0,0 @@
---@class Select
local Select = {}
---Initialize Select module with required dependencies
---@param deps table
function Select.init(deps)
Select._ErrorHandler = deps.ErrorHandler
Select._Context = deps.Context
Select._StateManager = deps.StateManager
Select._utils = deps.utils
Select._Element = deps.Element
end
---Initialize selectParent state on an element
---@param element Element
---@param selectParentConfig table
function Select.initSelectParent(element, selectParentConfig)
element._selectState = {
value = selectParentConfig.value,
open = selectParentConfig.open or false,
placeholder = selectParentConfig.placeholder,
selectFrame = nil,
selectAnchor = nil,
onChange = selectParentConfig.onChange,
options = {},
optionLookup = {},
expectedFrameParent = nil,
frameAdopted = false,
}
-- Restore select state from StateManager. Mode-aware via
-- Context.isImmediateMode (behavior-mode-unification task 11).
if Select._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then
local state = Select._StateManager.getState(element._stateId)
if state and state._selectOpen ~= nil then
element._selectState.open = state._selectOpen
end
if state and state._selectValue ~= nil then
element._selectState.value = state._selectValue
if element.selectParent then
element.selectParent.value = state._selectValue
end
end
if state and state._selectSelectedLabel ~= nil then
element._selectState.selectedLabel = state._selectSelectedLabel
end
end
end
---Initialize selectOption on an element
---@param element Element
---@param selectOptionConfig table
function Select.initSelectOption(element, selectOptionConfig)
element.selectOption = {
value = selectOptionConfig.value,
label = selectOptionConfig.label or element.text,
disabled = selectOptionConfig.disabled or false,
}
end
---@param selectParent Element
function Select.rebuildOptionLookup(selectParent)
if not selectParent or not selectParent._selectState then
return
end
selectParent._selectState.optionLookup = {}
for _, optionElement in ipairs(selectParent._selectState.options) do
if optionElement and optionElement.selectOption then
selectParent._selectState.optionLookup[optionElement.selectOption.value] = optionElement
end
end
end
---@param selectParent Element
function Select.syncOptionStates(selectParent)
if not selectParent or not selectParent._selectState then
return
end
local selectedOption = nil
local selectedLabel = selectParent._selectState.selectedLabel
for _, optionElement in ipairs(selectParent._selectState.options) do
local isSelected = optionElement.selectOption
and optionElement.selectOption.value == selectParent._selectState.value
optionElement._selectSelected = isSelected
optionElement.ariaChecked = isSelected
if isSelected then
selectedOption = optionElement
selectedLabel = optionElement.selectOption.label or optionElement.text
end
end
selectParent._selectState.selectedOption = selectedOption
selectParent._selectState.selectedLabel = selectedLabel
end
---@param element Element
function Select.resetOptions(element)
if not element._selectState then
return
end
element._selectState.options = {}
element._selectState.optionLookup = {}
element._selectState.selectedOption = nil
end
---@param frame any
---@return boolean
function Select.isValidSelectFrame(frame)
local Element = Select._Element
return type(frame) == "table" and getmetatable(frame) == Element
end
---@param element Element
---@param code string
---@param details table?
function Select.warnSelectFrame(element, code, details)
Select._ErrorHandler:warn("Element", code, details or { element = element.id })
end
---@param element Element
---@param frame Element
function Select.trackManagedFrame(element, frame)
element._selectState.selectFrame = frame
local expectedParent = element._selectState.selectAnchor or element
element._selectState.expectedFrameParent = expectedParent
element._selectState.frameAdopted = frame.parent == expectedParent
if frame._managedSelectBaseOpacity == nil then
frame._managedSelectBaseOpacity = frame.opacity
end
if frame._managedSelectBaseVisibility == nil then
frame._managedSelectBaseVisibility = frame.visibility or "visible"
end
if frame._managedSelectBaseDisabled == nil then
frame._managedSelectBaseDisabled = frame.disabled or false
end
frame._managedSelectOwner = element
frame._managedSelectFrame = true
end
---@param element Element
---@return Element
function Select.getOrCreateManagedAnchor(element)
if element._selectState.selectAnchor then
return element._selectState.selectAnchor
end
local Element = Select._Element
local anchor = Element.new({
id = string.format("%s__select_anchor", element.id or "select"),
parent = element,
positioning = Select._utils.enums.Positioning.ABSOLUTE,
left = 0,
top = element:getBorderBoxHeight(),
width = element:getBorderBoxWidth(),
opacity = 1,
visibility = "hidden",
disabled = true,
})
anchor._managedSelectAnchor = true
anchor._managedSelectOwner = element
element._selectState.selectAnchor = anchor
return anchor
end
---@param element Element
---@param frame Element
function Select.applyManagedFrameLayout(element, frame)
local anchor = Select.getOrCreateManagedAnchor(element)
local triggerBorderBoxWidth = element:getBorderBoxWidth()
anchor.left = 0
anchor.top = element:getBorderBoxHeight()
anchor.width = triggerBorderBoxWidth
anchor.units.left = { value = 0, unit = "px" }
anchor.units.top = { value = element:getBorderBoxHeight(), unit = "px" }
anchor.units.width = { value = triggerBorderBoxWidth, unit = "px" }
frame._managedSelectMinimumBorderBoxWidth = triggerBorderBoxWidth
frame.positioning = frame.positioning or Select._utils.enums.Positioning.RELATIVE
frame._explicitlyAbsolute = false
frame.left = nil
frame.top = nil
frame.right = nil
frame.bottom = nil
if frame.parent ~= anchor then
frame:setParent(anchor)
end
if frame.autosizing and frame.autosizing.width then
local contentWidth = frame:calculateAutoWidth()
frame._borderBoxWidth = contentWidth + frame.padding.left + frame.padding.right
frame.width = contentWidth
end
if frame.parent == anchor then
anchor.width = math.max(triggerBorderBoxWidth, frame:getBorderBoxWidth())
anchor.units.width = { value = anchor.width, unit = "px" }
end
element._selectState.expectedFrameParent = anchor
element._selectState.frameAdopted = frame.parent == anchor
end
---@param element Element
---@param frame Element
function Select.adoptSelectFrame(element, frame)
if not element._selectState then
return
end
if not Select.isValidSelectFrame(frame) then
Select.warnSelectFrame(element, "ELEM_007", {
element = element.id,
property = "selectParent.selectFrame",
got = type(frame),
})
return
end
if frame == element then
Select.warnSelectFrame(element, "ELEM_007", {
element = element.id,
property = "selectParent.selectFrame",
reason = "select cannot use itself as its managed frame",
})
return
end
local anchor = Select.getOrCreateManagedAnchor(element)
if frame.parent and frame.parent ~= element and frame.parent ~= anchor then
Select.warnSelectFrame(element, "ELEM_008", {
element = element.id,
frame = frame.id,
parent = frame.parent.id,
})
end
Select.trackManagedFrame(element, frame)
Select.applyManagedFrameLayout(element, frame)
Select.syncManagedFrameVisibility(element)
-- Layout is deferred to endFrame in immediate mode. shouldLayout()
-- encapsulates the mode check (behavior-mode-unification task 11).
if Select._StateManager.shouldLayout() then
anchor:layoutChildren()
element:layoutChildren()
end
local pendingOptions = {}
for _, child in ipairs(element.children) do
if child ~= frame and child.selectOption then
table.insert(pendingOptions, child)
end
end
for _, option in ipairs(pendingOptions) do
Select.attachOptionToManagedFrame(option)
end
end
---@param element Element
function Select.ensureFrameState(element)
if not element._selectState or not element._selectState.selectFrame then
return
end
local frame = element._selectState.selectFrame
local anchor = element._selectState.selectAnchor
if anchor then
local triggerBorderBoxWidth = element:getBorderBoxWidth()
anchor.left = 0
anchor.top = element:getBorderBoxHeight()
anchor.width = triggerBorderBoxWidth
anchor.units.left = { value = 0, unit = "px" }
anchor.units.top = { value = element:getBorderBoxHeight(), unit = "px" }
anchor.units.width = { value = triggerBorderBoxWidth, unit = "px" }
frame._managedSelectMinimumBorderBoxWidth = triggerBorderBoxWidth
if frame.autosizing and frame.autosizing.width then
local contentWidth = frame:calculateAutoWidth()
frame._borderBoxWidth = contentWidth + frame.padding.left + frame.padding.right
frame.width = contentWidth
end
if frame.parent == anchor then
anchor.width = math.max(triggerBorderBoxWidth, frame:getBorderBoxWidth())
anchor.units.width = { value = anchor.width, unit = "px" }
end
if frame.parent == anchor then
anchor:layoutChildren()
end
elseif frame.parent == element then
Select.applyManagedFrameLayout(element, frame)
end
local expectedParent = anchor or element._selectState.expectedFrameParent
if frame.parent ~= expectedParent then
Select.warnSelectFrame(element, "ELEM_009", {
element = element.id,
frame = frame.id,
expectedParent = expectedParent and expectedParent.id or nil,
actualParent = frame.parent and frame.parent.id or nil,
})
element._selectState.expectedFrameParent = frame.parent
element._selectState.frameAdopted = frame.parent == expectedParent
end
end
---@param element Element
function Select.syncManagedFrameVisibility(element)
if not element._selectState or not element._selectState.selectFrame then
return
end
local frame = element._selectState.selectFrame
local anchor = element._selectState.selectAnchor
local isOpen = element._selectState.open == true
frame.visibility = isOpen and (frame._managedSelectBaseVisibility or "visible") or "hidden"
frame.opacity = frame._managedSelectBaseOpacity or 1
if isOpen then
frame.disabled = frame._managedSelectBaseDisabled == true
else
frame.disabled = true
end
if anchor then
anchor.visibility = isOpen and "visible" or "hidden"
anchor.opacity = 1
anchor.disabled = not isOpen
end
end
---@param element Element
---@return Element?
function Select.findOwningSelectParent(element)
if element._selectParentHint and element._selectParentHint._selectState then
return element._selectParentHint
end
local current = element.parent
while current do
if current._selectState then
return current
end
current = current.parent
end
return nil
end
---@param element Element
function Select.registerWithSelectParent(element)
if not element.selectOption then
return
end
local selectParent = Select.findOwningSelectParent(element)
if not selectParent then
return
end
element._selectParentElement = selectParent
for _, optionElement in ipairs(selectParent._selectState.options) do
if optionElement == element then
return
end
end
table.insert(selectParent._selectState.options, element)
Select.rebuildOptionLookup(selectParent)
Select.syncOptionStates(selectParent)
end
---@param element Element
function Select.attachOptionToManagedFrame(element)
if not element.selectOption then
return
end
local selectParent = Select.findOwningSelectParent(element)
if not selectParent or not selectParent._selectState or not selectParent._selectState.selectFrame then
return
end
local selectFrame = selectParent._selectState.selectFrame
if element.parent ~= selectFrame then
element._selectParentHint = selectParent
if
element._originalPositioning == Select._utils.enums.Positioning.ABSOLUTE
and element._managedSelectOptionUsesFrameLayout == nil
then
element._managedSelectOptionUsesFrameLayout = true
element.positioning = Select._utils.enums.Positioning.RELATIVE
element._originalPositioning = nil
element._explicitlyAbsolute = false
element.left = nil
element.top = nil
element.right = nil
element.bottom = nil
end
element:setParent(selectFrame)
-- Ensure frame geometry eagerly only in retained mode; deferred to the
-- per-frame update in immediate mode (behavior-mode-unification task 11).
if Select._StateManager.shouldLayout() then
Select.ensureFrameState(selectParent)
end
end
end
---@param element Element
function Select.unregisterFromSelectParent(element)
if not element.selectOption or not element._selectParentElement or not element._selectParentElement._selectState then
element._selectParentElement = nil
return
end
local selectParent = element._selectParentElement
for index, optionElement in ipairs(selectParent._selectState.options) do
if optionElement == element then
table.remove(selectParent._selectState.options, index)
break
end
end
Select.rebuildOptionLookup(selectParent)
Select.syncOptionStates(selectParent)
element._selectParentElement = nil
end
---@param element Element
function Select.saveStateToStateManager(element)
if not element._selectState then
return
end
if element._stateId and Select._Context.isImmediateMode() and element._stateId ~= "" then
Select._StateManager.updateState(element._stateId, {
_selectOpen = element._selectState.open,
_selectValue = element._selectState.value,
_selectSelectedLabel = element._selectState.selectedLabel,
})
end
end
---@param element Element
function Select.openSelect(element)
if not element._selectState then
return
end
Select.ensureFrameState(element)
element._selectState.open = true
element.ariaExpanded = true
if element.selectParent then
element.selectParent.open = true
end
Select.syncManagedFrameVisibility(element)
Select.saveStateToStateManager(element)
end
---@param element Element
function Select.closeSelect(element)
if not element._selectState then
return
end
Select.ensureFrameState(element)
element._selectState.open = false
element.ariaExpanded = false
if element.selectParent then
element.selectParent.open = false
end
Select.syncManagedFrameVisibility(element)
Select.saveStateToStateManager(element)
end
---@param element Element
function Select.toggleSelect(element)
if not element._selectState then
return
end
if element.disabled then
return
end
if element._selectState.open then
Select.closeSelect(element)
else
Select.openSelect(element)
end
if element.onEvent then
element.onEvent(element, { type = "selecttoggle", open = element._selectState.open })
end
end
---@param element Element
---@return boolean
function Select.isSelectOpen(element)
return element._selectState ~= nil and element._selectState.open == true
end
---@param element Element
---@return any
function Select.getSelectValue(element)
if not element._selectState then
return nil
end
return element._selectState.value
end
---@param element Element
---@return string?
function Select.getSelectLabel(element)
if not element._selectState then
return nil
end
local selectedOption = element._selectState.selectedOption
or element._selectState.optionLookup[element._selectState.value]
if selectedOption and selectedOption.selectOption then
return selectedOption.selectOption.label or selectedOption.text
end
return element._selectState.selectedLabel or element._selectState.placeholder
end
---@param element Element
---@return boolean
function Select.isSelectedOption(element)
if not element.selectOption or not element._selectParentElement or not element._selectParentElement._selectState then
return false
end
return element._selectParentElement._selectState.value == element.selectOption.value
end
---@param element Element
---@param value any
---@param optionElement Element?
function Select.setSelectValue(element, value, optionElement)
if not element._selectState then
return
end
if element.disabled then
return
end
local didChange = element._selectState.value ~= value
element._selectState.value = value
if element.selectParent then
element.selectParent.value = value
end
if optionElement and optionElement.selectOption then
element._selectState.selectedLabel = optionElement.selectOption.label or optionElement.text
end
Select.syncOptionStates(element)
Select.closeSelect(element)
Select.saveStateToStateManager(element)
if element.onEvent then
element.onEvent(element, { type = "selectchange", value = value, option = optionElement })
end
if didChange and element._selectState.onChange then
element._selectState.onChange(element, value, optionElement and optionElement.selectOption or nil)
end
end
---@param element Element
function Select.handleRelease(element)
if element.disabled then
return
end
if element.selectOption then
local selectParent = element._selectParentElement or Select.findOwningSelectParent(element)
if not selectParent then
return
end
if element.selectOption.disabled then
Select.closeSelect(selectParent)
return
end
Select.setSelectValue(selectParent, element.selectOption.value, element)
return
end
if element._selectState then
Select.toggleSelect(element)
end
end
---Save select state for state persistence (called from Element:saveState)
---@param element Element
---@return table?
function Select.saveState(element)
if not element._selectState then
return nil
end
return {
value = element._selectState.value,
open = element._selectState.open,
selectedLabel = element._selectState.selectedLabel,
}
end
---Restore select state (called from Element:restoreState)
---@param element Element
---@param state table
function Select.restoreState(element, state)
if not element._selectState or not state then
return
end
element._selectState.value = state.value
element._selectState.open = state.open or false
element._selectState.selectedLabel = state.selectedLabel
if element.selectParent then
element.selectParent.value = state.value
element.selectParent.open = state.open or false
end
element.ariaExpanded = element._selectState.open
Select.syncOptionStates(element)
end
---Clean up select-related resources (called from Element:destroy)
---@param element Element
function Select.cleanupDestroy(element)
if element._selectState then
local frame = element._selectState.selectFrame
local anchor = element._selectState.selectAnchor
if frame then
frame._managedSelectOwner = nil
frame._managedSelectFrame = nil
frame._managedSelectBaseOpacity = nil
frame._managedSelectBaseVisibility = nil
frame._managedSelectBaseDisabled = nil
end
if anchor then
anchor._managedSelectOwner = nil
anchor._managedSelectAnchor = nil
end
element._selectState = nil
end
if element._managedSelectFrame and element._managedSelectOwner then
if element._managedSelectOwner._selectState then
element._managedSelectOwner._selectState.selectFrame = nil
element._managedSelectOwner._selectState.expectedFrameParent = nil
element._managedSelectOwner._selectState.frameAdopted = false
end
element._managedSelectOwner = nil
element._managedSelectFrame = nil
element._managedSelectBaseOpacity = nil
element._managedSelectBaseVisibility = nil
element._managedSelectBaseDisabled = nil
end
if element._managedSelectAnchor and element._managedSelectOwner then
if element._managedSelectOwner._selectState then
element._managedSelectOwner._selectState.selectAnchor = nil
end
element._managedSelectOwner = nil
element._managedSelectAnchor = nil
end
if element.selectParent then
element.selectParent.onChange = nil
end
end
--- Called when a select parent removes a child: clears frame/anchor refs if the removed child was the
--- select-managed frame or anchor. Keeps select state-mutation logic owned by the Select module.
---@param element Element The select parent whose child was removed.
---@param child Element The removed child.
function Select.handleChildRemoved(element, child)
if not element._selectState then
return
end
if element._selectState.selectFrame == child then
element._selectState.selectFrame = nil
element._selectState.expectedFrameParent = nil
element._selectState.frameAdopted = false
end
if element._selectState.selectAnchor == child then
element._selectState.selectAnchor = nil
end
end
--- Layout-path hook: adjust an auto-width child's border-box width for a managed-select frame.
--- Invoked from LayoutEngine (via the Element delegate) during vertical-flex auto-width calculation.
---@param element Element The managed-select frame (the dropdown container).
---@param child Element The flex child being measured.
---@param childBorderBoxWidth number Current computed border-box width of `child`.
---@return number Possibly-adjusted border-box width.
function Select.adjustAutoWidthChild(element, child, childBorderBoxWidth)
if
element._managedSelectFrame
and element.autosizing
and element.autosizing.width
and child.units
and child.units.width
and child.units.width.unit == "%"
then
local intrinsicBorderBoxWidth = child:calculateAutoWidth() + child.padding.left + child.padding.right
return math.max(childBorderBoxWidth, intrinsicBorderBoxWidth)
end
return childBorderBoxWidth
end
return Select
-790
View File
@@ -1,790 +0,0 @@
---@class StateManager
local StateManager = {}
-- ErrorHandler will be injected via init
local ErrorHandler
-- State storage: ID -> state table
local stateStore = {}
-- Frame tracking metadata: ID -> {lastFrame, createdFrame, accessCount}
local stateMetadata = {}
-- Frame counter
local frameNumber = 0
-- Counter to track multiple elements created at the same source location (e.g., in loops)
local callSiteCounters = {}
-- Stateful element mapping: stateId -> element instance
-- Used in retained mode for cache-through: StateManager resolves id -> element -> field
local statefulElements = {}
-- Dirty state tracking for flushFrame: set of {id, key} pairs modified this frame
local dirtyState = {}
-- Immediate mode flag
local _immediateMode = false
-- Configuration
local config = {
stateRetentionFrames = 2, -- Keep unused state for 2 frames
maxStateEntries = 1000, -- Maximum state entries before forced GC
}
-- Default state values (sparse storage - don't store these)
local stateDefaults = {
-- Interaction states
hover = false,
pressed = false,
focused = false,
disabled = false,
active = false,
-- Scrollbar states
scrollbarHoveredVertical = false,
scrollbarHoveredHorizontal = false,
scrollbarDragging = false,
hoveredScrollbar = nil,
scrollbarDragOffset = 0,
dragStartMouseX = 0,
dragStartMouseY = 0,
dragStartScrollX = 0,
dragStartScrollY = 0,
-- Scroll position
scrollX = 0,
scrollY = 0,
_scrollX = 0,
_scrollY = 0,
-- Click tracking
_clickCount = 0,
_lastClickTime = nil,
_lastClickButton = nil,
-- Internal states
_hovered = nil,
_focused = nil,
_cursorPosition = nil,
_selectionStart = nil,
_selectionEnd = nil,
_textBuffer = "",
_cursorBlinkTimer = 0,
_cursorVisible = true,
_cursorBlinkPaused = false,
_cursorBlinkPauseTimer = 0,
}
--- Check if a value equals the default for a key
---@param key string State key
---@param value any Value to check
---@return boolean isDefault True if value equals default
local function isDefaultValue(key, value)
local defaultVal = stateDefaults[key]
-- If no default defined, check for common defaults
if defaultVal == nil then
-- Empty tables are default
if type(value) == "table" and next(value) == nil then
return true
end
-- nil values are default
if value == nil then
return true
end
-- Otherwise, not a default value
return false
end
-- Compare values
if type(value) == "table" then
-- Empty tables are considered default
if next(value) == nil then
return true
end
-- For other tables, compare contents (shallow)
if type(defaultVal) ~= "table" then
return false
end
for k, v in pairs(value) do
if defaultVal[k] ~= v then
return false
end
end
return true
else
return value == defaultVal
end
end
-- ====================
-- ID Generation
-- ====================
--- Generate a hash from a table of properties
---@param props table
---@param visited table|nil Tracking table to prevent circular references
---@param depth number|nil Current recursion depth
---@return string
local function hashProps(props, visited, depth)
if not props then
return ""
end
-- Initialize visited table on first call
visited = visited or {}
depth = depth or 0
-- Limit recursion depth to prevent deep nesting issues
if depth > 3 then
return "[deep]"
end
-- Check if we've already visited this table (circular reference)
if visited[props] then
return "[circular]"
end
-- Mark this table as visited
visited[props] = true
local parts = {}
local keys = {}
-- Properties to skip (they cause issues or aren't relevant for ID generation)
local skipKeys = {
onEvent = true,
parent = true,
children = true,
onFocus = true,
onBlur = true,
onTextInput = true,
onTextChange = true,
onEnter = true,
userdata = true,
-- Dynamic input/state properties that should not affect ID stability
text = true, -- Text content changes as user types
placeholder = true, -- Placeholder text is presentational
editable = true, -- Editable state can be toggled dynamically
selectOnFocus = true, -- Input behavior flag
autoGrow = true, -- Auto-grow behavior flag
passwordMode = true, -- Password mode can be toggled
}
-- Collect and sort keys for consistent ordering
for k in pairs(props) do
if not skipKeys[k] then
table.insert(keys, k)
end
end
table.sort(keys)
-- Build hash string from sorted key-value pairs
for _, k in ipairs(keys) do
local v = props[k]
local vtype = type(v)
if vtype == "string" or vtype == "number" or vtype == "boolean" then
table.insert(parts, k .. "=" .. tostring(v))
elseif vtype == "table" then
table.insert(parts, k .. "={" .. hashProps(v, visited, depth + 1) .. "}")
end
end
return table.concat(parts, ";")
end
--- Generate a unique ID from call site and properties
---@param props table|nil Optional properties to include in ID generation
---@param parent table|nil Optional parent element for tree-based ID generation
---@return string
function StateManager.generateID(props, parent)
-- Get call stack information
local info = debug.getinfo(3, "Sl") -- Level 3: caller of Element.new -> caller of generateID
if not info then
-- Fallback to random ID if debug info unavailable
return "auto_" .. tostring(math.random(1000000, 9999999))
end
local source = info.source or "unknown"
local line = info.currentline or 0
-- Create base location key from source file and line number
local filename = source:match("([^/\\]+)$") or source -- Get filename
filename = filename:gsub("%.lua$", "") -- Remove .lua extension
local locationKey = filename .. "_L" .. line
-- If we have a parent, use tree-based ID generation for stability
if parent and parent.id and parent.id ~= "" then
-- For child elements, use call-site (file + line) like top-level elements
-- This ensures the same call site always generates the same ID, even when
-- retained children persist in parent.children array
local baseID = parent.id .. "_" .. locationKey
-- Count how many children have been created at THIS call site
local callSiteKey = parent.id .. "_" .. locationKey
callSiteCounters[callSiteKey] = (callSiteCounters[callSiteKey] or 0) + 1
local instanceNum = callSiteCounters[callSiteKey]
if instanceNum > 1 then
baseID = baseID .. "_" .. instanceNum
end
-- Add property hash if provided (for additional differentiation)
if props then
local propHash = hashProps(props)
if propHash ~= "" then
-- Use first 8 chars of a simple hash
local hash = 0
for i = 1, #propHash do
hash = (hash * 31 + string.byte(propHash, i)) % 1000000
end
baseID = baseID .. "_" .. hash
end
end
return baseID
end
-- No parent (top-level element): use call-site counter approach
-- Track how many elements have been created at this location
callSiteCounters[locationKey] = (callSiteCounters[locationKey] or 0) + 1
local instanceNum = callSiteCounters[locationKey]
local baseID = locationKey
-- Add instance number if multiple elements created at same location (e.g., in loops)
if instanceNum > 1 then
baseID = baseID .. "_" .. instanceNum
end
-- Add property hash if provided (for additional differentiation)
if props then
local propHash = hashProps(props)
if propHash ~= "" then
-- Use first 8 chars of a simple hash
local hash = 0
for i = 1, #propHash do
hash = (hash * 31 + string.byte(propHash, i)) % 1000000
end
baseID = baseID .. "_" .. hash
end
end
return baseID
end
-- ====================
-- State Management
-- ====================
--- Initialize StateManager with dependencies
---@param deps table Dependencies: { ErrorHandler = ErrorHandler }
function StateManager.init(deps)
if type(deps) == "table" then
ErrorHandler = deps.ErrorHandler
end
end
--- Get state for an element ID, creating if it doesn't exist
---@param id string Element ID
---@param defaultState table|nil Default state if creating new
---@return table state State table for the element
function StateManager.getState(id, defaultState)
if not id then
ErrorHandler:error("StateManager", "SYS_001", {
parameter = "id",
value = "nil",
})
end
-- Create state if it doesn't exist
if not stateStore[id] then
-- Start with empty state (sparse storage)
stateStore[id] = defaultState or {}
-- Create metadata
stateMetadata[id] = {
lastFrame = frameNumber,
createdFrame = frameNumber,
accessCount = 0,
}
else
-- Update metadata
local meta = stateMetadata[id]
meta.lastFrame = frameNumber
meta.accessCount = meta.accessCount + 1
end
return stateStore[id]
end
--- Set state for an element ID (replaces entire state)
---@param id string Element ID
---@param state table State to store
function StateManager.setState(id, state)
if not id then
ErrorHandler:error("StateManager", "SYS_001", {
parameter = "id",
value = "nil",
})
end
-- Create sparse state (remove default values)
local sparseState = {}
for key, value in pairs(state) do
if not isDefaultValue(key, value) then
sparseState[key] = value
end
end
stateStore[id] = sparseState
-- Update or create metadata
if not stateMetadata[id] then
stateMetadata[id] = {
lastFrame = frameNumber,
createdFrame = frameNumber,
accessCount = 1,
}
else
stateMetadata[id].lastFrame = frameNumber
end
end
--- Update state for an element ID (merges with existing state)
---@param id string Element ID
---@param newState table New state values to merge
function StateManager.updateState(id, newState)
local state = StateManager.getState(id)
-- Merge new state into existing state (with diffing optimization)
local changed = false
for key, value in pairs(newState) do
if state[key] ~= value then
state[key] = value
changed = true
end
end
-- Only update metadata if something actually changed
if changed then
stateMetadata[id].lastFrame = frameNumber
end
end
--- Update state only if values have changed (optimized for immediate mode)
---@param id string Element ID
---@param newState table New state values to merge
---@return boolean changed True if any values changed
function StateManager.updateStateIfChanged(id, newState)
local state = StateManager.getState(id)
local changed = false
for key, value in pairs(newState) do
-- Skip if value hasn't changed (optimization)
if state[key] ~= value then
state[key] = value
changed = true
end
end
if changed then
stateMetadata[id].lastFrame = frameNumber
end
return changed
end
--- Clear state for a specific element ID
---@param id string Element ID
function StateManager.clearState(id)
stateStore[id] = nil
stateMetadata[id] = nil
end
--- Mark state as used this frame (updates last accessed frame)
---@param id string Element ID
function StateManager.markStateUsed(id)
if stateMetadata[id] then
stateMetadata[id].lastFrame = frameNumber
end
end
-- ====================
-- Frame Management
-- ====================
--- Increment frame counter (called at frame start)
function StateManager.incrementFrame()
frameNumber = frameNumber + 1
-- Reset call site counters for new frame
callSiteCounters = {}
end
--- Get current frame number
---@return number
function StateManager.getFrameNumber()
return frameNumber
end
-- ====================
-- Granular State Access (Unified API for both modes)
-- ====================
--- Get a single state value by key for a given element ID.
--- Works identically in both modes — the caller does not need to know the mode.
---
--- Immediate mode: reads from persistent state store.
--- Retained mode: resolves through registered element field (cache-through).
---
---@param id string Element state ID
---@param key string State key
---@return any value The stored value, or nil if not found
function StateManager.getStateValue(id, key)
if not id or not key then
ErrorHandler:error("StateManager", "SYS_001", {
parameter = "id and key",
value = "missing",
})
end
-- Update metadata for access tracking
if stateMetadata[id] then
stateMetadata[id].lastFrame = frameNumber
stateMetadata[id].accessCount = stateMetadata[id].accessCount + 1
end
if _immediateMode then
-- Immediate mode: read from persistent state store
local state = stateStore[id]
if state then
return state[key]
end
return nil
else
-- Retained mode: resolve through element field
local element = statefulElements[id]
if element then
return element[key]
end
return nil
end
end
--- Set a single state value by key for a given element ID.
--- Works identically in both modes — the caller does not need to know the mode.
---
--- Immediate mode: marks dirty for flushFrame() persistence.
--- Retained mode: writes directly to element field (cache-through).
---
---@param id string Element state ID
---@param key string State key
---@param value any Value to store
function StateManager.setStateValue(id, key, value)
if not id or not key then
ErrorHandler:error("StateManager", "SYS_001", {
parameter = "id and key",
value = "missing",
})
end
-- Update metadata
if not stateMetadata[id] then
stateMetadata[id] = {
lastFrame = frameNumber,
createdFrame = frameNumber,
accessCount = 1,
}
else
stateMetadata[id].lastFrame = frameNumber
end
if _immediateMode then
-- Immediate mode: mark dirty for flushFrame persistence
local state = StateManager.getState(id)
state[key] = value
dirtyState[id] = dirtyState[id] or {}
dirtyState[id][key] = true
else
-- Retained mode: write directly to element field
local element = statefulElements[id]
if element then
element[key] = value
end
end
end
-- ====================
-- Stateful Element Registration (Retained Mode Cache-Through)
-- ====================
--- Register an element instance for retained-mode cache-through.
--- After registration, getStateValue/setStateValue will resolve through the element's fields.
---
--- Called by Element in _construct phase.
---
---@param id string State ID (typically element.id)
---@param element table Element instance to link
function StateManager.registerStateful(id, element)
if not id or not element then
return
end
statefulElements[id] = element
end
--- Unregister an element instance.
--- After unregistration, retained-mode access will fall back to nil.
---
--- Called by Element in _cleanup phase.
---
---@param id string State ID to unregister
function StateManager.unregisterStateful(id)
if id then
statefulElements[id] = nil
end
end
-- ====================
-- Frame Flush (Immediate Mode Dirty State Persistence)
-- ====================
--- Flush dirty state to persistent store at end of frame.
--- Called automatically at frame end in immediate mode.
--- Behaviors call setStateValue during update without knowing the mode.
---
--- In retained mode, this is a no-op (state is written directly to elements).
function StateManager.flushFrame()
if not _immediateMode then
return
end
-- All dirty writes were already applied to stateStore during setStateValue
-- This method exists for future extensions (e.g., batching, analytics)
-- Reset dirty tracking for next frame
dirtyState = {}
end
-- ====================
-- Mode Configuration
-- ====================
--- Configure immediate mode state.
--- Called by Context when immediate mode is enabled/disabled.
---
---@param enabled boolean Whether immediate mode is active
function StateManager.setImmediateMode(enabled)
_immediateMode = enabled
end
--- Check if immediate mode is active.
---@return boolean
function StateManager.isImmediateMode()
return _immediateMode
end
--- Whether at-construction layout / eager initialization should run now.
--- Returns true in retained mode (layout eagerly), false in immediate mode
--- (layout is deferred to `FlexLove.endFrame` / FlexLove so it runs once all
--- elements for the frame have been created). This replaces the scattered
--- `if not _immediateMode then layoutChildren()` mode checks with a single
--- mode-aware query (behavior-mode-unification task 11).
---@return boolean
function StateManager.shouldLayout()
return not _immediateMode
end
-- ====================
-- Cleanup & Maintenance
-- ====================
--- Clean up stale states (not accessed recently)
---@return number count Number of states cleaned up
function StateManager.cleanup()
local cleanedCount = 0
local retentionFrames = config.stateRetentionFrames
for id, meta in pairs(stateMetadata) do
local framesSinceAccess = frameNumber - meta.lastFrame
if framesSinceAccess > retentionFrames then
stateStore[id] = nil
stateMetadata[id] = nil
cleanedCount = cleanedCount + 1
end
end
-- Clean up empty states (sparse storage optimization)
for id, state in pairs(stateStore) do
if next(state) == nil then
stateStore[id] = nil
stateMetadata[id] = nil
cleanedCount = cleanedCount + 1
end
end
return cleanedCount
end
--- Force cleanup if state count exceeds maximum
---@return number count Number of states cleaned up
function StateManager.forceCleanupIfNeeded()
local stateCount = StateManager.getStateCount()
if stateCount > config.maxStateEntries then
-- Clean up states not accessed in last 10 frames (aggressive)
local cleanedCount = 0
for id, meta in pairs(stateMetadata) do
local framesSinceAccess = frameNumber - meta.lastFrame
if framesSinceAccess > 10 then
stateStore[id] = nil
stateMetadata[id] = nil
cleanedCount = cleanedCount + 1
end
end
return cleanedCount
end
return 0
end
--- Get total number of stored states
---@return number
function StateManager.getStateCount()
local count = 0
for _ in pairs(stateStore) do
count = count + 1
end
return count
end
--- Clear all states
function StateManager.clearAllStates()
stateStore = {}
stateMetadata = {}
end
--- Configure state management
---@param newConfig {stateRetentionFrames?: number, maxStateEntries?: number}
function StateManager.configure(newConfig)
if newConfig.stateRetentionFrames then
config.stateRetentionFrames = newConfig.stateRetentionFrames
end
if newConfig.maxStateEntries then
config.maxStateEntries = newConfig.maxStateEntries
end
end
--- Get state statistics for debugging
---@return table stats State usage statistics
function StateManager.getStats()
local stateCount = StateManager.getStateCount()
local oldest = nil
local newest = nil
for _, meta in pairs(stateMetadata) do
if not oldest or meta.createdFrame < oldest then
oldest = meta.createdFrame
end
if not newest or meta.createdFrame > newest then
newest = meta.createdFrame
end
end
-- Count callSiteCounters
local callSiteCount = 0
for _ in pairs(callSiteCounters) do
callSiteCount = callSiteCount + 1
end
-- Warn if callSiteCounters is unexpectedly large
if callSiteCount > 1000 then
if ErrorHandler then
ErrorHandler.warn("StateManager", "STATE_001", {
count = callSiteCount,
expected = "near 0",
frameNumber = frameNumber,
})
end
end
return {
stateCount = stateCount,
frameNumber = frameNumber,
oldestState = oldest,
newestState = newest,
callSiteCounterCount = callSiteCount,
}
end
--- Get internal state (for debugging/profiling only)
---@return table internal {stateStore, stateMetadata, callSiteCounters}
function StateManager._getInternalState()
return {
stateStore = stateStore,
stateMetadata = stateMetadata,
callSiteCounters = callSiteCounters,
}
end
--- Reset the entire state system (for testing)
function StateManager.reset()
stateStore = {}
stateMetadata = {}
frameNumber = 0
callSiteCounters = {}
statefulElements = {}
dirtyState = {}
_immediateMode = false
end
-- ====================
-- Convenience Functions (for backward compatibility)
-- ====================
--- Check if an element is currently hovered
---@param id string Element ID
---@return boolean
function StateManager.isHovered(id)
local state = StateManager.getState(id)
return state.hover or false
end
--- Check if an element is currently pressed
---@param id string Element ID
---@return boolean
function StateManager.isPressed(id)
local state = StateManager.getState(id)
return state.pressed or false
end
--- Check if an element is currently focused
---@param id string Element ID
---@return boolean
function StateManager.isFocused(id)
local state = StateManager.getState(id)
return state.focused or false
end
--- Check if an element is disabled
---@param id string Element ID
---@return boolean
function StateManager.isDisabled(id)
local state = StateManager.getState(id)
return state.disabled or false
end
--- Check if an element is active (e.g., input focused)
---@param id string Element ID
---@return boolean
function StateManager.isActive(id)
local state = StateManager.getState(id)
return state.active or false
end
return StateManager
File diff suppressed because it is too large Load Diff
-183
View File
@@ -1,183 +0,0 @@
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
-- Text sanitization, escaping, and input validation utilities.
-- ErrorHandler is injected via init() for truncation warnings.
local ErrorHandler = nil
--- Initialize dependencies
---@param deps table Dependencies: { ErrorHandler = ErrorHandler }
local function init(deps)
if type(deps) == "table" then
ErrorHandler = deps.ErrorHandler
end
end
--- Sanitize text to prevent security vulnerabilities
--- @param text string? Text to sanitize
--- @param options table? Sanitization options
--- @return string Sanitized text
local function sanitizeText(text, options)
local utf8 = require("utf8")
-- Handle nil or non-string inputs
if text == nil then
return ""
end
if type(text) ~= "string" then
text = tostring(text)
end
-- Default options
options = options or {}
local maxLength = options.maxLength or 10000
local allowNewlines = options.allowNewlines ~= false -- default true
local allowTabs = options.allowTabs ~= false -- default true
local stripControls = options.stripControls ~= false -- default true
local trimWhitespace = options.trimWhitespace ~= false -- default true
-- Remove null bytes (critical security risk)
text = text:gsub("%z", "")
-- Strip control characters except allowed ones
if stripControls then
local pattern = "[\1-\31\127]" -- All control characters
if allowNewlines and allowTabs then
pattern = "[\1-\8\11\12\14-\31\127]" -- Exclude \t (9), \n (10), \r (13)
elseif allowNewlines then
pattern = "[\1-\9\11\12\14-\31\127]" -- Exclude \n (10), \r (13)
elseif allowTabs then
pattern = "[\1-\8\10\12-\31\127]" -- Exclude \t (9)
end
text = text:gsub(pattern, "")
end
-- Trim leading/trailing whitespace
if trimWhitespace then
text = text:match("^%s*(.-)%s*$") or ""
end
-- Limit string length (use UTF-8 character count, not byte count)
local charCount = utf8.len(text)
if charCount and charCount > maxLength then
if ErrorHandler then
ErrorHandler:warn("utils", "UTIL_001", {
original = charCount,
truncated = maxLength,
})
end
-- Truncate to maxLength UTF-8 characters
local bytePos = utf8.offset(text, maxLength + 1)
if bytePos then
text = text:sub(1, bytePos - 1)
end
if ErrorHandler then
ErrorHandler:warn("utils", string.format("Text truncated from %d to %d characters", charCount, maxLength))
end
end
return text
end
--- Validate text input against rules
--- @param text string Text to validate
--- @param rules table Validation rules
--- @return boolean, string? Returns true if valid, or false with error message
local function validateTextInput(text, rules)
rules = rules or {}
-- Check minimum length
if rules.minLength and #text < rules.minLength then
return false, string.format("Text must be at least %d characters", rules.minLength)
end
-- Check maximum length
if rules.maxLength and #text > rules.maxLength then
return false, string.format("Text must be at most %d characters", rules.maxLength)
end
-- Check pattern match
if rules.pattern and not text:match(rules.pattern) then
return false, rules.patternError or "Text does not match required pattern"
end
-- Check character whitelist
if rules.allowedChars then
local pattern = "[^" .. rules.allowedChars .. "]"
if text:match(pattern) then
return false, "Text contains invalid characters"
end
end
-- Check character blacklist
if rules.forbiddenChars then
local pattern = "[" .. rules.forbiddenChars .. "]"
if text:match(pattern) then
return false, "Text contains forbidden characters"
end
end
return true, nil
end
--- Validate text against range/length rules (alias of validateTextInput)
--- @param text string Text to validate
--- @param rules table Validation rules (minLength, maxLength, pattern, etc.)
--- @return boolean, string? Returns true if valid, or false with error message
local function validateTextRange(text, rules)
return validateTextInput(text, rules)
end
--- Escape HTML special characters
--- @param text string Text to escape
--- @return string Escaped text
local function escapeHtml(text)
if text == nil then
return ""
end
text = tostring(text)
text = text:gsub("&", "&amp;")
text = text:gsub("<", "&lt;")
text = text:gsub(">", "&gt;")
text = text:gsub('"', "&quot;")
text = text:gsub("'", "&#39;")
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
-44
View File
@@ -1,44 +0,0 @@
---@class UTF8
---Compatibility layer for UTF-8 support across Lua versions
---Handles utf8 (Lua 5.3+), lua-utf8 (LuaRocks), and basic fallbacks
local UTF8 = {}
-- Try to load UTF-8 library in order of preference:
-- 1. Built-in utf8 (Lua 5.3+, LÖVE2D)
-- 2. lua-utf8 from LuaRocks (Lua 5.1, 5.2)
-- 3. Error if neither available
local function loadUTF8()
-- Try built-in utf8 first (Lua 5.3+ and LÖVE2D)
if utf8 and type(utf8) == "table" and utf8.len then
return utf8
end
-- Try lua-utf8 from LuaRocks
local ok, luautf8 = pcall(require, "lua-utf8")
if ok then
return luautf8
end
-- Try standard utf8 module name as fallback
ok, luautf8 = pcall(require, "utf8")
if ok then
return luautf8
end
-- No UTF-8 library available
error("No UTF-8 library available. Please install 'luautf8' via LuaRocks: luarocks install luautf8")
end
-- Load the UTF-8 implementation
local utf8lib = loadUTF8()
-- Export all utf8 functions
UTF8.char = utf8lib.char
UTF8.charpattern = utf8lib.charpattern
UTF8.codes = utf8lib.codes
UTF8.codepoint = utf8lib.codepoint
UTF8.len = utf8lib.len
UTF8.offset = utf8lib.offset
return UTF8
-335
View File
@@ -1,335 +0,0 @@
--- Utility module for parsing and resolving CSS-like units (px, %, vw, vh)
--- Provides unit parsing, validation, and conversion to pixel values
---@class Units
---@field _Context table? Context module dependency
---@field _ErrorHandler table? ErrorHandler module dependency
---@field _Calc table? Calc module dependency
local Units = {}
--- Initialize Units module with dependencies
---@param deps table Dependencies: { Context = table?, ErrorHandler = table?, Calc = table? }
function Units.init(deps)
Units._Context = deps.Context
Units._ErrorHandler = deps.ErrorHandler
Units._Calc = deps.Calc
end
--- Parse a unit value into numeric value and unit type
--- Supports: px (pixels), % (percentage), vw/vh (viewport), and calc() expressions
---@param value string|number|table The value to parse (e.g., "50px", "10%", "2vw", 100, or calc object)
---@return number|table numericValue The numeric portion of the value or calc object
---@return string unitType The unit type ("px", "%", "vw", "vh", "calc")
function Units.parse(value)
-- Check if value is a calc expression
if Units._Calc and Units._Calc.isCalc(value) then
return value, "calc"
end
if type(value) == "number" then
return value, "px"
end
if type(value) ~= "string" and type(value) ~= "table" then
Units._ErrorHandler:warn("Units", "VAL_001", {
property = "unit value",
expected = "string, number, or calc object",
got = type(value),
})
return 0, "px"
end
-- Check for unit-only input (e.g., "px", "%", "vw" without a number)
local validUnits = { px = true, ["%"] = true, vw = true, vh = true }
if validUnits[value] then
Units._ErrorHandler:warn("Units", "VAL_005", {
input = value,
expected = "number + unit (e.g., '50" .. value .. "')",
})
return 0, "px"
end
-- Check for invalid format (space between number and unit)
if value:match("%d%s+%a") then
Units._ErrorHandler:warn("Units", "VAL_005", {
input = value,
issue = "contains space between number and unit",
})
return 0, "px"
end
-- Match number followed by optional unit
local numStr, unit = value:match("^([%-]?[%d%.]+)(.*)$")
if not numStr then
Units._ErrorHandler:warn("Units", "VAL_005", {
input = value,
})
return 0, "px"
end
local num = tonumber(numStr)
if not num then
Units._ErrorHandler:warn("Units", "VAL_005", {
input = value,
issue = "numeric value cannot be parsed",
})
return 0, "px"
end
-- Default to pixels if no unit specified
if unit == "" then
unit = "px"
end
-- validUnits is already defined at the top of the function
if not validUnits[unit] then
Units._ErrorHandler:warn("Units", "VAL_005", {
input = value,
unit = unit,
validUnits = "px, %, vw, vh",
})
return num, "px"
end
return num, unit
end
--- Convert relative units to absolute pixel values
--- Resolves %, vw, vh units based on viewport and parent dimensions, and evaluates calc() expressions
---@param value number|table Numeric value to convert or calc object
---@param unit string Unit type ("px", "%", "vw", "vh", "calc")
---@param viewportWidth number Current viewport width in pixels
---@param viewportHeight number Current viewport height in pixels
---@param parentSize number? Required for percentage units (parent dimension in pixels)
---@return number resolvedValue Resolved pixel value
function Units.resolve(value, unit, viewportWidth, viewportHeight, parentSize)
if unit == "calc" then
-- Resolve calc expression
if Units._Calc then
return Units._Calc.resolve(value, viewportWidth, viewportHeight, parentSize)
else
Units._ErrorHandler:warn("Units", "VAL_006", {
unit = "calc",
issue = "Calc module not available",
})
return 0
end
elseif unit == "px" then
return value
elseif unit == "%" then
if not parentSize then
Units._ErrorHandler:warn("Units", "LAY_003", {
unit = "%",
issue = "parent dimension not available",
})
return 0
end
return (value / 100) * parentSize
elseif unit == "vw" then
return (value / 100) * viewportWidth
elseif unit == "vh" then
return (value / 100) * viewportHeight
else
Units._ErrorHandler:warn("Units", "VAL_005", {
unit = unit,
validUnits = "px, %, vw, vh, calc",
})
return 0
end
end
--- Get current viewport dimensions
--- Uses cached viewport during resize operations, otherwise queries LÖVE graphics
---@return number width Viewport width in pixels
---@return number height Viewport height in pixels
function Units.getViewport()
-- Return cached viewport if available (only during resize operations)
if Units._Context._cachedViewport and Units._Context._cachedViewport.width > 0 then
return Units._Context._cachedViewport.width, Units._Context._cachedViewport.height
end
if love.graphics and love.graphics.getDimensions then
return love.graphics.getDimensions()
else
local w, h = love.window.getMode()
return w, h
end
end
--- Apply base scale factor to a value based on axis
--- Used for responsive scaling of UI elements
---@param value number The value to scale
---@param axis "x"|"y" The axis to scale on
---@param scaleFactors {x:number, y:number} Scale factors for each axis
---@return number scaledValue The scaled value
function Units.applyBaseScale(value, axis, scaleFactors)
if axis == "x" then
return value * scaleFactors.x
else
return value * scaleFactors.y
end
end
--- Resolve spacing properties (margin, padding) to pixel values
--- Supports individual sides (top, right, bottom, left) and shortcuts (vertical, horizontal)
---@param spacingProps table? Spacing properties with top/right/bottom/left/vertical/horizontal
---@param parentWidth number Parent element width in pixels
---@param parentHeight number Parent element height in pixels
---@return table resolvedSpacing Table with top, right, bottom, left in pixels
function Units.resolveSpacing(spacingProps, parentWidth, parentHeight)
if not spacingProps then
return { top = 0, right = 0, bottom = 0, left = 0 }
end
local viewportWidth, viewportHeight = Units.getViewport()
local result = {}
local vertical = spacingProps.vertical
local horizontal = spacingProps.horizontal
if vertical then
if type(vertical) == "string" or (Units._Calc and Units._Calc.isCalc(vertical)) then
local value, unit = Units.parse(vertical)
vertical = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight)
end
end
if horizontal then
if type(horizontal) == "string" or (Units._Calc and Units._Calc.isCalc(horizontal)) then
local value, unit = Units.parse(horizontal)
horizontal = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth)
end
end
for _, side in ipairs({ "top", "right", "bottom", "left" }) do
local value = spacingProps[side]
if value then
if type(value) == "string" or (Units._Calc and Units._Calc.isCalc(value)) then
local numValue, unit = Units.parse(value)
local parentSize = (side == "top" or side == "bottom") and parentHeight or parentWidth
result[side] = Units.resolve(numValue, unit, viewportWidth, viewportHeight, parentSize)
else
result[side] = value
end
else
if side == "top" or side == "bottom" then
result[side] = vertical or 0
else
result[side] = horizontal or 0
end
end
end
return result
end
--- Validate a unit string format
--- Checks if the string can be successfully parsed as a valid unit or calc expression
---@param unitStr string|table The unit string to validate (e.g., "50px", "10%") or calc object
---@return boolean isValid True if the unit string is valid, false otherwise
function Units.isValid(unitStr)
-- Check if it's a calc expression
if Units._Calc and Units._Calc.isCalc(unitStr) then
return true
end
if type(unitStr) ~= "string" then
return false
end
-- Check for invalid format (space between number and unit)
if unitStr:match("%d%s+%a") then
return false
end
-- Match number followed by optional unit
local numStr, unit = unitStr:match("^([%-]?[%d%.]+)(.*)$")
if not numStr then
return false
end
-- Check if numeric part is valid
local num = tonumber(numStr)
if not num then
return false
end
-- Default to pixels if no unit specified
if unit == "" then
unit = "px"
end
-- Check if unit is valid
local validUnits = { px = true, ["%"] = true, vw = true, vh = true }
return validUnits[unit] == true
end
--- Parse CSS flex shorthand into flexGrow, flexShrink, flexBasis
--- Supports: number, "auto", "none", "grow shrink basis"
---@param flexValue number|string The flex shorthand value
---@return number flexGrow
---@return number flexShrink
---@return string|number flexBasis
function Units.parseFlexShorthand(flexValue)
-- Single number: flex-grow
if type(flexValue) == "number" then
return flexValue, 1, 0
end
-- String values
if type(flexValue) == "string" then
-- "auto" = 1 1 auto
if flexValue == "auto" then
return 1, 1, "auto"
end
-- "none" = 0 0 auto
if flexValue == "none" then
return 0, 0, "auto"
end
-- Parse "grow shrink basis" format
local parts = {}
for part in flexValue:gmatch("%S+") do
table.insert(parts, part)
end
local grow = 0
local shrink = 1
local basis = "auto"
if #parts == 1 then
-- Single value: could be grow (number) or basis (with unit)
local num = tonumber(parts[1])
if num then
grow = num
basis = 0
else
basis = parts[1]
end
elseif #parts == 2 then
-- Two values: grow shrink (both numbers) or grow basis
local num1 = tonumber(parts[1])
local num2 = tonumber(parts[2])
if num1 and num2 then
grow = num1
shrink = num2
basis = 0
elseif num1 then
grow = num1
basis = parts[2]
end
elseif #parts >= 3 then
-- Three values: grow shrink basis
grow = tonumber(parts[1]) or 0
shrink = tonumber(parts[2]) or 1
basis = parts[3]
end
return grow, shrink, basis
end
-- Default fallback
return 0, 1, "auto"
end
return Units
-35
View File
@@ -1,35 +0,0 @@
---@class ZIndex
local ZIndex = {}
-- The effective z-index formula used for sorting is:
-- rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ
-- where rootZ is the z-index of the top-level ancestor, depth is the
-- nesting level, and ownZ is the element's own z property.
--
-- Constraints enforced by these weights:
-- |ownZ| <= MAX_Z (must fit within DEPTH_WEIGHT digits)
-- DEPTH_WEIGHT has enough room for depths well beyond any practical tree
-- ROOT_WEIGHT has enough room for the rootZ without exceeding double-precision
---
---@type integer
ZIndex.MIN_Z = -999
---@type integer
ZIndex.MAX_Z = 999
---@type integer
ZIndex.ROOT_WEIGHT = 10000000000
---@type integer
ZIndex.DEPTH_WEIGHT = 1000
--- Clamp a z-index value to the valid range
---@param value number
---@return integer
function ZIndex.clamp(value)
if value < ZIndex.MIN_Z then
return ZIndex.MIN_Z
elseif value > ZIndex.MAX_Z then
return ZIndex.MAX_Z
end
return value
end
return ZIndex
@@ -1,245 +0,0 @@
-- modules/behaviors/Animated.lua
--
-- Concrete behavior: animation update, interpolation application, chaining
-- resolution, and transition wiring.
--
-- Task 06 of the behavior-mode-unification refactor. Moves the entire
-- animation-update block out of Element:update (lines ~2761-2800) into
-- `Animated.onUpdate(element, dt)`, and the `_ColorModule`/`_TransformModule`
-- init-time wiring into `Animated.onAttach(element)`.
--
-- This behavior is UNIQUE among the behavior set because it can attach
-- AFTER element creation. Animation is opt-in: a plain Element created without
-- `transitions` and without an `animation` field never attaches Animated.
-- The moment something creates an animation on the element — either directly
-- (`element.animation = Animation.new(...)`, `element:fadeIn(...)`) or via a
-- transition firing in `setProperty` — `Animated.ensureAttached(element)`
-- attaches this behavior on demand so subsequent `Element:update` frames
-- dispatch to `Animated.onUpdate`.
--
-- Attachment rule (shouldAttach): true when `props.transitions` is set OR an
-- `element.animation` already exists at runtime. The runtime arm covers the
-- late-attach case (animateTo / fadeIn / direct animation assignment).
--
-- State ownership (per the locked Behavior contract):
-- * Per-element runtime state lives ON THE ELEMENT (`element.animation`).
-- * The behavior instance itself is stateless and shared across elements.
-- * Element-class-level dependencies (Element._Animation, Element._Color,
-- Element._Transform) are resolved from the owning element's metatable,
-- exactly like Clickable does — keeping the behavior stateless without
-- expanding the 6-hook signature.
--
-- saveState/restoreState are no-ops: animations are ephemeral (an in-flight
-- animation is not part of immediate-mode persisted state — the next frame
-- re-evaluates transitions / re-applies animations fresh). Persisted scalar
-- props (`opacity`, `x`, ...) survive via Element.saveState's `_props` block,
-- not via the animation.
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
local Behavior = require(_pkg .. "Behavior")
-- Resolve the Element class from an element instance.
-- Element instances are created via `setmetatable({}, Element)` in _construct,
-- so their metatable IS the Element class — giving us Element._Animation,
-- Element._Color, Element._Transform, etc. without threading deps through the
-- behavior hook signature.
local function ElementClass(element)
return getmetatable(element)
end
-- ----------------------------------------------------------------------------
-- ensureAnimationModuleWiring — set Element._Animation._ColorModule /
-- _TransformModule. Idempotent; called from both onAttach and onUpdate so it
-- works even when an animation was assigned by a caller that bypassed
-- onAttach (direct `element.animation = Animation.new(...)`).
-- ----------------------------------------------------------------------------
local function ensureAnimationModuleWiring(element)
local Element = ElementClass(element)
local Animation = Element._Animation
if not Animation then
return
end
-- Ensure animation has Color module reference for color interpolation
if not Animation._ColorModule and Element._Color then
Animation._ColorModule = Element._Color
end
-- Ensure animation has Transform module reference for transform interpolation
if not Animation._TransformModule and Element._Transform then
Animation._TransformModule = Element._Transform
end
end
-- ----------------------------------------------------------------------------
-- shouldAttach (class-level predicate, no element required)
-- ----------------------------------------------------------------------------
-- True when the element declares transitions up front OR already has an
-- animation attached. The `animation` arm is consulted by ensureAttached at
-- runtime (after creation); the `transitions` arm lets Animated auto-attach
-- during Element.new for elements that pre-declare transitions.
local function shouldAttach(props)
if not props then
return false
end
if props.transitions ~= nil then
return true
end
-- Late-attach case: an animation was assigned after creation. When ensure
-- Attached passes the element instance as `props`, this arm catches it.
if type(props) == "table" and props.animation ~= nil then
return true
end
return false
end
-- ----------------------------------------------------------------------------
-- ensureAttached — dynamic late-attach entry point
-- ----------------------------------------------------------------------------
-- Idempotently attach the Animated behavior to an element that just gained an
-- animation (via animateTo / fadeIn / direct assignment / a firing transition
-- in setProperty). Called from Element.setProperty when a transition fires and
-- from the transition helper methods on Element. Safe to call when already
-- attached (no-op / returns false).
--
-- `animatedBehavior` is the shared behavior instance resolved lazily by
-- Element (see Element._resolveAnimatedBehavior). The behavior is looked up
-- from the registry once and cached on the class.
--
-- Returns true if the behavior was attached this call, false otherwise.
local function ensureAttached(element, animatedBehavior)
if not element or not animatedBehavior then
return false
end
-- Already attached? Avoid duplicate entries within one element lifetime
-- (a behavior may legitimately be re-added across immediate-mode frames
-- since Element is recreated each frame, but within one lifetime at most
-- once).
local behaviors = element.behaviors
if behaviors then
for i = 1, #behaviors do
if behaviors[i] == animatedBehavior then
return false
end
end
end
table.insert(element.behaviors, animatedBehavior)
animatedBehavior.onAttach(element)
return true
end
-- ----------------------------------------------------------------------------
-- onAttach — initialize Animation module references (formerly the
-- Element._Animation._ColorModule / _TransformModule wiring in Element:update
-- lines ~2772-2778).
-- ----------------------------------------------------------------------------
local function onAttach(element)
ensureAnimationModuleWiring(element)
end
-- ----------------------------------------------------------------------------
-- onUpdate — the animation update + interpolation + chain-resolution block
-- (formerly Element:update lines ~2761-2800).
-- ----------------------------------------------------------------------------
local function onUpdate(element, dt)
local animation = element.animation
if not animation then
return
end
-- (Re)ensure module wiring is present in case the Animation instance was
-- created by a caller that bypassed onAttach (e.g. direct
-- `element.animation = Animation.new(...)`). Cheap idempotent writes.
ensureAnimationModuleWiring(element)
local finished = animation:update(dt, element)
if finished then
-- Animation:update() already called onComplete callback.
-- Check for chained animation.
if animation._next then
element.animation = animation._next
elseif animation._nextFactory and type(animation._nextFactory) == "function" then
local success, nextAnim = pcall(animation._nextFactory, element)
if success and nextAnim then
element.animation = nextAnim
else
element.animation = nil
end
else
element.animation = nil
end
else
-- Apply animation interpolation during update.
animation:applyInterpolation(element)
end
end
-- ----------------------------------------------------------------------------
-- saveState / restoreState — no-ops (animations are ephemeral).
-- ----------------------------------------------------------------------------
-- Animations are not persisted across immediate-mode frames — they are
-- re-derived each frame from transitions / direct calls. The element's scalar
-- props (opacity, x, ...) are persisted by Element.saveState's _props block,
-- so a completed animation's final visual state still survives recreation.
-- While an animation is mid-flight in immediate mode, the element is recreated
-- and the animation is NOT carried over (intentional — animating in immediate
-- mode requires setting up the animation each frame).
local function saveState()
return nil
end
local function restoreState()
return nil
end
-- ----------------------------------------------------------------------------
-- Build the (stateless, shared) behavior instance.
-- ----------------------------------------------------------------------------
-- onDetach/onDraw omitted: they default to no-ops (the behavior allocates no
-- behavior-local state and animations have no draw pass). Animation state lives
-- on the element (`element.animation`); nothing to tear down on detach.
--
-- We build the immutable behavior via Behavior.new (for validation + freeze +
-- isBehavior parity with Clickable), then expose the late-attach helper on a
-- thin module table since the frozen instance cannot accept new keys. The
-- module table passes the behavior to the registry while making
-- `Animated.ensureAttached` callable from Element.setProperty / the transition
-- helpers — exactly as the task spec requires.
local behavior = Behavior.new({
onAttach = onAttach,
onUpdate = onUpdate,
saveState = saveState,
restoreState = restoreState,
shouldAttach = shouldAttach,
})
-- Thin module table: exposes the behavior instance (for the registry) plus the
-- late-attach helper (for Element.setProperty). All hooks delegate to the
-- frozen behavior instance so dispatch sites get the validated, frozen
-- implementation. shouldAttach is also exposed at module level (mirrors
-- Clickable.shouldAttach) for tests/callers without an element.
local Animated = {
behavior = behavior,
ensureAttached = ensureAttached,
shouldAttach = shouldAttach,
onAttach = onAttach,
onUpdate = onUpdate,
}
-- Metatable so the module table itself satisfies the duck-typed registry
-- contract (iterating `Element._behaviorRegistry` calls `behavior.shouldAttach`
-- and `behavior.onAttach` / `behavior.onUpdate` directly). Falls through to the
-- frozen behavior instance for every hook.
setmetatable(Animated, {
__index = behavior,
__tostring = function()
return "Animated"
end,
})
return Animated
@@ -1,344 +0,0 @@
-- modules/behaviors/Clickable.lua
--
-- Concrete behavior: mouse/touch event handling, pressed-state tracking,
-- hit-testing, and theme-state sync.
--
-- This is the largest behavior in the behavior-mode-unification refactor
-- (~200 LOC moved out of Element:update / _initSubSystems / saveState).
-- Task 02 extracts the entire `if self.onEvent or self.themeComponent or
-- self.editable or self._selectState or self.selectOption then ... end` block
-- from Element:update (hit-testing, mouse/touch event processing, immediate-
-- mode state save, theme-state update) plus EventHandler creation (formerly the
-- first half of Element:_initSubSystems) plus pressed-state drawing (formerly a
-- render layer in Renderer) plus EventHandler save/restore.
--
-- Attachment rule (shouldAttach): the same predicate that previously guarded
-- mouse-event processing in Element:update. An element owns the EventHandler /
-- gets press feedback exactly when it is interactive: when it declares an
-- `onEvent` callback, a `themeComponent`, is `editable`, or participates in a
-- Select group (selectParent / selectOption). A plain passive element never
-- attaches Clickable and therefore never allocates an EventHandler.
--
-- Element retains only the `self._eventHandler` field; Clickable owns it on
-- attach. All other Element paths that touched the EventHandler (handleTouchEvent,
-- handleGesture, getTouches) already nil-guard `self._eventHandler`, so they keep
-- working unchanged for non-clickable elements.
--
-- State ownership (per the locked Behavior contract):
-- * Per-element runtime state lives ON THE ELEMENT (self._eventHandler etc.).
-- * The behavior instance itself is stateless and shared across elements.
-- * Element-class-level dependencies (EventHandler factory, StateManager,
-- Context) are resolved from the owning element's metatable (the Element
-- class set by Element:_construct). This keeps the behavior stateless while
-- avoiding a dependency-injection parameter that would violate the locked
-- 6-hook signature `(element, ...)`.
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
local Behavior = require(_pkg .. "Behavior")
-- Resolve the Element class from an element instance.
-- Element instances are created via `setmetatable({}, Element)` in _construct,
-- so their metatable IS the Element class — giving us Element._EventHandler,
-- Element._eventHandlerDeps, Element._StateManager, Element._Context, etc.
-- without threading deps through the behavior hook signature.
local function ElementClass(element)
return getmetatable(element)
end
-- ----------------------------------------------------------------------------
-- shouldAttach (class-level predicate, no element required)
-- ----------------------------------------------------------------------------
-- Mirrors the cases that previously caused Element to allocate + use an
-- EventHandler. MUST cover every element that touches the EventHandler at
-- runtime: click (onEvent), theme press-feedback (themeComponent), text mouse
-- interaction (editable), Select groups (selectParent / selectOption), touch
-- callbacks (onTouchEvent), and gesture callbacks (onGesture). selectParent /
-- selectOption are the props that produce _selectState during _initSubSystems;
-- checking the props (rather than the runtime _selectState) lets shouldAttach
-- run before the Select subsystem is initialized.
local function shouldAttach(props)
props = props or {}
return props.onEvent ~= nil
or props.themeComponent ~= nil
or props.editable == true
or props.onTouchEvent ~= nil
or props.onGesture ~= nil
or props.selectOption ~= nil
or props.selectParent ~= nil
end
-- ----------------------------------------------------------------------------
-- onAttach — create the EventHandler (formerly Element:_initSubSystems
-- lines ~640-690) and restore immediate-mode EventHandler state.
-- ----------------------------------------------------------------------------
local function onAttach(element)
local Element = ElementClass(element)
local eventHandlerConfig = {
-- element.onEvent is source of truth; not cached on handler
onEventDeferred = element.onEventDeferred,
-- element.onTouchEvent is source of truth; not cached on handler
onTouchEventDeferred = element.onTouchEventDeferred,
-- element.onGesture is source of truth; not cached on handler
onGestureDeferred = element.onGestureDeferred,
touchEnabled = element.touchEnabled,
multiTouchEnabled = element.multiTouchEnabled,
}
-- In immediate mode, restore EventHandler state from StateManager so pressed
-- / hovered / click-count survive the per-frame element recreation cycle.
-- Mode-aware via Context.isImmediateMode (behavior-mode-unification task 11):
-- in retained mode the eventHandler persists, so nothing to restore.
if Element._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then
local state = Element._StateManager.getState(element._stateId)
if state then
-- Restore EventHandler state from StateManager (sparse storage — provide defaults)
eventHandlerConfig._pressed = state._pressed or {}
eventHandlerConfig._lastClickTime = state._lastClickTime
eventHandlerConfig._lastClickButton = state._lastClickButton
eventHandlerConfig._clickCount = state._clickCount or 0
eventHandlerConfig._dragStartX = state._dragStartX or {}
eventHandlerConfig._dragStartY = state._dragStartY or {}
eventHandlerConfig._lastMouseX = state._lastMouseX or {}
eventHandlerConfig._lastMouseY = state._lastMouseY or {}
eventHandlerConfig._hovered = state._hovered
end
end
element._eventHandler = Element._EventHandler.new(eventHandlerConfig, Element._eventHandlerDeps)
end
local function onDetach(element)
-- Clear focus callbacks read by KeyboardNavigation / TextEditor:focus so the
-- element's closure references can be collected in immediate mode (formerly
-- part of Element:_cleanup). The EventHandler instance itself is INTENTIONALLY
-- kept: Element:_cleanup preserves element structure for inspection (the
-- stale-element refs are released when the element is GC'd). onEvent,
-- onTouchEvent, onGesture are also left intact — the Renderer/EventHandler
-- read those directly from the element (not the cache), so clearing them
-- would break retained mode.
element.onFocus = nil
element.onBlur = nil
end
-- ----------------------------------------------------------------------------
-- onUpdate — the mouse hit-testing + event-processing + theme-state +
-- immediate-mode save block (formerly Element:update lines ~2813-2960).
-- ----------------------------------------------------------------------------
local function onUpdate(element, dt)
local Element = ElementClass(element)
local eventHandler = element._eventHandler
if not eventHandler then
return
end
local mx, my = love.mouse.getPosition()
-- Clickable area is the border box (x, y already includes padding)
-- BORDER-BOX MODEL: Use stored border-box dimensions for hit detection
local bx = element.x
local by = element.y
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
-- Account for scroll offsets from parent containers
-- Walk up the parent chain and accumulate scroll offsets. This stays in
-- Clickable because it's an interaction concern (hit-testing), not layout.
local scrollOffsetX = 0
local scrollOffsetY = 0
local current = element.parent
while current do
local overflowX = current.overflowX or current.overflow
local overflowY = current.overflowY or current.overflow
local hasScrollableOverflow = (
overflowX == "scroll"
or overflowX == "auto"
or overflowY == "scroll"
or overflowY == "auto"
or overflowX == "hidden"
or overflowY == "hidden"
)
if hasScrollableOverflow then
scrollOffsetX = scrollOffsetX + (current._scrollX or 0)
scrollOffsetY = scrollOffsetY + (current._scrollY or 0)
end
current = current.parent
end
-- Adjust mouse position by accumulated scroll offset for hit testing
local adjustedMx = mx + scrollOffsetX
local adjustedMy = my + scrollOffsetY
local isHovering = adjustedMx >= bx and adjustedMx <= bx + bw and adjustedMy >= by and adjustedMy <= by + bh
-- Check if this is the topmost interactive element at the mouse position
-- (z-index ordering). This prevents blocked/occluded elements from
-- receiving interactions or visual feedback. A single mode-agnostic lookup
-- via `Context.findInteractiveAtPosition` (unified-event-routing task 05)
-- replaces the previous immediate/retained-mode split that used
-- `getTopElementAt` in immediate mode and `_activeEventElement` in retained
-- mode. `findInteractiveAtPosition` routes every hit test through
-- `pointHitsElement` (the single canonical `display == false` guard) and
-- resolves occlusion by z-index in both modes, so the active element is the
-- same one that would receive a hit under the cursor.
local topElement = Element._Context.findInteractiveAtPosition(mx, my)
local isActiveElement = (topElement == element or topElement == nil)
-- Reset scrollbar press flag at start of each frame
eventHandler:resetScrollbarPressFlag()
-- Process mouse events through EventHandler FIRST
-- This ensures pressed states are updated before theme state is calculated
eventHandler:processMouseEvents(element, mx, my, isHovering, isActiveElement)
-- In immediate mode, save EventHandler state to StateManager after
-- processing events so it survives the per-frame recreation.
if element._stateId and Element._Context.isImmediateMode() and element._stateId ~= "" then
local eventHandlerState = eventHandler:getState()
Element._StateManager.updateState(element._stateId, {
_pressed = eventHandlerState._pressed,
_lastClickTime = eventHandlerState._lastClickTime,
_lastClickButton = eventHandlerState._lastClickButton,
_clickCount = eventHandlerState._clickCount,
_dragStartX = eventHandlerState._dragStartX,
_dragStartY = eventHandlerState._dragStartY,
_lastMouseX = eventHandlerState._lastMouseX,
_lastMouseY = eventHandlerState._lastMouseY,
_hovered = eventHandlerState._hovered,
})
end
-- Update theme state based on interaction. themeComponent state update
-- lives in Clickable because it is driven by hover/press state; the actual
-- theme RENDERING is the Themed behavior (task 07).
if element.themeComponent then
-- Check if any button is pressed via EventHandler
local anyPressed = eventHandler:isAnyButtonPressed()
-- Update theme state via ThemeManager
local isFocused = Element._Context.getFocused() == element
local newThemeState =
element._themeManager:updateState(isHovering and isActiveElement, anyPressed, isFocused, element.disabled)
if element._stateId and Element._Context.isImmediateMode() then
local hover = newThemeState == "hover"
local pressed = newThemeState == "pressed"
local focused = isFocused
Element._StateManager.updateState(element._stateId, {
hover = hover,
pressed = pressed,
focused = focused,
disabled = element.disabled,
active = element.active,
})
end
if element._renderer then
element._renderer:setThemeState(newThemeState)
end
end
-- Process touch events through EventHandler
eventHandler:processTouchEvents(element)
end
-- ----------------------------------------------------------------------------
-- onDraw — pressed-state visual feedback (formerly Renderer Layer 5).
-- ----------------------------------------------------------------------------
-- Draws the grey pressed overlay when any mouse button is currently pressed on
-- the element. Delegates the actual pixels to Renderer:drawPressedState (which
-- owns the RoundedRect + opacity math) but drives the DECISION + transform
-- context here, so the renderer no longer needs the `if element.onEvent ...`
-- behavioral branch. Honors disableHighlight (themes handle their own visual
-- feedback) exactly as the old render layer did.
local function onDraw(element)
if element.disableHighlight then
return
end
local eventHandler = element._eventHandler
if not eventHandler then
return
end
local anyPressed = false
local pressedState = eventHandler:getState()._pressed or {}
for _, pressed in pairs(pressedState) do
if pressed then
anyPressed = true
break
end
end
if not anyPressed then
return
end
local renderer = element._renderer
if not renderer then
return
end
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
-- Apply the element transform around the overlay, mirroring how the
-- Renderer wrapped its whole command buffer (pressed state was a render
-- layer subject to the same transform).
local Element = ElementClass(element)
local Transform = Element._Transform
local hasTransform = element.transform ~= nil and Transform ~= nil and not Transform.isIdentity(element.transform)
if hasTransform then
Transform.apply(element.transform, element.x, element.y, element.width, element.height)
end
renderer:drawPressedState(element.x, element.y, bw, bh, element.opacity, element.cornerRadius)
if hasTransform then
Transform.unapply()
end
end
-- ----------------------------------------------------------------------------
-- saveState / restoreState — EventHandler state (formerly the eventHandler
-- branches of Element:saveState / Element:restoreState).
-- ----------------------------------------------------------------------------
local function saveState(element)
if element._eventHandler then
return { eventHandler = element._eventHandler:getState() }
end
return nil
end
local function restoreState(element, state)
if not state then
return nil
end
if element._eventHandler and state.eventHandler then
element._eventHandler:setState(state.eventHandler)
end
return nil
end
-- ----------------------------------------------------------------------------
-- Build the (stateless, shared, immutable) behavior instance.
-- ----------------------------------------------------------------------------
local Clickable = Behavior.new({
onAttach = onAttach,
onDetach = onDetach,
onUpdate = onUpdate,
onDraw = onDraw,
saveState = saveState,
restoreState = restoreState,
shouldAttach = shouldAttach,
})
-- Expose the predicate at module level so callers/tests can reference it
-- directly without an element instance (mirrors Behavior.shouldAttach).
Clickable.shouldAttach = shouldAttach
return Clickable
@@ -1,282 +0,0 @@
-- modules/behaviors/Imageable.lua
--
-- Concrete behavior: image loading + image rendering config.
--
-- Imageable owns the image side of the Renderer: it runs the deferred image-
-- load pipeline (cache check → defer → load → fire onImageLoad/onImageError
-- callbacks), populates the resolved `_loadedImage` cache on both the element
-- and the shared renderer, and persists that cache across immediate-mode
-- recreation. It is the behavior-mode-unification replacement for the image-
-- loading half of Element:_initImageAndRenderer and the deferred
-- Element:_loadImage method (behavior-mode-unification task 07).
--
-- Image value props (imagePath/image/objectFit/objectPosition/imageOpacity/
-- imageRepeat/imageTint) are bound on the ELEMENT by Element:_applyProps and read
-- from the element at draw time (Renderer._executeDrawCommand image branch) —
-- Imageable does NOT mirror them onto the renderer, so bare writes and
-- setProperty(...) are immediately consistent. Only the resolved _loadedImage
-- cache (the love.Image produced by the load pipeline) is renderer-mirrored,
-- because Renderer:draw reads `self._loadedImage`.
--
-- Runtime reload: setProperty("imagePath", ...) / setProperty("image", ...) and
-- the bare-write-equivalent setImage* flows route through element._reloadImage
-- (installed below) which re-runs the load pipeline. See
-- TestRetainedPropertyConsistency (image props) and TestImageableIntegration.
--
-- Attachment rule (shouldAttach): an element owns image concern exactly when it
-- declares an `imagePath` (load-from-path) or a direct `image` (already-loaded
-- love.Image). Mirrors the old `if self.imagePath / if self.image` init branches.
--
-- Pairing with Themed: Themed.onAttach creates the Renderer with theme/blur
-- config; Imageable.onAttach enriches the SAME renderer instance with image
-- config + kicks off loading. They share `element._renderer`. In the registry
-- Imageable runs after Themed, so the renderer already exists; the create-or-
-- reuse guard below covers the defensive case where Imageable attaches first.
--
-- onDraw: the image LAYER is rendered by the integrated `Renderer:draw` call
-- (owned by the Themed behavior) which executes the renderer's `image` draw
-- command using the config Imageable.onAttach wired. Imageable.onDraw is
-- therefore a no-op for the draw call itself — there is no separate
-- `_renderer:_drawImage` entry point; pixel emission lives in the integrated
-- Renderer:draw command buffer. Splitting it out would require Renderer surgery
-- with no behavioral gain (Renderer:draw already conditionally skips the image
-- layer when no image is loaded).
--
-- State ownership (per the locked Behavior contract):
-- * Per-element runtime state lives ON THE ELEMENT (`element._loadedImage`,
-- `element._renderer._loadedImage`). The behavior instance is stateless.
-- * saveState/restoreState persist `_loadedImage` across immediate-mode frames
-- so the image renders even if the ImageCache is cleared between frames and
-- so the renderer's loaded-image cache survives element recreation.
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
local Behavior = require(_pkg .. "Behavior")
-- Lua 5.4 removed the global `unpack`; mirror Element's alias.
local unpack = table.unpack or unpack
-- Resolve the Element class from an element instance (mirrors Clickable/Themed).
local function ElementClass(element)
return getmetatable(element)
end
-- ----------------------------------------------------------------------------
-- shouldAttach (class-level predicate, no element required)
-- ----------------------------------------------------------------------------
local function shouldAttach(props)
props = props or {}
return props.imagePath ~= nil or props.image ~= nil
end
-- ----------------------------------------------------------------------------
-- Image callback helper (moved from Element._fireImageCallback).
-- Fires a user-supplied image callback (onImageLoad/onImageError) under pcall,
-- honoring the onXDeferred flag when `honorDeferred` is true, and emits a single
-- EVT_002 warn on failure. The direct-`image` sync init path passes
-- honorDeferred=false to preserve immediate firing (image is already loaded).
-- ----------------------------------------------------------------------------
local function fireImageCallback(element, callbackField, honorDeferred, ...)
local cb = element[callbackField]
if type(cb) ~= "function" then
return
end
local Element = ElementClass(element)
local argc = select("#", ...)
local args = { ... }
local function invoke()
local ok, err = pcall(cb, element, unpack(args, 1, argc))
if not ok then
Element._ErrorHandler:warn("Element", "EVT_002", {
callback = callbackField,
error = tostring(err),
})
end
end
if honorDeferred and element[callbackField .. "Deferred"] then
Element._Context.deferCallback(invoke)
else
invoke()
end
end
-- ----------------------------------------------------------------------------
-- Deferred image loader (replaces Element:_loadImage).
--
-- Invoked by Element's deferred-method dispatcher via the instance closure that
-- onAttach installs on `element._loadImage`. Loads the image from cache or disk
-- (I/O), updates BOTH the element and renderer `_loadedImage` caches so the
-- image draws after an async load, and fires the load/error callback (deferred,
-- honoring onImageLoadDeferred / onImageErrorDeferred).
-- ----------------------------------------------------------------------------
local function loadImage(element)
if not element.imagePath or element.image then
return
end
local Element = ElementClass(element)
local loadedImage, err = Element._ImageCache.load(element.imagePath)
if loadedImage then
element._loadedImage = loadedImage
if element._renderer then
element._renderer._loadedImage = loadedImage
end
fireImageCallback(element, "onImageLoad", true, loadedImage)
else
fireImageCallback(element, "onImageError", true, err or "Unknown error")
end
end
-- ----------------------------------------------------------------------------
-- reloadImage — recompute the loaded-image cache from the current image/imagePath.
--
-- This is the single entry point for (re)loading after either initial attach or
-- a runtime property change (see Element._specialSetHandlers.imagePath/image,
-- which call element:_reloadImage()). Precedence matches onAttach: a direct
-- `image` wins over `imagePath`; `nil` for both clears the cache.
--
-- * direct image → set _loadedImage immediately, fire onImageLoad SYNC (the
-- image is already loaded; honorDeferred=false preserves the
-- original synchronous init contract).
-- * imagePath → cache CHECK only (no I/O) so a cached image can draw this
-- frame, then defer the loader (_loadImage) for the actual
-- I/O + deferred callbacks. load bails if `image` is later set.
-- * neither → clear _loadedImage on both element + renderer.
--
-- Image value props (objectFit/imageOpacity/imageRepeat/imageTint/objectPosition)
-- and imagePath/image themselves live on the ELEMENT as source of truth; the
-- renderer reads them at draw time, so reloadImage does NOT mirror them onto the
-- renderer — only the resolved _loadedImage cache is pushed.
-- ----------------------------------------------------------------------------
local function reloadImage(element)
local Element = ElementClass(element)
local renderer = element._renderer
if element.image then
element._loadedImage = element.image
if renderer then
renderer._loadedImage = element.image
end
fireImageCallback(element, "onImageLoad", false, element.image)
elseif element.imagePath then
-- Cache check (no I/O). Populate both caches immediately if cached so the
-- image can draw this frame without waiting for the deferred load.
local cached = Element._ImageCache.get(element.imagePath)
element._loadedImage = cached
if renderer then
renderer._loadedImage = cached
end
-- Kick off the deferred I/O load + callbacks (idempotent: loadImage bails
-- if image is set or imagePath is nil by the time it runs).
if element._loadImage then
element:_deferMethod("_loadImage")
end
else
element._loadedImage = nil
if renderer then
renderer._loadedImage = nil
end
end
end
-- ----------------------------------------------------------------------------
-- onAttach — enrich the shared renderer with image config + kick off loading
-- (formerly the image block of Element:_initImageAndRenderer).
-- ----------------------------------------------------------------------------
local function onAttach(element)
local Element = ElementClass(element)
-- Ensure the renderer exists (Thamed normally creates it; this create-or-reuse
-- guard is defensive for the Imageable-attaches-first ordering).
if not element._renderer then
element._renderer = Element._Renderer.new({
theme = element.theme,
scaleCorners = element.scaleCorners,
scalingAlgorithm = element.scalingAlgorithm,
contentBlur = element.contentBlur,
backdropBlur = element.backdropBlur,
}, Element._rendererDeps)
end
-- Install the (re)load hooks as instance methods so Element's
-- deferred-method dispatcher / setProperty special handlers can trigger a
-- reload without Element needing a behavior reference. This keeps Element
-- decoupled from the Imageable behavior (mirrors the stateless-behavior +
-- element-owned-state contract). Image value props and imagePath/image live
-- on the element as source of truth (read at draw time); only the resolved
-- _loadedImage cache is mirrored onto the renderer by reloadImage.
element._loadImage = function(el)
loadImage(el)
end
element._reloadImage = function(el)
reloadImage(el)
end
-- Initial load: compute _loadedImage + defer the I/O load.
reloadImage(element)
end
-- ----------------------------------------------------------------------------
-- onDraw — no-op (see file header: the image layer is rendered by the integrated
-- Renderer:draw call owned by the Themed behavior, using the config wired here).
-- ----------------------------------------------------------------------------
-- ----------------------------------------------------------------------------
-- saveState / restoreState — `_loadedImage` cache (for immediate-mode).
-- ----------------------------------------------------------------------------
local function saveState(element)
if element._loadedImage ~= nil then
return { _loadedImage = element._loadedImage }
end
return nil
end
local function restoreState(element, state)
if not state or state._loadedImage == nil then
return nil
end
local loadedImage = state._loadedImage
element._loadedImage = loadedImage
if element._renderer then
element._renderer._loadedImage = loadedImage
end
return nil
end
-- ----------------------------------------------------------------------------
-- onDetach — release image-load callback closures so the element can be GC'd
-- cleanly in immediate mode (formerly part of Element:_cleanup). The cached
-- `_loadedImage` is reproduced on the next attach via the Imageable saveState
-- -> restoreState cycle, so dropping the live references is always safe.
-- ----------------------------------------------------------------------------
local function onDetach(element)
element.onImageLoad = nil
element.onImageError = nil
end
-- ----------------------------------------------------------------------------
-- Build the (stateless, shared, immutable) behavior instance.
-- ----------------------------------------------------------------------------
local Imageable = Behavior.new({
onAttach = onAttach,
onDetach = onDetach,
onUpdate = function() end,
onDraw = function() end,
saveState = saveState,
restoreState = restoreState,
shouldAttach = shouldAttach,
})
-- Expose the predicate at module level so callers/tests can reference it
-- directly without an element instance (mirrors Behavior.shouldAttach /
-- Clickable.shouldAttach). `loadImage` is NOT exposed on the (frozen) behavior
-- instance; it is captured as a module-local upvalue by the onAttach closure that
-- installs `element._loadImage`.
Imageable.shouldAttach = shouldAttach
return Imageable
@@ -1,132 +0,0 @@
-- modules/behaviors/Persistable.lua
--
-- Concrete behavior: generic public-property persistence across the immediate-
-- mode recreation cycle (behavior-mode-unification task 12).
--
-- Owns the ONE piece of Element save/restore state that is NOT subsystem state:
-- the snapshot of an element's own public scalar fields (`text`, `display`,
-- `opacity`, `x`, `width`, ...). Event-driven mutations to these fields (a
-- release callback changing `text`, a toggle hiding a panel via `display =
-- false`) must survive the per-frame Element recreation that defines immediate
-- mode. Persistable captures them in `saveState` and reapplies them in
-- `restoreState`, so the caller never branches on mode.
--
-- This behavior is the final home for the former `Element:saveState` `_props`
-- block and the former `Element:restoreState` `_props` block (~20 LOC moved out
-- of Element.lua). With it in place, `Element:saveState` / `Element:restoreState`
-- collapse to a pure behavior-dispatch loop and Element owns zero property-
-- extraction logic — every persisted slice is owned by exactly one behavior.
--
-- Attachment rule (shouldAttach): every element. Persistable attaches
-- unconditionally (mirrors the pre-refactor invariant that every element's
-- public scalar props were scanned). The actual snapshot is mode-gated inside
-- `saveState` (immediate-mode-only, matching the legacy contract); in retained
-- mode `saveState` returns nil and `restoreState` is a no-op unless a snapshot
-- is explicitly passed.
--
-- Registry ordering: Persistable is intentionally placed LAST in the behavior
-- registry. `restoreState` applies `_props` AFTER every other behavior has
-- hydrated its subsystem state, so a persisted public-prop mutation (e.g.
-- `text = "mutated"`) overrides the freshly-restored TextEditor/Select state —
-- preserving the legacy restore ordering (behaviors first, `_props` tail).
--
-- State ownership (per the locked Behavior contract):
-- * The persisted props live ON the element (they ARE the element's public
-- fields). The behavior instance is stateless + immutable and shared.
-- * The snapshot is returned under the `_props` key (prefixed with `_` so
-- the public-prop scan itself skips it — avoiding self-recursion).
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
local Behavior = require(_pkg .. "Behavior")
-- Resolve the Element class from an element instance (mirrors Clickable /
-- Themed). Element instances are created via `setmetatable({}, Element)`, so
-- their metatable IS the Element class — giving access to Element._StateManager
-- without threading deps through the hook signature.
local function ElementClass(element)
return getmetatable(element)
end
-- ============================================================================
-- shouldAttach (class-level predicate, no element required)
-- ============================================================================
-- Every element's public scalar props are persistable, so this behavior
-- attaches unconditionally. The mode gate lives inside saveState (it needs the
-- runtime mode, which is only available with an element via StateManager).
local function shouldAttach()
return true
end
-- ============================================================================
-- saveState — snapshot public scalar fields (immediate-mode-only).
-- ============================================================================
-- Mirrors the former `Element:saveState` `_props` block exactly:
-- * Only string keys NOT prefixed with `_` (so internal fields like
-- `_renderer`, `_themeState`, `_initProps` are excluded).
-- * Only scalar values (numbers, strings, booleans); tables and functions
-- are excluded (children, padding, onEvent, ...).
-- Returns `{ _props = {...} }` when there is at least one persistable prop and
-- the element is in immediate mode; nil otherwise (retained mode no-op —
-- state lives on the element directly there, so nothing to snapshot).
local function saveState(element)
local Element = ElementClass(element)
if not Element._StateManager.isImmediateMode() then
return nil
end
local props = {}
for k, v in pairs(element) do
if type(k) == "string" and k:sub(1, 1) ~= "_" and type(v) ~= "table" and type(v) ~= "function" then
props[k] = v
end
end
if next(props) then
return { _props = props }
end
return nil
end
-- ============================================================================
-- restoreState — reapply the persisted public-prop snapshot onto a fresh
-- element (mode-agnostic; only fires when a `_props` slice is present).
-- ============================================================================
-- Applies persisted mutations on top of whatever the constructor + other
-- behaviors already set, so event-driven changes from the previous frame
-- override the declarative props of the recreated element. Runs last in the
-- behavior dispatch (Persistable is the registry tail) to preserve the legacy
-- restore ordering (subsystem restore first, `_props` override last).
local function restoreState(element, state)
if not state or not state._props then
return
end
for k, v in pairs(state._props) do
element[k] = v
end
end
-- ============================================================================
-- onAttach / onUpdate / onDraw / onDetach — no-ops.
-- ============================================================================
-- Persistable owns no subsystem and allocates no per-element state (the
-- "state" it persists IS the element's own fields). The lifecycle is purely
-- save/restore.
-- ============================================================================
-- Build the (stateless, shared, immutable) behavior instance.
-- ============================================================================
local Persistable = Behavior.new({
saveState = saveState,
restoreState = restoreState,
shouldAttach = shouldAttach,
})
-- Expose the predicate at module level so callers/tests can reference it
-- directly without an element instance (mirrors Behavior.shouldAttach /
-- Clickable.shouldAttach).
Persistable.shouldAttach = shouldAttach
return Persistable
@@ -1,264 +0,0 @@
-- modules/behaviors/Scrollable.lua
--
-- Concrete behavior: ScrollManager lifecycle (creation + immediate-mode
-- scrollbar interaction-state restore).
--
-- Scrollable owns the per-element ScrollManager instance — the subsystem that
-- manages overflow detection, scrollbar geometry, scroll position, and scrollbar
-- drag/hover interaction. It is the behavior-mode-unification replacement for
-- the former `Element:_initScrollManager` phase (~84 LOC) of Element.new
-- (behavior-mode-unification task 03 / landed as part of the task 08 capstone).
--
-- Attachment rule (shouldAttach): an element owns a ScrollManager exactly when
-- it declares an `overflow`, `overflowX`, or `overflowY` prop — mirroring the
-- legacy `if props.overflow or props.overflowX or props.overflowY then` guard
-- in `Element:_initScrollManager`. The ScrollManager is created and its
-- normalized fields are exposed back onto the element (so the Renderer /
-- ScrollManager delegates read `element.overflow` / `element.scrollbarWidth`
-- etc.) exactly as the legacy inline phase did.
--
-- Why onAttach reads `element._initProps` (not element fields): the scrollbar
-- configuration props (scrollbarWidth / scrollbarColor / scrollSpeed /
-- scrollbarPlacement / scrollbarBalance / invertScroll / smoothScrollEnabled /
-- scrollBarStyle / scrollbarKnobOffset / hideScrollbars / scrollbarRadius /
-- scrollbarPadding / scrollbarTrackColor / _scrollX / _scrollY) are listed in
-- SPECIAL_PROPS and therefore NOT bound onto the element by the schema-driven
-- `_applyProps` loop — they are consumed only by the ScrollManager constructor.
-- The locked behavior hook signature is `(element, ...)` with no props arg, so
-- the original construction props are stashed on the element as `_initProps` by
-- `Element:_construct` and read back here. (`overflow` / `overflowX` /
-- `overflowY` ARE bound onto the element by `_applyProps` so that
-- `Element:addChild`'s scroll-container auto-size guard sees them during
-- declarative-children processing in `_finalizeConstruction`, which runs BEFORE
-- this onAttach; onAttach then overwrites them with the ScrollManager's
-- normalized values, matching the legacy field-exposure order.)
--
-- onUpdate / onDraw / saveState / restoreState are deferred to the
-- behavior-driven update/draw tasks (09 / 12): the ScrollManager update,
-- interaction, scrollbar drawing, and state save/restore currently stay inline
-- in `Element:update` / `Element:draw` / `Element:saveState` /
-- `Element:restoreState` (delegated through the ScrollManager API bound in
-- `Element.init`). Those inline call sites are NOT behavioral `if` branches —
-- they are unconditional 1-line delegates — so leaving them in Element does not
-- regress the behavior-dispatch goals of tasks 09/12; task 09 will fold them
-- into Scrollable hooks.
--
-- State ownership (per the locked Behavior contract):
-- * Per-element runtime state lives ON THE ELEMENT (`element._scrollManager`,
-- `element.overflow`, `element._scrollX`, `element._scrollbarDragging`, ...).
-- * The behavior instance is stateless + immutable and shared across elements.
-- * Element-class-level dependencies (`Element._ScrollManager`,
-- `Element._scrollManagerDeps`, `Element._Context`, `Element._StateManager`)
-- are resolved from the owning element's metatable (the Element class set by
-- `Element:_construct`).
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
local Behavior = require(_pkg .. "Behavior")
-- Resolve the Element class from an element instance.
-- `setmetatable({}, Element)` in `_construct` makes the instance metatable BE
-- the Element class, so this yields Element._ScrollManager,
-- Element._scrollManagerDeps, Element._Context, Element._StateManager without
-- threading deps through the hook signature.
local function ElementClass(element)
return getmetatable(element)
end
-- ----------------------------------------------------------------------------
-- shouldAttach (class-level predicate, no element required)
-- ----------------------------------------------------------------------------
-- Mirrors the legacy `if props.overflow or props.overflowX or props.overflowY`
-- guard. Uses `~= nil` (rather than truthiness) so that an explicit
-- `overflow = false` / `overflow = ""` does not spuriously attach — though in
-- practice overflow values are always strings or unset, matching the predicate
-- semantics of the other behaviors (Clickable / TextEditable / Selectable).
local function shouldAttach(props)
props = props or {}
return props.overflow ~= nil or props.overflowX ~= nil or props.overflowY ~= nil
end
-- ----------------------------------------------------------------------------
-- onAttach — create the ScrollManager + expose its fields + restore immediate-
-- mode scrollbar interaction state (formerly Element:_initScrollManager).
-- ----------------------------------------------------------------------------
local function onAttach(element)
local Element = ElementClass(element)
-- Construction props are stashed on the element by _construct (the scrollbar
-- config props are SPECIAL_PROPS and not bound as element fields).
local props = element._initProps or {}
element._scrollManager = Element._ScrollManager.new({
overflow = props.overflow,
overflowX = props.overflowX,
overflowY = props.overflowY,
scrollbarWidth = props.scrollbarWidth,
scrollbarColor = props.scrollbarColor,
scrollbarTrackColor = props.scrollbarTrackColor,
scrollbarRadius = props.scrollbarRadius,
scrollbarPadding = props.scrollbarPadding,
scrollSpeed = props.scrollSpeed,
invertScroll = props.invertScroll,
smoothScrollEnabled = props.smoothScrollEnabled,
scrollBarStyle = props.scrollBarStyle,
scrollbarKnobOffset = props.scrollbarKnobOffset,
hideScrollbars = props.hideScrollbars,
scrollbarPlacement = props.scrollbarPlacement,
scrollbarBalance = props.scrollbarBalance,
_scrollX = props._scrollX,
_scrollY = props._scrollY,
}, Element._scrollManagerDeps)
-- Expose ScrollManager properties for backward compatibility (Renderer access).
local sm = element._scrollManager
element.overflow = sm.overflow
element.overflowX = sm.overflowX
element.overflowY = sm.overflowY
element.scrollbarWidth = sm.scrollbarWidth
element.scrollbarColor = sm.scrollbarColor
element.scrollbarTrackColor = sm.scrollbarTrackColor
element.scrollbarRadius = sm.scrollbarRadius
element.scrollbarPadding = sm.scrollbarPadding
element.scrollSpeed = sm.scrollSpeed
element.invertScroll = sm.invertScroll
element.scrollBarStyle = sm.scrollBarStyle
element.scrollbarKnobOffset = sm.scrollbarKnobOffset
element.hideScrollbars = sm.hideScrollbars
element.scrollbarPlacement = sm.scrollbarPlacement
element.scrollbarBalance = sm.scrollbarBalance
-- Initialize state properties (will be synced from ScrollManager).
element._overflowX = false
element._overflowY = false
element._contentWidth = 0
element._contentHeight = 0
element._scrollX = 0
element._scrollY = 0
element._maxScrollX = 0
element._maxScrollY = 0
element._scrollbarHoveredVertical = false
element._scrollbarHoveredHorizontal = false
element._scrollbarDragging = false
element._hoveredScrollbar = nil
element._scrollbarDragOffset = 0
-- Restore scrollbar state from StateManager in immediate mode (must happen
-- before layout). Mirrors the legacy _initScrollManager restore block.
-- Mode-aware via Context.isImmediateMode (behavior-mode-unification task 11).
if Element._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then
local state = Element._StateManager.getState(element._stateId)
if state and state.scrollManager then
element._scrollbarHoveredVertical = state.scrollManager._scrollbarHoveredVertical or false
element._scrollbarHoveredHorizontal = state.scrollManager._scrollbarHoveredHorizontal or false
element._scrollbarDragging = state.scrollManager._scrollbarDragging or false
element._hoveredScrollbar = state.scrollManager._hoveredScrollbar
element._scrollbarDragOffset = state.scrollManager._scrollbarDragOffset or 0
-- Apply to ScrollManager immediately.
sm._scrollbarHoveredVertical = element._scrollbarHoveredVertical
sm._scrollbarHoveredHorizontal = element._scrollbarHoveredHorizontal
sm._scrollbarDragging = element._scrollbarDragging
sm._hoveredScrollbar = element._hoveredScrollbar
sm._scrollbarDragOffset = element._scrollbarDragOffset
-- Restore drag start positions for relative movement tracking.
sm._dragStartMouseX = state.scrollManager._dragStartMouseX or 0
sm._dragStartMouseY = state.scrollManager._dragStartMouseY or 0
sm._dragStartScrollX = state.scrollManager._dragStartScrollX or 0
sm._dragStartScrollY = state.scrollManager._dragStartScrollY or 0
end
end
end
-- --------------------------------------------------------------------------
-- onUpdate — scroll-position momentum + scrollbar hover/drag/press interaction
-- (formerly the inline ScrollManager blocks in Element:update).
-- Runs BEFORE Clickable.onUpdate in the registry so the scrollbar press flag
-- is set before Clickable's EventHandler processes mouse events.
-- --------------------------------------------------------------------------
local function onUpdate(element, dt)
local Element = ElementClass(element)
local sm = element._scrollManager
if not sm then
return
end
-- Restore scrollbar interaction state from StateManager in immediate mode
-- (no-op outside immediate mode / when no state is stored).
Element._ScrollManager.restoreImmediateState(element)
-- Smooth-scroll / momentum interpolation.
sm:update(dt)
element:_syncScrollManagerState()
-- Scrollbar hover / drag / press interaction. Captures the mouse here so the
-- interaction state is consistent across the rest of the frame's behaviors.
local mx, my = love.mouse.getPosition()
Element._ScrollManager.updateInteraction(element, mx, my)
end
-- --------------------------------------------------------------------------
-- onDraw — scrollbar rendering (post-children overlay). Marked
-- `drawLayer = "overlay"` so Element:draw dispatches it AFTER children, so
-- scrollbars paint on top of clipped child content and without parent clipping.
-- --------------------------------------------------------------------------
local function onDraw(element, _ctx)
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
if overflowX ~= "scroll" and overflowX ~= "auto" and overflowY ~= "scroll" and overflowY ~= "auto" then
return
end
local scrollbarDims = element:_calculateScrollbarDimensions()
if not (scrollbarDims.vertical.visible or scrollbarDims.horizontal.visible) then
return
end
-- Clear any parent scissor clipping before drawing scrollbars so they render
-- fully visible (scrollbars must not be clipped by ancestor overflow).
love.graphics.setScissor()
element._renderer:drawScrollbars(element, element.x, element.y, element.width, element.height, scrollbarDims)
end
-- --------------------------------------------------------------------------
-- saveState / restoreState — ScrollManager state snapshot for immediate-mode
-- recreation (formerly the inline blocks in Element:saveState/
-- Element:restoreState). Returns a table merged under the `scrollManager` key
-- by Element:saveState's behavior loop, mirroring the legacy contract.
-- --------------------------------------------------------------------------
local function saveState(element)
local sm = element._scrollManager
if not sm then
return nil
end
return { scrollManager = sm:getState() }
end
local function restoreState(element, state)
if not state then
return
end
local sm = element._scrollManager
local smState = state.scrollManager
if sm and smState then
sm:setState(smState)
end
end
local Scrollable = Behavior.new({
onAttach = onAttach,
onDetach = function() end,
onUpdate = onUpdate,
onDraw = onDraw,
saveState = saveState,
restoreState = restoreState,
drawLayer = "overlay",
})
-- Expose the predicate at module level so callers/tests can reference it
-- directly without an element instance (mirrors Clickable.shouldAttach /
-- Selectable.shouldAttach).
Scrollable.shouldAttach = shouldAttach
return Scrollable
@@ -1,206 +0,0 @@
-- modules/behaviors/Selectable.lua
--
-- Concrete behavior: Select state-machine lifecycle for dropdown-style
-- select groups. Owns the per-element Select subsystem initialization, the
-- managed-frame layout sync each frame, and select save/restore across the
-- immediate-mode recreation cycle.
--
-- This behavior consolidates the legacy `if self._selectState` / `if
-- self.selectOption` branches that previously lived inside Element.lua:
--
-- * Select subsystem init (formerly Element:_initSubSystems lines ~810-825 —
-- `Select.initSelectParent` / `Select.initSelectOption`).
-- * Managed-frame adoption (formerly Element:_initPositioning lines ~1700-
-- 1702 — `Select.adoptSelectFrame`).
-- * Per-frame frame-state sync (formerly Element:update line ~2747 —
-- `Select.ensureFrameState`).
-- * Save/restore of select open/value/label (formerly the `select` branch of
-- Element:saveState / Element:restoreState).
--
-- Element retains `self._selectState` and `self.selectOption` for backward-
-- compat field access; runtime state lives ON THE ELEMENT. The behavior itself
-- is stateless + immutable (a single shared instance attaches to every
-- selectable element).
--
-- The 20 Element select-API delegate methods (openSelect, closeSelect,
-- toggleSelect, isSelectOpen, getSelectValue, setSelectValue, ...) stay as
-- 1-line forwarders into the Select module — the behavior owns the
-- *lifecycle* (attach / update / save / restore / detach), not the API
-- surface (per task 05 spec notes).
--
-- State ownership (per the locked Behavior contract):
-- * Per-element runtime state lives ON THE ELEMENT (self._selectState,
-- self.selectOption, self._selectParentElement, ...).
-- * The behavior instance is stateless + immutable and shared across elements.
-- * Element-class-level dependencies are resolved via `getmetatable(element)`
-- (which IS the Element class set by Element._construct), so the hook
-- signature stays exactly `(element, ...)` with no DI parameters.
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
local Behavior = require(_pkg .. "Behavior")
-- Resolve the Element class from an element instance.
-- `setmetatable({}, Element)` in `_construct` makes the instance metatable BE
-- the Element class, so this yields Element._Select, Element._Context,
-- Element._StateManager, etc. without threading deps through the hook signature.
local function ElementClass(element)
return getmetatable(element)
end
-- ============================================================================
-- shouldAttach (class-level predicate, no element required)
-- ============================================================================
-- Mirrors the cases that previously caused Element to initialize a Select
-- subsystem. An element owns select state exactly when it declares a
-- `selectParent` config (the dropdown trigger) or a `selectOption` config (an
-- option inside a dropdown). Checking the props (rather than the runtime
-- `_selectState`) lets shouldAttach run before onAttach initializes the
-- subsystem, matching the auto-attach contract established by Clickable /
-- TextEditable.
local function shouldAttach(props)
props = props or {}
return type(props.selectParent) == "table" or type(props.selectOption) == "table"
end
-- ============================================================================
-- onAttach — initialize the Select subsystem (formerly Element:_initSubSystems
-- lines ~810-825) and adopt the managed frame (formerly Element:_initPositioning
-- lines ~1700-1702).
-- ============================================================================
local function onAttach(element)
local Element = ElementClass(element)
-- Initialize the appropriate select role. Mirrors the legacy _initSubSystems
-- block exactly: selectParent → initSelectParent (sets _selectState +
-- immediate-mode restore from StateManager); selectOption → initSelectOption
-- (sets the option value/label/disabled).
if type(element.selectParent) == "table" then
Element._Select.initSelectParent(element, element.selectParent)
end
if type(element.selectOption) == "table" then
Element._Select.initSelectOption(element, element.selectOption)
end
-- Adopt the managed dropdown frame. This was formerly the tail of
-- _initPositioning (after the select parent's own addChild). It creates the
-- select anchor, reparents the frame under it, and syncs visibility. Moving
-- it here is safe because onAttach runs after _initPositioning: the parent's
-- own positioning is finalized, so the anchor's geometry can be computed.
if element._selectState and type(element.selectParent) == "table" and element.selectParent.selectFrame ~= nil then
Element._Select.adoptSelectFrame(element, element.selectParent.selectFrame)
end
-- Backfill option registration for children added BEFORE this behavior
-- attached. The auto-attach pass runs at the very end of Element.new
-- (after _finalizeConstruction, which processes declarative `children`).
-- Declarative select-option children are addChild'd to this element during
-- _finalizeConstruction — at that point _selectState did not yet exist (this
-- onAttach had not run), so their registerWithSelectParent call walked the
-- parent chain, found no _selectState, and returned early. Re-scan now that
-- _selectState is initialized so these options are registered + reparented
-- into the managed frame exactly like runtime-added options.
-- (registerWithSelectParent is idempotent — it skips options already
-- registered — so this is a no-op for children added after _selectState was
-- set, e.g. the common `FlexLove.new({ parent = sp, selectOption = {...} })`
-- pattern.)
if element._selectState then
for _, child in ipairs(element.children) do
if child.selectOption then
Element._Select.registerWithSelectParent(child)
Element._Select.attachOptionToManagedFrame(child)
end
end
end
end
local function onDetach(element)
-- Clear select-managed fields so the element can be GC'd cleanly in immediate
-- mode (formerly part of Element:_cleanup). This mirrors the select-clearing
-- block that lived in Element:_cleanup; Element:destroy separately routes
-- through Select.cleanupDestroy for full teardown (idempotent with this).
if element.selectParent then
element.selectParent.onChange = nil
end
element._selectState = nil
element._managedSelectOwner = nil
element._managedSelectFrame = nil
element._managedSelectAnchor = nil
element._managedSelectBaseOpacity = nil
element._managedSelectBaseVisibility = nil
element._managedSelectBaseDisabled = nil
end
-- ============================================================================
-- onUpdate — per-frame managed-frame layout sync (formerly Element:update
-- line ~2747 — `Select.ensureFrameState`).
-- ============================================================================
local function onUpdate(element, dt)
local Element = ElementClass(element)
Element._Select.ensureFrameState(element)
end
-- ============================================================================
-- onDraw — no-op.
-- ============================================================================
-- Select rendering is driven by the managed frame / anchor elements themselves
-- (visibility synced by Select.syncManagedFrameVisibility), not by the select
-- parent's draw path. The parent's own pixels are the theme/renderer's job.
local function onDraw() end
-- ============================================================================
-- saveState / restoreState — select open/value/label (formerly the `select`
-- branch of Element:saveState / Element:restoreState).
-- ============================================================================
-- Returns a snapshot under the `select` key to match the legacy immediate-mode
-- restoreState contract (Element:restoreState looked up state.select). The
-- behavior-dispatch loop merges behavior snapshots into the top-level state
-- table, so returning { select = ... } slots in identically to the old inline
-- `state.select = selectState` assignment.
local function saveState(element)
local Element = ElementClass(element)
local selectState = Element._Select.saveState(element)
if selectState then
return { select = selectState }
end
return nil
end
-- Consumes the previously-saved snapshot keyed under `select`. The behavior-
-- dispatch loop passes the FULL top-level state table; this hook reads only
-- its own `state.select` slice, mirroring the legacy `if state.select then`
-- guard in Element:restoreState.
local function restoreState(element, state)
if not state then
return
end
local Element = ElementClass(element)
if state.select then
Element._Select.restoreState(element, state.select)
end
end
-- ============================================================================
-- Build the (stateless, shared, immutable) behavior instance.
-- ============================================================================
local Selectable = Behavior.new({
onAttach = onAttach,
onDetach = onDetach,
onUpdate = onUpdate,
onDraw = onDraw,
saveState = saveState,
restoreState = restoreState,
shouldAttach = shouldAttach,
})
-- Expose the predicate at module level so callers/tests can reference it
-- directly without an element instance (mirrors Behavior.shouldAttach).
Selectable.shouldAttach = shouldAttach
return Selectable
@@ -1,576 +0,0 @@
-- modules/behaviors/TextEditable.lua
--
-- Concrete behavior: TextEditor subsystem ownership — text editing, cursor
-- management, text selection, text-related input handling, and text-editor
-- state save/restore.
--
-- This behavior consolidates the legacy `if self._textEditor` nil-guard
-- patterns that previously lived inside Element.lua:
--
-- * TextEditor creation + immediate-mode state restore (formerly
-- Element:_initSubSystems lines ~813-830 — the `if self.editable then
-- self._textEditor = Element._TextEditor.new {...}` block).
-- * Cursor-blink update (formerly Element:update line ~2810 —
-- `if self._textEditor then self._textEditor:update(self, dt) end`).
-- * The 27 text-editor delegate methods (formerly Element:setText /
-- getText / setCursorPosition / setSelection / focus / textinput /
-- keypressed / _handleTextClick / _handleTextDrag / ...). Each was a 3-line
-- nil-guard stub (check `_textEditor`, forward call, end). They are now
-- module-level functions on this behavior; Element retains only 1-line
-- forwarders that route through `Element._TextEditable.<fn>(self, ...)`.
-- * Text-editor state save/restore (formerly the textEditor branch of
-- Element:saveState / Element:restoreState), including the cursor/selection
-- field sync and the text-selection drag-tracking fields
-- (`_mouseDownPosition` / `_textDragOccurred`).
--
-- Element retains the `self._textEditor` field for backward-compat field
-- access (Renderer:drawText reads it directly for cursor/selection rendering);
-- runtime state lives ON THE ELEMENT. The behavior itself is stateless +
-- immutable + shared across elements.
--
-- State ownership (per the locked Behavior contract):
-- * Per-element runtime state lives ON THE ELEMENT (`self._textEditor`,
-- `self._mouseDownPosition`, `self._textDragOccurred`). The behavior
-- instance is stateless + immutable and shared across all editable
-- elements.
-- * Element-class-level dependencies are resolved via `getmetatable(element)`
-- (which IS the Element class set by Element._construct), so the hook
-- signature stays exactly `(element, ...)` with no DI parameters.
--
-- onDraw is a no-op: text/cursor/selection rendering stays in the Renderer's
-- command buffer (Layer 4 "text"), driven by the Thamed behavior's single
-- `Renderer:draw` call. The Renderer's `drawText` already reads
-- `element._textEditor` for cursor/selection, so TextEditable OWNS the
-- subsystem that drawText consumes, but the draw dispatch stays in the
-- renderer to preserve the unified transform/scissor command-buffer ordering
-- (mirrors Selectable.onDraw's no-op precedent, where rendering is owned by a
-- different layer). Hoisting drawText into this behavior's onDraw would
-- double-render text, since the Renderer command buffer already emits a "text"
-- layer for every element.
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
local Behavior = require(_pkg .. "Behavior")
-- Resolve the Element class from an element instance (mirrors Clickable /
-- Selectable). `setmetatable({}, Element)` in `_construct` makes the instance
-- metatable BE the Element class, so this yields Element._TextEditor,
-- Element._textEditorDeps, Element._Context, Element._StateManager, etc.
-- without threading deps through the hook signature.
local function ElementClass(element)
return getmetatable(element)
end
-- ============================================================================
-- shouldAttach (class-level predicate, no element required)
-- ============================================================================
-- Mirrors the spec predicate: attach when the element is text-editable OR
-- carries text content. onAttach only ALLOCATES a TextEditor when
-- `element.editable` is true (preserving the pre-refactor creation invariant
-- "TextEditor created iff editable"), so non-editable text labels attach the
-- behavior but allocate no TextEditor — their onUpdate/onDraw/saveState are
-- nil-guarded no-ops, and the Element forwarders route them through the
-- non-editable branch of each delegate function (reads/writes `element.text`
-- directly). This keeps shouldAttach faithful to the spec while preserving
-- exact pre-refactor allocation behavior.
local function shouldAttach(props)
props = props or {}
return props.editable == true or props.text ~= nil
end
-- ============================================================================
-- onAttach — create the TextEditor (formerly Element:_initSubSystems lines
-- ~813-830) and restore immediate-mode TextEditor state.
-- ============================================================================
local function onAttach(element)
local Element = ElementClass(element)
-- Only editable elements own a TextEditor. Preserves the exact pre-refactor
-- creation guard (`if self.editable then ... end`) — non-editable text
-- elements attach the behavior (so their forwarders route through a single
-- code path) but allocate no TextEditor.
if not element.editable then
return
end
-- Config is sourced from element fields (bound by _applyProps / _initVisualState
-- before _attachBehaviors runs at the tail of Element.new) — NOT from raw
-- props. The callbacks (onFocus/onBlur/onTextInput/onTextChange/onEnter) are
-- schema-bound element fields by this point, and `element.text` is set by
-- _initVisualState, so no `props` reference is needed here (the hook
-- signature is `(element)`).
element._textEditor = Element._TextEditor.new({
editable = element.editable,
multiline = element.multiline,
passwordMode = element.passwordMode,
textWrap = element.textWrap,
maxLines = element.maxLines,
maxLength = element.maxLength,
placeholder = element.placeholder,
inputType = element.inputType,
textOverflow = element.textOverflow,
scrollable = element.scrollable,
autoGrow = element.autoGrow,
selectOnFocus = element.selectOnFocus,
cursorColor = element.cursorColor,
selectionColor = element.selectionColor,
cursorBlinkRate = element.cursorBlinkRate,
text = element.text or "",
onFocus = element.onFocus,
onBlur = element.onBlur,
onTextInput = element.onTextInput,
onTextChange = element.onTextChange,
onEnter = element.onEnter,
}, Element._textEditorDeps)
-- Restore TextEditor state from StateManager in immediate mode. Mirrors the
-- legacy _initSubSystems immediate-mode restore. Safe to run here (after
-- _construct registered the element with StateManager) — the StateManager
-- lookup is sparse and returns nil for a fresh element. Mode-aware via
-- Context.isImmediateMode (behavior-mode-unification task 11).
if Element._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then
local state = Element._StateManager.getState(element._stateId)
if state and state.textEditor then
element._textEditor:setState(state.textEditor, element)
end
end
end
local function onDetach(element)
-- Clear text-input callback closures read by TextEditor / KeyboardNavigation
-- so the element's closure references can be collected in immediate mode
-- (formerly part of Element:_cleanup). The TextEditor instance itself is
-- INTENTIONALLY kept: Element:_cleanup preserves element structure for
-- inspection (released when the element is GC'd).
element.onTextInput = nil
element.onTextChange = nil
element.onEnter = nil
end
-- ============================================================================
-- onUpdate — cursor-blink animation (formerly Element:update line ~2810).
-- ============================================================================
-- Drives TextEditor:update (cursor blink + blink-pause timer). Guarded on
-- `element._textEditor` because non-editable text elements attach this
-- behavior (per shouldAttach) but own no TextEditor. Element:update contains
-- zero text-editor references — the dispatch loop calls this hook.
local function onUpdate(element, dt)
local textEditor = element._textEditor
if textEditor then
textEditor:update(element, dt)
end
end
-- ============================================================================
-- onDraw — no-op (see file header: text rendering stays in the Renderer
-- command buffer driven by the Thamed behavior's Renderer:draw call).
-- ============================================================================
local function onDraw() end
-- ============================================================================
-- saveState / restoreState — TextEditor state + text-selection drag
-- tracking (formerly the textEditor branch of Element:saveState /
-- Element:restoreState, including the _mouseDownPosition / _textDragOccurred
-- fields).
-- ============================================================================
-- Returns a snapshot under the `textEditor` key to match the legacy immediate-
-- mode restoreState contract (Element:restoreState looked up state.textEditor).
-- The behavior-dispatch loop in Element:saveState merges behavior snapshots
-- into the top-level state table, so returning { textEditor = ... } slots in
-- identically to the old inline `state.textEditor = self._textEditor:getState()`
-- assignment. The drag-tracking fields are merged at the top level too
-- (matching the legacy `state._mouseDownPosition` / `state._textDragOccurred`
-- assignments) since they are text-selection state.
local function saveState(element)
local textEditor = element._textEditor
if not textEditor then
-- Non-editable text element: still persist drag-tracking fields if set
-- (they are only ever set for editable elements, but persist defensively).
local hasDragState = element._mouseDownPosition ~= nil or element._textDragOccurred ~= nil
if not hasDragState then
return nil
end
local snapshot = {}
if element._mouseDownPosition ~= nil then
snapshot._mouseDownPosition = element._mouseDownPosition
end
if element._textDragOccurred ~= nil then
snapshot._textDragOccurred = element._textDragOccurred
end
return snapshot
end
local snapshot = { textEditor = textEditor:getState() }
if element._mouseDownPosition ~= nil then
snapshot._mouseDownPosition = element._mouseDownPosition
end
if element._textDragOccurred ~= nil then
snapshot._textDragOccurred = element._textDragOccurred
end
return snapshot
end
-- Consumes the previously-saved snapshot keyed under `textEditor` plus the
-- drag-tracking fields. The behavior-dispatch loop passes the FULL top-level
-- state table; this hook reads only its own slices, mirroring the legacy
-- `if self._textEditor and state.textEditor then ... end` guard.
local function restoreState(element, state)
if not state then
return
end
local textEditor = element._textEditor
if textEditor and state.textEditor then
textEditor:setState(state.textEditor, element)
-- Sync TextEditor's focus/cursor/selection state to Element for theme
-- management (mirrors the legacy restoreState field sync).
element._focused = textEditor._focused
element._cursorPosition = textEditor._cursorPosition
element._selectionStart = textEditor._selectionStart
element._selectionEnd = textEditor._selectionEnd
element._textBuffer = textEditor._textBuffer
end
-- Restore drag-tracking state for text selection (top-level keys).
if state._mouseDownPosition ~= nil then
element._mouseDownPosition = state._mouseDownPosition
end
if state._textDragOccurred ~= nil then
element._textDragOccurred = state._textDragOccurred
end
end
-- ============================================================================
-- Text-editor delegate functions.
--
-- These are the module-level implementations of the 27 text-editor delegate
-- methods that previously lived on Element. Each mirrors the pre-refactor
-- Element method body VERBATIM (with `self` → `element`), including the
-- `element._textEditor` nil-guard: the guard is required because (a) non-
-- editable text elements attach this behavior (per shouldAttach) but own no
-- TextEditor, and (b) Element forwards these methods BEFORE onAttach has run
-- (e.g. an `onCreate` callback firing during _finalizeConstruction, which
-- runs before _attachBehaviors). The nil-guards live in THIS file (not in
-- Element.lua), so the Element.lua `if self._textEditor` count drops to 0.
--
-- Element retains 1-line forwarders: `Element.setText = function(self, text)
-- return Element._TextEditable.setText(self, text) end` (etc.), so external
-- callers (EventHandler, KeyboardNavigation, game UI) keep working unchanged.
--
-- The TextEditor API is mixed: most methods take the element as first arg
-- (`te:method(element, ...)` — "passesSelf"); a few getters omit it
-- (`te:method()`). The delegation contract is pinned by
-- subsystem_delegation_test.lua, so this mapping must match TextEditor's
-- method signatures exactly.
-- ============================================================================
-- --- Cursor management (passesSelf = element forwarded) ------------------
local function setCursorPosition(element, position)
local textEditor = element._textEditor
if textEditor then
textEditor:setCursorPosition(element, position)
end
end
local function getCursorPosition(element)
local textEditor = element._textEditor
if textEditor then
return textEditor:getCursorPosition()
end
return 0
end
local function moveCursorBy(element, delta)
local textEditor = element._textEditor
if textEditor then
textEditor:moveCursorBy(element, delta)
end
end
local function moveCursorToStart(element)
local textEditor = element._textEditor
if textEditor then
textEditor:moveCursorToStart(element)
end
end
local function moveCursorToEnd(element)
local textEditor = element._textEditor
if textEditor then
textEditor:moveCursorToEnd(element)
end
end
local function moveCursorToLineStart(element)
local textEditor = element._textEditor
if textEditor then
textEditor:moveCursorToLineStart(element)
end
end
local function moveCursorToLineEnd(element)
local textEditor = element._textEditor
if textEditor then
textEditor:moveCursorToLineEnd(element)
end
end
local function moveCursorToPreviousWord(element)
local textEditor = element._textEditor
if textEditor then
textEditor:moveCursorToPreviousWord(element)
end
end
local function moveCursorToNextWord(element)
local textEditor = element._textEditor
if textEditor then
textEditor:moveCursorToNextWord(element)
end
end
-- --- Selection management ------------------------------------------------
local function setSelection(element, startPos, endPos)
local textEditor = element._textEditor
if textEditor then
textEditor:setSelection(element, startPos, endPos)
end
end
local function getSelection(element)
local textEditor = element._textEditor
if textEditor then
return textEditor:getSelection()
end
return nil
end
local function hasSelection(element)
local textEditor = element._textEditor
if textEditor ~= nil then
return textEditor:hasSelection()
end
return false
end
local function clearSelection(element)
local textEditor = element._textEditor
if textEditor then
textEditor:clearSelection(element)
end
end
local function selectAll(element)
local textEditor = element._textEditor
if textEditor then
textEditor:selectAll(element)
end
end
local function getSelectedText(element)
local textEditor = element._textEditor
if textEditor then
return textEditor:getSelectedText()
end
return nil
end
local function deleteSelection(element)
local textEditor = element._textEditor
if textEditor then
return textEditor:deleteSelection(element)
end
return false
end
-- --- Focus management ----------------------------------------------------
local function focus(element)
local textEditor = element._textEditor
if textEditor then
textEditor:focus(element)
end
end
local function blur(element)
local textEditor = element._textEditor
if textEditor then
textEditor:blur(element)
end
end
local function isFocused(element)
local textEditor = element._textEditor
if textEditor then
return textEditor:isFocused()
end
return false
end
-- --- Text buffer management (with post-delegation sync) ------------------
-- These methods sync `element.text` from the TextEditor result + drive
-- auto-grow, exactly as the legacy Element methods did.
local function getText(element)
local textEditor = element._textEditor
if textEditor then
return textEditor:getText()
end
return element.text or ""
end
local function setText(element, text)
local textEditor = element._textEditor
if textEditor then
textEditor:setText(element, text)
element.text = textEditor:getText() -- Sync display text
textEditor:updateAutoGrowHeight(element)
return
end
element.text = text
end
local function insertText(element, text, position)
local textEditor = element._textEditor
if textEditor then
textEditor:insertText(element, text, position)
element.text = textEditor:getText() -- Sync display text
textEditor:updateAutoGrowHeight(element)
end
end
local function deleteText(element, startPos, endPos)
local textEditor = element._textEditor
if textEditor then
textEditor:deleteText(element, startPos, endPos)
element.text = textEditor:getText() -- Sync display text
textEditor:updateAutoGrowHeight(element)
end
end
local function replaceText(element, startPos, endPos, newText)
local textEditor = element._textEditor
if textEditor then
textEditor:replaceText(element, startPos, endPos, newText)
element.text = textEditor:getText() -- Sync display text
textEditor:updateAutoGrowHeight(element)
end
end
-- --- Mouse text selection ------------------------------------------------
local function handleTextClick(element, mouseX, mouseY, clickCount)
local textEditor = element._textEditor
if textEditor then
textEditor:handleTextClick(element, mouseX, mouseY, clickCount)
-- Store mouse down position on element for drag tracking
if clickCount == 1 then
element._mouseDownPosition = textEditor:mouseToTextPosition(element, mouseX, mouseY)
end
end
end
local function handleTextDrag(element, mouseX, mouseY)
local textEditor = element._textEditor
if textEditor then
textEditor:handleTextDrag(element, mouseX, mouseY)
element._textDragOccurred = textEditor._textDragOccurred
end
end
-- --- Keyboard input ------------------------------------------------------
local function textinput(element, text)
local textEditor = element._textEditor
if textEditor then
textEditor:handleTextInput(element, text)
element.text = textEditor:getText() -- Sync display text
textEditor:updateAutoGrowHeight(element)
end
end
local function keypressed(element, key, scancode, isrepeat)
local textEditor = element._textEditor
if textEditor then
textEditor:handleKeyPress(element, key, scancode, isrepeat)
element.text = textEditor:getText() -- Sync display text
textEditor:updateAutoGrowHeight(element)
end
end
-- ============================================================================
-- Build the (stateless, shared, immutable) behavior instance + thin module
-- table exposing the delegate functions (mirrors the Animated pattern).
-- ============================================================================
local behavior = Behavior.new({
onAttach = onAttach,
onDetach = onDetach,
onUpdate = onUpdate,
onDraw = onDraw,
saveState = saveState,
restoreState = restoreState,
shouldAttach = shouldAttach,
})
-- Thin module table: exposes the frozen behavior instance (for the registry)
-- plus the text-editor delegate functions (for Element's 1-line forwarders).
-- All hooks delegate to the frozen behavior instance so dispatch sites get
-- the validated, frozen implementation. shouldAttach is also exposed at module
-- level (mirrors Clickable.shouldAttach) for tests/callers without an element.
local TextEditable = {
behavior = behavior,
shouldAttach = shouldAttach,
onAttach = onAttach,
onUpdate = onUpdate,
onDraw = onDraw,
saveState = saveState,
restoreState = restoreState,
-- Text-editor delegate functions (Element forwarders route through these):
setCursorPosition = setCursorPosition,
getCursorPosition = getCursorPosition,
moveCursorBy = moveCursorBy,
moveCursorToStart = moveCursorToStart,
moveCursorToEnd = moveCursorToEnd,
moveCursorToLineStart = moveCursorToLineStart,
moveCursorToLineEnd = moveCursorToLineEnd,
moveCursorToPreviousWord = moveCursorToPreviousWord,
moveCursorToNextWord = moveCursorToNextWord,
setSelection = setSelection,
getSelection = getSelection,
hasSelection = hasSelection,
clearSelection = clearSelection,
selectAll = selectAll,
getSelectedText = getSelectedText,
deleteSelection = deleteSelection,
focus = focus,
blur = blur,
isFocused = isFocused,
getText = getText,
setText = setText,
insertText = insertText,
deleteText = deleteText,
replaceText = replaceText,
_handleTextClick = handleTextClick,
_handleTextDrag = handleTextDrag,
textinput = textinput,
keypressed = keypressed,
}
-- Metatable so the module table itself satisfies the duck-typed registry
-- contract (iterating `Element._behaviorRegistry` calls `behavior.shouldAttach`
-- and `behavior.onAttach` / `behavior.onUpdate` directly). Falls through to the
-- frozen behavior instance for every hook / isBehavior parity.
setmetatable(TextEditable, {
__index = behavior,
__tostring = function()
return "TextEditable"
end,
})
return TextEditable
-178
View File
@@ -1,178 +0,0 @@
-- modules/behaviors/Themed.lua
--
-- Concrete behavior: Renderer ownership + theme-state rendering.
--
-- Themed owns the per-element Renderer instance and the single
-- `Renderer:draw` call that paints the core visual layers (background, image,
-- theme 9-patch, borders, text, customDraw). It is the behavior-mode-unification
-- replacement for the former `_initImageAndRenderer` Renderer creation block and
-- the former first `self._renderer:draw(self, backdropCanvas)` call in
-- Element:draw (behavior-mode-unification task 07).
--
-- Attachment rule (shouldAttach): every renderable Element. The pre-refactor
-- code unconditionally created a Renderer for every Element and unconditionally
-- called `Renderer:draw` in Element:draw; Themed mirrors that invariant so the
-- Renderer is always available to subsystems that depend on it (TextEditor font
-- / wrap delegation, ScrollManager scrollbar drawing) AND so visual rendering of
-- background / border / theme / image layers is preserved for every element.
-- Restricting attachment to `themeComponent`-only elements would break editable
-- text fields and scrollable containers (which need a Renderer for subsystem
-- delegation even when they have no theme component). The 9-patch theme-state
-- rendering within `Renderer:draw` is a no-op for elements without a
-- `themeComponent`, so always-attaching carries no rendering cost.
--
-- Themed and Imageable are paired (both configure the same `element._renderer`):
-- Themed.onAttach creates the Renderer with the theme/blur config; Imageable
-- (attached for imagePath/image elements) enriches the SAME renderer instance with
-- image config + deferred image loading. They share `element._renderer`.
--
-- onUpdate is a no-op: theme-state transitions are DRIVEN by the Clickable
-- behavior (whose onUpdate recomputes hover/press/focus and calls
-- `renderer:setThemeState`). Themed only READS that state for rendering, so it has
-- no per-frame update work.
--
-- saveState owns the blur-region snapshot (`state.blur`): the per-frame blur
-- geometry + radius/quality used by the Blur cache for invalidation (formerly
-- the inline `if self.backdropBlur or self.contentBlur` block of
-- Element:saveState — behavior-mode-unification task 12). restoreState is a
-- no-op: blur cache data is used for invalidation, not restoration (the Blur
-- cache is keyed by element id and cleared via `Blur.clearElementCache` from
-- FlexLove.endFrame, not replayed through restoreState).
--
-- State ownership (per the locked Behavior contract):
-- * Per-element runtime state lives ON THE ELEMENT (`element._renderer`,
-- `element._themeState`, `element.backdropBlur`, `element.contentBlur`).
-- The behavior instance is stateless and shared.
-- * `element._renderer` is recreated on attach; onDetach is a no-op — the
-- reference is released when the element is GC'd (Element:_cleanup keeps
-- element structure for inspection).
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
local Behavior = require(_pkg .. "Behavior")
-- Resolve the Element class from an element instance (mirrors Clickable).
-- Element instances are created via `setmetatable({}, Element)`, so their
-- metatable IS the Element class — giving access to Element._Renderer,
-- Element._rendererDeps, etc. without threading deps through the hook signature.
local function ElementClass(element)
return getmetatable(element)
end
-- ----------------------------------------------------------------------------
-- shouldAttach (class-level predicate, no element required)
-- ----------------------------------------------------------------------------
-- Returns true for every renderable Element. See file header for the rationale:
-- the pre-refactor invariant was "every Element has a Renderer; Element:draw
-- always calls Renderer:draw", and Thamed is the behavior-system embodiment of
-- that invariant. Returns true for `themeComponent`-bearing props (the spec's
-- headline case) and for every other element so subsystems/rendering stay intact.
local function shouldAttach(props)
return true
end
-- ----------------------------------------------------------------------------
-- onAttach — create the Renderer with theme/blur config (formerly the
-- Renderer.new block of Element:_initImageAndRenderer).
-- ----------------------------------------------------------------------------
local function onAttach(element)
local Element = ElementClass(element)
-- Create-or-reuse the Renderer. Thamed is the first render behavior in the
-- registry, so it normally creates the instance; Imageable (if attached) will
-- reuse this same instance for image config. Guarded so Imageable-onAttach-
-- first (defensive) does not clobber an existing renderer.
if element._renderer then
return
end
-- NOTE: backgroundColor/borderColor/opacity/cornerRadius/themeComponent are
-- intentionally NOT passed here. Renderer:draw() reads them from the element
-- as the single source of truth (see Renderer.lua draw()). Only renderer-owned
-- state (theme, blur) is cached on the renderer; image config is added by the
-- Imageable behavior. border is element-sourced too.
element._renderer = Element._Renderer.new({
theme = element.theme,
scaleCorners = element.scaleCorners,
scalingAlgorithm = element.scalingAlgorithm,
contentBlur = element.contentBlur,
backdropBlur = element.backdropBlur,
}, Element._rendererDeps)
end
-- ----------------------------------------------------------------------------
-- onDraw — the single Renderer:draw call (formerly the first call in
-- Element:draw). Paints all core visual layers for this element.
-- ----------------------------------------------------------------------------
local function onDraw(element, ctx)
local renderer = element._renderer
if not renderer then
return
end
renderer:draw(element, ctx and ctx.backdropCanvas)
end
-- ----------------------------------------------------------------------------
-- onDetach — no-op. Element:_cleanup preserves element structure for
-- inspection (the original invariant), so the Renderer reference is released
-- when the element is GC'd rather than torn down here. Present as an explicit
-- hook so the behavior conforms to the full lifecycle contract.
-- ----------------------------------------------------------------------------
local function onDetach() end
-- ----------------------------------------------------------------------------
-- saveState — blur-region snapshot (formerly the `blur` branch of
-- Element:saveState). Returns `{ blur = {...} }` when the element configures a
-- backdrop or content blur, so the Blur cache can invalidate by element id;
-- nil otherwise. Mode-agnostic to match the legacy contract (the snapshot is
-- only read back by the cache-invalidation path, which itself is
-- immediate-mode-only via FlexLove.endFrame).
-- ----------------------------------------------------------------------------
local function saveState(element)
if not (element.backdropBlur or element.contentBlur) then
return nil
end
local blur = {
_blurX = element.x,
_blurY = element.y,
_blurWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right),
_blurHeight = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom),
}
if element.backdropBlur then
blur._backdropBlurRadius = element.backdropBlur.radius
blur._backdropBlurQuality = element.backdropBlur.quality or 5
end
if element.contentBlur then
blur._contentBlurRadius = element.contentBlur.radius
blur._contentBlurQuality = element.contentBlur.quality or 5
end
return { blur = blur }
end
-- restoreState — no-op: blur cache data is used for invalidation, not
-- restoration (see file header). Present so the behavior conforms to the
-- lifecycle contract without replaying geometry that the cache recomputes.
-- ----------------------------------------------------------------------------
-- Build the (stateless, shared, immutable) behavior instance.
-- ----------------------------------------------------------------------------
local Themed = Behavior.new({
onAttach = onAttach,
onDetach = onDetach,
onUpdate = function() end,
onDraw = onDraw,
saveState = saveState,
restoreState = function() end,
})
-- Expose the predicate at module level so callers/tests can reference it
-- directly without an element instance (mirrors Behavior.shouldAttach /
-- Clickable.shouldAttach).
Themed.shouldAttach = shouldAttach
return Themed
-662
View File
@@ -1,662 +0,0 @@
---@class SelectOptionProps
---@field value any -- Stable option value owned by the parent select
---@field label string? -- Optional label override, falls back to the element text
---@field disabled boolean? -- Whether the option can be selected
local SelectOptionProps = {}
---@class SelectParentProps
---@field value any -- Currently selected option value
---@field open boolean? -- Initial open state for the select container
---@field placeholder string? -- Fallback text when no option is selected
---@field selectFrame Element? -- Optional pre-instantiated dropdown container; intended to be unattached before being adopted by the select
---@field onChange fun(element:Element, value:any, option:SelectOptionProps)? -- Called when selection changes
local SelectParentProps = {}
---@class Animation
local Animation = {}
---@class Color
local Color = {}
---@class Theme
local Theme = {}
---@class ThemeManager
local ThemeManager = {}
--=====================================--
-- For Animation.lua
--=====================================--
---@alias EasingFunction fun(t:number): number
---@class AnimationProps
---@field duration number -- Duration in seconds
---@field start table -- Starting values (can contain: width, height, opacity, x, y, gap, imageOpacity, backgroundColor, borderColor, textColor, padding, margin, cornerRadius, transform, etc.)
---@field final table -- Final values (same properties as start)
---@field easing string? -- Easing function name: "linear", "easeInQuad", "easeOutQuad", "easeInOutQuad", "easeInCubic", "easeOutCubic", "easeInOutCubic", "easeInQuart", "easeOutQuart", "easeInExpo", "easeOutExpo" (default: "linear")
---@field keyframes AnimationKeyframe[]? -- Array of keyframes for complex animations
---@field onStart fun(animation:Animation, element:Element?)? -- Called when animation starts
---@field onUpdate fun(animation:Animation, element:Element?, progress:number)? -- Called each frame with progress (0-1)
---@field onComplete fun(animation:Animation, element:Element?)? -- Called when animation completes
---@field onCancel fun(animation:Animation, element:Element?)? -- Called when animation is cancelled
---@field transform TransformProps? -- Additional transform properties (legacy support)
---@field transition table? -- Transition properties (legacy support)
local AnimationProps = {}
---@class Transform
---@field rotate number? Rotation in radians (default: 0)
---@field scaleX number? X-axis scale (default: 1)
---@field scaleY number? Y-axis scale (default: 1)
---@field translateX number? X translation in pixels (default: 0)
---@field translateY number? Y translation in pixels (default: 0)
---@field skewX number? X-axis skew in radians (default: 0)
---@field skewY number? Y-axis skew in radians (default: 0)
---@field originX number? Transform origin X (0-1, default: 0.5)
---@field originY number? Transform origin Y (0-1, default: 0.5)
local Transform = {}
---@alias TransformProps Transform
---@class TransitionProps
---@field duration number?
---@field easing string?
---@field delay number?
---@field onComplete fun(element:Element)?
--=====================================--
-- For Element.lua
--=====================================--
---@class ElementProps
---@field id string? -- Unique identifier for the element (auto-generated in immediate mode if not provided)
---@field mode "immediate"|"retained"|nil -- Lifecycle mode override: "immediate" (auto-managed state), "retained" (manual state), nil (use global mode from FlexLove.getMode(), default)
---@field parent Element? -- Parent element for hierarchical structure
---@field x number|string|CalcObject? -- X coordinate: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: 0)
---@field y number|string|CalcObject? -- Y coordinate: number (px), string ("50%", "10vh"), or CalcObject from FlexLove.calc() (default: 0)
---@field z number? -- Z-index for layering (default: 0, clamped to -999..999)
---@field tabIndex number? -- Tab navigation order: >0 (explicit order, visited first), 0 or nil (natural document order), -1 (excluded from keyboard navigation)
---@field width number|string|CalcObject? -- Width of the element: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: calculated automatically)
---@field height number|string|CalcObject? -- Height of the element: number (px), string ("50%", "10vh"), or CalcObject from FlexLove.calc() (default: calculated automatically)
---@field minWidth number|string|CalcObject? -- Minimum width constraint: number (px), string ("50%", "10vw"), or CalcObject. Clamps both fixed `width` and the flex-distributed main size when horizontal.
---@field maxWidth number|string|CalcObject? -- Maximum width constraint: number (px), string ("50%", "10vw"), or CalcObject. Clamps both fixed `width` and the flex-distributed main size when horizontal.
---@field minHeight number|string|CalcObject? -- Minimum height constraint: number (px), string ("50%", "10vh"), or CalcObject. Clamps both fixed `height` and the flex-distributed main size when vertical.
---@field maxHeight number|string|CalcObject? -- Maximum height constraint: number (px), string ("50%", "10vh"), or CalcObject. Clamps both fixed `height` and the flex-distributed main size when vertical.
---@field top number|string|CalcObject? -- Offset from top edge: number (px), string ("50%", "10vh"), or CalcObject (CSS-style positioning)
---@field right number|string|CalcObject? -- Offset from right edge: number (px), string ("50%", "10vw"), or CalcObject (CSS-style positioning)
---@field bottom number|string|CalcObject? -- Offset from bottom edge: number (px), string ("50%", "10vh"), or CalcObject (CSS-style positioning)
---@field left number|string|CalcObject? -- Offset from left edge: number (px), string ("50%", "10vw"), or CalcObject (CSS-style positioning)
---@field border Border? -- Border configuration for the element
---@field borderColor Color? -- Color of the border (default: black)
---@field opacity number? -- Element opacity 0-1 (default: 1)
---@field visibility "visible"|"hidden"? -- Element visibility (default: "visible")
---@field display boolean? -- Whether element participates in layout, rendering, and hit testing (default: true). Set false for CSS display:none behavior (zero layout space, no rendering, no hit testing). NOTE: In retained mode, toggling at runtime requires setting the parent's `_dirty = true` or calling `layoutChildren()` on the parent to trigger re-layout.
---@field backgroundColor Color? -- Background color (default: transparent)
---@field cornerRadius number|{topLeft:number?, topRight:number?, bottomLeft:number?, bottomRight:number?}? -- Corner radius: number (all corners) or table for individual corners (default: 0)
---@field gap number|string|CalcObject? -- Space between children elements: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: 0)
---@field padding number|string|CalcObject|{top:number|string|CalcObject?, right:number|string|CalcObject?, bottom:number|string|CalcObject?, left:number|string|CalcObject?, horizontal:number|string|CalcObject?, vertical:number|string|CalcObject?}? -- Padding around children: single value, string, CalcObject for all sides, or table for individual sides (default: {top=0, right=0, bottom=0, left=0})
---@field margin number|string|CalcObject|{top:number|string|CalcObject?, right:number|string|CalcObject?, bottom:number|string|CalcObject?, left:number|string|CalcObject?, horizontal:number|string|CalcObject?, vertical:number|string|CalcObject?}? -- Margin around element: single value, string, CalcObject for all sides, or table for individual sides (default: {top=0, right=0, bottom=0, left=0})
---@field text string? -- Text content to display (default: nil)
---@field textAlign TextAlignSpec? -- Alignment of the text content: simple string, compound string ("top-left"), or {horizontal, vertical} table (default: START)
---@field textColor Color? -- Color of the text content (default: black or theme text color)
---@field textSize number|string? -- Font size: number (px), string with units ("2vh", "10%"), or preset ("xxs"|"xs"|"sm"|"md"|"lg"|"xl"|"xxl"|"3xl"|"4xl") (default: "md" or 12px)
---@field minTextSize number? -- Minimum text size in pixels for auto-scaling
---@field maxTextSize number? -- Maximum text size in pixels for auto-scaling
---@field fontFamily string? -- Font family name from theme or path to font file (default: theme default or system default, inherits from parent)
---@field autoScaleText boolean? -- Whether text should auto-scale with window size (default: true)
---@field positioning Positioning? -- Layout positioning mode: "absolute"|"relative"|"flex"|"grid" (default: RELATIVE)
---@field flexDirection FlexDirection? -- Direction of flex layout: "horizontal"|"vertical"|"row"|"column"|"row-reverse"|"column-reverse"|"horizontal-reverse"|"vertical-reverse" (row→horizontal, column→vertical, row-reverse→horizontal-reverse, column-reverse→vertical-reverse, default: HORIZONTAL)
---@field justifyContent JustifyContent? -- Alignment of items along main axis (default: FLEX_START)
---@field alignItems AlignItems? -- Alignment of items along cross axis (default: STRETCH)
---@field alignContent AlignContent? -- Alignment of lines in multi-line flex containers (default: STRETCH)
---@field flexWrap FlexWrap? -- Whether children wrap to multiple lines: "nowrap"|"wrap"|"wrap-reverse" (default: NOWRAP)
---@field flex number|string? -- Shorthand for flexGrow, flexShrink, flexBasis: number (flex-grow only), string ("1 0 auto"), or nil (default: nil)
---@field flexGrow number? -- How much the element should grow relative to siblings (default: 0)
---@field flexShrink number? -- How much the element should shrink relative to siblings (default: 1)
---@field flexBasis number|string|CalcObject? -- Initial size before growing/shrinking: number (px), string ("50%", "10vw", "auto"), or CalcObject (default: "auto")
---@field justifySelf JustifySelf? -- Alignment of the item itself along main axis (default: AUTO)
---@field alignSelf AlignSelf? -- Alignment of the item itself along cross axis (default: AUTO)
---@field onEvent fun(element:Element, event:InputEvent)? -- Callback function for interaction events
---@field onEventDeferred boolean? -- Whether onEvent callback should be deferred until after canvases are released (default: false)
---@field onFocus fun(element:Element)? -- Callback when element receives focus
---@field onFocusDeferred boolean? -- Whether onFocus callback should be deferred (default: false)
---@field dropFocusOnSelection boolean? -- Override keyboard-navigation focus drop after Enter/Space activation (default: nil, uses KeyboardNavigation.config.dropFocusOnSelection)
---@field onBlur fun(element:Element)? -- Callback when element loses focus
---@field onBlurDeferred boolean? -- Whether onBlur callback should be deferred (default: false)
---@field onTextInput fun(element:Element, text:string)? -- Callback when text is input
---@field onTextInputDeferred boolean? -- Whether onTextInput callback should be deferred (default: false)
---@field onTextChange fun(element:Element, text:string)? -- Callback when text content changes
---@field onTextChangeDeferred boolean? -- Whether onTextChange callback should be deferred (default: false)
---@field onEnter fun(element:Element)? -- Callback when Enter key is pressed
---@field onEnterDeferred boolean? -- Whether onEnter callback should be deferred (default: false)
---@field onCreate fun(element:Element, props:table)? -- Callback when element is created, receives the element and original creation props
---@field onCreateDeferred boolean? -- Whether onCreate callback should be deferred (default: false)
---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Callback for touch-specific events (touchpress, touchmove, touchrelease)
---@field onTouchEventDeferred boolean? -- Whether onTouchEvent callback should be deferred (default: false)
---@field onGesture fun(element:Element, gesture:table)? -- Callback for recognized gestures (tap, swipe, pinch, etc.)
---@field onGestureDeferred boolean? -- Whether onGesture callback should be deferred (default: false)
---@field touchEnabled boolean? -- Whether the element responds to touch events (default: true)
---@field multiTouchEnabled boolean? -- Whether the element supports multiple simultaneous touches (default: false)
---@field transform TransformProps? -- Transform properties for animations and styling
---@field transition TransitionProps? -- Transition settings for animations
---@field customDraw fun(element:Element)? -- Custom rendering callback called after standard rendering but before visual feedback (default: nil)
---@field gridRows number|table? -- Number of equal 1fr rows, or array of track specs (e.g. {"1fr","100px","auto"})
---@field gridColumns number|table? -- Number of equal 1fr columns, or array of track specs (e.g. {"1fr","100px","auto"})
---@field columnGap number|string|CalcObject? -- Gap between grid columns: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: 0)
---@field rowGap number|string|CalcObject? -- Gap between grid rows: number (px), string ("50%", "10vh"), or CalcObject from FlexLove.calc() (default: 0)
---@field theme string? -- Theme name to use (e.g., "space", "metal"). Defaults to theme from flexlove.init()
---@field themeComponent string? -- Theme component to use (e.g., "panel", "button", "input"). If nil, no theme is applied
---@field disabled boolean? -- Whether the element is disabled (default: false)
---@field active boolean? -- Whether the element is active/focused (for inputs, default: false)
---@field disableHighlight boolean? -- Whether to disable the pressed state highlight overlay (default: false, or true when using themeComponent)
---@field themeStateLock boolean|string? -- Lock theme state: true/"default" = lock to base state, false = normal behavior, string = specific state ("hover", "pressed", "active", "disabled") (default: false)
---@field themeComponentDisabledStates string[]? -- List of theme states to suppress visually (e.g. {"hover", "pressed"}). Interaction logic still fires.
---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Multiplier for auto-sized content dimensions (default: sourced from theme or {1, 1})
---@field scaleCorners number? -- Scale multiplier for 9-patch corners/edges. E.g., 2 = 2x size (overrides theme setting)
---@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for 9-patch corners: "nearest" (sharp/pixelated) or "bilinear" (smooth) (overrides theme setting)
---@field contentBlur {radius:number, quality:number?}? -- Blur the element's content including children (radius: pixels, quality: 1-10, default(quality): 5)
---@field backdropBlur {radius:number, quality:number?}? -- Blur content behind the element (radius: pixels, quality: 1-10, default(quality): 5)
---@field editable boolean? -- Whether the element is editable (default: false)
---@field multiline boolean? -- Whether the element supports multiple lines (default: false)
---@field textWrap boolean|"word"|"char"? -- Text wrapping mode (default: false for single-line, "word" for multi-line)
---@field maxLines number? -- Maximum number of lines (default: nil)
---@field maxLength number? -- Maximum text length in characters (default: nil)
---@field placeholder string? -- Placeholder text when empty (default: nil)
---@field passwordMode boolean? -- Whether to display text as password (default: false, disables multiline)
---@field inputType "text"|"number"|"email"|"url"? -- Input type for validation (default: "text")
---@field textOverflow "clip"|"ellipsis"|"scroll"? -- Text overflow behavior (default: "clip")
---@field scrollable boolean? -- Whether text is scrollable (default: false for single-line, true for multi-line)
---@field autoGrow boolean? -- Whether element auto-grows with text (default: false for single-line, true for multi-line)
---@field selectOnFocus boolean? -- Whether to select all text on focus (default: false)
---@field cursorColor Color? -- Cursor color (default: nil, uses textColor)
---@field selectionColor Color? -- Selection background color (default: nil, uses theme or default)
---@field cursorBlinkRate number? -- Cursor blink rate in seconds (default: 0.5)
---@field selectParent SelectParentProps? -- Parent-owned select/dropdown state and callbacks
---@field selectOption SelectOptionProps? -- Option metadata attached to a child of a select parent
---@field overflow "visible"|"hidden"|"scroll"|"auto"? -- Overflow behavior (default: "hidden")
---@field overflowX "visible"|"hidden"|"scroll"|"auto"? -- X-axis overflow (overrides overflow)
---@field overflowY "visible"|"hidden"|"scroll"|"auto"? -- Y-axis overflow (overrides overflow)
---@field scrollbarWidth number? -- Width of scrollbar track in pixels (default: 12)
---@field scrollbarColor Color? -- Scrollbar thumb color (default: Color.new(0.5, 0.5, 0.5, 0.8))
---@field scrollbarTrackColor Color? -- Scrollbar track color (default: Color.new(0.2, 0.2, 0.2, 0.5))
---@field scrollbarRadius number? -- Corner radius for scrollbar (default: 6)
---@field scrollbarPadding number? -- Padding between scrollbar and edge (default: 2)
---@field scrollSpeed number? -- Pixels per wheel notch (default: 20)
---@field invertScroll boolean? -- Invert mouse wheel scroll direction (default: false)
---@field smoothScrollEnabled boolean? -- Enable smooth scrolling animation for wheel events (default: false)
---@field scrollBarStyle string? -- Scrollbar style name from theme (selects from theme.scrollbars, default: uses first scrollbar or fallback rendering)
---@field scrollbarKnobOffset number|{x:number, y:number}|{horizontal:number, vertical:number}? -- Offset for scrollbar knob/handle position in pixels (number for both axes, or table for per-axis control, default: 0, adds to theme offset)
---@field scrollbarPlacement "reserve-space"|"overlay"? -- Scrollbar rendering mode: "reserve-space" (reduces content area, default) or "overlay" (renders over content)
---@field scrollbarBalance boolean? -- When true, reserve scrollbar space on both sides of content for visual balance (default: false)
---@field hideScrollbars boolean|{vertical:boolean, horizontal:boolean}? -- Hide scrollbars (boolean for both, or table for individual control, default: false)
---@field imagePath string? -- Path to image file (auto-loads via ImageCache)
---@field image love.Image? -- Image object to display
---@field objectFit "fill"|"contain"|"cover"|"scale-down"|"none"? -- Image fit mode (default: "fill")
---@field objectPosition string? -- Image position like "center center", "top left", "50% 50%" (default: "center center")
---@field imageOpacity number? -- Image opacity 0-1 (default: 1, combines with element opacity)
---@field imageRepeat "no-repeat"|"repeat"|"repeat-x"|"repeat-y"|"space"|"round"? -- Image repeat/tiling mode (default: "no-repeat")
---@field imageTint Color? -- Color to tint the image (default: nil/white, no tint)
---@field onImageLoad fun(element:Element, image:love.Image)? -- Callback when image loads successfully
---@field onImageLoadDeferred boolean? -- Whether onImageLoad callback should be deferred (default: false)
---@field onImageError fun(element:Element, error:string)? -- Callback when image fails to load
---@field onImageErrorDeferred boolean? -- Whether onImageError callback should be deferred (default: false)
---@field _scrollX number? -- Internal: scroll X position (restored in immediate mode)
---@field _scrollY number? -- Internal: scroll Y position (restored in immediate mode)
---@field children? ElementProps[]
---@field userdata table? -- User-defined data storage for custom properties
---@field ariaRole ARIA? -- ARIA role for screen readers (e.g., "button", "link", "dialog")
---@field ariaLabel string? -- Accessible name for screen readers (overrides text content)
---@field ariaDescribedBy string? -- ID of element that describes this element
---@field ariaExpanded boolean? -- Whether element is expanded/collapsed (for containers)
---@field ariaPressed boolean? -- Whether element is pressed (for toggle buttons)
---@field ariaChecked boolean? -- Whether element is checked (for checkboxes/radios)
---@field ariaDisabled boolean? -- Whether element is disabled (overrides disabled property)
---@field ariaBusy boolean? -- Whether element is processing (for live regions)
---@field ariaLive "off"|"polite"|"assertive"? -- Live region priority for announcements
local ElementProps = {}
---@class Border
---@field top boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0)
---@field right boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0)
---@field bottom boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0)
---@field left boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0)
local Border = {}
--=====================================--
-- For KeyboardNavigation.lua
--=====================================--
---@class KeyboardNavigationKeyConfig
---@field next string -- Key used to move to the next focusable element
---@field previous string -- Key used to move to the previous focusable element
---@field up string -- Key used for directional navigation upward
---@field down string -- Key used for directional navigation downward
---@field left string -- Key used for directional navigation leftward
---@field right string -- Key used for directional navigation rightward
---@field activate string[] -- Keys that activate the currently focused element
---@field dismiss string -- Key used to dismiss or clear the currently focused element
---@field toggleDebug string -- Key used to toggle keyboard-navigation debug tooling
---@field inspect string -- Key used to inspect the currently focused element in developer tools
local KeyboardNavigationKeyConfig = {}
---@class KeyboardNavigationDeveloperToolsConfig
---@field enabled boolean? -- Enable keyboard-navigation developer tools (default: true)
---@field showProperties boolean? -- Show focused element properties in developer tools (default: true)
---@field highlightColor number[]? -- RGBA color used for keyboard-navigation debug highlighting (default: {1, 0.8, 0, 0.5})
local KeyboardNavigationDeveloperToolsConfig = {}
---@class KeyboardNavigationFocusIndicatorConfig
---@field enabled boolean? -- Enable the keyboard focus indicator (default: true)
---@field color number[]? -- RGBA color of the focus indicator (default: {0.2, 0.6, 1.0, 0.8})
---@field lineWidth number? -- Focus indicator stroke width in pixels (default: 2)
---@field inset number? -- Offset from the element bounds in pixels (default: -3)
---@field borderRadius number? -- Focus indicator border radius in pixels (default: 4)
---@field animationDuration number? -- Focus indicator entrance animation duration in seconds (default: 0.15)
---@field pulseEnabled boolean? -- Enable pulse animation for the focus indicator when supported
---@field pulseDuration number? -- Seconds per pulse cycle
---@field pulseScaleMin number? -- Minimum scale during pulse animation
---@field pulseScaleMax number? -- Maximum scale during pulse animation
---@field draw fun(element:Element, bounds:table, style:KeyboardNavigationFocusIndicatorConfig)? -- Custom focus indicator renderer
local KeyboardNavigationFocusIndicatorConfig = {}
---@class KeyboardNavigationConfig
---@field enabled boolean? -- Enable or disable keyboard navigation globally (default: true)
---@field debugMode boolean? -- Enable keyboard-navigation debug logging (default: false)
---@field keys KeyboardNavigationKeyConfig? -- Key bindings used by keyboard navigation
---@field wrapAround boolean? -- Allow wrapping from last to first focusable element (default: true)
---@field directionalNavigation boolean? -- Enable arrow-key directional navigation (default: true)
---@field focusVisible boolean? -- Show the focus indicator for keyboard-driven focus (default: true)
---@field autofocusOnCreate boolean? -- Auto-focus the first focusable element on creation (default: false)
---@field dropFocusOnSelection boolean? -- Drop focus after Enter/Space activates an element (default: true)
---@field developerTools KeyboardNavigationDeveloperToolsConfig? -- Developer tool settings for keyboard navigation
---@field focusIndicator KeyboardNavigationFocusIndicatorConfig? -- Focus indicator style configuration
local KeyboardNavigationConfig = {}
--=====================================--
-- For FlexLove.init()
--=====================================--
---@class FlexLoveConfig
---@field baseScale {width:number?, height:number?}? -- Base resolution for responsive scaling (default: nil, no scaling)
---@field theme string|ThemeDefinition? -- Theme name (string) or ThemeDefinition to use (default: nil, no theme)
---@field immediateMode boolean? -- Enable immediate mode (React-like, recreates UI each frame) vs retained mode (default: false)
---@field autoFrameManagement boolean? -- Automatically call beginFrame/endFrame (default: false)
---@field stateRetentionFrames number? -- Number of frames to retain unused state in immediate mode (default: 60)
---@field maxStateEntries number? -- Maximum number of state entries before forcing cleanup (default: 1000)
---@field includeStackTrace boolean? -- Include stack traces in error messages (default: true)
---@field reportingLogLevel LOG_LEVEL? -- Error log level: 1: critical, 2: error, 3: warn, 4: info, 5: debug/all (default: 3:warn)
---@field errorLogTarget string? -- Error log target: "console", "file", "both" (default: "console")
---@field errorLogFile string? -- Path to error log file (default: "flexlove_errors.log")
---@field errorLogMaxSize number? -- Maximum error log file size in bytes (default: 1048576, 1MB)
---@field maxErrorLogFiles number? -- Maximum number of rotated error log files (default: 5)
---@field errorLogRotateEnabled boolean? -- Enable error log rotation (default: true)
---@field performanceMonitoring boolean? -- Enable performance monitoring (default: true)
---@field performanceHudKey string? -- Key to toggle performance HUD (default: "f3")
---@field performanceHudPosition {x:number, y:number}? -- Position of performance HUD (default: {x=10, y=10})
---@field performanceWarningThreshold number? -- Frame time warning threshold in ms (default: 13.0)
---@field performanceCriticalThreshold number? -- Frame time critical threshold in ms (default: 16.67)
---@field performanceLogToConsole boolean? -- Log performance metrics to console (default: false)
---@field performanceWarnings boolean? -- Enable performance warnings (default: false)
---@field memoryProfiling boolean? -- Enable memory profiling (default: false, auto-enabled in immediate mode)
---@field gcStrategy string? -- Garbage collection strategy: "auto", "periodic", "manual", "disabled" (default: "auto")
---@field gcMemoryThreshold number? -- Memory threshold in MB before forcing GC (default: 100)
---@field gcInterval number? -- Frames between GC steps in periodic mode (default: 60)
---@field gcStepSize number? -- Work units per GC step, higher = more aggressive (default: 200)
---@field immediateModeBlurOptimizations boolean? -- Cache blur canvases in immediate mode to avoid re-rendering each frame (default: true)
---@field keyboardNavigation boolean|KeyboardNavigationConfig? -- Enable keyboard navigation with defaults (`true`) or provide configuration overrides
---@field debugDraw boolean? -- Enable debug draw overlay showing element boundaries with random colors (default: false)
---@field debugDrawKey string? -- Key to toggle debug draw overlay at runtime (default: nil, no toggle key)
local FlexLoveConfig = {}
--=====================================--
-- Public FlexLove API
--=====================================--
---@alias TextAlignCompound "top-left" | "top-center" | "top-right" | "center-left" | "center-center" | "center-right" | "bottom-left" | "bottom-center" | "bottom-right"
---@alias TextAlignSpec TextAlign | TextAlignCompound | {horizontal: TextAlign, vertical: TextAlignVertical}
---@class FlexLoveEnums
---@field TextAlign TextAlign
---@field TextAlignVertical TextAlignVertical
---@field Positioning Positioning
---@field FlexDirection FlexDirection
---@field JustifyContent JustifyContent
---@field JustifySelf JustifySelf
---@field AlignItems AlignItems
---@field AlignSelf AlignSelf
---@field AlignContent AlignContent
---@field FlexWrap FlexWrap
---@field TextSize TextSize
---@field ImageRepeat ImageRepeat
---@field ARIA ARIA
local FlexLoveEnums = {}
---@class AnimationKeyframe
---@field at number -- Normalized time position (0-1)
---@field values table -- Property values at this keyframe
---@field easing string|EasingFunction? -- Easing used between this and the next keyframe
local AnimationKeyframe = {}
---@class AnimationGroupProps
---@field animations Animation[] -- Animations to coordinate
---@field mode "parallel"|"sequence"|"stagger"? -- Group playback mode (default: "parallel")
---@field stagger number? -- Delay between staggered animations in seconds (default: 0.1)
---@field onComplete fun(group:AnimationGroup)? -- Called when all animations complete
---@field onStart fun(group:AnimationGroup)? -- Called when the group starts
local AnimationGroupProps = {}
---@class AnimationGroup
---@field animations Animation[]
---@field mode "parallel"|"sequence"|"stagger"
---@field stagger number
---@field onComplete fun(group:AnimationGroup)?
---@field onStart fun(group:AnimationGroup)?
local AnimationGroup = {}
---@class Animation
---@field duration number
---@field start table
---@field final table
---@field elapsed number
---@field easing EasingFunction
---@field keyframes AnimationKeyframe[]?
---@field transform TransformProps?
---@field transition TransitionProps?
---@field onStart fun(animation:Animation, element:Element?)?
---@field onUpdate fun(animation:Animation, element:Element?, progress:number)?
---@field onComplete fun(animation:Animation, element:Element?)?
---@field onCancel fun(animation:Animation, element:Element?)?
---@field update fun(self:Animation, dt:number, element:table?): boolean
---@field findKeyframes fun(self:Animation, progress:number): AnimationKeyframe?, AnimationKeyframe?
---@field lerpKeyframes fun(self:Animation, prevFrame:AnimationKeyframe, nextFrame:AnimationKeyframe, easedT:number): table
---@field interpolate fun(self:Animation): table
---@field apply fun(self:Animation, element:table)
---@field pause fun(self:Animation)
---@field resume fun(self:Animation)
---@field isPaused fun(self:Animation): boolean
---@field reverse fun(self:Animation)
---@field isReversed fun(self:Animation): boolean
---@field setSpeed fun(self:Animation, speed:number)
---@field getSpeed fun(self:Animation): number
---@field seek fun(self:Animation, time:number)
---@field getState fun(self:Animation): string
---@field cancel fun(self:Animation, element:table?)
---@field reset fun(self:Animation)
---@field getProgress fun(self:Animation): number
---@field chain fun(self:Animation, nextAnimation:Animation|function): Animation
---@field delay fun(self:Animation, seconds:number): Animation
---@field repeatCount fun(self:Animation, count:number): Animation
---@field yoyo fun(self:Animation, enabled:boolean?): Animation
---@class AnimationModule
---@field Easing table<string, EasingFunction|fun(...):EasingFunction> -- Built-in easing functions and easing factories
---@field Transform table? -- Animation transform helpers exposed by the animation module
---@field Group AnimationGroup -- Animation group class table
---@field new fun(props:AnimationProps): Animation
---@field fade fun(duration:number, fromOpacity:number, toOpacity:number, easing:string?): Animation
---@field scale fun(duration:number, fromScale:{width:number, height:number}, toScale:{width:number, height:number}, easing:string?): Animation
---@field keyframes fun(props:{duration:number, keyframes:AnimationKeyframe[], onStart:function?, onUpdate:function?, onComplete:function?, onCancel:function?}): Animation
---@field chainSequence fun(animations:Animation[]): Animation
local AnimationModule = {}
---@class ColorInputTable
---@field [1] number?
---@field [2] number?
---@field [3] number?
---@field [4] number?
---@field r number?
---@field g number?
---@field b number?
---@field a number?
local ColorInputTable = {}
---@alias ColorInput string|Color|ColorInputTable
---@class ColorModule
---@field new fun(r:number?, g:number?, b:number?, a:number?): Color
---@field fromHex fun(hexWithTag:string): Color
---@field validateColorChannel fun(value:any, max:number?): boolean, number?
---@field validateHexColor fun(hex:string): boolean, string?
---@field validateRGBColor fun(r:number, g:number, b:number, a:number?, max:number?): boolean, string?
---@field isValidColorFormat fun(value:any): string?
---@field sanitizeColor fun(value:any, default:Color?): Color
---@field parse fun(value:any): Color
---@field lerp fun(colorA:Color, colorB:Color, t:number): Color
local ColorModule = {}
---@class ThemeManagerConfig
---@field theme string? -- Theme name override
---@field themeComponent string? -- Component name to resolve from the theme
---@field disabled boolean? -- Force disabled theme state
---@field active boolean? -- Force active theme state
---@field disableHighlight boolean? -- Disable pressed highlight overlay
---@field themeStateLock boolean|string? -- Lock the theme state to base/default or a named state
---@field themeComponentDisabledStates string[]? -- List of theme states to suppress visually
---@field scaleCorners number? -- Scale multiplier for 9-patch corners and edges
---@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for non-stretched theme regions
local ThemeManagerConfig = {}
---@class ThemeRegion
---@field x number
---@field y number
---@field w number
---@field h number
local ThemeRegion = {}
---@class ThemeComponent
---@field atlas string|love.Image?
---@field insets {left:number, top:number, right:number, bottom:number}?
---@field regions {topLeft:ThemeRegion, topCenter:ThemeRegion, topRight:ThemeRegion, middleLeft:ThemeRegion, middleCenter:ThemeRegion, middleRight:ThemeRegion, bottomLeft:ThemeRegion, bottomCenter:ThemeRegion, bottomRight:ThemeRegion}?
---@field stretch {horizontal:table<integer, string>, vertical:table<integer, string>}?
---@field states table<string, ThemeComponent>?
---@field contentAutoSizingMultiplier {width:number?, height:number?}?
---@field scaleCorners number?
---@field scalingAlgorithm "nearest"|"bilinear"?
---@field knobOffset number|{x:number, y:number}|{horizontal:number, vertical:number}?
local ThemeComponent = {}
---@class ThemeDefinition
---@field name string
---@field atlas string|love.Image?
---@field components table<string, ThemeComponent>
---@field scrollbars table<string, ThemeComponent>?
---@field colors table<string, Color>?
---@field fonts table<string, string>?
---@field contentAutoSizingMultiplier {width:number?, height:number?}?
local ThemeDefinition = {}
---@class Theme
---@field name string
---@field atlas love.Image?
---@field atlasData love.ImageData?
---@field components table<string, ThemeComponent>
---@field scrollbars table<string, ThemeComponent>
---@field colors table<string, Color>
---@field fonts table<string, string>
---@field contentAutoSizingMultiplier {width:number?, height:number?}?
---@class ThemeManager
---@field theme string?
---@field themeComponent string?
---@field disabled boolean
---@field active boolean
---@field disableHighlight boolean?
---@field themeStateLock boolean|string?
---@field themeComponentDisabledStates table<string, boolean>
---@field scaleCorners number?
---@field scalingAlgorithm "nearest"|"bilinear"?
---@field updateState fun(self:ThemeManager, isHovered:boolean, isPressed:boolean, isFocused:boolean, isDisabled:boolean): string
---@field getState fun(self:ThemeManager): string
---@field setState fun(self:ThemeManager, state:string)
---@field hasThemeComponent fun(self:ThemeManager): boolean
---@field getTheme fun(self:ThemeManager): Theme?
---@field getComponent fun(self:ThemeManager): ThemeComponent?
---@field getStateComponent fun(self:ThemeManager): ThemeComponent?
---@field getScrollbarComponent fun(self:ThemeManager, scrollbarName:string?): ThemeComponent?
---@field getStyle fun(self:ThemeManager, property:string): any?
---@field _getScaledContentPaddingForState fun(self:ThemeManager, state:string, borderBoxWidth:number, borderBoxHeight:number): table?
---@field getScaledContentPaddingForState fun(self:ThemeManager, state:string, borderBoxWidth:number, borderBoxHeight:number): table? -- deprecated, use getScaledContentPadding
---@field getScaledContentPadding fun(self:ThemeManager, borderBoxWidth:number, borderBoxHeight:number): table?
---@field getContentAutoSizingMultiplier fun(self:ThemeManager): table?
---@field getDefaultFontFamily fun(self:ThemeManager): string?
---@field setTheme fun(self:ThemeManager, themeName:string?, componentName:string?)
---@field validateThemeStateLock fun(self:ThemeManager): boolean
---@class Color
---@field r number
---@field g number
---@field b number
---@field a number
---@field toRGBA fun(self:Color): number, number, number, number
---@class ThemeModule
---@field Manager ThemeManager -- Theme manager class table
---@field new fun(definition:ThemeDefinition): Theme
---@field load fun(path:string): Theme?
---@field setActive fun(themeOrName:string|Theme)
---@field getActive fun(): Theme?
---@field getComponent fun(componentName:string, state:string?): ThemeComponent?
---@field getDefaultScrollbar fun(): ThemeComponent?
---@field getScrollbar fun(scrollbarName:string, state:string?): ThemeComponent?
---@field getFont fun(fontName:string): string?
---@field getColor fun(colorName:string): Color?
---@field hasActive fun(): boolean
---@field getRegisteredThemes fun(): table<string, Theme>
---@field getColorNames fun(): string[]
---@field getAllColors fun(): table<string, Color>
---@field getColorOrDefault fun(colorName:string, fallback:Color): Color
---@field get fun(themeName:string): Theme?
---@field validateTheme fun(theme:table?, options:table?): boolean, table
---@field sanitizeTheme fun(theme:table?): table
local ThemeModule = {}
---@class FlexLove
---@field _VERSION string
---@field _DESCRIPTION string
---@field _URL string
---@field _LICENSE string
---@field Animation AnimationModule?
---@field Color ColorModule
---@field Theme ThemeModule?
---@field enums FlexLoveEnums
---@field isReady fun(): boolean
---@field init fun(config:FlexLoveConfig?)
---@field setKeyboardNavigationDebug fun(enabled:boolean)
---@field enableKeyboardNavigation fun(config:KeyboardNavigationConfig?)
---@field deferCallback fun(callback:function)
---@field executeDeferredCallbacks fun()
---@field resize fun()
---@field setMode fun(mode:"immediate"|"retained")
---@field getMode fun(): "immediate"|"retained"
---@field beginFrame fun()
---@field endFrame fun()
---@field draw fun(gameDrawFunc:function|nil, postDrawFunc:function|nil)
---@field getElementAtPosition fun(x:number, y:number): Element?
---@field update fun(dt:number)
---@field collectGarbage fun(mode:string?, stepSize:number?): number?
---@field setGCStrategy fun(strategy:"auto"|"periodic"|"manual"|"disabled")
---@field getGCStats fun(): GCStats
---@field textinput fun(text:string)
---@field keypressed fun(key:string, scancode:string, isrepeat:boolean)
---@field wheelmoved fun(dx:number, dy:number)
---@field touchpressed fun(id:lightuserdata, x:number, y:number, dx:number, dy:number, pressure:number)
---@field touchmoved fun(id:lightuserdata, x:number, y:number, dx:number, dy:number, pressure:number)
---@field touchreleased fun(id:lightuserdata, x:number, y:number, dx:number, dy:number, pressure:number)
---@field getActiveTouchCount fun(): number
---@field getTouchOwner fun(touchId:string): Element?
---@field getById fun(id:string): Element?
---@field destroy fun()
---@field new fun(props:ElementProps, callback:function?): Element?
---@field getStateCount fun(): number
---@field clearState fun(id:string)
---@field clearAllStates fun()
---@field getStateStats fun(): table
---@field calc fun(expr:string): CalcObject
---@field getFocusedElement fun(): Element?
---@field setFocusedElement fun(element:Element?)
---@field clearFocus fun()
---@field setDebugDraw fun(enabled:boolean)
---@field getDebugDraw fun(): boolean
local FlexLove = {}
--=====================================--
-- For State Persistence
--=====================================--
---@class ElementStateData
---@field _focused boolean?
---@field eventHandler table? -- EventHandler state
---@field textEditor table? -- TextEditor state
---@field scrollManager table? -- ScrollManager state
---@field blur BlurCacheData? -- Blur cache invalidation data
---@class BlurCacheData
---@field _blurX number
---@field _blurY number
---@field _blurWidth number
---@field _blurHeight number
---@field _backdropBlurRadius number?
---@field _backdropBlurQuality number?
---@field _contentBlurRadius number?
---@field _contentBlurQuality number?
--=====================================--
-- For Calc.lua
--=====================================--
---@class CalcDependencies
---@field ErrorHandler ErrorHandler? -- Error handler module
---@class CalcToken
---@field type string -- Token type: "NUMBER", "UNIT", "PLUS", "MINUS", "MULTIPLY", "DIVIDE", "LPAREN", "RPAREN", "EOF"
---@field value number? -- Numeric value (for NUMBER tokens)
---@field unit string? -- Unit type: "px", "%", "vw", "vh" (for NUMBER tokens)
---@class CalcASTNode
---@field type string -- Node type: "number", "add", "subtract", "multiply", "divide"
---@field value number? -- Numeric value (for "number" nodes)
---@field unit string? -- Unit type (for "number" nodes)
---@field left CalcASTNode? -- Left operand (for operator nodes)
---@field right CalcASTNode? -- Right operand (for operator nodes)
---@class CalcObject
---@field _isCalc boolean -- Marker to identify calc objects (always true)
---@field _expr string -- Original expression string
---@field _ast CalcASTNode? -- Parsed abstract syntax tree (nil if parsing failed)
---@field _error string? -- Error message if parsing failed
--=====================================--
-- For FlexLove.lua Internals
--=====================================--
---@class GCConfig
---@field strategy string -- "auto", "periodic", "manual", or "disabled"
---@field memoryThreshold number -- MB before forcing GC
---@field interval number -- Frames between GC steps (for periodic mode)
---@field stepSize number -- Work units per GC step (higher = more aggressive)
---@class GCState
---@field framesSinceLastGC number -- Frames elapsed since last GC
---@field lastMemory number -- Last recorded memory usage in MB
---@field gcCount number -- Total number of GC operations performed
---@class GCStats
---@field gcCount number -- Total number of GC operations performed
---@field framesSinceLastGC number -- Frames elapsed since last GC
---@field currentMemoryMB number -- Current memory usage in MB
---@field strategy string -- Current GC strategy
---@field threshold number -- Memory threshold in MB
---@class FlexLoveDependencies
---@field Context table -- Context module
---@field Theme Theme? -- Theme module
---@field Color Color -- Color module
---@field Calc Calc -- Calc module
---@field Units table -- Units module
---@field Blur table? -- Blur module
---@field ImageRenderer table? -- ImageRenderer module
---@field ImageScaler table? -- ImageScaler module
---@field NinePatch table? -- NinePatch module
---@field RoundedRect table -- RoundedRect module
---@field ImageCache table? -- ImageCache module
---@field utils table -- Utils module
---@field Grid table -- Grid module
---@field InputEvent table -- InputEvent module
---@field GestureRecognizer table? -- GestureRecognizer module
---@field StateManager StateManager -- StateManager module
---@field TextEditor table -- TextEditor module
---@field LayoutEngine LayoutEngine -- LayoutEngine module
---@field Renderer table -- Renderer module
---@field EventHandler EventHandler -- EventHandler module
---@field ScrollManager table -- ScrollManager module
---@field ErrorHandler ErrorHandler -- ErrorHandler module
---@field Performance Performance? -- Performance module
---@field Transform table? -- Transform module
-319
View File
@@ -1,319 +0,0 @@
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
-- Focused sub-modules (utils now re-exports their surfaces as backward-compatible
-- aliases so call sites needn't change). Loaded eagerly so the aliases resolve.
local NumberValidation = req("NumberValidation")
local TextSanitizer = req("TextSanitizer")
local PathValidator = req("PathValidator")
local FontCache = req("FontCache")
local Enums = req("Enums")
-- ErrorHandler is injected via init() (safeLoadImage closes over this upvalue).
local ErrorHandler = nil
local enums = Enums.enums
-- Generic math, table, and path helpers (utils' own concern).
-- All validation, font-cache, text-sanitization, and path-validation logic
-- lives in the focused sub-modules above and is re-exported below.
--- Get current keyboard modifiers state
---@return {shift:boolean, ctrl:boolean, alt:boolean, super:boolean}
local function getModifiers()
return {
shift = love.keyboard.isDown("lshift", "rshift"),
ctrl = love.keyboard.isDown("lctrl", "rctrl"),
alt = love.keyboard.isDown("lalt", "ralt"),
---@diagnostic disable-next-line
super = love.keyboard.isDown("lgui", "rgui"), -- cmd/windows key
}
end
local TEXT_SIZE_PRESETS = {
["2xs"] = 0.75,
xxs = 0.75,
xs = 1.25,
sm = 1.75,
md = 2.25,
lg = 2.75,
xl = 3.5,
xxl = 4.5,
["2xl"] = 4.5,
["3xl"] = 5.0,
["4xl"] = 7.0,
}
--- Resolve text size preset to viewport units
---@param sizeValue string|number
---@return number?, string?
local function resolveTextSizePreset(sizeValue)
if type(sizeValue) == "string" then
local preset = TEXT_SIZE_PRESETS[sizeValue]
if preset then
return preset, "vh"
end
end
return nil, nil
end
--- Auto-detect the base path where FlexLove is located
---@return string filesystemPath
local function getFlexLoveBasePath()
local info = debug.getinfo(1, "S")
if info and info.source then
local source = info.source
if source:sub(1, 1) == "@" then
source = source:sub(2)
end
local filesystemPath = source:match("(.*/)")
if filesystemPath then
local fsPath = filesystemPath
fsPath = fsPath:gsub("^%./", "")
fsPath = fsPath:gsub("/$", "")
fsPath = fsPath:gsub("/modules$", "")
return fsPath
end
end
return "libs"
end
local FLEXLOVE_FILESYSTEM_PATH = getFlexLoveBasePath()
--- Helper function to resolve paths relative to FlexLove
---@param path string
---@return string
local function resolveImagePath(path)
if path:match("^/") or path:match("^[A-Z]:") then
return path
end
return FLEXLOVE_FILESYSTEM_PATH .. "/" .. path
end
-- Math utilities
--- Clamp a value between optional min/max bounds. Either bound may be nil.
--- When both bounds are inverted (min > max), max wins (matches CSS behavior).
---@param value number Value to clamp
---@param min number|nil Minimum value (nil = no lower bound)
---@param max number|nil Maximum value (nil = no upper bound)
---@return number Clamped value
local function clamp(value, min, max)
if min and value < min then
value = min
end
if max and value > max then
value = max
end
return value
end
--- Linear interpolation between two values
---@param a number Start value
---@param b number End value
---@param t number Interpolation factor (0-1)
---@return number Interpolated value
local function lerp(a, b, t)
return a + (b - a) * t
end
--- Round a number to the nearest integer
---@param value number Value to round
---@return number Rounded value
local function round(value)
return math.floor(value + 0.5)
end
-- Image utilities
--- Safely load an image with error handling
--- Returns both Image and ImageData to avoid deprecated getData() API
---@param imagePath string Path to image file
---@return love.Image?, love.ImageData?, string? Returns image, imageData, or nil with error message
local function safeLoadImage(imagePath)
local success, imageData = pcall(function()
return love.image.newImageData(imagePath)
end)
if not success then
local errorMsg = string.format("Failed to load image data: %s - %s", imagePath, tostring(imageData))
if ErrorHandler then
ErrorHandler:warn("utils", "RES_004", {
resourceType = "image data",
path = imagePath,
error = tostring(imageData),
})
end
return nil, nil, errorMsg
end
local imageSuccess, image = pcall(function()
return love.graphics.newImage(imageData)
end)
if imageSuccess then
return image, imageData, nil
else
local errorMsg = string.format("Failed to create image: %s - %s", imagePath, tostring(image))
if ErrorHandler then
ErrorHandler:warn("utils", "RES_004", {
resourceType = "image",
path = imagePath,
error = tostring(image),
})
end
return nil, nil, errorMsg
end
end
-- Color manipulation utilities
--- Brighten a color by a factor
---@param r number Red component (0-1)
---@param g number Green component (0-1)
---@param b number Blue component (0-1)
---@param a number Alpha component (0-1)
---@param factor number Brightness factor (e.g., 1.2 for 20% brighter)
---@return number, number, number, number Brightened color components
local function brightenColor(r, g, b, a, factor)
return math.min(1, r * factor), math.min(1, g * factor), math.min(1, b * factor), a
end
-- Property normalization utilities
--- Normalize a boolean or table property with vertical/horizontal fields
---@param value boolean|table|nil Input value (boolean applies to both, table for individual control)
---@param defaultValue boolean Default value if nil (default: false)
---@return table Normalized table with vertical and horizontal fields
local function normalizeBooleanTable(value, defaultValue)
defaultValue = defaultValue or false
if value == nil then
return { vertical = defaultValue, horizontal = defaultValue }
end
if type(value) == "boolean" then
return { vertical = value, horizontal = value }
end
if type(value) == "table" then
return {
vertical = value.vertical ~= nil and value.vertical or defaultValue,
horizontal = value.horizontal ~= nil and value.horizontal or defaultValue,
}
end
return { vertical = defaultValue, horizontal = defaultValue }
end
--- Normalize an offset value to {x, y} or {horizontal, vertical} format
---@param value number|table|nil Input value (number applies to both, table for individual control)
---@param defaultValue number Default value if nil (default: 0)
---@return table Normalized table with x/y or horizontal/vertical fields
local function normalizeOffsetTable(value, defaultValue)
defaultValue = defaultValue or 0
if value == nil then
return { x = defaultValue, y = defaultValue, horizontal = defaultValue, vertical = defaultValue }
end
if type(value) == "number" then
return { x = value, y = value, horizontal = value, vertical = value }
end
if type(value) == "table" then
-- Support both {x, y} and {horizontal, vertical} formats
local x = value.x or value.horizontal or defaultValue
local y = value.y or value.vertical or defaultValue
return {
x = x,
y = y,
horizontal = x,
vertical = y,
}
end
return { x = defaultValue, y = defaultValue, horizontal = defaultValue, vertical = defaultValue }
end
--- Apply content auto-sizing multiplier to a dimension
---@param value number The dimension value
---@param multiplier table? The contentAutoSizingMultiplier table {width:number?, height:number?}
---@param axis "width"|"height" Which axis to apply
---@return number The multiplied value
local function applyContentMultiplier(value, multiplier, axis)
if multiplier and multiplier[axis] then
return value * multiplier[axis]
end
return value
end
--- Initialize dependencies
---@param deps table Dependencies: { ErrorHandler = ErrorHandler }
local function init(deps)
if type(deps) == "table" then
ErrorHandler = deps.ErrorHandler
end
-- Propagate shared ErrorHandler to focused sub-modules that need it.
NumberValidation.init({ ErrorHandler = ErrorHandler, clamp = clamp })
TextSanitizer.init({ ErrorHandler = ErrorHandler })
FontCache.init({ ErrorHandler = ErrorHandler, resolveImagePath = resolveImagePath })
-- PathValidator has no external dependencies.
end
return {
enums = enums,
FONT_CACHE = FontCache.FONT_CACHE,
resolveTextSizePreset = resolveTextSizePreset,
getModifiers = getModifiers,
TEXT_SIZE_PRESETS = TEXT_SIZE_PRESETS,
init = init,
clamp = clamp,
-- Alias for `clamp`; exposed under the size-clamping name so Element/LayoutEngine
-- and tests can reference min/max content-size clamping explicitly.
clampSize = clamp,
lerp = lerp,
round = round,
safeLoadImage = safeLoadImage,
brightenColor = brightenColor,
resolveImagePath = resolveImagePath,
normalizeBooleanTable = normalizeBooleanTable,
normalizeOffsetTable = normalizeOffsetTable,
applyContentMultiplier = applyContentMultiplier,
-- Backward-compatible aliases (delegated to focused sub-modules)
validateEnum = NumberValidation.validateEnum,
validateRange = NumberValidation.validateRange,
validateType = NumberValidation.validateType,
isNaN = NumberValidation.isNaN,
isInfinity = NumberValidation.isInfinity,
validateNumber = NumberValidation.validateNumber,
sanitizeNumber = NumberValidation.sanitizeNumber,
validateInteger = NumberValidation.validateInteger,
validatePercentage = NumberValidation.validatePercentage,
validateOpacity = NumberValidation.validateOpacity,
validateDegrees = NumberValidation.validateDegrees,
validateCoordinate = NumberValidation.validateCoordinate,
validateDimension = NumberValidation.validateDimension,
normalizePath = PathValidator.normalizePath,
sanitizePath = PathValidator.sanitizePath,
isPathSafe = PathValidator.isPathSafe,
validatePath = PathValidator.validatePath,
getFileExtension = PathValidator.getFileExtension,
hasAllowedExtension = PathValidator.hasAllowedExtension,
sanitizeText = TextSanitizer.sanitizeText,
validateTextInput = TextSanitizer.validateTextInput,
validateTextRange = TextSanitizer.validateTextRange,
escapeHtml = TextSanitizer.escapeHtml,
escapeLuaPattern = TextSanitizer.escapeLuaPattern,
stripNonPrintable = TextSanitizer.stripNonPrintable,
resolveFontPath = FontCache.resolveFontPath,
getFont = FontCache.getFont,
getFontCacheStats = FontCache.getFontCacheStats,
setFontCacheSize = FontCache.setFontCacheSize,
clearFontCache = FontCache.clearFontCache,
preloadFont = FontCache.preloadFont,
resetFontCacheStats = FontCache.resetFontCacheStats,
}
+277 -47
View File
@@ -10,6 +10,24 @@
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
local LaunchOptions = require("src.core.LaunchOptions")
local NxDisplay = require("src.core.NxDisplay")
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
do
local defaultErrorHandler = love.errorhandler
function love.errorhandler(msg)
local hint = SwitchDiagnostics.logLuaError(msg)
if hint and type(msg) == "string" then
msg = msg .. "\n\n" .. hint
end
if defaultErrorHandler then
return defaultErrorHandler(msg)
end
end
end
local Game, EditorApp, Importer, TouchEditor
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
@@ -103,6 +121,11 @@ local function openEditor(version, slotId)
require("src.import.CacheFs").mountVersion(version)
editorVersion = version
editorHost = Importer
-- Drop launcher pad/FlexLove so the save editor owns input (NX shim +
-- virtual cursor + system hand cursor). Desktop park is a light no-op.
if Importer and Importer.prepareOverlayHandoff then
Importer:prepareOverlayHandoff()
end
Importer = nil
editorMode = true
resizeForEditor()
@@ -129,6 +152,9 @@ function closeEditor()
restoreWindow()
Importer = editorHost
editorHost = nil
if Importer and Importer.resumeAfterOverlay then
Importer:resumeAfterOverlay()
end
if Importer and version and Importer.savesChanged then
Importer:savesChanged(version)
end
@@ -142,6 +168,9 @@ local closeTouchControlsEditor -- forward declaration
local function openTouchControlsEditor()
touchEditorHost = Importer
if Importer and Importer.prepareOverlayHandoff then
Importer:prepareOverlayHandoff()
end
Importer = nil
TouchEditor = require("src.ui.TouchControlsEditor")
TouchEditor.load({ onClose = function() closeTouchControlsEditor() end })
@@ -152,6 +181,9 @@ function closeTouchControlsEditor()
TouchEditor = nil
Importer = touchEditorHost
touchEditorHost = nil
if Importer and Importer.resumeAfterOverlay then
Importer:resumeAfterOverlay()
end
end
local function bootGame(version)
@@ -161,7 +193,16 @@ local function bootGame(version)
-- data, so data/generated + assets/generated resolve to that version's files.
local GameVersion = require("src.core.GameVersion")
GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red")
require("src.import.CacheFs").mountVersion(GameVersion.get())
local CacheFs = require("src.import.CacheFs")
-- Keep CacheFs.prefix aligned for any CacheFs.read fallback during Data:load
-- (Blue/Yellow caches live under blue/ / yellow/).
CacheFs.prefix = GameVersion.cachePrefix()
CacheFs.mountVersion(GameVersion.get())
-- NX: always write nx-asset-probe.log so Yellow/Blue art failures are
-- diagnosable from the SD without enabling switch-debug.txt.
pcall(function()
require("src.debug.SwitchDiagnostics").probeAssets(GameVersion.get())
end)
if love.window and love.window.setTitle then
local Version = require("src.core.Version")
love.window.setTitle(Version.title(
@@ -189,6 +230,13 @@ function love.load(args)
-- of each flashing their own cmd.exe window (#606). No-op elsewhere.
require("src.core.HostShell").hideHostConsole()
-- NX fused mounts are unreliable for the blue|yellow cache overlay: wrap
-- the love loaders once so every generated-asset read falls back to the
-- versioned save-dir copy. Never installed on desktop/Android/iOS.
if require("src.core.Platform").isNX() then
require("src.core.NxAssetOverlay").install()
end
-- Self-updater boot shell: a fused build may mount and chainload a newer
-- downloaded payload here. True means it took over, so we must stop. A
-- dev / source checkout no-ops (see src/update/Boot.lua).
@@ -208,6 +256,16 @@ function love.load(args)
end
end
love.graphics.setDefaultFilter("nearest", "nearest")
-- NX: handheld 720p / docked 1080p. Runs for every boot path (launcher,
-- editor, scripted); no-op on desktop/mobile.
NxDisplay.sync()
-- Apply the persisted Android orientation lock (#592) before the launcher
-- shows: SDL created the window with no orientation hint, so without this
-- the launcher would rotate freely until Game:applyOptions runs at boot.
-- No-op on desktop / iOS / when options.lua does not exist yet.
require("src.core.Orientation").applyOptions(
require("src.core.SaveData").loadOptions())
-- Standalone editor. A bare `--editor` run has no launcher behind it, so
-- Close quits; --save points it at a specific file, otherwise it opens the
@@ -256,6 +314,38 @@ function love.load(args)
return
end
-- The launcher draws before any game boots, so the mod loader has not run
-- and Strings has no catalog. Routing the launcher's text through Strings
-- (#767) only pays off if something fills that catalog this early, and no
-- restart could: the ordering is the same on every launch. Read the
-- enabled mods' string catalogs -- data only, no entry chunk -- so a
-- translation reaches the launcher too. Game:load replaces this with the
-- real merged catalog once a version boots.
do
local preload = require("src.mods.LauncherMods").translationStrings()
if preload then require("src.core.Strings").load({ strings = preload }) end
end
-- LAUNCH OPTIONS: skip the launcher and boot a game directly.
-- --game red|blue|yellow (or POKEPORT_GAME / POKEPORT_LAUNCH)
-- --slot <id> optional; picks the save slot to load
-- --launcher force the launcher even if a game is set
-- This is what a desktop shortcut, a Steam entry, or a frontend like
-- EmulationStation needs: one click into the game the player wants, with no
-- menu in between. A game that is not imported falls through to the
-- launcher on its tab rather than booting into nothing.
local launchGame, launchSlot = LaunchOptions.resolve(arg)
if launchGame and not LaunchOptions.forceLauncher(arg) then
if RomImporter.isReady(launchGame) then
if launchSlot then LaunchOptions.selectSlot(launchGame, launchSlot) end
bootGame(launchGame)
return
end
-- Not importable yet: open the launcher already showing that game, so the
-- shortcut still lands the player where they meant to go.
LaunchOptions.pendingTab = launchGame
end
-- Interactive: the launcher always runs. Red, Blue, and Yellow are each
-- live: a column shows Play when that game's ROM is already imported, or
-- Choose ROM / drag-drop when it is not. Any dropped .gb is routed by its
@@ -273,6 +363,9 @@ function love.load(args)
end
function love.update(dt)
SwitchDiagnostics.maybeFlush(false)
-- NX only (no-op elsewhere): follow dock/undock without waiting for SDL.
NxDisplay.sync()
if editorMode then return EditorApp.update(dt) end
if TouchEditor then return TouchEditor.update(dt) end
if Importer then return Importer:update(dt) end
@@ -347,48 +440,140 @@ function love.keyreleased(key)
end
function love.gamepadpressed(joystick, button)
if editorMode or TouchEditor then return end
SwitchDiagnostics.onJoystickEvent("gamepadpressed", joystick, button)
if editorMode then
if EditorApp and EditorApp.gamepadpressed then
return EditorApp.gamepadpressed(joystick, button)
end
return
end
if TouchEditor then
if TouchEditor.gamepadpressed then
return TouchEditor.gamepadpressed(joystick, button)
end
return
end
if Importer then return Importer:gamepadpressed(joystick, button) end
Game:gamepadpressed(joystick, button)
end
function love.gamepadreleased(joystick, button)
if editorMode or TouchEditor then return end
SwitchDiagnostics.onJoystickEvent("gamepadreleased", joystick, button)
if editorMode then
if EditorApp and EditorApp.gamepadreleased then
return EditorApp.gamepadreleased(joystick, button)
end
return
end
if TouchEditor then
if TouchEditor.gamepadreleased then
return TouchEditor.gamepadreleased(joystick, button)
end
return
end
if Importer then return Importer:gamepadreleased(joystick, button) end
Game:gamepadreleased(joystick, button)
end
function love.gamepadaxis(joystick, axis, value)
if editorMode or TouchEditor then return end
SwitchDiagnostics.onJoystickEvent("gamepadaxis", joystick, axis, { value = value })
if editorMode then
if EditorApp and EditorApp.gamepadaxis then
return EditorApp.gamepadaxis(joystick, axis, value)
end
return
end
if TouchEditor then
if TouchEditor.gamepadaxis then
return TouchEditor.gamepadaxis(joystick, axis, value)
end
return
end
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
Game:gamepadaxis(joystick, axis, value)
end
function love.joystickpressed(joystick, button)
if editorMode or TouchEditor then return end
SwitchDiagnostics.onJoystickEvent("joystickpressed", joystick, button)
if editorMode then
if EditorApp and EditorApp.joystickpressed then
return EditorApp.joystickpressed(joystick, button)
end
return
end
if TouchEditor then
if TouchEditor.joystickpressed then
return TouchEditor.joystickpressed(joystick, button)
end
return
end
if Importer then return Importer:joystickpressed(joystick, button) end
Game:joystickpressed(joystick, button)
end
function love.joystickreleased(joystick, button)
if editorMode or TouchEditor then return end
SwitchDiagnostics.onJoystickEvent("joystickreleased", joystick, button)
if editorMode then
if EditorApp and EditorApp.joystickreleased then
return EditorApp.joystickreleased(joystick, button)
end
return
end
if TouchEditor then
if TouchEditor.joystickreleased then
return TouchEditor.joystickreleased(joystick, button)
end
return
end
if Importer then return Importer:joystickreleased(joystick, button) end
Game:joystickreleased(joystick, button)
end
function love.joystickaxis(joystick, axis, value)
if editorMode or TouchEditor then return end
SwitchDiagnostics.onJoystickEvent("joystickaxis", joystick, axis, { value = value })
if editorMode then
if EditorApp and EditorApp.joystickaxis then
return EditorApp.joystickaxis(joystick, axis, value)
end
return
end
if TouchEditor then
if TouchEditor.joystickaxis then
return TouchEditor.joystickaxis(joystick, axis, value)
end
return
end
if Importer then return Importer:joystickaxis(joystick, axis, value) end
Game:joystickaxis(joystick, axis, value)
end
function love.joystickhat(joystick, hat, direction)
if editorMode or TouchEditor then return end
SwitchDiagnostics.onJoystickEvent("joystickhat", joystick, hat, { direction = direction })
if editorMode then
if EditorApp and EditorApp.joystickhat then
return EditorApp.joystickhat(joystick, hat, direction)
end
return
end
if TouchEditor then
if TouchEditor.joystickhat then
return TouchEditor.joystickhat(joystick, hat, direction)
end
return
end
if Importer then return Importer:joystickhat(joystick, hat, direction) end
Game:joystickhat(joystick, hat, direction)
end
function love.joystickadded(joystick)
SwitchDiagnostics.onJoystickEvent("joystickadded", joystick)
if editorMode or TouchEditor then return end
if Importer then return end
Game:joystickadded(joystick)
end
function love.joystickremoved(joystick)
SwitchDiagnostics.onJoystickEvent("joystickremoved", joystick)
if editorMode or TouchEditor then return end
if Importer then return end
Game:joystickremoved(joystick)
@@ -400,6 +585,7 @@ end
function love.focus(f)
if editorMode or TouchEditor then return end
if Importer then
require("src.core.Input"):reset()
if Importer.focus then Importer:focus(f) end
return
end
@@ -409,12 +595,29 @@ end
-- v is true when the window becomes visible again, false on minimize.
function love.visible(v)
if editorMode or TouchEditor then return end
if Importer then return end
if Importer then
require("src.core.Input"):reset()
return
end
Game:visible(v)
end
function love.lowmemory()
if editorMode or TouchEditor or Importer then return end
if Game then Game:onResume() end
end
function love.touchpressed(id, x, y, dx, dy, pressure)
if editorMode then return end
if editorMode then
-- iOS synthesizes mousepressed for the primary touch; forwarding here
-- would double-fire. Android / NX need the explicit touch → click path
-- (love-nx does not synthesize mouse for the editor the way desktop does).
if love.system.getOS() == "iOS" then return end
if EditorApp and EditorApp.mousepressed then
return EditorApp.mousepressed(x, y, 1)
end
return
end
if TouchEditor then
-- iOS synthesizes mousepressed for the primary touch (same as the
-- launcher); Android drives the editor through love.touch directly.
@@ -422,12 +625,12 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
return TouchEditor.touchpressed(id, x, y)
end
if Importer then
if love.system.getOS() == "iOS" then
return Importer:touchpressed(id, x, y)
end
return Importer:mousepressed(x, y, 1)
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
-- polled inside the view; the istouch filter on mousepressed still drops
-- Android's synthesized mouse twin so Import cannot double-fire (#553).
return Importer:touchpressed(id, x, y, dx, dy, pressure)
end
Game:touchpressed(id, x, y)
Game:touchpressed(id, x, y, dx, dy, pressure)
end
function love.touchmoved(id, x, y, dx, dy, pressure)
@@ -437,12 +640,9 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
return TouchEditor.touchmoved(id, x, y)
end
if Importer then
if love.system.getOS() == "iOS" then
return Importer:touchmoved(id, x, y)
end
return
return Importer:touchmoved(id, x, y, dx, dy, pressure)
end
Game:touchmoved(id, x, y)
Game:touchmoved(id, x, y, dx, dy, pressure)
end
function love.touchreleased(id, x, y, dx, dy, pressure)
@@ -452,12 +652,9 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
return TouchEditor.touchreleased(id, x, y)
end
if Importer then
if love.system.getOS() == "iOS" then
return Importer:touchreleased(id, x, y)
end
return
return Importer:touchreleased(id, x, y, dx, dy, pressure)
end
Game:touchreleased(id, x, y)
Game:touchreleased(id, x, y, dx, dy, pressure)
end
function love.wheelmoved(x, y)
@@ -478,33 +675,35 @@ function love.mousepressed(x, y, button, istouch)
return TouchEditor.mousepressed(x, y, button)
end
if Importer then
-- The same double-fire TouchEditor guards against, which the launcher was
-- missing: love.touchpressed above forwards the primary touch to the
-- Importer on Android, and LÖVE ALSO synthesizes a mouse press for that
-- same touch, so one tap ran every launcher button twice. On Import that
-- meant two choose() calls and two stacked SAF picker activities: the
-- player picked their ROM, the top picker closed, and the second was still
-- underneath asking for it again, which is the "import the file twice"
-- in #553. Filtering on istouch keeps a real mouse (DeX, a Chromebook, a
-- USB mouse) working, which an Android-wide return would have broken.
--
-- ANDROID ONLY, and the OS test is load bearing: love.touchpressed above
-- returns early on iOS and never forwards, so there the synthesized mouse
-- press is the ONLY event the launcher gets. Filtering istouch on both
-- killed every tap on iOS outright.
-- love.touchpressed already forwards the primary touch into FlexLove for
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
-- reached a press handler, one tap ran every launcher button twice and
-- stacked two SAF pickers (#553). Clicks are polled inside FlexLove from
-- love.touch / mouse.isDown, so dropping the synthesized istouch press is
-- safe. A real mouse (DeX, Chromebook, USB) still reaches mousepressed.
if istouch and (love.system.getOS() == "Android"
or love.system.getOS() == "iOS") then return end
return Importer:mousepressed(x, y, button)
end
if editorMode and EditorApp.mousepressed then
-- Same Android double-fire guard: touchpressed already clicked for the
-- save editor; a synthesized mouse press must not fire again.
if istouch and love.system.getOS() == "Android" then return end
return EditorApp.mousepressed(x, y, button)
end
if mouseTouch and Game and button == 1 then
Game:touchpressed("mouse", x, y)
if mouseTouch then
-- the mouse is standing in for a finger: the touch path owns it, and
-- feeding the same press back in as a mouse pointer would double it
if Game and button == 1 then Game:touchpressed("mouse", x, y) end
return
end
-- #807: a real mouse reaches gameplay as a pointer event for mods; Game
-- drops synthesized istouch twins so a mobile touch that already arrived
-- through love.touchpressed cannot fire twice
if Game then Game:mousepressed(x, y, button, istouch) end
end
function love.mousereleased(x, y, button)
function love.mousereleased(x, y, button, istouch)
if TouchEditor then
if love.system.getOS() == "Android" then return end
return TouchEditor.mousereleased(x, y, button)
@@ -513,20 +712,24 @@ function love.mousereleased(x, y, button)
if editorMode and EditorApp.mousereleased then
return EditorApp.mousereleased(x, y, button)
end
if mouseTouch and Game and button == 1 then
Game:touchreleased("mouse", x, y)
if mouseTouch then
if Game and button == 1 then Game:touchreleased("mouse", x, y) end
return
end
if Game then Game:mousereleased(x, y, button, istouch) end
end
function love.mousemoved(x, y)
function love.mousemoved(x, y, dx, dy, istouch)
if TouchEditor then
if love.system.getOS() == "Android" then return end
return TouchEditor.mousemoved(x, y)
end
if editorMode or Importer then return end
if mouseTouch and Game and love.mouse.isDown(1) then
Game:touchmoved("mouse", x, y)
if mouseTouch then
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
return
end
if Game then Game:mousemoved(x, y, dx, dy, istouch) end
end
function love.textinput(text)
@@ -537,10 +740,31 @@ function love.textinput(text)
end
end
-- #785: set once love.quit has routed a window close into HostShell.restart,
-- so the follow-up quit event the restart itself raises (quit("restart") on
-- desktop; AppImage and Android relaunch the process instead, #575) falls
-- through to the normal shutdown below instead of restarting forever.
local quitToLauncher = false
function love.quit()
if editorMode and EditorApp.quit then
return EditorApp.quit() -- return true to abort quit
end
-- Closing the window of a running game returns to the launcher instead of
-- exiting the app, so testing a mod does not need a relaunch every time
-- (#785). Game is only non-nil once bootGame ran; Importer non-nil means
-- the launcher (or its import) owns the window and its close still quits.
-- Scripted and headless runs (autopilot, frame driver, import-only, ROM
-- path import) keep the plain exit so they terminate as before. Nothing
-- is saved here on purpose: a window close never wrote the save, and the
-- restart path must be no worse than that, not quietly better.
local scripted = os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER")
or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or os.getenv("POKEPORT_IMPORT_ROM")
if Game and not Importer and not quitToLauncher and not scripted then
quitToLauncher = true
require("src.core.HostShell").restart()
return true -- abort this quit; the restart lands back in the launcher
end
pcall(function()
require("src.core.DiscordPresence").shutdown()
end)
@@ -554,6 +778,12 @@ function love.quit()
if package.loaded["src.update.Check"] then
pcall(package.loaded["src.update.Check"].shutdown)
end
-- The launcher's fetch pool is the same story: its workers idle in
-- Channel:demand(), which never returns on its own, so a launcher that ever
-- touched the network would hang the process on exit (#339's shape again).
if package.loaded["src.net.Fetch"] then
pcall(package.loaded["src.net.Fetch"].shutdown)
end
end
function love.filedropped(file)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 42 KiB

@@ -259,7 +259,19 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
return false;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
// NOT FindClass: this is the one bridge called off the main thread
// (love.thread workers in src/net/fetch_worker.lua and
// src/update/check_worker.lua). A worker is a raw pthread whose JNI
// class loader is the system one, which cannot see app classes, so
// FindClass("org/love2d/android/GameActivity") left a pending
// ClassNotFoundException and the next JNI call aborted the process --
// opening FIND MODS killed the app on the first stats fetch. Resolving
// through the live activity instance works from any attached thread.
jobject activityObj = (jobject) SDL_AndroidGetActivity();
if (activityObj == nullptr)
return false;
jclass activity = env->GetObjectClass(activityObj);
env->DeleteLocalRef(activityObj);
// Old APK / new liblove skew: report "no transport" the same way a
// missing curl does, instead of aborting on a missing method (#597).
+28
View File
@@ -12,6 +12,34 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.1.64",
"date": "2026-08-03",
"size": 9317129,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.64/gen1recomp-0.1.64-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @ShaneMcGovernIE"
},
{
"version": "0.1.63",
"date": "2026-08-03",
"size": 9316977,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.63/gen1recomp-0.1.63-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #623 Game Corner Prize Exchange various issues\n- #624 Coins purschase menu missing your current money and coins\n- #636 Inaccurate Old Man tutorial in yellow + misc issues relating to him\n- #637 True color broken on Hall of Fame and Party Stats when using the wide battle option\n- #639 Pokédex can't show the correct stats when you catch 100 or more pokémon + overly simplified layout\n- #650 Rocket Hideout Progression Bug - Stuck with door closed after defeating Jessie and James in Yellow\n- #697 Missaligned HUD on Hall of Fame\n- #704 Player doesn't follow Prof. Oak to the Hall of Fame on the first screen\n- #722 Issues with Giovanni in Silph C.O.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.62",
"date": "2026-08-03",
"size": 9309934,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.62/gen1recomp-0.1.62-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.61",
"date": "2026-08-03",
"size": 9052744,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.61/gen1recomp-0.1.61-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #325 Launcher crashes importing a mod .zip with non-ASCII characters in the filename (Windows)\n- #646 Fuchsia City Pokemon not being added to Pokedex.\n- #651 Parasect trading NPC mute and deaf\n- #665 fix: file path sanitizing is needed\n- #706 Champion theme playing too late\n- #709 Fainted Pokemon plays its normal cry instead of the pitched-down, slower faint cry\n- #719 Silph C.O workers dialog bug.\n\n## Contributors\n\n- @bryanthaboi\n- @castdrian\n- @jherediagu\n- @johnjohto\n- @ShaneMcGovernIE\n- hernan"
},
{
"version": "0.1.60",
"date": "2026-08-02",
+39
View File
@@ -30,6 +30,45 @@ public final class GRPickerBridge: NSObject {
// (<sandbox>/Library/Application Support/<identity>).
private static let loveIdentity = "pokemon-love2d"
@objc(httpDownloadWithUrl:destination:userAgent:accept:)
public static func httpDownload(url: UnsafePointer<CChar>?,
destination: UnsafePointer<CChar>?,
userAgent: UnsafePointer<CChar>?,
accept: UnsafePointer<CChar>?) -> Bool {
guard let url, let destination,
let requestURL = URL(string: String(cString: url)) else { return false }
var request = URLRequest(url: requestURL)
request.timeoutInterval = 300
if let userAgent, userAgent.pointee != 0 {
request.setValue(String(cString: userAgent), forHTTPHeaderField: "User-Agent")
}
if let accept, accept.pointee != 0 {
request.setValue(String(cString: accept), forHTTPHeaderField: "Accept")
}
let target = URL(fileURLWithPath: String(cString: destination))
let semaphore = DispatchSemaphore(value: 0)
var succeeded = false
let task = URLSession.shared.downloadTask(with: request) { temporary, response, error in
defer { semaphore.signal() }
guard error == nil, let temporary,
let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else { return }
try? FileManager.default.removeItem(at: target)
do {
try FileManager.default.moveItem(at: temporary, to: target)
succeeded = true
} catch {
succeeded = false
}
}
task.resume()
guard semaphore.wait(timeout: .now() + 305) == .success else {
task.cancel()
return false
}
return succeeded
}
// MARK: - Entry points called from liblove (C strings on purpose)
@objc(presentPickerWithKind:saveDir:)
+30 -2
View File
@@ -103,6 +103,7 @@ WRAP_REGISTRATION = """#ifdef LOVE_IOS
{ "pickFile", w_pickFile },
{ "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps },
{ "httpDownload", w_httpDownload },
#endif
"""
@@ -144,6 +145,32 @@ int w_syncHealthSteps(lua_State *L)
WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
{ "syncHealthSteps", w_syncHealthSteps },
{ "httpDownload", w_httpDownload },
#endif
"""
BRIDGE_EXTRA_FUNCS = """
#ifdef LOVE_IOS
int w_httpDownload(lua_State *L)
{
const char *url = luaL_checkstring(L, 1);
const char *destination = luaL_checkstring(L, 2);
const char *userAgent = luaL_optstring(L, 3, "gen1recomp");
const char *accept = luaL_optstring(L, 4, "");
Class cls = objc_getClass("GRPickerBridge");
if (cls == nullptr)
{
lua_pushboolean(L, 0);
return 1;
}
typedef signed char (*GRDownload)(Class, SEL, const char *, const char *,
const char *, const char *);
signed char ok = ((GRDownload)objc_msgSend)(
cls, sel_registerName("httpDownloadWithUrl:destination:userAgent:accept:"),
url, destination, userAgent, accept);
lua_pushboolean(L, ok != 0);
return 1;
}
#endif
"""
@@ -216,7 +243,8 @@ def patch_wrap_system():
if anchor not in text:
fail(f"anchor not found in {WRAP_SYSTEM}")
has_native_picker = re.search(r"\bint w_pickFile\s*\(", text) is not None
text = text.replace(anchor, (WRAP_SYNC_FUNCS if has_native_picker else WRAP_FUNCS) + anchor, 1)
bridge_funcs = WRAP_SYNC_FUNCS if has_native_picker else WRAP_FUNCS
text = text.replace(anchor, bridge_funcs + BRIDGE_EXTRA_FUNCS + anchor, 1)
reg_anchor = '\t{ "vibrate", w_vibrate },\n'
if reg_anchor not in text:
fail(f"registration anchor not found in {WRAP_SYSTEM}")
@@ -224,7 +252,7 @@ def patch_wrap_system():
text = text.replace(reg_anchor, reg_anchor + registration, 1)
WRAP_SYSTEM.write_text(text)
print("patch_love_src: wrap_System.cpp patched "
"(pickFile/createFile/syncHealthSteps)")
"(pickFile/createFile/syncHealthSteps/httpDownload)")
def patch_pbxproj():
Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+119
View File
@@ -0,0 +1,119 @@
# Gen1Recomp Xbox UWP build notes
This is the Xbox Dev Mode package for Gen1Recomp.
The rough shape is:
- `Gen1RecompUWP.exe` starts LÖVE through SDL's WinRT wrapper
- the bundled LÖVE 11.5 UWP backend provides LuaJIT and the Xbox file picker
- the bundled SDL2 runtime contains the Xbox controller mapping
- ANGLE provides OpenGL ES over D3D11
- the bundled runtime contains the audio, font, video, and compression libraries
## What You Need
The tested toolchain is:
- Visual Studio 2022 17.14
- MSVC v143 x64/x86 build tools
- C++ Universal Windows Platform tools
- Windows 11 SDK `10.0.26100.0`
- CMake 3.24 or newer
- Git for Windows
- Info-ZIP `zip` and `unzip`
Use Visual Studio Installer to add **Universal Windows Platform development**, the v143 C++ tools, CMake tools for Windows, and Windows SDK `10.0.26100.0`.
The x64 UWP dependencies are committed under `third_party`. Their versions,
source revisions, licences, and hashes are recorded in `third_party/manifest.json`.
No additional checkout or environment variable is required for a normal game
build.
## Rebuild the Dependencies
Run the dependency rebuild from the repository root:
```powershell
.\scripts\xbox-uwp\rebuild_dependencies.ps1
```
The script clones the pinned SDL2, LÖVE, LuaJIT, vcpkg, depot_tools, and ANGLE
sources when they are missing. It applies the Xbox SDL2 patch, builds the x64
UWP Release libraries, stages the required DLLs, import libraries, headers, and
licences under `third_party`, updates every SHA-256 entry in the manifest, then
builds the Release MSIX.
The generated source checkouts are ignored by Git. A fresh ANGLE sync is about
10 GB, so allow at least 20 GB of free disk space for all sources and build
outputs. Use `-SkipAngle` to retain the existing pinned ANGLE runtime while
rebuilding SDL2, LÖVE, LuaJIT, and the vcpkg libraries. Use `-SkipPackage` when
only the dependency bundle needs to be refreshed. The rebuild stops if a source
checkout has local changes. Remove that generated `source` directory to restore
the pinned revision.
## Build the MSIX
Run the Xbox build from Git Bash at the repository root:
```bash
scripts/build_xbox_uwp.sh --release --version 1.2.3
```
The build uses `scripts/pack_love.sh` to create and verify the same ROM-free
`game.love` payload used by the other release targets. It then links the UWP
host and stages LÖVE, LuaJIT, SDL2, ANGLE, and the vcpkg runtime DLLs.
Use `--relwithdebinfo` for a package with symbols. To package a `.love` produced
by another build or downloaded from CI, pass `--game-love path/to/game.love`.
The upstream `X.Y.Z` release becomes `X.Y.Z.0` in the generated MSIX manifest.
Neither the manifest template nor `src/core/Version.lua` is edited in place.
The manifest publisher must match the signing certificate subject. Pass it
when preparing a signed package:
```bash
scripts/build_xbox_uwp.sh --release --version 1.2.3 \
--publisher "CN=Gen1Recomp"
```
The normal build is unsigned. Release CI supplies the private PFX and password
from `XBOX_UWP_SIGNING_CERTIFICATE` and `XBOX_UWP_SIGNING_PASSWORD`; neither may
be committed. The public certificate is safe to include with the release.
Run the offline packaging checks from Git Bash:
```bash
bash scripts/xbox-uwp/selftest_build_xbox_uwp.sh
```
## Build Output
Visual Studio package output lands under:
```text
ports\uwp\build\release\AppPackages\Gen1RecompUWP
```
The build also stages the distributable archive and checksum under:
```text
dist\xbox-uwp\gen1recomp-X.Y.Z-xbox-uwp.zip
dist\xbox-uwp\gen1recomp-X.Y.Z-xbox-uwp.zip.sha256
```
The archive contains the MSIX, framework dependencies, build provenance and,
for a signed release, the public certificate. The third-party notices are
packaged inside the MSIX. Install the MSIX and dependency packages through
Xbox Device Portal.
## Runtime Data
The package contains no ROM, generated cache, save or mod data. The Xbox file
picker copies user-selected files into LocalState and the launcher imports them
from there. Saves, ROM cache and installed mods remain under the LÖVE save
directory in LocalState.
LuaJIT requires the `codeGeneration` capability. `removableStorage` exposes
external media to the Xbox picker. The network capabilities support relay play
and direct hosting. The package does not request full trust or broad filesystem
access.
+149
View File
@@ -0,0 +1,149 @@
cmake_minimum_required(VERSION 3.24)
project(Gen1RecompUWP LANGUAGES CXX)
if(NOT CMAKE_SYSTEM_NAME STREQUAL "WindowsStore")
message(FATAL_ERROR "Configure with a WindowsStore preset.")
endif()
get_filename_component(GAME_ROOT "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE)
set(THIRD_PARTY_ROOT "${CMAKE_CURRENT_LIST_DIR}/third_party")
set(LOVE_ROOT "${THIRD_PARTY_ROOT}/love")
set(SDL2_ROOT "${THIRD_PARTY_ROOT}/sdl2")
set(ANGLE_ROOT "${THIRD_PARTY_ROOT}/angle")
set(RUNTIME_ROOT "${THIRD_PARTY_ROOT}/runtime")
set(GEN1RECOMP_VERSION "0.0.0" CACHE STRING "Gen1Recomp release version")
if(NOT GEN1RECOMP_VERSION MATCHES "^[0-9]+\\.[0-9]+\\.[0-9]+$")
message(FATAL_ERROR "GEN1RECOMP_VERSION must use X.Y.Z format.")
endif()
string(REPLACE "." ";" VERSION_PARTS "${GEN1RECOMP_VERSION}")
foreach(part IN LISTS VERSION_PARTS)
if(part GREATER 65535)
message(FATAL_ERROR "MSIX version components cannot exceed 65535.")
endif()
endforeach()
set(GEN1RECOMP_UWP_PUBLISHER "CN=Gen1Recomp" CACHE STRING
"Publisher subject from the MSIX signing certificate")
set(UWP_PUBLISHER_XML "${GEN1RECOMP_UWP_PUBLISHER}")
string(REPLACE "&" "&amp;" UWP_PUBLISHER_XML "${UWP_PUBLISHER_XML}")
string(REPLACE "\"" "&quot;" UWP_PUBLISHER_XML "${UWP_PUBLISHER_XML}")
string(REPLACE "<" "&lt;" UWP_PUBLISHER_XML "${UWP_PUBLISHER_XML}")
string(REPLACE ">" "&gt;" UWP_PUBLISHER_XML "${UWP_PUBLISHER_XML}")
set(UWP_PACKAGE_VERSION "${GEN1RECOMP_VERSION}.0")
set(PACKAGE_MANIFEST "${CMAKE_CURRENT_BINARY_DIR}/Package.appxmanifest")
configure_file(
"${CMAKE_CURRENT_LIST_DIR}/Package.appxmanifest.in"
"${PACKAGE_MANIFEST}"
@ONLY
)
set(REQUIRED_FILES
"${THIRD_PARTY_ROOT}/manifest.json"
"${LOVE_ROOT}/lib/lovestatic.lib"
"${LOVE_ROOT}/lib/liblove.lib"
"${LOVE_ROOT}/lib/lua51.lib"
"${LOVE_ROOT}/bin/love.dll"
"${LOVE_ROOT}/bin/lua51.dll"
"${SDL2_ROOT}/include/SDL2/SDL.h"
"${SDL2_ROOT}/lib/SDL2.lib"
"${SDL2_ROOT}/bin/SDL2.dll"
"${ANGLE_ROOT}/bin/libEGL.dll"
"${ANGLE_ROOT}/bin/libGLESv2.dll"
"${ANGLE_ROOT}/bin/d3dcompiler_47.dll"
)
foreach(path IN LISTS REQUIRED_FILES)
if(NOT EXISTS "${path}")
message(FATAL_ERROR "Missing UWP dependency: ${path}")
endif()
endforeach()
set(GEN1RECOMP_LOVE "${GAME_ROOT}/.bazinga/work/game.love" CACHE FILEPATH
"Path to the game.love payload produced by scripts/pack_love.sh")
if(NOT EXISTS "${GEN1RECOMP_LOVE}")
message(FATAL_ERROR
"Missing game.love payload: ${GEN1RECOMP_LOVE}\n"
"Build it with scripts/build_xbox_uwp.sh or scripts/pack_love.sh.")
endif()
set(GAME_ARCHIVE "${CMAKE_CURRENT_BINARY_DIR}/gen1recomp.love")
add_custom_command(
OUTPUT "${GAME_ARCHIVE}"
COMMAND "${CMAKE_COMMAND}" -E copy_if_different
"${GEN1RECOMP_LOVE}" "${GAME_ARCHIVE}"
DEPENDS "${GEN1RECOMP_LOVE}"
VERBATIM
)
add_custom_target(gen1recomp_love ALL DEPENDS "${GAME_ARCHIVE}")
set_source_files_properties("${GAME_ARCHIVE}" PROPERTIES GENERATED TRUE)
add_custom_target(verify_uwp_dependencies
COMMAND powershell -NoProfile -ExecutionPolicy Bypass -File
"${GAME_ROOT}/scripts/xbox-uwp/verify_dependencies.ps1"
VERBATIM
)
add_executable(${PROJECT_NAME} WIN32 "app/main.cpp")
add_dependencies(${PROJECT_NAME} gen1recomp_love verify_uwp_dependencies)
set_target_properties(${PROJECT_NAME} PROPERTIES
CXX_STANDARD 17
CXX_STANDARD_REQUIRED YES
VS_GLOBAL_DefaultLanguage "en-US"
VS_SDK_REFERENCES "Microsoft.VCLibs, Version=14.0"
)
target_include_directories(${PROJECT_NAME} PRIVATE "${SDL2_ROOT}/include/SDL2")
target_link_libraries(${PROJECT_NAME} PRIVATE
"${LOVE_ROOT}/lib/lovestatic.lib"
"${LOVE_ROOT}/lib/liblove.lib"
"${SDL2_ROOT}/lib/SDL2.lib"
"${LOVE_ROOT}/lib/lua51.lib"
WindowsApp.lib
)
set(PACKAGE_ROOT_FILES
"${PACKAGE_MANIFEST}"
"${GAME_ARCHIVE}"
"${LOVE_ROOT}/bin/love.dll"
"${LOVE_ROOT}/bin/lua51.dll"
"${SDL2_ROOT}/bin/SDL2.dll"
"${ANGLE_ROOT}/bin/libEGL.dll"
"${ANGLE_ROOT}/bin/libGLESv2.dll"
"${ANGLE_ROOT}/bin/d3dcompiler_47.dll"
)
set_source_files_properties("${PACKAGE_MANIFEST}" PROPERTIES GENERATED TRUE)
set(RUNTIME_NAMES
brotlicommon.dll brotlidec.dll bz2.dll fmt.dll freetype.dll libpng16.dll
OpenAL32.dll theora.dll theoradec.dll vorbis.dll vorbisfile.dll ogg.dll z.dll
)
foreach(name IN LISTS RUNTIME_NAMES)
set(runtime "${RUNTIME_ROOT}/bin/${name}")
if(NOT EXISTS "${runtime}")
message(FATAL_ERROR "Missing UWP runtime DLL: ${name}")
endif()
list(APPEND PACKAGE_ROOT_FILES "${runtime}")
endforeach()
set_source_files_properties(${PACKAGE_ROOT_FILES} PROPERTIES
VS_COPY_TO_OUT_DIR Always
VS_DEPLOYMENT_CONTENT TRUE
VS_DEPLOYMENT_LOCATION "."
)
file(GLOB PACKAGE_ASSETS CONFIGURE_DEPENDS "${CMAKE_CURRENT_LIST_DIR}/Assets/*.png")
set_source_files_properties(${PACKAGE_ASSETS} PROPERTIES
VS_DEPLOYMENT_CONTENT TRUE
VS_DEPLOYMENT_LOCATION "Assets"
)
file(GLOB PACKAGE_LICENSES CONFIGURE_DEPENDS "${THIRD_PARTY_ROOT}/licenses/*.txt")
set_source_files_properties(${PACKAGE_LICENSES} PROPERTIES
VS_TOOL_OVERRIDE "Content"
VS_COPY_TO_OUT_DIR Always
VS_DEPLOYMENT_CONTENT TRUE
VS_DEPLOYMENT_LOCATION "licenses"
)
target_sources(${PROJECT_NAME} PRIVATE
${PACKAGE_ROOT_FILES}
${PACKAGE_ASSETS}
${PACKAGE_LICENSES}
)
+41
View File
@@ -0,0 +1,41 @@
{
"version": 6,
"configurePresets": [
{
"name": "uwp-common",
"hidden": true,
"generator": "Visual Studio 17 2022",
"architecture": "x64",
"cacheVariables": {
"CMAKE_SYSTEM_NAME": "WindowsStore",
"CMAKE_SYSTEM_VERSION": "10.0"
}
},
{
"name": "uwp-relwithdebinfo",
"inherits": "uwp-common",
"displayName": "Gen1Recomp Xbox UWP (RelWithDebInfo)",
"binaryDir": "${sourceDir}/build/relwithdebinfo"
},
{
"name": "uwp-release",
"inherits": "uwp-common",
"displayName": "Gen1Recomp Xbox UWP (Release)",
"binaryDir": "${sourceDir}/build/release"
}
],
"buildPresets": [
{
"name": "uwp-relwithdebinfo",
"configurePreset": "uwp-relwithdebinfo",
"configuration": "RelWithDebInfo",
"jobs": 8
},
{
"name": "uwp-release",
"configurePreset": "uwp-release",
"configuration": "Release",
"jobs": 8
}
]
}
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
IgnorableNamespaces="uap mp">
<Identity Name="Gen1RecompUWP" Publisher="@UWP_PUBLISHER_XML@" Version="@UWP_PACKAGE_VERSION@" />
<mp:PhoneIdentity PhoneProductId="5074018d-a46b-4dd3-b7fa-93a1e39858ad" PhonePublisherId="00000000-0000-0000-0000-000000000000" />
<Properties>
<DisplayName>Gen1Recomp</DisplayName>
<PublisherDisplayName>Gen1Recomp</PublisherDisplayName>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.19041.0" MaxVersionTested="10.0.26100.0" />
</Dependencies>
<Resources>
<Resource Language="x-generate" />
</Resources>
<Applications>
<Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="Gen1RecompUWP.App">
<uap:VisualElements
DisplayName="Gen1Recomp"
Description="Gen1Recomp for Xbox Dev Mode"
BackgroundColor="#101820"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile
Square71x71Logo="Assets\SmallTile.png"
Wide310x150Logo="Assets\WideTile.png"
Square310x310Logo="Assets\LargeTile.png" />
<uap:SplashScreen Image="Assets\SplashScreen.png" BackgroundColor="#101820" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<Capability Name="internetClient" />
<Capability Name="internetClientServer" />
<Capability Name="privateNetworkClientServer" />
<Capability Name="codeGeneration" />
<uap:Capability Name="removableStorage" />
</Capabilities>
</Package>
+30
View File
@@ -0,0 +1,30 @@
#include <Windows.h>
#include <SDL.h>
#include <string>
#include <winrt/Windows.ApplicationModel.h>
#include <winrt/Windows.Storage.h>
extern "C" int SDL_main(int argc, char **argv);
namespace
{
int runLove(int, char **)
{
std::wstring packagePath = winrt::Windows::ApplicationModel::Package::Current()
.InstalledLocation().Path().c_str();
std::string gamePath = winrt::to_string(packagePath + L"\\gen1recomp.love");
char executable[] = "Gen1RecompUWP";
char fused[] = "--fused";
char *loveArgv[] = {executable, gamePath.data(), fused, nullptr};
return SDL_main(3, loveArgv);
}
} // namespace
int CALLBACK WinMain(HINSTANCE, HINSTANCE, LPSTR, int)
{
SDL_SetHint(SDL_HINT_WINRT_HANDLE_BACK_BUTTON, "1");
return SDL_WinRTRunApp(runLove, nullptr);
}
+11
View File
@@ -0,0 +1,11 @@
# UWP dependencies
This directory contains the complete x64 UWP Release dependency bundle used by the package build:
- `love` contains the LÖVE 11.5 and LuaJIT binaries.
- `sdl2` contains the matching SDL headers, import library, and runtime.
- `angle` contains the EGL and GLES runtime.
- `runtime` contains the codec, font, compression, and audio DLLs used by LÖVE.
- `licenses` contains the corresponding third party notices.
`manifest.json` pins the source revisions and SHA-256 hashes. Run `scripts/xbox-uwp/verify_dependencies.ps1` from the repository root after updating any dependency.
+89
View File
@@ -0,0 +1,89 @@
# This is the official list of The ANGLE Project Authors
# for copyright purposes.
# This file is distinct from the CONTRIBUTORS files.
# See the latter for an explanation.
# Names should be added to this file as
# Name or Organization
# Email addresses for individuals are tracked elsewhere to avoid spam.
Google Inc.
TransGaming Inc.
3DLabs Inc. Ltd.
Adobe Systems Inc.
Autodesk, Inc.
BlackBerry Limited
Cable Television Laboratories, Inc.
Collabora, Ltd.
Cloud Party, Inc.
Igalia, S.L.
Imagination Technologies Ltd.
Intel Corporation
LunarG, Inc.
Mozilla Corporation
Turbulenz
Klarälvdalens Datakonsult AB
Microsoft Corporation
Microsoft Open Technologies, Inc.
NVIDIA Corporation
Opera Software ASA
The Qt Company Ltd.
Advanced Micro Devices, Inc.
LG Electronics, Inc.
IBM Inc.
AdaptVis GmbH
Samsung Electronics, Inc.
Arm Ltd.
Broadcom Inc.
Facebook, Inc.
The Khronos Group, Inc.
Numfum GmbH
Yandex LLC
Rive
Institute of Software, Chinese Academy of Sciences
Guangdong OPPO Mobile Telecommunications Corp., Ltd
Qualcomm Innovation Center, Inc.
Jacek Caban
Mark Callow
Ginn Chen
Tibor den Ouden
Régis Fénéon
James Hauxwell
Sam Hocevar
Pierre Leveille
Jonathan Liu
Boying Lu
Aitor Moreno
Yuri O'Donnell
Josh Soref
Ma Aiguo
Maks Naumov
Jinyoung Hur
Sebastian Bergstein
James Ross-Gowan
Nickolay Artamonov
Ihsan Akmal
Andrei Volykhin
Jérôme Duval
Руслан Ижбулатов
Thomas Miller
Till Rathmann
Nick Shaforostov
Jaime Bernardo
Le Hoang Quyen
Lu Yahan
Ethan Lee
Renaud Lepage
Artem Bolgar
Wander Lairson Costa
Stephan Hartmann
SeongHwan Park
Xiaopeng Li
Akihiko Odaki
Ho Cheung
Tao Wang
Phan Quang Minh
Hongchen Yan
Andrew Sumsion
+32
View File
@@ -0,0 +1,32 @@
// Copyright 2018 The ANGLE Project Authors.
// All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions
// are met:
//
// Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
//
// Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following
// disclaimer in the documentation and/or other materials provided
// with the distribution.
//
// Neither the name of TransGaming Inc., Google Inc., 3DLabs Inc.
// Ltd., nor the names of their contributors may be used to endorse
// or promote products derived from this software without specific
// prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
// COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
// INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
// BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
// LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
// ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE.
+7
View File
@@ -0,0 +1,7 @@
# ANGLE UWP runtime
`libEGL.dll` and `libGLESv2.dll` were built for x64 UWP from [SternXD/angle](https://github.com/SternXD/angle) commit `45b0b1e03400b7a10aaa9a077e196d1abcddafce`. ANGLE version `2.1.25011` and source hash `45b0b1e03400`.
`d3dcompiler_47.dll` is the x64 Direct3D HLSL compiler redistributable from Windows SDK `10.0.26100.7705`. Current hashes are recorded in `../manifest.json`.
ANGLE's upstream `LICENSE` and `AUTHORS` files are included beside its binaries.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More