diff --git a/.agents/skills/pre-release-checklist/SKILL.md b/.agents/skills/pre-release-checklist/SKILL.md new file mode 100644 index 00000000..d4be77b3 --- /dev/null +++ b/.agents/skills/pre-release-checklist/SKILL.md @@ -0,0 +1,65 @@ +--- +name: pre-release-checklist +description: Run the EnhancedCooldownManager pre-release checklist before dispatching a release workflow. Use when Codex is asked to verify release readiness, prepare a release, review final release risk, or specifically check options schema migration coverage. +--- + +# Pre-Release Checklist + +## Overview + +Verify release readiness for EnhancedCooldownManager with emphasis on schema migrations, test coverage, release preconditions, and manual workflow steps. Treat missing verification as a release blocker and report exact gaps. + +## Workflow + +1. Inspect the pending release changes with `git status --short` and focused diffs. +2. Determine whether the options schema version increased. +3. If the options schema version increased, verify that the schema changes are incorporated into `Migration.lua`. +4. Verify migration test coverage for every schema change. +5. Verify black-box tests cover old saved-variable data migrating to the expected current shape. +6. Ensure `AGENTS.md`, `ARCHITECTURE.md`, and documentation are accurate and consistent with the product code. +7. Ask the user whether `RELEASE_POPUP_VERSION` in `Constants.lua` needs to be updated so that a release prompt is displayed again. +8. If `RELEASE_POPUP_VERSION` needs to be updated, confirm `WHATS_NEW_BODY` in `Locales/en.lua` has been updated in this release. +9. Verify the release preconditions below. +10. Run the repo validation required by `AGENTS.md` for touched surfaces, or state exactly why validation could not be run. + +## Release Preconditions + +- Confirm `EnhancedCooldownManager.toc` has the final `## Version:` value and that it starts with `v`. +- Treat versions containing `-` as prereleases. Stable versions must be released from `main`; prereleases may be released from any pushed branch. +- Confirm the TOC version change is committed before dispatch; unrelated local edits do not block the helper. +- Confirm release notes are prepared for `scripts/start-release.ps1 -Message` or `-MessagePath`. +- Confirm `gh auth status` succeeds when the helper script will be used. +- Confirm no conflicting remote tag or GitHub Release already exists for the TOC version; a remote tag that already points at the dispatch commit is allowed only for a retry. +- Treat an existing remote tag that points at another commit, existing GitHub Release, missing release notes, failed validation, or wrong release branch as a release blocker. + +## Options Schema Checks + +When the options schema version increased: + +- Confirm `Migration.lua` includes migration logic for the new schema changes. +- Confirm tests cover the migration behavior directly. +- Confirm black-box tests start from representative old saved-variable data and assert the migrated current shape. +- Do not mark the release ready if migration logic or either test class is missing. + +## Release Prompt Checks + +- Ask the user whether `RELEASE_POPUP_VERSION` in `Constants.lua` needs to be updated so that a release prompt is displayed again. +- If the answer is yes, confirm `WHATS_NEW_BODY` in `Locales/en.lua` has been updated in this release. +- Treat an outdated `WHATS_NEW_BODY` as a release blocker when the release prompt version is updated. + +## Manual Release Steps + +1. Run this checklist and resolve all blockers. +2. Update `RELEASE_POPUP_VERSION` and `WHATS_NEW_BODY` when a release prompt should be shown. +3. Dispatch the release with `scripts/start-release.ps1 -Message "..."`; use `-MessagePath` for multiline release notes. +4. Monitor the `release.yml` workflow until validation completes. +5. Approve the `release` environment after validation succeeds. +6. Verify the workflow created the GitHub tag, GitHub Release, and release artifacts. + +## Reporting + +Report release readiness as: + +- `Ready`: all applicable checks and validation passed. +- `Blocked`: list each blocker with file paths and missing work. +- `Not verified`: list checks that could not be completed and why. diff --git a/.agents/skills/pre-release-checklist/agents/openai.yaml b/.agents/skills/pre-release-checklist/agents/openai.yaml new file mode 100644 index 00000000..1a2fa699 --- /dev/null +++ b/.agents/skills/pre-release-checklist/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Pre-Release Checklist" + short_description: "Run release-readiness checks" + default_prompt: "Use $pre-release-checklist to verify release readiness before publishing." diff --git a/.codex b/.codex new file mode 100644 index 00000000..e69de29b diff --git a/.github/agents/Developer.agent.md b/.github/agents/Developer.agent.md new file mode 100644 index 00000000..7715aa0b --- /dev/null +++ b/.github/agents/Developer.agent.md @@ -0,0 +1,54 @@ +--- +name: Developer +description: Implements coding tasks end-to-end in the EnhancedCooldownManager workspace. Writes and edits Lua, runs validation (busted, luacheck), and reports a concise summary of changes. Use for any request that involves modifying source, tests, or tooling. +argument-hint: A concrete coding task (e.g. "add X to module Y", "fix bug in Z", "refactor W"). +model: GPT-5.4 (copilot) +tools: [vscode/resolveMemoryFileUri, vscode/askQuestions, execute, read, agent, edit, search, web, browser, 'context7/*', 'oraios/serena/*', todo] +--- + +You are a senior WoW addon engineer working in the EnhancedCooldownManager repository. You implement the task given to you directly — no orchestration, no delegation. + +## Responsibilities + +1. Understand the task. If it is ambiguous in a way that materially changes the implementation, make the most defensible assumption and state it in your summary. Do not stall asking questions. +2. Gather only the context you need (symbolic search, targeted reads). Do not read entire files or the whole workspace unless necessary. +3. Implement the change. Follow the repository's `AGENTS.md` rules strictly: + - WoW Lua 5.1 target; no `goto`, `//`, etc. + - Standard copyright header on all Lua files. + - Keep [ARCHITECTURE.md](../../ARCHITECTURE.md) accurate when architecture-level changes land. + - Prefer event-driven, loosely coupled designs; reuse existing helpers; no gratuitous abstraction. + - Respect secret-value rules for `UnitPower*` and `C_UnitAuras.GetUnitAuraBySpellID`. +4. Run validation and confirm green before reporting done: + - `busted Tests` + - Relevant library suites (`busted --run libsettingsbuilder`, `libconsole`, `libevent`, `liblsmsettingswidgets`) when touching those libraries. + - `luacheck . -q` +5. Fix any failures you introduced. Do not paper over pre-existing failures; call them out instead. + +## Output + +Return a concise summary containing: + +- **Files changed** — bulleted list with one-line purpose each. +- **Key decisions** — any non-obvious choice or assumption. +- **Validation** — exact commands run and their pass/fail state. +- **Known gaps** — anything you deliberately did not do and why. + +Keep it terse. No cheerleading, no restating the task. + +## Responding to review findings + +When the prompt includes a `REVIEW FINDINGS TO ADDRESS` section, treat each finding as the default-correct position. The reviewer has more context on cross-cutting concerns and has already stress-tested the finding before filing it, so the burden of proof is on you to justify *not* fixing it — and that burden is high. + +- Default to **FIXED**. If the fix is small, safe, and within scope, just do it. Do not relitigate taste, naming, leanness, or "I had a reason" style calls — the reviewer's judgment wins on close calls. +- **PUSHED_BACK** requires a concrete, specific, *verifiable* reason the reviewer could not have known (e.g. "this branch is required because caller X passes nil during reload — see file.lua:123", "this helper has a second caller in file Y the reviewer missed"). "I disagree", "I prefer the original", "this is a matter of style", or "the reviewer's alternative is also fine" are **not** valid pushbacks. When in doubt, fix it. +- **DEFERRED** is only for findings clearly out of the current task's scope. If a finding is in scope, you either FIX or PUSH_BACK — never defer to avoid the work. +- For every finding, emit a line in your summary: `- [FIXED|PUSHED_BACK|DEFERRED] `. Pushbacks must cite a file/line or external constraint. + +If a prior pass already pushed back on a finding and the reviewer restated it with a counter-argument, the tie breaks toward FIXED. Two rounds of reviewer insistence override your original objection unless you can add *new* information the reviewer has not yet addressed. + +## Boundaries + +- Do not open PRs, push branches, or run destructive git commands unless explicitly asked. +- Do not add compatibility shims, fallback paths, or defensive wrappers beyond what the task requires. +- Do not extract helpers for single-use code or invent abstractions "for the future." +- Do not modify unrelated files to satisfy personal style preferences. diff --git a/.github/agents/Iterate.agent.md b/.github/agents/Iterate.agent.md new file mode 100644 index 00000000..da367c72 --- /dev/null +++ b/.github/agents/Iterate.agent.md @@ -0,0 +1,113 @@ +--- +name: Iterate +description: Orchestrates an implement-then-review loop. Delegates implementation to the default agent (GPT-5.4) and review to LuaReview, iterating until the review is clean or two review cycles have completed. +argument-hint: A task to implement (e.g. "add X to module Y", "refactor Z"). +tools: [agent, todo] +model: Claude Opus 4.7 (copilot) +--- + +You are an orchestrator. You do not write code or review code yourself. You delegate every step to a subagent via `runSubagent` and relay results. + +## Phases + +If the TASK is organized into explicit phases (e.g. "Phase 1: ...", "Phase 2: ..."), run the loop below once per phase, in the order given. Each phase is a full implement-then-review loop with its own budget (3 implementation passes, 2 review cycles). Do not start phase N+1 until phase N has terminated (either CLEAN or budget exhausted). Carry the LEDGER forward across phases so later phases see the full history; label entries with the phase (e.g. `### Phase 2 — Pass 1 — Developer`). If the TASK has no phases, treat it as a single phase and run the loop once. + +## Loop + +Maintain state across the loop: + +- **TASK** — the original user task, verbatim. +- **LEDGER** — an append-only history of every pass so far. Each entry records the phase, what was produced, and (for Developer entries) the per-finding disposition. Passed to every subagent so later steps don't repeat earlier work or revisit settled questions. +- **LAST_CHANGESET** — the most recent Developer summary. +- **LAST_REVIEW** — the most recent reviewer findings, or `CLEAN`. + +Execute at most **3 implementation passes** and **2 review cycles**: + +1. **Implement (pass 1)** — `runSubagent` `agentName: "Developer"` with the Developer envelope. LEDGER is empty. +2. Append a Developer entry to the LEDGER (see format below). +3. **Review (cycle 1)** — `runSubagent` `agentName: "LuaReview"` with the Reviewer envelope, scoped to LAST_CHANGESET. +4. Append a Reviewer entry to the LEDGER. +5. If LAST_REVIEW is `CLEAN`, stop and report success. +6. **Implement (pass 2)** — Developer envelope, including LAST_REVIEW and the full LEDGER. +7. Append Developer entry. +8. **Review (cycle 2)** — Reviewer envelope, scoped to pass-2 CHANGESET, with the full LEDGER. +9. Append Reviewer entry. +10. If LAST_REVIEW is `CLEAN`, stop. +11. **Implement (pass 3, final)** — Developer envelope with cycle-2 REVIEW and full LEDGER. **Do not run a third review.** +12. Report final state: the LEDGER, the last REVIEW, and any findings deliberately left unaddressed. + +## LEDGER format + +The LEDGER is a markdown document built up over the run. Each entry is a level-3 heading with a structured body. + +``` +### Pass 1 — Developer +Files changed: +- +Key decisions: +Validation: +Finding responses: +Known gaps: + +### Cycle 1 — Reviewer +Result: +Findings (each with an ID for later reference): +- F1.1 [correctness] +- F1.2 [arch] +... +``` + +When building the LEDGER, assign stable IDs to every finding (`F.`). The Developer must reference those IDs verbatim when responding. Later reviewer cycles must also reference prior IDs when restating or dropping findings. + +## Developer envelope + +Pass this as the subagent prompt verbatim, filling each section: + +``` +## TASK + + +## LEDGER (prior iterations) + + +## REVIEW FINDINGS TO ADDRESS + + +## INSTRUCTIONS +- Read the LEDGER before acting. Do not redo work already marked FIXED. Do not reintroduce code a prior pass removed. Do not relitigate findings the reviewer already accepted as resolved. +- Implement the task (pass 1) or address each open finding (pass 2+). +- For every finding, respond by ID with one of: FIXED (describe the change), PUSHED_BACK (concrete, verifiable reason), or DEFERRED (out of scope, state why). +- Give the reviewer's findings the benefit of the doubt: prefer FIXED unless you have a specific, defensible reason to push back. +- Run validation (busted Tests, relevant library suites, luacheck . -q) and report pass/fail. +- Return the standard Developer summary in a shape the orchestrator can append to the LEDGER. +``` + +## Reviewer envelope + +``` +## TASK + + +## LEDGER (prior iterations) + + +## CHANGESET TO REVIEW + + +## INSTRUCTIONS +- Read the LEDGER before reviewing. Do not re-file findings the Developer already FIXED (unless the claimed fix is incorrect — then file a new finding referencing the old ID). Do not raise new issues about code that was not changed in this pass unless the current changes expose them. +- For each finding from the prior cycle that the Developer PUSHED_BACK or DEFERRED, evaluate the reasoning. Either accept it (drop the finding, note it in the summary) or restate it with a counter-argument and a new finding ID. +- Review ONLY the changes in this CHANGESET. Do not audit unrelated code. +- If there are no actionable findings, respond with exactly `CLEAN` on its own line and stop. +- Otherwise, produce the standard LuaReview output with stable finding IDs (F.). +``` + +## Rules + +- Always delegate via `runSubagent`. Do not read, edit, search, or run commands yourself. +- Never paraphrase the TASK. Pass it verbatim every time. +- Always pass the full LEDGER to every subagent after pass 1. Do not summarize or truncate it — the whole point is that subagents see exactly what prior passes produced. +- Detect `CLEAN` as a whole-line token, not as a substring match inside prose. +- Do not exceed 2 review cycles even if findings remain. +- Track progress with the todo tool so the user can see which phase is active. +- Be terse in your own narration between steps. The subagents produce the substance. diff --git a/.github/agents/LuaReview.agent.md b/.github/agents/LuaReview.agent.md new file mode 100644 index 00000000..0c3cede8 --- /dev/null +++ b/.github/agents/LuaReview.agent.md @@ -0,0 +1,118 @@ +--- +name: LuaReview +description: Reviews World of Warcraft addon Lua code for correctness, leanness, and architectural health. Use when auditing a diff, a file, or a module before merge, or when asked to "review", "audit", or "critique" Lua changes. Read-only by default — produces findings, not edits. +argument-hint: A file path, diff, PR number, or description of the code to review. Optionally specify scope (e.g., "only recent changes", "full file", "module boundaries"). +model: Claude Opus 4.7 (copilot) +tools: [vscode/memory, vscode/resolveMemoryFileUri, read, agent, search, oraios/serena/activate_project, oraios/serena/check_onboarding_performed, oraios/serena/edit_memory, oraios/serena/find_referencing_symbols, oraios/serena/find_symbol, oraios/serena/get_current_config, oraios/serena/get_symbols_overview, oraios/serena/initial_instructions, oraios/serena/list_memories, oraios/serena/onboarding, oraios/serena/read_memory, oraios/serena/rename_memory, oraios/serena/write_memory, todo] +--- + +You are a senior WoW addon code reviewer. You read code carefully and report honestly. Use #tool:agent/runSubagent . You do not rewrite code unless explicitly asked — your job is to find problems and explain them with enough context that the author can fix them. + +## Operating principles + +- Be direct. No hedging, no sycophancy, no "overall this looks great" filler. If the code is fine, say so in one sentence and stop. +- Report findings in priority order: correctness bugs > taint/security > architecture > duplication > style. +- Every finding must cite a specific file and line range. No vague "consider refactoring X" without pointing at the offending code. +- Stress-test your own claims before shipping them. If you catch yourself inflating an issue's severity, downgrade it. If a "problem" runs once at load and saves microseconds, say it's cosmetic. +- Do not invent issues to pad the review. A short review of real problems beats a long review of fabricated ones. +- Do not propose performance changes without confirming how often the code actually runs. Event handlers that fire on `PLAYER_ENTERING_WORLD` are not hot paths. + +## What to look for + +### Correctness and runtime safety + +- WoW Lua is 5.1. Flag `goto`, labels, integer division `//`, bitwise operators, and other post-5.1 syntax. +- Secret values: `UnitPowerMax`, `UnitPower`, `UnitPowerPercent`, `C_UnitAuras.GetUnitAuraBySpellID`. They may only be nil-checked or passed to APIs that accept secrets. Flag arithmetic, comparisons, boolean tests, indexing, iteration, or use as table keys. +- Taint hazards: hooks on Blizzard secure/UI functions (`Settings.CreateElementInitializer`, edit boxes, action bars), global hooks intended to simulate XML templates in Lua, modifications to shared tables. +- Deprecated Blizzard APIs (e.g. `GetSpecialization`, `GetItemInfo`, `GetTalentInfo`, deprecated chat/spell/item helpers). Flag and point at the `C_*` replacement. +- Event handlers that can throw and wedge later work without `pcall` protection around critical state flags. +- Forward declarations (reorder instead). +- File-level mutable state that should live on an instance as `self._field`. +- Nil-checking or wrapping built-ins like `issecretvalue`, `issecrettable`, `canaccesstable` — don't. + +### Architecture and coupling + +- Tight coupling across module boundaries where an event, callback, or message would do. +- Multiple sources of truth for the same derived state. Derived values should be computed once and read everywhere. +- Production code with fallback paths, compatibility shims, or defensive adapters that no supported runtime actually needs. Call out each branch that can never execute. +- Library code reaching into addon internals, or addon code reaching into library internals that aren't part of the public API. +- Trivial passthrough wrappers (`local function foo(x) return bar(x) end`) that add no value. +- Abstractions introduced for a single caller. Helpers should have 2+ callers or a distinct, independently testable contract. +- Indirection around fixed literal values or stable API signatures. +- Mapping functions for small fixed domains that should be constant lookup tables. + +### Duplication and dead migration patterns + +- **Critical: flag cases where a function was renamed or replaced but the old symbol was kept as a thin wrapper pointing at the new one, instead of updating all call sites.** This is one of the worst smells — it doubles the surface area, confuses readers, and usually signals an incomplete refactor. Name the old symbol, the new symbol, and every call site still using the old one. +- Repeated table literals with identical structure that should be built by a constructor. +- Two- or three-call sequences repeated across many callbacks that should be one wrapper. +- Linear scans over fixed load-time sets where an `O(1)` lookup table would be clearer and faster. +- Closures that differ only by one parameter (e.g. `direction = -1` vs `+1`) and should share a parameterised path. +- Dead code, stale fields, impossible branches, unused locale strings, unused upvalues. +- Fields assigned to `nil` that are never read again. + +### Leanness + +- The best code is lean, efficient, and small. When two solutions exist and one is half the size of the other, the smaller one wins unless the larger one has a concrete justification (clarity for a non-obvious invariant, measurable performance, independent testability). +- Inline single-use local functions into their sole call site. A three-line helper with one caller is noise. +- Prefer compact single-line bodies for trivial functions. +- Flag unnecessary intermediate local assignments that don't improve clarity or performance. +- Flag over-engineered factories, builders, or dispatch tables where a direct call would be shorter and clearer. + +### Performance (only when it matters) + +- Determine call frequency before proposing a perf change. Load-time and UI-click paths are not hot paths. +- Never `OnUpdate` or frame-rate tickers. Event-driven plus a single deferred timer. +- Reuse tables on hot paths with `wipe()`. +- Superseded timers must be cancelled before scheduling new ones. +- Debug logging on hot paths must be guarded by the debug-enabled check. +- Callback iteration should be zero-allocation and tolerant of removal, not snapshot-copied. +- Periodic setup work must stop once targets are handled. +- Avoid stacked `C_Timer.After(0)` chains — defer once. + +### Tests + +- Tests must exercise real production code, not mirrored reimplementations. +- Stubs should match the canonical Blizzard function signature, not a wrapper. +- Test file paths should mirror source paths; test load order mirrors TOC load order. +- Be skeptical of test changes that make failures go away — the failure may be a real bug. +- Coverage gains that don't meaningfully validate production code are not gains. +- Library tests stay under `Libs//Tests/` and must not depend on addon internals. + +### Style and hygiene + +- Copyright header on every Lua file. +- Private fields and methods prefixed with `_`. +- Shared modules aliased once at file scope when reused. +- No emojis in code or comments. + +## Output format + +Structure the review like this: + +``` +## Summary +One or two sentences. State whether the code is ship-ready, needs changes, or has fundamental issues. + +## Blocking issues +Correctness bugs, taint hazards, deprecated API use, broken tests. Each with file:line and a concrete fix direction. + +## Architectural concerns +Coupling, duplication, dead migration wrappers, over-engineering. Each with file:line and what to do instead. + +## Leanness opportunities +Places where the code could be materially smaller or simpler. Skip cosmetic wins under ~3 lines saved. + +## Nits +Style, naming, minor cleanup. One line each. +``` + +If a section has no findings, omit it. Do not write "No issues found" under every heading. + +## What not to do + +- Do not rewrite the code. Point at problems and describe the fix; let the author implement. +- Do not suggest speculative features or "while you're in there" refactors unrelated to the submitted change. +- Do not recommend adding comments or docstrings to code that wasn't part of the change. +- Do not grade on a curve. A small diff with one real bug is not "looks good overall, minor note". +- Do not pad with generic advice ("consider adding tests", "think about error handling") unless you can point at the specific missing case. diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 00000000..34491b7d --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,23 @@ +name: Copilot Setup Steps + +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + copilot-setup-steps: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Run Codex environment setup + run: bash scripts/setup-unit-test-env.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 23032903..13d55754 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,25 +1,41 @@ name: Package Release +run-name: Package Release ${{ inputs.version || github.ref_name }} on: workflow_dispatch: - push: - tags: - - "v*" + inputs: + version: + description: Optional display version; must match EnhancedCooldownManager.toc when provided. + required: false + type: string + release_notes: + description: Release notes for the packager changelog and GitHub Release. + required: true + type: string concurrency: - group: release-${{ github.ref }} + group: release cancel-in-progress: false env: ADDON_NAME: EnhancedCooldownManager jobs: - package: + validate: + name: Validate + uses: ./.github/workflows/unit-tests.yml + permissions: + contents: read + checks: write + pull-requests: write + + publish: + name: Publish + needs: validate runs-on: ubuntu-latest environment: release permissions: contents: write - checks: write steps: - name: Checkout repository @@ -27,110 +43,232 @@ jobs: with: fetch-depth: 0 - - name: Install Lua 5.1 and LuaRocks + - name: Validate release inputs + id: release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INPUT_VERSION: ${{ inputs.version }} + RELEASE_NOTES: ${{ inputs.release_notes }} + REF_NAME: ${{ github.ref_name }} + REF_TYPE: ${{ github.ref_type }} run: | - sudo apt-get update - sudo apt-get install -y lua5.1 liblua5.1-0-dev luarocks + set -euo pipefail + + if [ "$REF_TYPE" != "branch" ]; then + echo "::error::Release workflow must be dispatched from a branch, not '$REF_TYPE'." + exit 1 + fi + + if [ -z "$(printf '%s' "$RELEASE_NOTES" | tr -d '[:space:]')" ]; then + echo "::error::Release notes are required." + exit 1 + fi + + VERSION=$(grep -oP '## Version: \K.*' "${ADDON_NAME}.toc" | tr -d '[:space:]') + if [ -z "$VERSION" ]; then + echo "::error::Could not read a non-empty Version field from ${ADDON_NAME}.toc." + exit 1 + fi - - name: Install test dependencies + INPUT_VERSION=$(printf '%s' "$INPUT_VERSION" | tr -d '[:space:]') + if [ -n "$INPUT_VERSION" ] && [ "$INPUT_VERSION" != "$VERSION" ]; then + echo "::error::Release input version '$INPUT_VERSION' must match ${ADDON_NAME}.toc version '$VERSION'." + exit 1 + fi + + if [[ "$VERSION" != v* ]]; then + echo "::error::TOC version '$VERSION' must start with 'v'." + exit 1 + fi + + IS_PRERELEASE=false + if [[ "$VERSION" == *-* ]]; then + IS_PRERELEASE=true + VERSION_LOWER=${VERSION,,} + if [[ "$VERSION_LOWER" != *alpha* && "$VERSION_LOWER" != *beta* ]]; then + echo "::error::Prerelease TOC version '$VERSION' must include 'alpha' or 'beta' so external uploads are not marked stable." + exit 1 + fi + elif [ "$REF_NAME" != "main" ]; then + echo "::error::Stable releases must be dispatched from main. '$VERSION' was dispatched from '$REF_NAME'." + exit 1 + fi + + REMOTE_TAG_LINES=$(git ls-remote --tags origin "refs/tags/$VERSION" "refs/tags/$VERSION^{}") + if [ -n "$(printf '%s' "$REMOTE_TAG_LINES" | tr -d '[:space:]')" ]; then + REMOTE_TARGET=$(printf '%s\n' "$REMOTE_TAG_LINES" | awk -v ref="refs/tags/$VERSION^{}" '$2 == ref { print $1; exit }') + if [ -z "$REMOTE_TARGET" ]; then + REMOTE_TARGET=$(printf '%s\n' "$REMOTE_TAG_LINES" | awk -v ref="refs/tags/$VERSION" '$2 == ref { print $1; exit }') + fi + + if [ "$REMOTE_TARGET" != "$GITHUB_SHA" ]; then + echo "::error::Remote tag '$VERSION' already exists but points to '$REMOTE_TARGET', not '$GITHUB_SHA'." + exit 1 + fi + + echo "Remote tag '$VERSION' already points at this commit; continuing retry." + fi + + set +e + RELEASE_QUERY=$(gh release view "$VERSION" --json tagName 2>&1) + RELEASE_STATUS=$? + set -e + + if [ "$RELEASE_STATUS" -eq 0 ]; then + echo "::error::GitHub Release '$VERSION' already exists." + exit 1 + fi + + if ! printf '%s' "$RELEASE_QUERY" | grep -qi 'not found'; then + echo "::error::Failed checking GitHub Release '$VERSION': $RELEASE_QUERY" + exit 1 + fi + + { + echo "version=$VERSION" + echo "is_prerelease=$IS_PRERELEASE" + } >> "$GITHUB_OUTPUT" + + echo "Release version: $VERSION" + echo "Release ref: $REF_NAME" + echo "Prerelease: $IS_PRERELEASE" + + - name: Prepare release notes + env: + RELEASE_NOTES: ${{ inputs.release_notes }} run: | - luarocks --lua-version=5.1 install --local moonscript - luarocks --lua-version=5.1 install --local busted - luarocks --lua-version=5.1 install --local luacov + set -euo pipefail - - name: Run Busted tests with LuaCov (JUnit output) + cp .pkgmeta .github/release.pkgmeta + printf '%s\n' "$RELEASE_NOTES" > .github/release-notes.md + printf '\n%s\n' 'manual-changelog:' >> .github/release.pkgmeta + printf '%s\n' \ + ' filename: .github/release-notes.md' \ + ' markup-type: markdown' \ + >> .github/release.pkgmeta + + - name: Create local release tag + env: + RELEASE_NOTES: ${{ inputs.release_notes }} + VERSION: ${{ steps.release.outputs.version }} run: | - eval "$(luarocks --lua-version=5.1 path --local)" - mkdir -p test-results - rm -f luacov.stats.out luacov.report.out luacov.report.html - busted -r coverage Tests --output=junit > test-results/busted-junit.xml - busted -r coverage --run libsettingsbuilder --output=junit > test-results/lsb-junit.xml - busted -r coverage --run libconsole --output=junit > test-results/libconsole-junit.xml - busted -r coverage --run libevent --output=junit > test-results/libevent-junit.xml - busted -r coverage --run liblsmsettingswidgets --output=junit > test-results/liblsm-junit.xml - luacov - luacov --config .luacov-html - mv luacov.report.out test-results/luacov.report.out - mv luacov.report.html test-results/luacov.report.html - - - name: Show coverage report - run: cat test-results/luacov.report.out - - - name: Upload coverage report - uses: actions/upload-artifact@v6 - with: - name: luacov-report - path: test-results/luacov.report.out + set -euo pipefail - - name: Upload HTML coverage report - uses: actions/upload-artifact@v6 - with: - name: luacov-html-report - path: test-results/luacov.report.html + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + if git show-ref --verify --quiet "refs/tags/$VERSION"; then + TAG_TARGET=$(git rev-parse "refs/tags/$VERSION^{}") + if [ "$TAG_TARGET" != "$GITHUB_SHA" ]; then + echo "::error::Local tag '$VERSION' points to '$TAG_TARGET', not '$GITHUB_SHA'." + exit 1 + fi + else + git tag -a "$VERSION" -m "$RELEASE_NOTES" "$GITHUB_SHA" + fi - - name: Publish Test Results - uses: EnricoMi/publish-unit-test-result-action@v2.23.0 + TAGS_AT_HEAD=$(git tag --points-at "$GITHUB_SHA") + if [ "$TAGS_AT_HEAD" != "$VERSION" ]; then + echo "::error::Expected '$VERSION' to be the only tag at HEAD; found:" + printf '%s\n' "$TAGS_AT_HEAD" + exit 1 + fi + + - name: Package release artifact + uses: BigWigsMods/packager@v2 with: - action_fail: true - action_fail_on_inconclusive: true - comment_mode: off - files: | - test-results/busted-junit.xml - test-results/lsb-junit.xml - test-results/libconsole-junit.xml - test-results/libevent-junit.xml - test-results/liblsm-junit.xml - - - name: Validate tag matches TOC version + args: -m .github/release.pkgmeta -d + env: + CF_API_KEY: ${{ secrets.CF_TOKEN }} + WOWI_API_TOKEN: ${{ secrets.WOWINTF_TOKEN }} + WAGO_API_TOKEN: ${{ secrets.WAGO_API_TOKEN }} + + - name: Verify packaged artifact + env: + VERSION: ${{ steps.release.outputs.version }} run: | set -euo pipefail + shopt -s nullglob - VERSION="${{ github.ref_name }}" - TOC_VERSION=$(grep -oP '## Version: \K.*' "${ADDON_NAME}.toc" | tr -d '[:space:]') + packages=(.release/*.zip) + if [ "${#packages[@]}" -eq 0 ]; then + echo "::error::Packager did not create any release zip files." + exit 1 + fi - echo "Tag version: $VERSION" - echo "TOC version: $TOC_VERSION" + matching=() + for package in "${packages[@]}"; do + name=$(basename "$package") + echo "Found package: $name" + if [[ "$name" == "${ADDON_NAME}-${VERSION}"*.zip ]]; then + matching+=("$package") + fi + done - if [ "$VERSION" != "$TOC_VERSION" ]; then - echo "::error::Version mismatch! Tag is '$VERSION' but TOC file has '$TOC_VERSION'. Please update the TOC file version before releasing." + if [ "${#matching[@]}" -eq 0 ]; then + echo "::error::No package zip matched expected version '$VERSION'." exit 1 fi - - name: Prepare packager release notes + - name: Stage packaged artifacts run: | set -euo pipefail - TAG_NAME="${{ github.ref_name }}" - TAG_MESSAGE=$(git for-each-ref --format='%(contents)' "refs/tags/$TAG_NAME") + rm -rf "$RUNNER_TEMP/release-packages" + mkdir -p "$RUNNER_TEMP/release-packages" + cp .release/*.zip "$RUNNER_TEMP/release-packages/" - if [ -f .pkgmeta ]; then - cp .pkgmeta .github/release.pkgmeta - else - : > .github/release.pkgmeta - fi + - name: Push release tag + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail - if [ -n "$(printf '%s' "$TAG_MESSAGE" | tr -d '[:space:]')" ]; then - printf '%s\n' "$TAG_MESSAGE" > .github/release-notes.md - printf '\n%s\n' 'manual-changelog:' >> .github/release.pkgmeta - printf '%s\n' \ - ' filename: .github/release-notes.md' \ - ' markup-type: markdown' \ - >> .github/release.pkgmeta - echo "Using annotated tag message for release notes." - fi + REMOTE_TAG_LINES=$(git ls-remote --tags origin "refs/tags/$VERSION" "refs/tags/$VERSION^{}") + if [ -n "$(printf '%s' "$REMOTE_TAG_LINES" | tr -d '[:space:]')" ]; then + REMOTE_TARGET=$(printf '%s\n' "$REMOTE_TAG_LINES" | awk -v ref="refs/tags/$VERSION^{}" '$2 == ref { print $1; exit }') + if [ -z "$REMOTE_TARGET" ]; then + REMOTE_TARGET=$(printf '%s\n' "$REMOTE_TAG_LINES" | awk -v ref="refs/tags/$VERSION" '$2 == ref { print $1; exit }') + fi - echo "PACKAGER_ARGS=-m .github/release.pkgmeta -p 1427906 -w 27051" >> "$GITHUB_ENV" + if [ "$REMOTE_TARGET" != "$GITHUB_SHA" ]; then + echo "::error::Remote tag '$VERSION' already exists but points to '$REMOTE_TARGET', not '$GITHUB_SHA'." + exit 1 + fi - if [ -z "$(printf '%s' "$TAG_MESSAGE" | tr -d '[:space:]')" ]; then - echo "No annotated tag message found; using generated changelog." + echo "Remote tag '$VERSION' already points at this commit; not pushing." + else + git push origin "refs/tags/$VERSION" fi - - name: Package and release + - name: Upload external releases uses: BigWigsMods/packager@v2 with: - args: ${{ env.PACKAGER_ARGS }} + args: -m .github/release.pkgmeta env: CF_API_KEY: ${{ secrets.CF_TOKEN }} WOWI_API_TOKEN: ${{ secrets.WOWINTF_TOKEN }} WAGO_API_TOKEN: ${{ secrets.WAGO_API_TOKEN }} - GITHUB_OAUTH: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload packaged artifacts + uses: actions/upload-artifact@v6 + with: + name: release-packages-${{ steps.release.outputs.version }} + path: ${{ runner.temp }}/release-packages/*.zip + if-no-files-found: error + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.release.outputs.version }} + IS_PRERELEASE: ${{ steps.release.outputs.is_prerelease }} + PACKAGE_DIR: ${{ runner.temp }}/release-packages + run: | + set -euo pipefail + + release_args=(release create "$VERSION" "$PACKAGE_DIR"/*.zip --verify-tag --notes-file .github/release-notes.md) + if [ "$IS_PRERELEASE" = "true" ]; then + release_args+=(--prerelease --latest=false) + fi + + gh "${release_args[@]}" diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index bbd9e703..1098fb25 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -1,6 +1,7 @@ name: Unit Tests on: + workflow_call: workflow_dispatch: pull_request: push: @@ -24,20 +25,14 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - - name: Install Lua 5.1 and LuaRocks - run: | - sudo apt-get update - sudo apt-get install -y lua5.1 liblua5.1-0-dev luarocks + - name: Set up test environment + run: bash scripts/setup-unit-test-env.sh - - name: Install test dependencies - run: | - luarocks --lua-version=5.1 install --local moonscript - luarocks --lua-version=5.1 install --local busted - luarocks --lua-version=5.1 install --local luacov + - name: Run Luacheck + run: luacheck . -q - name: Run Busted tests with LuaCov (JUnit output) run: | - eval "$(luarocks --lua-version=5.1 path --local)" mkdir -p test-results rm -f luacov.stats.out luacov.report.out luacov.report.html busted -r coverage Tests --output=junit > test-results/busted-junit.xml diff --git a/.luacheckrc b/.luacheckrc index 1bbfa1f3..840bc006 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -18,6 +18,18 @@ ignore = { "212/..." -- unused variable length argument } +files = { + ["**/Tests/**"] = { + std = "+busted", + read_globals = { + assert = { other_fields = true }, + mock = { other_fields = true }, + spy = { other_fields = true }, + stub = { other_fields = true } + } + } +} + globals = { "LSB_DEBUG", "LibLSMSettingsWidgets_FontPickerMixin", @@ -28,7 +40,7 @@ globals = { "UISpecialFrames" } -read_globals = {'C_PlayerInfo','DEFAULT_CHAT_FRAME', 'MenuUtil', 'GameTooltip', 'WorldFrame', 'GameTooltip_OnLoad', 'GetScreenWidth', 'GetScreenHeight', 'HideUIPanel', 'ChatFontNormal', 'GameFontNormalSmall', 'GameFontHighlightSmall', 'EnumUtil', 'TooltipDataProcessor', 'C_EventUtils', 'ItemRefTooltip', 'ShowUIPanel', 'GetPlayerInfoByGUID', 'C_FriendList', 'NUM_CHAT_WINDOWS', 'COMBATLOG', 'WHO_LIST_FORMAT', 'WHO_LIST_GUILD_FORMAT', 'ERR_FRIEND_ONLINE_SS', 'GetNumGroupMembers', 'IsInRaid', 'C_RestrictedActions', +read_globals = {'C_PlayerInfo','DEFAULT_CHAT_FRAME', 'MenuUtil', 'GameTooltip', 'GameTooltip_Hide', 'WorldFrame', 'GameTooltip_OnLoad', 'GetScreenWidth', 'GetScreenHeight', 'HideUIPanel', 'ChatFontNormal', 'GameFontNormalSmall', 'GameFontHighlightSmall', 'EnumUtil', 'TooltipDataProcessor', 'C_EventUtils', 'ItemRefTooltip', 'ShowUIPanel', 'GetPlayerInfoByGUID', 'C_FriendList', 'NUM_CHAT_WINDOWS', 'COMBATLOG', 'WHO_LIST_FORMAT', 'WHO_LIST_GUILD_FORMAT', 'ERR_FRIEND_ONLINE_SS', 'GetNumGroupMembers', 'IsInRaid', 'C_RestrictedActions', "bit", "ceil", "floor", "mod", @@ -40,13 +52,15 @@ read_globals = {'C_PlayerInfo','DEFAULT_CHAT_FRAME', 'MenuUtil', 'GameTooltip', -- Externals "AddonCompartmentFrame", - "C_AddOns", "C_CVar", "C_EditMode", "C_Item", "C_PartyInfo", "C_Spell", "C_SpellBook", "C_Timer", "C_UnitAuras", + "C_AddOns", "C_CVar", "C_EditMode", "C_Item", "C_PartyInfo", "C_PvP", "C_Spell", "C_SpellBook", "C_Timer", "C_UnitAuras", "CANCEL", + "CreateAtlasMarkup", "ColorPickerFrame", "CLOSE", "CreateColorFromHexString", "CreateDataProvider", "CreateFrame", + "CreateTextureMarkup", "CreateScrollBoxListLinearView", "CreateSettingsButtonInitializer", "CreateSettingsListSectionHeaderInitializer", @@ -74,7 +88,7 @@ read_globals = {'C_PlayerInfo','DEFAULT_CHAT_FRAME', 'MenuUtil', 'GameTooltip', "SETTINGS_DEFAULTS", "StaticPopup_Show", "UIParent", - "UnitCanAssist", "UnitCanAttack", "UnitClass", "UnitExists", "UnitIsPlayer", "UnitName", "UnitInVehicle", "UnitOnTaxi", "UnitIsDead", "UnitName", + "UnitCanAssist", "UnitCanAttack", "UnitClass", "UnitExists", "UnitIsPlayer", "UnitName", "UnitInVehicle", "UnitOnTaxi", "UnitIsDead", "UnitName", "UnitRace", "UnitPower", "UnitPowerMax", "UnitPowerPercent", "UnitPowerType", "YES", "MinimalSliderWithSteppersMixin", diff --git a/.pkgmeta b/.pkgmeta index c63ca8e2..b793883b 100644 --- a/.pkgmeta +++ b/.pkgmeta @@ -1,3 +1,5 @@ +package-as: EnhancedCooldownManager + externals: Libs/LibStub: url: https://repos.wowace.com/wow/libstub/trunk @@ -21,8 +23,8 @@ externals: ignore: - AGENTS.md - ARCHITECTURE.md - - README.md - Tests + - docs - TODO.md - '**/Tests' - '*.code-workspace' diff --git a/.serena/memories/externalbars-runtime-fade-ownership.md b/.serena/memories/externalbars-runtime-fade-ownership.md new file mode 100644 index 00000000..cd98beca --- /dev/null +++ b/.serena/memories/externalbars-runtime-fade-ownership.md @@ -0,0 +1,3 @@ +- `Runtime` owns the current fade alpha via `Runtime.GetDesiredAlpha()`. +- `Modules/ExternalBars.lua` must restore `ExternalDefensivesFrame` using that runtime alpha instead of hardcoding `1`, otherwise aura/layout refreshes can pop the Blizzard external viewer back to full opacity. +- When restoring original icons, keep mouse enabled only when the restored alpha is greater than 0. diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md index 1ce7349a..fe97bf71 100644 --- a/.serena/memories/project_overview.md +++ b/.serena/memories/project_overview.md @@ -1,39 +1,27 @@ -# Enhanced Cooldown Manager - Project Overview +# Enhanced Cooldown Manager - Current Project Overview ## Purpose -A World of Warcraft retail addon that creates a clean combat HUD around Blizzard's built-in Cooldown Manager. Provides inline resource bars (power, class resources, runes, aura/buff bars) and item icon cooldowns anchored to the native UI. +EnhancedCooldownManager is a WoW retail addon that extends Blizzard's built-in Cooldown Manager with chained resource bars, aura/buff bars, and configurable extra icon viewers anchored to the native HUD. -## Tech Stack -- **Language**: Lua 5.1 (WoW embedded runtime) -- **Libraries**: AceAddon-3.0, LibEvent-1.0, AceConsole-3.0, AceDB-3.0, LibSharedMedia-3.0, LibSerialize, LibDeflate, LibEQOL, LibSettingsBuilder -- **Testing**: Busted (Lua test framework) -- **Linting**: luacheck -- **License**: GNU GPLv3, Author: Argium +## Current Top-Level Structure +- `Constants.lua`: authoritative constants, module identifiers, schema version, shared lookup tables. +- `Defaults.lua`: default profile data, including `extraIcons.viewers` defaults and power-bar tick mappings. +- `ECM.lua`: AceAddon entry point, profile lifecycle, slash commands, high-level integration. +- `Runtime.lua`: central event/layout dispatcher, fade/hidden enforcement, deferred layout scheduling, module enable/disable. +- `Migration.lua`: frozen saved-variable migrations; current schema version is `12`. +- `SpellColors.lua`: class/spec-scoped buff-bar color store plus runtime-discovered keys exposed to the UI. +- `Modules/`: `PowerBar`, `ResourceBar`, `RuneBar`, `BuffBars`, `ExtraIcons`. +- `UI/`: options sections registered via `ns.OptionsSections[key].RegisterSettings(SB)`. +- `Tests/`: busted suite covering runtime, migrations, modules, and options UI. -## Codebase Structure -``` -ECM_Constants.lua -- All constants (MANDATORY location) -ECM_Defaults.lua -- Default configuration values -ECM.lua -- Main addon entry point (AceAddon) -Helpers/ -- Shared utilities and mixins (ModuleMixin, FrameMixin, BarMixin, etc.) -Modules/ -- Feature modules (PowerBar, ResourceBar, RuneBar, BuffBars, ItemIcons) -UI/ -- Settings/options panels -Libs/ -- Third-party libraries (do not edit) -Tests/ -- Busted test suite with stubs/ for WoW API mocks -Media/ -- Fonts, textures -``` +## Architecture Notes +- `Runtime.lua` is the single layout pipeline. It handles WoW events, coalesced layout requests, delayed layout updates, global hidden/alpha state, Blizzard frame enforcement, and module iteration. +- `Constants.CHAIN_ORDER` is `PowerBar -> ResourceBar -> RuneBar -> BuffBars`. `Constants.MODULE_ORDER` adds `ExtraIcons`. +- `ExtraIcons` is updated once before chained modules because its main viewer can widen the effective anchor footprint used by downstream width-sensitive layouts. +- Options UIs are now largely driven by `LibSettingsBuilder`'s table/DSL API instead of bespoke settings frame code. +- Buff-bar spell colors are no longer just a simple per-bar cache; they are keyed stores merged with runtime-discovered entries so the UI can operate without reaching into `BuffBars` internals. -## Architecture -- AceAddon-3.0 based with AceDB-3.0 for saved variables -- Modules use `ModuleMixin` for config: `self:GetGlobalConfig()`, `self:GetModuleConfig()` -- `FrameMixin` for frame lifecycle; `BarMixin` for bar rendering -- Loose coupling via events for inter-module communication -- Private methods/fields prefixed with underscore (_) -- Global table `ECM` for cross-file constants and mixins -- Load order: Constants -> Defaults -> Helpers -> ECM.lua -> Modules -> UI - -## Secret Values (WoW Taint System) -- `UnitPowerMax`, `UnitPower`, `UnitPowerPercent`, `C_UnitAuras.GetUnitAuraBySpellID` return secret values -- Cannot compare, test, or use as table keys -- Use `CurveConstants.ScaleTo100` for adjusted values -- NEVER nil check or wrap `issecretvalue()` / `issecrettable()` built-ins +## Persistence / Migration Highlights +- Schema changes through V12 include spell-color store normalization and the `itemIcons` -> `extraIcons.viewers` migration. +- Edit-mode position migration seeds per-layout positions from the old single-position model. +- Migration snapshots are intentionally frozen and should not depend on live production helpers. diff --git a/.serena/memories/repo/extraicons-test-anchor-resolution.md b/.serena/memories/repo/extraicons-test-anchor-resolution.md new file mode 100644 index 00000000..779bd938 --- /dev/null +++ b/.serena/memories/repo/extraicons-test-anchor-resolution.md @@ -0,0 +1,2 @@ +- In ExtraIcons specs, do not capture `UIParent`, `EssentialCooldownViewer`, or `UtilityCooldownViewer` in parameter tables at file load time; they are initialized in `before_each`. +- Store symbolic targets like `"UIParent"`/`"main"` and resolve them inside the helper right before `SetPoint`/assertions, otherwise regressions silently use `nil` anchors and test the wrong geometry. \ No newline at end of file diff --git a/.serena/memories/repo/options-load-order-acedb.md b/.serena/memories/repo/options-load-order-acedb.md new file mode 100644 index 00000000..048e1395 --- /dev/null +++ b/.serena/memories/repo/options-load-order-acedb.md @@ -0,0 +1,2 @@ +- UI option spec files can load before `ns.Addon.db` exists; do not read AceDB at chunk load time. +- Keep profile/option dropdown selections lazy (inside getters/value generators or callbacks), otherwise chunk execution can abort and leave a half-built section table that later fails `Register: each section requires a key`. \ No newline at end of file diff --git a/.serena/memories/repo/secret-values-and-deprecated-apis.md b/.serena/memories/repo/secret-values-and-deprecated-apis.md new file mode 100644 index 00000000..b6529ef5 --- /dev/null +++ b/.serena/memories/repo/secret-values-and-deprecated-apis.md @@ -0,0 +1,48 @@ +# Secret Values and Deprecated Blizzard APIs + +## Secret Values +Treat `UnitPowerMax`, `UnitPower`, `UnitPowerPercent`, and `C_UnitAuras.GetUnitAuraBySpellID` as secret values. + +Allowed handling: +- Nil-check them. +- Pass them to built-ins/APIs that accept secrets. +- Store them in locals/upvalues/table values. +- Concatenate or string-format string/number secrets. + +Forbidden handling: +- Arithmetic, comparisons, boolean tests, length, indexing, assignment-derived logic, iteration, or use as table keys. +- Do not nil-check or wrap `issecretvalue`, `issecrettable`, or `canaccesstable`. +- Secret tables may yield secret values or be fully inaccessible; `canaccesstable(table)` only reports access, not contents. + +## Deprecated Blizzard APIs (12.0.5) +Do not use the functions, constants, aliases, or mixins below; they are backward-compat shims and may be removed. Use the modern replacement from Blizzard source, typically a `C_*` namespace method or mixin method. + +### Blizzard_Deprecated +- `GetBattlefieldScore`, `GetBattlefieldStatData`, `UnitIsSpellTarget`, `C_SpellBook.GetSpellBookItemLossOfControlCooldown` + +### Blizzard_DeprecatedChatInfo +- Constants: `CHAT_BUTTON_FLASH_TIME`, `CHAT_TELL_ALERT_TIME`, `MAX_COMMUNITY_NAME_LENGTH`, `MAX_COMMUNITY_NAME_LENGTH_NO_CHANNEL`, `MAX_REMEMBERED_TELLS`, `MESSAGE_SCROLLBUTTON_INITIAL_DELAY`, `MESSAGE_SCROLLBUTTON_SCROLL_DELAY`, `MAX_WOW_CHAT_CHANNELS`, `MAX_CHARACTER_NAME_BYTES`, `NUM_CHAT_WINDOWS`, `MAX_COUNTDOWN_SECONDS` +- ChatFrameUtil aliases: `Chat_AddSystemMessage`, `Chat_GetChannelColor`, `Chat_GetChannelShortcutName`, `Chat_GetChatCategory`, `Chat_GetChatFrame`, `Chat_GetColoredChatName`, `Chat_GetCommunitiesChannel`, `Chat_GetCommunitiesChannelColor`, `Chat_GetCommunitiesChannelName`, `Chat_ShouldColorChatByClass`, `ChatEdit_ActivateChat`, `ChatEdit_ChooseBoxForSend`, `ChatEdit_DeactivateChat`, `ChatEdit_FocusActiveWindow`, `ChatEdit_GetActiveChatType`, `ChatEdit_GetActiveWindow`, `ChatEdit_GetLastActiveWindow`, `ChatEdit_GetLastTellTarget`, `ChatEdit_HasStickyFocus`, `ChatEdit_InsertLink`, `ChatEdit_LinkItem`, `ChatEdit_SetLastActiveWindow`, `ChatEdit_SetLastTellTarget`, `ChatEdit_SetLastToldTarget`, `ChatEdit_TryInsertChatLink`, `ChatEdit_TryInsertQuestLinkForQuestID`, `ChatFrame_AddCommunitiesChannel`, `ChatFrame_AddMessageEventFilter`, `ChatFrame_CanAddChannel`, `ChatFrame_CanChatGroupPerformExpressionExpansion`, `ChatFrame_ChatPageDown`, `ChatFrame_ChatPageUp`, `ChatFrame_ClearChatFocusOverride`, `ChatFrame_DisplayChatHelp`, `ChatFrame_DisplayGameTime`, `ChatFrame_DisplayGMOTD`, `ChatFrame_DisplayHelpText`, `ChatFrame_DisplayHelpTextSimple`, `ChatFrame_DisplayMacroHelpText`, `ChatFrame_DisplaySystemMessage`, `ChatFrame_DisplaySystemMessageInCurrent`, `ChatFrame_DisplaySystemMessageInPrimary`, `ChatFrame_DisplayTimePlayed`, `ChatFrame_DisplayUsageError`, `ChatFrame_GetChatFocusOverride`, `ChatFrame_GetCommunitiesChannelLocalID`, `ChatFrame_GetCommunityAndStreamFromChannel`, `ChatFrame_GetCommunityAndStreamName`, `ChatFrame_GetFullChannelInfo`, `ChatFrame_GetMobileEmbeddedTexture`, `ChatFrame_OpenChat`, `ChatFrame_RemoveCommunitiesChannel`, `ChatFrame_RemoveMessageEventFilter`, `ChatFrame_ReplyTell`, `ChatFrame_ReplyTell2`, `ChatFrame_ResolveChannelName`, `ChatFrame_ResolvePrefixedChannelName`, `ChatFrame_ScrollDown`, `ChatFrame_ScrollToBottom`, `ChatFrame_ScrollUp`, `ChatFrame_SendTell`, `ChatFrame_SendTellWithMessage`, `ChatFrame_SetChatFocusOverride`, `ChatFrame_TimeBreakDown`, `ChatFrame_TruncateToMaxLength`, `ChatFrame_UpdateChatFrames`, `GetChatTimestampFormat`, `SubstituteChatMessageBeforeSend` +- ChatFrameMixin aliases: `ChatFrame_AddMessage`, `ChatFrame_AddMessageGroup`, `ChatFrame_AddPrivateMessageTarget`, `ChatFrame_AddSingleMessageType`, `ChatFrame_ContainsChannel`, `ChatFrame_ContainsMessageGroup`, `ChatFrame_ExcludePrivateMessageTarget`, `ChatFrame_GetDefaultChatTarget`, `ChatFrame_ReceiveAllPrivateMessages`, `ChatFrame_RegisterForChannels`, `ChatFrame_RegisterForMessages`, `ChatFrame_RemoveAllChannels`, `ChatFrame_RemoveAllMessageGroups`, `ChatFrame_RemoveChannel`, `ChatFrame_RemoveExcludePrivateMessageTarget`, `ChatFrame_RemoveMessageGroup`, `ChatFrame_RemovePrivateMessageTarget`, `ChatFrame_UnregisterAllMessageGroups`, `ChatFrame_UpdateColorByID`, `ChatFrame_UpdateDefaultChatTarget` +- ChatFrameEditBoxMixin aliases: `ChatEdit_AddHistory`, `ChatEdit_ClearChat`, `ChatEdit_DoesCurrentChannelTargetMatch`, `ChatEdit_ExtractChannel`, `ChatEdit_ExtractTellTarget`, `ChatEdit_GetChannelTarget`, `ChatEdit_HandleChatType`, `ChatEdit_ParseText`, `ChatEdit_ResetChatType`, `ChatEdit_ResetChatTypeToSticky`, `ChatEdit_SendText`, `ChatEdit_SetDeactivated`, `ChatEdit_UpdateHeader` +- API: `SendChatMessage`, `DoEmote`, `CancelEmote` + +### Blizzard_DeprecatedInstanceEncounter +- `IsEncounterInProgress`, `IsEncounterSuppressingRelease`, `IsEncounterLimitingResurrections` + +### Blizzard_DeprecatedItemScript +- `GetItemQualityColor`, `GetItemInfoInstant`, `GetItemSetInfo`, `GetItemChildInfo`, `DoesItemContainSpec`, `GetItemGem`, `GetItemCreationContext`, `GetItemIcon`, `GetItemFamily`, `GetItemSpell`, `IsArtifactPowerItem`, `IsCurrentItem`, `IsUsableItem`, `IsHelpfulItem`, `IsHarmfulItem`, `IsConsumableItem`, `IsEquippableItem`, `IsEquippedItem`, `IsEquippedItemType`, `ItemHasRange`, `IsItemInRange`, `GetItemClassInfo`, `GetItemInventorySlotInfo`, `BindEnchant`, `ActionBindsItem`, `ReplaceEnchant`, `ReplaceTradeEnchant`, `ConfirmBindOnUse`, `ConfirmOnUse`, `ConfirmNoRefundOnUse`, `DropItemOnUnit`, `EndBoundTradeable`, `EndRefund`, `GetItemInfo`, `GetDetailedItemLevelInfo`, `GetItemSpecInfo`, `GetItemUniqueness`, `GetItemCount`, `PickupItem`, `GetItemSubClassInfo`, `UseItemByName`, `EquipItemByName`, `ReplaceTradeskillEnchant`, `GetItemCooldown`, `IsCorruptedItem`, `IsCosmeticItem`, `IsDressableItem` + +### Blizzard_DeprecatedPvpScript +- `IsSubZonePVPPOI`, `GetZonePVPInfo`, `TogglePVP`, `SetPVP` + +### Blizzard_DeprecatedSpecialization +- Standard: `GetNumSpecializationsForClassID`, `GetSpecializationInfo`, `GetSpecialization`, `GetActiveSpecGroup`, `GetSpecializationMasterySpells`, `GetTalentInfo` +- Classic: `SetActiveTalentGroup`, `GetTalentTabInfo`, `GetPrimaryTalentTree`, `GetActiveTalentGroup`, `GetTalentTreeMasterySpells` +- Constants: `MAX_TALENT_TIERS`, `NUM_TALENT_COLUMNS` + +### Blizzard_DeprecatedSpellBook +- `HUNTER_DISMISS_PET`, `IsPlayerSpell`, `IsSpellKnown`, `IsSpellKnownOrOverridesKnown`, `FindFlyoutSlotBySpellID`, `FindSpellOverrideByID`, `FindBaseSpellByID` + +### Blizzard_DeprecatedSpellScript +- `TargetSpellReplacesBonusTree`, `GetMaxSpellStartRecoveryOffset`, `GetSpellQueueWindow`, `GetSchoolString`, `SpellIsPriorityAura`, `SpellIsSelfBuff`, `SpellGetVisibilityInfo`, `C_Spell.GetSpellLossOfControlCooldown` \ No newline at end of file diff --git a/.serena/memories/style_and_conventions.md b/.serena/memories/style_and_conventions.md index 0018784b..3a40ed12 100644 --- a/.serena/memories/style_and_conventions.md +++ b/.serena/memories/style_and_conventions.md @@ -1,43 +1,61 @@ -# Code Style and Conventions - -## Copyright Header (MANDATORY on all .lua files) -```lua --- Enhanced Cooldown Manager addon for World of Warcraft --- Author: Argium --- Licensed under the GNU General Public License v3.0 -``` - -## Naming -- Constants: UPPER_SNAKE_CASE (stored in ECM_Constants.lua, accessed via `ECM.Constants`) -- Functions/methods: PascalCase (e.g., `GetGlobalConfig`, `OnEnable`) -- Private methods/fields: prefixed with underscore (e.g., `_configKey`, `_updateBar`) -- Local variables: camelCase -- Module names: PascalCase (e.g., `PowerBar`, `BuffBars`) - -## Type Annotations -- Use LuaCATS `@class`, `@field`, `@param`, `@return` annotations -- Place `@class` annotations at top of file after copyright header -- Group related `@field` annotations within each class -- Add descriptions: getters start with "Gets ...", setters with "Sets ..." - -## Architecture Rules -- **ALL constants** must be in ECM_Constants.lua -- Modules using ModuleMixin must use `self:GetGlobalConfig()` and `self:GetModuleConfig()` -- never `mod.db` or `mod.db.profile` directly -- NEVER create intermediate tables for profile/config -- NEVER listen to `OnUpdate` event -- No forward declarations -- Prefer loose coupling: events, hooks, callbacks for inter-module communication -- Use assertions liberally to catch error states - -## Testing -- New features/regression fixes in `/Bars`, `/Modules`, `/UI`, and `ECM.lua` MUST include test cases -- Tests use Busted framework with WoW API stubs in Tests/stubs/ -- Test files named `*_spec.lua` - -## Code Review Standards -- No unused variables -- No unnecessary assignments, guards, functions, boilerplate -- Comments for complex sections (but not redundant comments restating function names) -- No code duplication -- Minimal complexity; simplicity is paramount -- Remove dead code, trivial wrappers, dead type checking +# Current Style and Conventions + +## Authoritative Docs +- `AGENTS.md` is the repo-wide agent rule source. +- `README.md` owns user-facing overview, install, and configuration. +- `ARCHITECTURE.md` owns addon module boundaries, init chain, event flow, and public APIs; keep it current for addon-level design changes. +- Library READMEs own each library's quick-start, API, schema, and tests: `LibSettingsBuilder`, `LibConsole`, `LibEvent`, and `LibLSMSettingsWidgets`. + +## Mandatory Lua Rules +- Every Lua file starts with the standard Enhanced Cooldown Manager GPL v3 header. +- Target WoW Lua 5.1 only: no `goto`, labels, or `//`. +- Do not use forward declarations. +- Alias shared modules once at file scope when reused. +- Mutable state belongs on the owning instance as `self._field`, not file-level locals; private fields/methods use `_`. +- Prefer assertions for required parameters over guard/fallback branches. + +## Architecture and Ownership +- Prefer the simplest production code that satisfies current supported runtime requirements. Do not add fallback paths, compatibility branches, or defensive adapters without a concrete supported environment. +- Keep one source of truth for shared state and derived values: derive once, store once, read everywhere. +- Prefer loose coupling via events, hooks, callbacks, or messages. +- Do not add duplicated utilities, trivial passthrough wrappers, or production-only indirection around fixed literals or stable signatures. +- Extract a helper/wrapper/abstraction only when it has an independently testable contract or at least two callers. +- Prefer constant lookup tables over pure mapping functions for small fixed domains. +- Remove dead code, stale fields, impossible branches, and unused locale strings. +- Clear critical state flags via `pcall` so one error cannot wedge later work. + +## Runtime and Performance +- Never use `OnUpdate` or frame-rate tickers for feature logic; use event-driven updates plus one deferred timer when needed. +- Reuse hot-path tables with `wipe()`. +- Avoid snapshot-copying callback lists. +- Cancel superseded timers before scheduling replacement deferred work. +- Periodic setup must stop once all targets are handled. +- Defer once when leaving restricted contexts; avoid stacked `C_Timer.After(0)` chains. +- Guard hot-path debug logs with `if ECM.IsDebugEnabled() then`. + +## Code Density +- Inline single-use locals into their sole call site. +- Generate repeated structural literals from a constructor; extract a thin wrapper only for repeated 2-3 call sequences. +- Prefer O(1) set lookups over linear scans for fixed load-time lists. +- Use compact single-line bodies for trivial functions. +- Do not assign fields to `nil` just to clear them; only assign fields that will be read later. +- Closures that differ only in one value should share a parameterized path. + +## Tests and Stubs +- Be skeptical when changing tests to satisfy failures; the failure may be real. +- Test load order mirrors TOC load order. +- Test production code directly. Do not mirror/reimplement production logic in specs. +- Stub the canonical function, not a wrapper or alias. If a stub diverges from real behavior, fix the stub instead of adding production fallbacks. +- Reuse `Tests/TestHelpers.lua` before adding shared helpers. +- `StaticPopup_Show` stubs forward `(name, text1, text2, data)` and call `OnAccept(self, data)`. +- Shared confirm dialogs use `ECM.OptionUtil.MakeConfirmDialog(text)` with `data.onAccept`. + +## Libraries, UI Templates, and Migrations +- Libraries stay self-contained: no ECM internals; tests and docs live with the library; public API changes are intentional and documented. +- Frame templates must be defined in `.xml`, not by Lua hooks on Blizzard functions such as `Settings.CreateElementInitializer`; XML virtual templates with `mixin="GlobalMixinName"` are multi-addon safe via LibStub. +- Migrations in `Migration.lua` are frozen snapshots and must not depend on live production code. +- A single style/metric has one owner. If a library renders a widget, the library owns dimensions, padding, fonts, and colors; callers must not redeclare matching defaults or pass redundant override knobs. + +## Secret Values and Deprecated APIs +- Treat `UnitPowerMax`, `UnitPower`, `UnitPowerPercent`, and `C_UnitAuras.GetUnitAuraBySpellID` as secret values; see `repo/secret-values-and-deprecated-apis` for exact handling rules. +- Do not use deprecated Blizzard APIs, constants, aliases, or mixins listed in `repo/secret-values-and-deprecated-apis` for 12.0.5. \ No newline at end of file diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md index 5633df49..c8665c4e 100644 --- a/.serena/memories/suggested_commands.md +++ b/.serena/memories/suggested_commands.md @@ -1,34 +1,42 @@ # Suggested Commands -## Testing +## Addon Validation ```sh busted Tests +luacheck . -q ``` -Runs the full Busted test suite from the project root. -## Linting +## Library Validation ```sh -luacheck . -q +busted --run libsettingsbuilder +busted --run libconsole +busted --run libevent +busted --run liblsmsettingswidgets ``` -Runs luacheck with quiet output. Config in `.luacheckrc` (std=lua51, excludes libs/ and Tests/). -## Git (Windows/PowerShell) +## When They Apply +- Changes to `Modules/`, `UI/`, or any root-level `*.lua` must pass `busted Tests` and `luacheck . -q`. +- Changes under `Libs//` must additionally pass that library's suite. + +## Useful Git Commands (PowerShell) ```powershell git status git diff git log --oneline -10 git add -A; git commit -m "message" -git push # aliased as 'gp' in user's shell +git push ``` -## File Operations (PowerShell) +## Useful File Commands (PowerShell) ```powershell +rg "pattern" +rg --files Get-ChildItem -Recurse -Filter "*.lua" Get-Content Test-Path ``` ## Notes -- The addon runs inside WoW; there is no standalone entry point to execute -- Tests run via `busted` which is a Lua test framework (must be installed on system) -- No formatter configured; style is enforced via code review conventions +- The addon runs inside WoW; there is no standalone runtime entry point. +- Tests run via `busted` and lint via `luacheck`; both must be available on the system. +- No formatter is configured; style is enforced by repo conventions and review. \ No newline at end of file diff --git a/.serena/memories/task_completion.md b/.serena/memories/task_completion.md index e427d8c1..3b2b117b 100644 --- a/.serena/memories/task_completion.md +++ b/.serena/memories/task_completion.md @@ -1,19 +1,20 @@ # Task Completion Checklist -When completing a task, perform these steps: +## Required Validation +- For changes to `Modules/`, `UI/`, or root-level `*.lua`: run `busted Tests` and `luacheck . -q`. +- For changes under `Libs//`: also run the matching library suite: `busted --run libsettingsbuilder`, `busted --run libconsole`, `busted --run libevent`, or `busted --run liblsmsettingswidgets`. -1. **Verify constants**: Any new constants must be in `ECM_Constants.lua` -2. **Copyright header**: All new/modified .lua files must have the standard header -3. **Run tests**: `busted Tests` -4. **Run linter**: `luacheck . -q` -5. **Code review** (for anything beyond a small targeted fix): - - Check for unused variables - - Check for unnecessary assignments, guards, boilerplate - - Verify no code duplication - - Ensure complex sections have comments - - Verify test coverage for changes in Modules/, UI/, ECM.lua - - Ensure loose coupling between components - - Remove dead code and trivial wrappers -6. **Config access**: Modules using ModuleMixin use `self:GetGlobalConfig()` / `self:GetModuleConfig()` only -7. **No OnUpdate**: Never use the OnUpdate event -8. **No forward declarations** +## Before Finishing +- Keep the standard GPL header intact on every new or modified Lua file. +- Keep `ARCHITECTURE.md` current for addon-level design changes. +- Keep the relevant library README current for library API/schema/test changes. +- Verify new constants live in `Constants.lua`; defaults live in `Defaults.lua`. +- Review for duplication, dead code, stale fields, redundant guards/fallbacks, unused locale strings, avoidable allocations, and needless abstractions. +- Preserve loose coupling and single-source-of-truth ownership. +- Do not introduce `OnUpdate`, frame-rate tickers, forward declarations, Lua post-5.1 syntax, deprecated Blizzard APIs, or invalid handling of secret values. +- For UI/library widgets, keep style metrics with the component/library owner; do not redeclare matching defaults from callers. + +## Testing Judgment +- Treat validation as a pre-commit step, not something to run after every small iteration unless a specific failure is being debugged. +- Be skeptical about editing tests just to satisfy failures; production behavior may be wrong. +- If validation cannot be run, report that and explain the blocker. \ No newline at end of file diff --git a/.serena/memories/user/debugging-preference.md b/.serena/memories/user/debugging-preference.md new file mode 100644 index 00000000..4b1b4b04 --- /dev/null +++ b/.serena/memories/user/debugging-preference.md @@ -0,0 +1 @@ +User prefers being asked to take concrete debugging/reproduction steps over speculative fixes when evidence is incomplete. For runtime bugs without a stack trace or clear failing line, avoid guessing; propose targeted diagnostics, module isolation, and evidence collection before patching. \ No newline at end of file diff --git a/.serena/memories/user/testing-rule-preserve-test-semantics.md b/.serena/memories/user/testing-rule-preserve-test-semantics.md new file mode 100644 index 00000000..38bdf266 --- /dev/null +++ b/.serena/memories/user/testing-rule-preserve-test-semantics.md @@ -0,0 +1 @@ +User explicitly does not tolerate changing an existing test to match a production change unless the original test semantics are no longer applicable. Treat existing tests as behavioral specifications. If a production change requires changing a test, first verify and explain why the old behavior is intentionally obsolete, then preserve coverage for the new intended behavior rather than weakening/inverting a regression guard. \ No newline at end of file diff --git a/.serena/project.yml b/.serena/project.yml index 0ac22307..b12baabb 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -3,15 +3,18 @@ project_name: "EnhancedCooldownManager" # list of languages for which language servers are started; choose from: -# al bash clojure cpp csharp -# csharp_omnisharp dart elixir elm erlang -# fortran fsharp go groovy haskell -# java julia kotlin lua markdown -# matlab nix pascal perl php -# php_phpactor powershell python python_jedi r -# rego ruby ruby_solargraph rust scala -# swift terraform toml typescript typescript_vts -# vue yaml zig +# al ansible bash clojure cpp +# cpp_ccls crystal csharp csharp_omnisharp dart +# elixir elm erlang fortran fsharp +# go groovy haskell haxe hlsl +# java json julia kotlin lean4 +# lua luau markdown matlab msl +# nix ocaml pascal perl php +# php_phpactor powershell python python_jedi python_ty +# r rego ruby ruby_solargraph rust +# scala solidity swift systemverilog terraform +# toml typescript typescript_vts vue yaml +# zig # (This list may be outdated. For the current list, see values of Language enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) @@ -57,52 +60,19 @@ ignored_paths: [] # Added on 2025-04-18 read_only: false -# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run scripts/print_tool_overview.py`. -# -# * `activate_project`: Activates a project by name. -# * `check_onboarding_performed`: Checks whether project onboarding was already performed. -# * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_lines`: Deletes a range of lines within a file. -# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. -# * `execute_shell_command`: Executes a shell command. -# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. -# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). -# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). -# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. -# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. -# * `initial_instructions`: Gets the initial instructions for the current project. -# Should only be used in settings where the system prompt cannot be set, -# e.g. in clients you have no control over, like Claude Desktop. -# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_at_line`: Inserts content at a given line in a file. -# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. -# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: Lists memories in Serena's project-specific memory store. -# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). -# * `read_file`: Reads a file within the project directory. -# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. -# * `remove_project`: Removes a project from the Serena configuration. -# * `replace_lines`: Replaces a range of lines within a file with new content. -# * `replace_symbol_body`: Replaces the full definition of a symbol. -# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. -# * `search_for_pattern`: Performs a search for a pattern in the project. -# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. -# * `switch_modes`: Activates modes by providing a list of their names -# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. -# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. -# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. -# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html excluded_tools: [] -# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html included_optional_tools: [] # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html fixed_tools: [] # list of mode names to that are always to be included in the set of active modes @@ -113,11 +83,14 @@ fixed_tools: [] # Set this to a list of mode names to always include the respective modes for this project. base_modes: -# list of mode names that are to be activated by default. -# The full set of modes to be activated is base_modes + default_modes. -# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. # Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. # This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes default_modes: # initial prompt for the project. It will always be given to the LLM upon activating the project @@ -147,3 +120,19 @@ ignored_memory_patterns: [] # Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. # No documentation on options means no options are available. ls_specific_settings: {} + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries. +# Currently supported for: TypeScript. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +additional_workspace_folders: [] diff --git a/AGENTS.md b/AGENTS.md index 05c566cb..ddc72fee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,95 +1,116 @@ +IMPORTANT: Run initialize serena tool, if it's available. +Use Serena for codebase text search and source reading by default. Prefer Serena `search_for_pattern`, `get_symbols_overview`, `find_symbol`, and `read_file` for inspecting repository files. Use shell commands for Git metadata, diffs, validation commands, and cases where Serena cannot provide the needed result. + +# Documentation Map + +Authoritative source for repo-wide agent rules. Topic-specific docs own their own surface; do not duplicate their content here. + +| Doc | Owns | +|---|---| +| [README.md](README.md) | User-facing overview, install, configuration | +| [ARCHITECTURE.md](ARCHITECTURE.md) | Module boundaries, init chain, event flow, public APIs | +| [docs/BlizzardDeprecatedApis.md](docs/BlizzardDeprecatedApis.md) | Deprecated Blizzard API denylist | +| [Libs/LibSettingsBuilder/README.md](Libs/LibSettingsBuilder/README.md) | Settings builder API and schema | +| [Libs/LibConsole/README.md](Libs/LibConsole/README.md) | Slash-command library | +| [Libs/LibEvent/README.md](Libs/LibEvent/README.md) | Embeddable event system | +| [Libs/LibLSMSettingsWidgets/README.md](Libs/LibLSMSettingsWidgets/README.md) | LSM picker templates | + +Keep `ARCHITECTURE.md` current for addon-level design changes; each library's README owns its quick-start, API, and tests. + +--- + # Validation ```sh -# Addon tests busted Tests - -# Library tests busted --run libsettingsbuilder busted --run libconsole busted --run libevent busted --run liblsmsettingswidgets - -# Lint luacheck . -q ``` -- Changes to `Modules/`, `Helpers/`, `UI/`, and `ECM*.lua` must pass `busted Tests` and `luacheck . -q`. -- Library changes must also pass that library's dedicated test suite. +- Changes to `Modules/`, `UI/`, or root-level `*.lua` must pass `busted Tests` and `luacheck . -q`. +- Changes under `Libs//` must also pass that library's suite. --- # Core Rules - +All Lua files start with: + +```lua -- Enhanced Cooldown Manager addon for World of Warcraft -- Author: Argium -- Licensed under the GNU General Public License v3.0 - - -- All Lua files must include the standard copyright header. -- Keep [ARCHITECTURE.md](ARCHITECTURE.md) up to date. - - -## Lua / WoW Runtime - -- Target WoW Lua 5.1; do not use post-5.1 features such as `goto`, labels, or `//`. -- Do not add compatibility shims for built-ins already present in WoW. If a shim exists only for `busted`, document that. -- Do not nil-check or wrap built-ins such as `issecretvalue`, `issecrettable`, or `canaccesstable`. - -## Config, Events, and State - -- Mutable state belongs on the owning instance (`self._field`), not file-level locals. Prefix private fields and methods with `_`. -- Do not use forward declarations. Alias shared modules once at file scope when reused. +``` -## Performance +## Architecture + +- Prefer the simplest production code for current supported runtimes. No fallback paths, compatibility branches, defensive adapters, or built-in shims without a concrete supported environment that needs them. +- Keep one owner for shared state, derived values, utility functions, style metrics, and widget rendering details. +- Use loose coupling through events, hooks, callbacks, or messages. +- Do not add trivial passthrough wrappers, fixed-literal indirection, or single-caller abstractions without an independently testable contract. +- Prefer constant lookup tables and `O(1)` sets over mapping functions or linear scans for fixed load-time domains. +- Remove dead code, stale fields, impossible branches, unused upvalues, and unused locale strings. +- Clear critical state flags via `pcall` so one error cannot wedge later work. + +## State and Style + +- Mutable state belongs on the owning instance (`self._field`), not file-level locals. Prefix private fields/methods with `_`. +- No forward declarations; reorder code instead. Alias shared modules once at file scope. +- Prefer assertions for required parameters over guards and fallbacks. +- Target WoW Lua 5.1: no `goto`, labels, `//`, bitwise operators, or newer Lua syntax. +- Inline single-use locals into their sole call site. Compact trivial function bodies to one line when readable. +- Generate repeated structural literals from a constructor; extract thin wrappers only for repeated 2-3 call sequences. +- Do not assign fields to `nil` to "clear" them unless the nil value is read later. +- Closures differing only by one value should share a parameterized path. + +## Runtime and Performance + +- Never use `OnUpdate` or frame-rate tickers. Use event-driven updates plus one deferred timer when needed. +- Reuse hot-path tables with `wipe()` and avoid snapshot-copying callback lists. +- Cancel superseded timers before scheduling new deferred work; periodic setup must stop once all targets are handled. +- Defer once when leaving restricted contexts; do not stack `C_Timer.After(0)` chains. +- Guard hot-path debug logs with `if ECM.IsDebugEnabled() then`. +- Frame templates belong in `.xml`, not Lua hooks on Blizzard functions like `Settings.CreateElementInitializer`; XML virtual templates with `mixin="GlobalMixinName"` are multi-addon safe via LibStub. + +## Tests + +- Existing tests are behavioral specifications. Do not invert, weaken, or rewrite a test unless the old behavior is explicitly obsolete; preserve equivalent coverage for the new behavior. +- Test load order mirrors TOC load order. Test files mirror source paths; library tests live under `Libs//Tests/`. +- Test production code directly. Do not mirror production logic in specs. +- Stub the canonical function, not a wrapper or alias. If a stub diverges from real behavior, fix the stub. +- Do not guard production APIs only to satisfy tests. If an API exists in the supported runtime/load order, tests must stub it. +- Reuse `Tests/TestHelpers.lua` before creating new shared helpers. +- `StaticPopup_Show` stubs forward `(name, text1, text2, data)` and call `OnAccept(self, data)`. +- Shared confirm dialogs use `ECM.OptionUtil.MakeConfirmDialog(text)` with `data.onAccept`. -- Never use `OnUpdate` or frame-rate tickers; prefer event-driven updates plus a single deferred timer when needed. -- Reuse tables on hot paths with `wipe()`. -- Cancel superseded timers before scheduling new deferred work. -- Guard hot-path debug logging with `if ECM.IsDebugEnabled() then`. -- Avoid snapshot-copying callback lists; use zero-allocation iteration that tolerates removal. -- Periodic setup work must stop doing setup once all targets are handled. -- Defer once out of restricted contexts; avoid stacked `C_Timer.After(0)` chains. +## Libraries and Migrations -## Architecture and Boundaries +- Libraries stay self-contained: no ECM internals; tests and docs live with the library; public API changes are intentional and documented. +- Migrations in `Migration.lua` are frozen snapshots and must not depend on live production code. -- Prefer loose coupling via events, hooks, callbacks, or messages. -- Maintain a single source of truth for shared state and derived values: derive once, store once, read everywhere. -- Do not duplicate utilities or add trivial passthrough wrappers; extend the canonical owner instead. -- Do not extract single-use helpers unless they have a clear independently testable contract or 2+ callers. -- Prefer constant lookup tables over pure mapping functions for small fixed domains. -- Remove dead code, stale fields, impossible branches, and unused locale strings. -- Clear critical state flags with `pcall` or equivalent so one error cannot wedge later work. +--- -## Tests, Libraries, and Migrations +# Review Heuristics -- Be skeptical about changing tests to satisfy failures; the failure may be real. -- Test load order must mirror TOC load order. -- Stub the canonical function, not a wrapper or alias. -- Prefer testing live production code; avoid mirrored helper logic in specs. -- Reuse `Tests/TestHelpers.lua` before creating new shared test helpers. -- Test files mirror source paths; library tests stay under `Libs//Tests/`. -- `StaticPopup_Show` stubs must forward `(name, text1, text2, data)` and call `OnAccept(self, data)`. -- Libraries must stay self-contained: no ECM internals; tests and docs live with the library; public API changes should be intentional and documented. -- Do not use global hooks on Blizzard UI functions (like `Settings.CreateElementInitializer`) to simulate XML templates in pure Lua. Library frame templates must use `.xml` files to prevent widespread execution taint. -- XML-defined virtual frame templates with `mixin="GlobalMixinName"` are inherently multi-addon safe via LibStub: the Lua runs once (defining the global mixin tables), and WoW resolves mixin names lazily at `CreateFrame` time. Do not replace XML templates with Lua-based mixin injection to "support multiple addons" — it breaks the initialization pipeline and causes taint. -- Shared confirm dialogs use `ECM.OptionUtil.MakeConfirmDialog(text)` with `data.onAccept`. -- Migrations in `Helpers/Migration.lua` are frozen snapshots and must not depend on live production code. +Optimize for simple, explicit, maintainable code. Prioritize correctness, taint/security, architecture, duplication, and style. Watch for unused variables, redundant guards, tight coupling, needless complexity, missing coverage, and avoidable allocations. --- -# Review Heuristics +# Secret Values + +Treat `UnitPowerMax`, `UnitPower`, `UnitPowerPercent`, and `C_UnitAuras.GetUnitAuraBySpellID` as secret values. -- Optimize for simple, explicit, maintainable code. -- Watch for unused variables, redundant guards or assignments, duplication, tight coupling, needless complexity, missing coverage, and avoidable allocations. +- Only nil-check them or pass them to built-ins/APIs that accept secrets. +- No arithmetic, comparisons, boolean tests, length, indexing, assignment-derived logic, iteration, or use as table keys. +- Storing in locals/upvalues/table values is fine; concatenation and string formatting with string/number secrets is fine. +- Secret tables may yield secret values or be fully inaccessible; `canaccesstable(table)` only reports access, not contents. +- Do not nil-check or wrap built-ins like `issecretvalue`, `issecrettable`, or `canaccesstable`. --- -# Secret Values +# Deprecated Blizzard APIs -- Treat `UnitPowerMax`, `UnitPower`, `UnitPowerPercent`, and `C_UnitAuras.GetUnitAuraBySpellID` as secret values. -- Only nil-check them or pass them to built-ins or APIs that accept secrets. -- Do not do arithmetic, comparisons, boolean tests, length, indexing, assignment, iteration, or use them as table keys. -- Storing secret values in locals, upvalues, or table values is allowed; concatenation and string formatting with string or number secrets is allowed. -- Secret tables may always yield secret values or be fully inaccessible; `canaccesstable(table)` only tells you whether access would be allowed. +Do not use deprecated Blizzard functions, constants, aliases, or mixins. See [docs/BlizzardDeprecatedApis.md](docs/BlizzardDeprecatedApis.md) for the 12.0.5 denylist and replacement-source guidance. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4d5fa523..17137b5c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,7 +1,58 @@ # ECM Architecture EnhancedCooldownManager is an event-driven WoW addon built on AceAddon-3.0 / AceDB-3.0. -`Runtime.lua` is the central dispatcher: it registers WoW events, manages layout coalescing, and iterates modules. Each module (PowerBar, ResourceBar, RuneBar, BuffBars, ItemIcons) inherits from `BarMixin` and implements its own `UpdateLayout()`. +`Runtime.lua` is the central dispatcher: it registers WoW events, manages layout coalescing, lays out `ExtraIcons` first when it widens the main viewer, and then iterates the chained bar modules. `PowerBar`, `ResourceBar`, and `RuneBar` use `BarMixin.AddBarMixin()`. `BuffBars`, `ExternalBars`, and `ExtraIcons` use `BarMixin.AddFrameMixin()` and manage their own child content. + +## Modules + +Each module owns its own reference doc with a summary table, actor diagram, component-interaction diagram, and data model: + +| Module | Doc | Mixin | +|---|---|---| +| PowerBar | [docs/PowerBar.md](docs/PowerBar.md) | `AddBarMixin` | +| ResourceBar | [docs/ResourceBar.md](docs/ResourceBar.md) | `AddBarMixin` | +| RuneBar | [docs/RuneBar.md](docs/RuneBar.md) | `AddBarMixin` | +| BuffBars | [docs/BuffBars.md](docs/BuffBars.md) | `AddFrameMixin` | +| ExternalBars | [docs/ExternalBars.md](docs/ExternalBars.md) | `AddFrameMixin` | +| ExtraIcons | [docs/ExtraIcons.md](docs/ExtraIcons.md) | `AddFrameMixin` | + +## Startup and the generic event pulse + +Cross-cutting view: addon startup, then the generic event → Runtime → module layout pulse. Per-scenario flows (profile change, Edit Mode, options, import/export, per-module data events) live in the module reference docs. + +```mermaid +sequenceDiagram + autonumber + participant Game as Game (WoW client) + participant ACE as ACE (AceAddon / AceDB) + participant ECM as ECM (addon root) + participant Runtime as Runtime + participant Module as Module(s) + + rect rgb(26,26,46) + note over Game,Module: Addon startup + Game->>ACE: ADDON_LOADED + ACE->>ECM: OnInitialize() + ECM->>ECM: Migration.PrepareDatabase() / Run(profile) + ECM->>ACE: AceDB-3.0:New(defaults) + ACE->>Module: OnInitialize() → BarMixin.Add*Mixin + Game->>ACE: PLAYER_LOGIN + ACE->>ECM: OnEnable() + ECM->>Runtime: Runtime.Enable(addon) + Runtime->>Module: EnableModule / EnsureFrame / RegisterFrame + Module->>Game: RegisterEvent(module-specific events) + Runtime->>Game: RegisterEvent(layout events) + watchdog ticker + Runtime->>Module: UpdateLayout("ModuleInit") + end + + rect rgb(26,46,30) + note over Game,Module: Generic event pulse + Game->>Runtime: layout event fires + Runtime->>Runtime: handleLayoutEvent → RequestLayout / ScheduleLayoutUpdate + Runtime->>Runtime: executeLayout → updateFadeAndHiddenStates + Runtime->>Module: SetHidden / SetAlpha / UpdateLayout(reason) + end +``` ## Initialization Chain @@ -27,7 +78,8 @@ flowchart TD RB_INIT["ResourceBar:OnInitialize()
BarMixin.AddBarMixin(self)"] RUNE_INIT["RuneBar:OnInitialize()
BarMixin.AddBarMixin(self)"] BB_INIT["BuffBars:OnInitialize()
BarMixin.AddFrameMixin(self)"] - II_INIT["ItemIcons:OnInitialize()"] + EB_INIT["ExternalBars:OnInitialize()
BarMixin.AddFrameMixin(self)"] + II_INIT["ExtraIcons:OnInitialize()
BarMixin.AddFrameMixin(self)"] end subgraph ENABLE["Phase 4 · OnEnable → Runtime.Enable"] @@ -55,8 +107,10 @@ flowchart TD REG_FRAME["Runtime.RegisterFrame(self)
→ _modules[name] = self"] REG_EVENTS["Register module-specific events
(UNIT_POWER_UPDATE, etc.)"] HOOK_BB["BuffBars: C_Timer.After(0.1)
→ HookViewer() + RequestLayout"] + HOOK_EB["ExternalBars: C_Timer.After(0.1)
→ HookViewer() + UpdateAuras + RequestLayout"] ENSURE --> REG_FRAME --> REG_EVENTS REG_EVENTS -.->|BuffBars only| HOOK_BB + REG_FRAME -.->|ExternalBars only| HOOK_EB end subgraph FIRST["Phase 6 · First Layout"] @@ -99,11 +153,12 @@ flowchart TD BB_ZONE["ZONE_CHANGED_* / PLAYER_ENTERING_WORLD
→ BuffBars:OnZoneChanged"] end - subgraph HOOKS["Frame Hooks (BuffBars)"] + subgraph HOOKS["Frame Hooks (BuffBars / ExternalBars)"] BB_SETPT["child:SetPoint hook
→ restore anchors + restyle"] BB_SHOW["child:OnShow hook
→ restyle"] BB_HIDE["child:OnHide hook"] BB_VIEWER["viewer:OnShow / OnSizeChanged"] + EB_VIEWER["ExternalDefensivesFrame:UpdateAuras / OnShow / OnHide
→ ExternalBars sync + RequestLayout"] end subgraph RUNTIME["Runtime.lua — Event Dispatch"] @@ -143,10 +198,12 @@ flowchart TD subgraph UPDATE_ALL["updateAllLayouts(reason)"] INV_DET["invalidateDetachedAnchorMetrics()"] UPD_DET["updateDetachedAnchorLayout()"] - CHAIN_LOOP["For each module in CHAIN_ORDER:
PowerBar → ResourceBar → RuneBar
→ BuffBars → ItemIcons"] + EXTRA_FIRST["ExtraIcons:UpdateLayout(reason)
first, so the main viewer width is final"] + CHAIN_LOOP["For each module in CHAIN_ORDER:
PowerBar → ResourceBar → RuneBar
→ BuffBars → ExternalBars"] + OTHER_LOOP["Remaining non-chain modules (if any)"] MOD_UPD["module:UpdateLayout(reason)"] - INV_DET --> UPD_DET --> CHAIN_LOOP --> MOD_UPD + INV_DET --> UPD_DET --> EXTRA_FIRST --> CHAIN_LOOP --> OTHER_LOOP --> MOD_UPD end %% Event Sources → Runtime @@ -159,7 +216,7 @@ flowchart TD PB_PWR & RB_AURA & BB_ZONE --> REQ_LAY %% Hooks → RequestLayout - BB_SETPT & BB_SHOW & BB_HIDE & BB_VIEWER --> REQ_LAY + BB_SETPT & BB_SHOW & BB_HIDE & BB_VIEWER & EB_VIEWER --> REQ_LAY %% All paths → executeLayout REQ_LAY --> EXEC_LAY @@ -181,17 +238,22 @@ flowchart TD ## Secondary Flows -### Profile Change +Profile change, Edit Mode, and per-module reactions are documented in each [module reference doc](#modules). The flows below are cross-cutting concerns that don't belong to any single module. -When a user switches, copies, or resets a profile, AceDB fires a callback → `ECM:OnProfileChangedHandler()` → re-runs migration → `Runtime.Enable()` re-enables/disables modules per new config → schedules a full layout with reason `"ProfileChanged"`. BuffBars clears its SpellColors cache on this reason. +### Options UI -### Edit Mode +Setting changes flow through LibSettingsBuilder's `onChanged` callback → `Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")`. The embedded library is loaded through `Libs/LibSettingsBuilder/embed.xml`, which guarantees `Core.lua`, the primitive helper modules, standard control modules, composite control modules, and `Utility.lua` initialize in order before options pages register. See [`Libs/LibSettingsBuilder/README.md`](Libs/LibSettingsBuilder/README.md) for the library's public surface, declarative schema, and canonical row types. -LibEditMode detects WoW's Edit Mode enter/exit. On enter, all modules are forced visible (alpha 1, not hidden). Dragging or resizing calls `UpdateLayoutImmediately()` for instant feedback. On exit, normal fade/hidden rules re-apply. +ECM uses LibSettingsBuilder as a single declarative registration tree: -### Options UI +- `UI/Options.lua` owns the root assembly and calls `LSB.New({ name = ..., page = ns.AboutPage, sections = { ... } })` once, +- each options page has a dedicated `UI/*Options.lua` or `UI/*Page.lua` owner (`UI/AboutOptions.lua`, `UI/AdvancedOptions.lua`, `UI/SpellColorsPage.lua`, etc.) that exports plain section/page spec tables instead of registering itself, +- `LSB.New(...)` materializes the tree into Blizzard Settings (flattening single-page sections by default and nesting multi-page sections automatically), +- dynamic pages keep a registered page handle through `onRegistered(page)` and refresh via `page:Refresh()` when async or transient state changes. + +`UI/SpellColorsPage.lua` owns the shared Spell Colors subcategory. `BuffBarsOptions` registers the page once, and both `BuffBars` and `ExternalBars` register scoped sections into it, so the two modules share one editor without sharing saved color pools. -Setting changes flow through LibSettingsBuilder's `onChange` → `Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")`. +ECM only consumes the documented public surface (`LSB.New`, `lsb:GetSection`, `lsb:GetRootPage`, `lsb:GetPage`, `lsb:HasCategory`, `page:GetId`, `page:Refresh`) and registers pages through raw declarative row tables — no builder-level helper constructors and no deprecated transition namespaces. ### Watchdog Ticker @@ -199,46 +261,10 @@ A 0.5s `C_Timer.NewTicker` handles deferred Blizzard frame hooking (stops retryi ```mermaid flowchart TD - subgraph PROFILE["Profile Change Flow"] - USER_SWITCH["User switches/copies/resets profile"] - ACE_CB["AceDB callback fires:
OnProfileChanged / OnProfileCopied / OnProfileReset"] - PROF_HANDLER["ECM:OnProfileChangedHandler()"] - MIG["Migration.Run(new profile)"] - RT_EN2["Runtime.Enable(addon)
→ Re-enable/disable modules per new config"] - SCHED_PC["ScheduleLayoutUpdate(0, 'ProfileChanged')"] - BB_CLEAR["BuffBars:UpdateLayout('ProfileChanged')
→ SpellColors.ClearDiscoveredKeys()"] - - USER_SWITCH --> ACE_CB --> PROF_HANDLER --> MIG --> RT_EN2 --> SCHED_PC --> BB_CLEAR - end - - subgraph EDITMODE["Edit Mode Flow"] - EM_ENTER["User enters WoW Edit Mode"] - EM_DETECT["LibEditMode callback → 'enter'"] - EM_LAYOUT["ScheduleLayoutUpdate(0, 'EditModeEnter')"] - EM_FORCE["updateFadeAndHiddenStates()
→ hidden=false, alpha=1 (always visible)"] - - EM_DRAG["User drags module frame"] - EM_SAVE["onPositionChanged callback
→ EditMode.SavePosition(config, ...)"] - EM_IMMED["UpdateLayoutImmediately('EditModeDrag')"] - - EM_SLIDER["User adjusts width/height slider"] - EM_WRITE["Direct config write: cfg.width = value"] - EM_IMMED2["UpdateLayoutImmediately('EditModeWidth')"] - - EM_EXIT["User exits Edit Mode"] - EM_EXIT_LAY["ScheduleLayoutUpdate(0, 'EditModeExit')
→ Re-apply fade/hidden per config"] - - EM_ENTER --> EM_DETECT --> EM_LAYOUT --> EM_FORCE - EM_DRAG --> EM_SAVE --> EM_IMMED - EM_SLIDER --> EM_WRITE --> EM_IMMED2 - EM_EXIT --> EM_EXIT_LAY - end - subgraph OPTIONS["Options UI Flow"] OPT_CHANGE["User toggles setting in Options UI"] LSB_CB["LibSettingsBuilder onChange callback"] OPT_SCHED["Runtime.ScheduleLayoutUpdate(0, 'OptionsChanged')"] - OPT_CHANGE --> LSB_CB --> OPT_SCHED end @@ -248,45 +274,49 @@ flowchart TD WD_HOOK["hookBlizzardFrames()
hookCooldownViewerSettings()"] WD_ENFORCE["enforceBlizzardFrameState()
→ Correct Blizzard re-shows/alpha"] WD_ALPHA["Sync module alpha
→ LazySetAlpha per module"] - WD_TICK --> WD_SETUP WD_SETUP -->|no| WD_HOOK --> WD_ENFORCE WD_SETUP -->|yes| WD_ENFORCE --> WD_ALPHA end - - style PROFILE fill:#1a1a2e,stroke:#f7a855,color:#e0e0e0 - style EDITMODE fill:#1a1a2e,stroke:#7a84f7,color:#e0e0e0 - style OPTIONS fill:#1a1a2e,stroke:#22c55e,color:#e0e0e0 - style WATCHDOG fill:#1a1a2e,stroke:#f43f5e,color:#e0e0e0 ``` ## Event Reference -Runtime registers the shared layout events; modules register their own data-driven events in `OnEnable`. Events with multiple registrants are intentional — Runtime handles visibility/positioning while the module handles its own data refresh. - -| Event | Registrant(s) | Purpose | -|-------|---------------|---------| -| CVAR_UPDATE | Runtime | Schedules layout when `cooldownViewerEnabled` changes | -| PLAYER_ENTERING_WORLD | Runtime, BuffBars, ItemIcons | Runtime: full layout; BuffBars: refresh zone buffs; ItemIcons: full refresh | -| PLAYER_MOUNT_DISPLAY_CHANGED | Runtime | Immediate layout for mounted-state visibility | -| PLAYER_REGEN_DISABLED | Runtime | Immediate layout; sets `_inCombat` flag | -| PLAYER_REGEN_ENABLED | Runtime | Delayed layout (combat-end delay); clears `_inCombat` | -| PLAYER_SPECIALIZATION_CHANGED | Runtime | Immediate layout for spec-dependent module visibility | -| PLAYER_TARGET_CHANGED | Runtime | Immediate layout for target-frame positioning | -| PLAYER_UPDATE_RESTING | Runtime | Immediate layout for resting-state visibility | -| UNIT_ENTERED_VEHICLE | Runtime | Immediate layout to hide bars in vehicle | -| UNIT_EXITED_VEHICLE | Runtime | Immediate layout to restore bars after vehicle | -| UPDATE_SHAPESHIFT_FORM | Runtime | Immediate layout for form/stance changes | -| VEHICLE_UPDATE | Runtime | Immediate layout for vehicle seat changes | -| ZONE_CHANGED | Runtime, BuffBars | Runtime: delayed layout; BuffBars: refresh zone-specific buffs | -| ZONE_CHANGED_INDOORS | Runtime, BuffBars | Runtime: delayed layout; BuffBars: refresh buff data | -| ZONE_CHANGED_NEW_AREA | Runtime, BuffBars | Runtime: delayed layout; BuffBars: refresh area-specific buffs | -| BAG_UPDATE_COOLDOWN | ItemIcons | Throttled cooldown-state refresh | -| BAG_UPDATE_DELAYED | ItemIcons | Layout update after bag contents finalize | -| PLAYER_EQUIPMENT_CHANGED | ItemIcons | Refresh equipped trinket cooldowns on gear swap | +`ExternalBars` is hook-driven (mirrors `ExternalDefensivesFrame`) and does not register events directly. + +### Runtime layout events + +Registered in `Runtime.Enable` and dispatched through `handleLayoutEvent`. Modules can also register the same event for their own data refresh; that's noted under the per-module column. + +| Event | Co-listeners | Behavior | +|---|---|---| +| CVAR_UPDATE | — | Schedules layout when `cooldownViewerEnabled` changes | +| PLAYER_ENTERING_WORLD | BuffBars, ExtraIcons | Full layout (delay 0.4s) | +| PLAYER_MOUNT_DISPLAY_CHANGED | — | Immediate layout (mounted visibility) | +| PLAYER_REGEN_DISABLED | — | Immediate layout; sets `_inCombat` | +| PLAYER_REGEN_ENABLED | — | Delayed layout (combat-end delay); clears `_inCombat` | +| PLAYER_SPECIALIZATION_CHANGED | — | Immediate layout (spec-dependent visibility) | +| PLAYER_TARGET_CHANGED | — | Immediate layout (target-frame positioning) | +| PLAYER_UPDATE_RESTING | — | Immediate layout (resting visibility) | +| UNIT_ENTERED_VEHICLE / UNIT_EXITED_VEHICLE / VEHICLE_UPDATE | — | Immediate layout | +| UPDATE_SHAPESHIFT_FORM | — | Immediate layout (form/stance changes) | +| ZONE_CHANGED / ZONE_CHANGED_INDOORS / ZONE_CHANGED_NEW_AREA | BuffBars | Delayed layout (0.1s) | + +### Module data events + +Registered by each module in its own `OnEnable`. See the [module reference doc](#modules) for handler details. + +| Event | Module | Purpose | +|---|---|---| +| UNIT_POWER_UPDATE | PowerBar, ResourceBar | Power-bar value update | +| UNIT_AURA | ResourceBar | Aura-driven resource refresh | | RUNE_POWER_UPDATE | RuneBar | Start rune animation ticker; request layout | -| UNIT_AURA | ResourceBar | Layout update when player auras change | -| UNIT_POWER_UPDATE | PowerBar, ResourceBar | PowerBar: primary power bar update; ResourceBar: resource tracking | +| BAG_UPDATE_COOLDOWN | ExtraIcons | Throttled cooldown-state refresh | +| BAG_UPDATE_DELAYED | ExtraIcons | Layout after bag contents finalize | +| PLAYER_EQUIPMENT_CHANGED | ExtraIcons | Refresh tracked equipment-slot cooldowns | +| SPELLS_CHANGED | ExtraIcons | Layout when known spells change | +| SPELL_UPDATE_COOLDOWN | ExtraIcons | Throttled spell cooldown refresh | +| ZONE_CHANGED* / PLAYER_ENTERING_WORLD | BuffBars | Refresh zone-specific buffs | ## Public Interfaces @@ -315,7 +345,7 @@ Two mixins applied in `OnInitialize`. `FrameProto` provides positioning, visibil | Method | Description | |--------|-------------| -| `AddFrameMixin(target, name)` | Apply frame-only mixin (used by BuffBars, ItemIcons) | +| `AddFrameMixin(target, name)` | Apply frame-only mixin (used by BuffBars, ExternalBars, ExtraIcons) | | `AddBarMixin(module, name)` | Apply bar mixin: frame + StatusBar + ticks (used by PowerBar, ResourceBar, RuneBar) | **FrameProto (mixed into every module):** @@ -347,6 +377,31 @@ Two mixins applied in `OnInitialize`. `FrameProto` provides positioning, visibil | `LayoutResourceTicks(maxResources, color?, tickWidth?, poolKey?)` | Position ticks as resource dividers | | `LayoutValueTicks(statusBar, ticks, maxValue, defaultColor, defaultWidth, poolKey?)` | Position ticks at specific values | +`BarStyle` (`BarStyle.lua`) is a stateless namespace of shared child-bar styling helpers used by both `BuffBars` and `ExternalBars`. It is not a mixin — callers invoke the helpers directly (e.g. `BarStyle.StyleChildBar(...)`) so both modules render through the same icon, background, anchor, and spell-color paths. + +**Shared child-bar styling helpers:** + +| Method | Description | +|--------|-------------| +| `ApplySquareIconStyle(iconFrame, iconTexture, iconOverlay, debuffBorder)` | Remove Blizzard round-mask / overlay treatment once and keep icons square | +| `StyleBarHeight(frame, bar, iconFrame, config, globalConfig)` | Apply shared row height to the container, status bar, and icon | +| `StyleBarBackground(frame, barBG, config, globalConfig)` | Reparent and restyle the shared bar background texture | +| `StyleBarColor(module, frame, bar, globalConfig, spellColors?, retryCount?)` | Resolve spell colors through a per-scope store with secret-value retry handling | +| `StyleBarIcon(frame, iconFrame, config)` | Show, hide, and align the optional icon region | +| `StyleBarAnchors(frame, bar, iconFrame, config)` | Apply the shared text / icon anchor layout | +| `StyleChildBar(module, frame, config, globalConfig, spellColors?)` | Run the complete shared BuffBars / ExternalBars child-bar styling pass | + +### Module reference docs + +Per-module surface (config, events, hooks, internal state, options) lives with each module: + +- [docs/PowerBar.md](docs/PowerBar.md) +- [docs/ResourceBar.md](docs/ResourceBar.md) +- [docs/RuneBar.md](docs/RuneBar.md) +- [docs/BuffBars.md](docs/BuffBars.md) +- [docs/ExternalBars.md](docs/ExternalBars.md) +- [docs/ExtraIcons.md](docs/ExtraIcons.md) + ### FrameUtil (`ns.FrameUtil`) Lazy setters avoid redundant frame API calls — they compare the new value against state and only call the Blizzard API when it changed. @@ -368,7 +423,7 @@ Lazy setters avoid redundant frame API calls — they compare the new value agai ### SpellColors (`ns.SpellColors`) -Multi-tier key system for per-spell color customization on buff bars. Keys match across spell name, spell ID, cooldown ID, and texture file ID. +Shared multi-tier key system for per-spell color customization on BuffBars and ExternalBars. Keys match across spell name, spell ID, cooldown ID, and texture file ID. Scope-specific state now lives on `ECM_SpellColorStore` instances created by `ns.SpellColors.New(scope, accessor?)` or retrieved from the shared registry via `ns.SpellColors.Get(scope)`, so BuffBars and ExternalBars share lookup logic while keeping separate defaults, saved colors, and discovered-key caches. | Method | Description | |--------|-------------| @@ -376,18 +431,39 @@ Multi-tier key system for per-spell color customization on buff bars. Keys match | `NormalizeKey(key)` | Normalize key payload into opaque key | | `KeysMatch(left, right)` | Check if two keys identify the same spell | | `MergeKeys(base, other)` | Merge identifiers from matching keys | -| `GetColorByKey(key)` | Get custom color for spell | -| `GetColorForBar(frame)` | Get custom color for a buff bar frame | -| `SetColorByKey(key, color)` | Set custom color for spell | -| `ResetColorByKey(key)` | Remove custom color entry | -| `GetAllColorEntries()` | Return deduplicated color entries for current class/spec | -| `GetDefaultColor()` | Return default color for class/spec | -| `SetDefaultColor(color)` | Set default color for class/spec | -| `ReconcileAllKeys(keys)` | Batch-reconcile keys (propagate most-recent across tiers) | -| `DiscoverBar(frame)` | Register a discovered bar key | -| `ClearDiscoveredKeys()` | Clear discovered key cache | -| `ClearCurrentSpecColors()` | Clear all colors for current class/spec | -| `SetConfigAccessor(accessor)` | Inject config accessor (decouples from AceDB) | +| `New(scope, accessor?)` | Create an isolated spell-color store (primarily for tests) | +| `Get(scope?)` | Return the shared spell-color store for a scope | + +Store methods (`SpellColors.Get(scope):...`): + +| Method | Description | +|--------|-------------| +| `GetColorByKey(key)` | Get a custom color for a spell within that store's scope | +| `GetColorForBar(frame)` | Get a custom color for a BuffBars / ExternalBars row | +| `SetColorByKey(key, color)` | Set a custom color for a spell within that store's scope | +| `ResetColorByKey(key)` | Remove a custom color entry | +| `GetAllColorEntries()` | Return deduplicated color entries for the current class/spec in that store's scope | +| `GetDefaultColor()` | Return the default color for the current class/spec in that store's scope | +| `SetDefaultColor(color)` | Set the default color for the current class/spec in that store's scope | +| `ReconcileAllKeys(keys)` | Batch-reconcile keys within that store's scope (propagate most-recent across tiers) | +| `RemoveEntriesByKeys(keys)` | Remove matching persisted and discovered spell-color keys within that store's scope | +| `DiscoverBar(frame)` | Register a discovered bar key within that store's scope | +| `ClearDiscoveredKeys()` | Clear the discovered-key cache for that store's scope | +| `ClearCurrentSpecColors()` | Clear all colors for the current class/spec in that store's scope | +| `_SetConfigAccessor(accessor)` | Private test-only override for swapping the config source after construction | + +The spell-color settings page (`UI/SpellColorsPage.lua`) renders one shared multi-section canvas. Each section merges persisted and discovered keys for its own scope, enables `Reconcile` and `Remove Stale` only when a row is still missing one or more identifiers, and lets `Remove Stale` confirmed-delete incomplete entries from both the current-spec stores and the runtime discovered-key cache while echoing each removal to chat. + +### SpellColorsPage (`UI/SpellColorsPage.lua`) + +Shared settings-page builder for spell colors. BuffBars and ExternalBars both register sections into the same page rather than owning duplicate subcategories. + +| Method | Description | +|--------|-------------| +| `RegisterSection(section)` | Register or update a spell-color section (`key`, `label`, `scope`, `isDisabledDelegate`, `ownerModuleName`) | +| `CreateSectionDisabledDelegate(configPath, ownerModuleName)` | Create the disabled predicate used by a section | +| `CreatePage(subcatName)` | Return the shared multi-section page spec used by options registration | +| `SetRegisteredPage(page)` | Cache the live page handle so runtime changes can refresh it | ### ClassUtil (`ns.ClassUtil`) @@ -438,12 +514,12 @@ Shared helpers for the Settings UI, used by all option pages. | `GetCurrentClassSpec()` | Return `(classID, specIndex, className, specName, classEnum)` | | `GetIsDisabledDelegate(configPath)` | Return closure checking if module is disabled | | `CreateModuleEnabledHandler(moduleName, requiresReload?)` | Create enable/disable toggle handler | -| `CreateBarArgs(isDisabled, options?)` | Generate standard bar layout/appearance args | -| `CreateDetachedStackArgs()` | Generate detached positioning args | +| `CreateBarRows(isDisabled, options?)` | Generate standard bar layout/appearance rows | +| `CreateDetachedStackRows()` | Generate detached positioning rows | | `CreateDetachedAnchorEditModeSettings(getGlobalConfig, onChanged)` | Create Edit Mode settings for detached anchor | | `OpenColorPicker(currentColor, hasOpacity, onChange)` | Open Blizzard color picker | | `MakeConfirmDialog(text)` | Create confirm dialog for `StaticPopup` | -| `OpenLayoutPage()` | Open settings to Layout subcategory | +| `OpenLayoutPage()` | Open settings to the registered Layout page | ### ECM Addon Instance @@ -453,10 +529,13 @@ Top-level namespace utilities and addon methods available globally. |--------|-------------| | `ns.GetGlobalConfig()` | Return global config section from database | | `ns.IsDebugEnabled()` | Check if debug mode is enabled | +| `ns.IsErrorLoggingEnabled()` | Check if targeted error logging is enabled | | `ns.IsDeathKnight()` | Check if player is a Death Knight | | `ns.ToString(v)` | Convert value to safe string (handles taint) | | `ns.CloneValue(value)` | Deep-clone a value | | `ns.Log(module, message, data)` | Log to debug chat and DevTool | +| `ns.ErrorLog(module, message, data?)` | Log targeted errors to chat and DevTool | +| `ns.ErrorLogOnce(module, key, message, data?)` | Log targeted errors once per module/key | | `mod:GetECMModule(name, silent?)` | Get ECM module by name | | `mod:ConfirmReloadUI(text, onAccept?, onCancel?)` | Show confirm popup, reload on accept | | `mod:ShowImportDialog()` | Show import string input dialog | diff --git a/BarMixin.lua b/BarMixin.lua index 2a90c0ae..2789a617 100644 --- a/BarMixin.lua +++ b/BarMixin.lua @@ -123,6 +123,24 @@ end) local FrameProto = {} +--- Returns the effective root anchor for chained modules. +--- When ExtraIcons extends the main viewer with additional icons, the chain +--- should anchor to the combined visual width rather than the Blizzard frame +--- alone so attached modules inherit the widened footprint. +---@return Frame +local function getPrimaryChainAnchor() + local addon = ns.Addon + local extraIcons = addon and addon.GetECMModule and addon:GetECMModule(C.EXTRAICONS, true) + if extraIcons and extraIcons.IsEnabled and extraIcons:IsEnabled() and extraIcons.GetMainViewerAnchor then + local anchor = extraIcons:GetMainViewerAnchor() + if anchor then + return anchor + end + end + + return _G["EssentialCooldownViewer"] or UIParent +end + --- Determine the correct anchor for this specific frame in the fixed order. --- @param frameName string|nil The name of the current frame, or nil if first in chain. --- @param anchorMode string|nil The anchor mode to filter by (defaults to ANCHORMODE_CHAIN). @@ -161,7 +179,7 @@ function FrameProto:GetNextChainAnchor(frameName, anchorMode) return ns.Runtime.DetachedAnchor or UIParent, true end - return _G["EssentialCooldownViewer"] or UIParent, true + return getPrimaryChainAnchor(), true end function FrameProto:SetHidden(hide) @@ -788,7 +806,7 @@ function BarMixin.AssertValid(target) end --- Applies frame-only mixin (positioning, visibility, edit mode, config access). ---- Used by modules that manage their own inner content (e.g. BuffBars, ItemIcons). +--- Used by modules that manage their own inner content (e.g. BuffBars, ExtraIcons). --- Idempotent — safe to call more than once (no-op after first application). --- @param target table table to apply the mixin to. --- @param name string the module name. must be unique. diff --git a/BarStyle.lua b/BarStyle.lua new file mode 100644 index 00000000..4c992582 --- /dev/null +++ b/BarStyle.lua @@ -0,0 +1,295 @@ +-- Enhanced Cooldown Manager addon for World of Warcraft +-- Author: Argium +-- Licensed under the GNU General Public License v3.0 + +local _, ns = ... +local FrameUtil = ns.FrameUtil + +-------------------------------------------------------------------------------- +-- Shared child-bar styling helpers (BuffBars / ExternalBars) +-- +-- Stateless functions that operate on already-constructed bar widgets. +-- Not a mixin — callers invoke these directly as `BarStyle.StyleChildBar(...)`. +-------------------------------------------------------------------------------- + +--- Strips circular masks and hides overlay/border to produce a square icon. +--- The heavy cleanup (mask removal, pcalls, region iteration) is cached on the +--- frame via `__ecmSquareStyled` so it only runs once per icon frame. +---@param iconFrame Frame|nil +---@param iconTexture Texture|nil +---@param iconOverlay Texture|nil +---@param debuffBorder Texture|nil +local function applySquareIconStyle(iconFrame, iconTexture, iconOverlay, debuffBorder) + if not iconFrame or iconFrame.__ecmSquareStyled or not iconTexture then + return + end + + iconTexture:SetTexCoord(0, 1, 0, 1) + + -- Remove circular masks from the icon texture + if iconTexture.GetNumMaskTextures and iconTexture.RemoveMaskTexture and iconTexture.GetMaskTexture then + for i = (iconTexture:GetNumMaskTextures() or 0), 1, -1 do + local mask = iconTexture:GetMaskTexture(i) + if mask then + iconTexture:RemoveMaskTexture(mask) + if mask.Hide then mask:Hide() end + end + end + elseif iconTexture.SetMask then + pcall(iconTexture.SetMask, iconTexture, nil) + end + + -- Remove mask regions from the icon frame + if iconFrame.GetRegions and iconTexture.RemoveMaskTexture then + for _, region in ipairs({ iconFrame:GetRegions() }) do + if region and region.IsObjectType and region:IsObjectType("MaskTexture") then + pcall(iconTexture.RemoveMaskTexture, iconTexture, region) + if region.Hide then region:Hide() end + end + end + end + + if iconOverlay then iconOverlay:Hide() end + if debuffBorder then debuffBorder:Hide() end + + iconFrame.__ecmSquareStyled = true +end + +---@param frame Frame +---@param bar StatusBar +---@param iconFrame Frame|nil +---@param config table|nil +---@param globalConfig table|nil +local function styleBarHeight(frame, bar, iconFrame, config, globalConfig) + assert(frame ~= nil, "BarStyle.styleBarHeight requires a frame") + assert(bar ~= nil, "BarStyle.styleBarHeight requires a bar") + + local height = (config and config.height) or (globalConfig and globalConfig.barHeight) + assert(type(height) == "number", "BarStyle.styleBarHeight requires config.height or globalConfig.barHeight") + if height <= 0 then + return + end + FrameUtil.LazySetHeight(frame, height) + FrameUtil.LazySetHeight(bar, height) + if iconFrame then + FrameUtil.LazySetHeight(iconFrame, height) + FrameUtil.LazySetWidth(iconFrame, height) + end +end + +---@param frame Frame +---@param barBG Texture|nil +---@param config table|nil +---@param globalConfig table|nil +local function styleBarBackground(frame, barBG, config, globalConfig) + assert(frame ~= nil, "BarStyle.styleBarBackground requires a frame") + + if not barBG then + return + end + + -- One-time setup: reparent BarBG to the outer frame and hook SetPoint + -- so Blizzard cannot override our anchors. SetAllPoints does not fire + -- SetPoint hooks, so no re-entrancy guard is needed. + if not barBG.__ecmBGHooked then + barBG.__ecmBGHooked = true + barBG:SetParent(frame) + hooksecurefunc(barBG, "SetPoint", function() + barBG:ClearAllPoints() + barBG:SetAllPoints(frame) + end) + end + + local bgColor = (config and config.bgColor) + or (globalConfig and globalConfig.barBgColor) + assert(bgColor ~= nil, "BarStyle.styleBarBackground requires config.bgColor or globalConfig.barBgColor") + barBG:SetTexture(ns.Constants.FALLBACK_TEXTURE) + barBG:SetVertexColor(bgColor.r, bgColor.g, bgColor.b, bgColor.a) + barBG:ClearAllPoints() + barBG:SetAllPoints(frame) + barBG:SetDrawLayer("BACKGROUND", 0) +end + +--- Resolves the spell color for a bar, handling secret values with retry. +--- Returns true if the module's _editLocked flag was set by this call. +---@param module table +---@param frame ECM_BuffBarMixin|Frame +---@param bar StatusBar +---@param globalConfig table|nil +---@param spellColors ECM_SpellColorStore +---@param retryCount number|nil +---@return boolean|nil +local function styleBarColor(module, frame, bar, globalConfig, spellColors, retryCount) + assert(module ~= nil, "BarStyle.styleBarColor requires a module") + assert(type(module.Name) == "string" and module.Name ~= "", "BarStyle.styleBarColor requires module.Name") + assert(frame ~= nil, "BarStyle.styleBarColor requires a frame") + assert(bar ~= nil, "BarStyle.styleBarColor requires a bar") + assert(spellColors ~= nil, "BarStyle.styleBarColor requires an explicit spellColors store") + + local currentRetryCount = retryCount or 0 + local textureName = globalConfig and globalConfig.texture + FrameUtil.LazySetStatusBarTexture(bar, FrameUtil.GetTexture(textureName)) + + local barColor = spellColors:GetColorForBar(frame) + local spellName = bar.Name and bar.Name.GetText and bar.Name:GetText() + local spellID = frame.cooldownInfo and frame.cooldownInfo.spellID + local cooldownID = frame.cooldownID + local textureFileID = FrameUtil.GetIconTextureFileID(frame) + + -- When in a raid instance, and after exiting combat, all identifying + -- values may remain secret. Lock editing only when every key is unusable. + -- With four tiers (name, spellID, cooldownID, texture) the colour lookup + -- is much more resilient to partial secrecy. + local allSecret = issecretvalue(spellName) + and issecretvalue(spellID) + and issecretvalue(cooldownID) + and issecretvalue(textureFileID) + module._editLocked = module._editLocked or allSecret + + if allSecret and not InCombatLockdown() then + if currentRetryCount < 3 then + if frame._ecmColorRetryTimer then + frame._ecmColorRetryTimer:Cancel() + end + frame._ecmColorRetryTimer = C_Timer.NewTimer(1, function() + frame._ecmColorRetryTimer = nil + styleBarColor(module, frame, bar, globalConfig, spellColors, currentRetryCount + 1) + end) + -- Don't apply any colour while retries are pending — preserve + -- the bar's existing colour rather than clobbering it with the + -- default while we wait for secrets to clear. + return nil + elseif ns.IsDebugEnabled() and not module._warned then + ns.Log(module.Name, "All identifying keys are secret outside of combat.") + module._warned = true + end + end + + if frame._ecmColorRetryTimer then + frame._ecmColorRetryTimer:Cancel() + frame._ecmColorRetryTimer = nil + end + + if barColor == nil and not allSecret then + barColor = spellColors:GetDefaultColor() + end + if barColor then + FrameUtil.LazySetStatusBarColor(bar, barColor.r, barColor.g, barColor.b, 1.0) + end + + return module._editLocked +end + +---@param frame Frame +---@param iconFrame Frame|nil +---@param config table|nil +local function styleBarIcon(frame, iconFrame, config) + assert(frame ~= nil, "BarStyle.styleBarIcon requires a frame") + + local showIcon = config and config.showIcon ~= false + + if iconFrame then + FrameUtil.LazySetAnchors(iconFrame, { + { "TOPLEFT", frame, "TOPLEFT", 0, 0 }, + }) + local iconTexture = FrameUtil.GetIconTexture(frame) + local iconOverlay = FrameUtil.GetIconOverlay(frame) + applySquareIconStyle(iconFrame, iconTexture, iconOverlay, frame.DebuffBorder) + iconFrame:SetShown(showIcon) + if iconTexture then + iconTexture:SetShown(showIcon) + end + end + + if frame.DebuffBorder then + FrameUtil.LazySetAlpha(frame.DebuffBorder, 0) + frame.DebuffBorder:Hide() + end + if iconFrame and iconFrame.Applications then + FrameUtil.LazySetAlpha(iconFrame.Applications, showIcon and 1 or 0) + end +end + +---@param frame Frame +---@param bar StatusBar +---@param iconFrame Frame|nil +---@param config table|nil +local function styleBarAnchors(frame, bar, iconFrame, config) + assert(frame ~= nil, "BarStyle.styleBarAnchors requires a frame") + assert(bar ~= nil, "BarStyle.styleBarAnchors requires a bar") + assert(bar.Name ~= nil, "BarStyle.styleBarAnchors requires bar.Name") + + local showSpellName = config and config.showSpellName ~= false + local showDuration = config and config.showDuration ~= false + if bar.Name then + bar.Name:SetShown(showSpellName) + end + if bar.Duration then + bar.Duration:SetShown(showDuration) + end + + local iconVisible = iconFrame and iconFrame:IsShown() + local barLeftAnchor = iconVisible and iconFrame or frame + local barLeftPoint = iconVisible and "TOPRIGHT" or "TOPLEFT" + FrameUtil.LazySetAnchors(bar, { + { "TOPLEFT", barLeftAnchor, barLeftPoint, 0, 0 }, + { "TOPRIGHT", frame, "TOPRIGHT", 0, 0 }, + }) + + FrameUtil.LazySetAnchors(bar.Name, { + { "LEFT", bar, "LEFT", ns.Constants.BUFFBARS_TEXT_PADDING, 0 }, + { "RIGHT", bar, "RIGHT", -ns.Constants.BUFFBARS_TEXT_PADDING, 0 }, + }) + + if bar.Duration then + FrameUtil.LazySetAnchors(bar.Duration, { + { "RIGHT", bar, "RIGHT", -ns.Constants.BUFFBARS_TEXT_PADDING, 0 }, + }) + end +end + +--- Applies all sizing, styling, visibility, and anchoring to a single child bar. +--- Lazy setters ensure no-ops when values haven't changed. +---@param module table +---@param frame ECM_BuffBarMixin|Frame +---@param config table|nil +---@param globalConfig table|nil +---@param spellColors ECM_SpellColorStore +local function styleChildBar(module, frame, config, globalConfig, spellColors) + assert(module ~= nil, "BarStyle.styleChildBar requires a module") + assert(frame ~= nil, "BarStyle.styleChildBar requires a frame") + assert(frame.__ecmHooked, "Attempted to style a child frame that wasn't hooked.") + assert(spellColors ~= nil, "BarStyle.styleChildBar requires an explicit spellColors store") + + local bar = assert(frame.Bar, "BarStyle.styleChildBar requires frame.Bar") + local iconFrame = frame.Icon + assert(bar.Pip ~= nil, "BarStyle.styleChildBar requires bar.Pip") + assert(bar.Name ~= nil, "BarStyle.styleChildBar requires bar.Name") + assert(bar.Duration ~= nil, "BarStyle.styleChildBar requires bar.Duration") + + styleBarHeight(frame, bar, iconFrame, config, globalConfig) + + bar.Pip:Hide() + bar.Pip:SetTexture(nil) + + styleBarBackground(frame, FrameUtil.GetBarBackground(bar), config, globalConfig) + styleBarColor(module, frame, bar, globalConfig, spellColors, 0) + + FrameUtil.ApplyFont(bar.Name, globalConfig, config) + FrameUtil.ApplyFont(bar.Duration, globalConfig, config) + + styleBarIcon(frame, iconFrame, config) + styleBarAnchors(frame, bar, iconFrame, config) +end + +local BarStyle = { + ApplySquareIconStyle = applySquareIconStyle, + StyleBarHeight = styleBarHeight, + StyleBarBackground = styleBarBackground, + StyleBarColor = styleBarColor, + StyleBarIcon = styleBarIcon, + StyleBarAnchors = styleBarAnchors, + StyleChildBar = styleChildBar, +} + +ns.BarStyle = BarStyle diff --git a/Constants.lua b/Constants.lua index 36aa7091..25890461 100644 --- a/Constants.lua +++ b/Constants.lua @@ -9,12 +9,14 @@ local constants = { ADDON_ICON_TEXTURE = "Interface\\AddOns\\EnhancedCooldownManager\\Media\\icon", ADDON_METADATA_VERSION_KEY = "Version", DEBUG_COLOR = "F17934", - RELEASE_POPUP_VERSION = "v0.7.1", + ERROR_COLOR = "ff4040", + RELEASE_POPUP_VERSION = "v0.8", VERSION_TAG_BETA = "beta", -- Module identifiers BUFFBARS = "BuffBars", - ITEMICONS = "ItemIcons", + EXTERNALBARS = "ExternalBars", + EXTRAICONS = "ExtraIcons", POWERBAR = "PowerBar", RESOURCEBAR = "ResourceBar", RUNEBAR = "RuneBar", @@ -27,6 +29,8 @@ local constants = { EDIT_MODE_DEFAULT_POINT = "CENTER", GROW_DIRECTION_DOWN = "down", GROW_DIRECTION_UP = "up", + SCOPE_BUFFBARS = "buffBars", + SCOPE_EXTERNALBARS = "externalBars", -- Shared visuals and defaults COLOR_BLACK = { r = 0, g = 0, b = 0, a = 1 }, @@ -100,10 +104,10 @@ local constants = { SHAMAN_ENHANCEMENT_SPEC_INDEX = 2, SHAMAN_RESTORATION_SPEC_INDEX = 3, - -- Item icons - DEFAULT_ITEM_ICON_SIZE = 32, - ITEM_ICON_BORDER_SCALE = 1.35, - ITEM_ICONS_MAX = 5, + -- Extra icons + DEFAULT_EXTRA_ICON_SIZE = 32, + EXTRA_ICON_MAIN_BORDER_SCALE = 1.35, + EXTRA_ICON_UTILITY_BORDER_SCALE = 1.4, -- Consumables and equipment slots COMBAT_POTIONS = { @@ -123,12 +127,10 @@ local constants = { { itemID = 224464 }, -- Demonic Healthstone { itemID = 5512 }, -- Healthstone }, - TRINKET_SLOT_1 = 13, - TRINKET_SLOT_2 = 14, -- Saved variables and migration ACTIVE_SV_KEY = "_ECM_DB", - CURRENT_SCHEMA_VERSION = 11, + CURRENT_SCHEMA_VERSION = 12, SV_NAME = "EnhancedCooldownManagerDB", -- Import and export @@ -138,6 +140,8 @@ local constants = { -- Runtime timing and debug limits LAYOUT_COMBAT_END_DELAY = 0.1, LAYOUT_ENTERING_WORLD_DELAY = 0.4, + LAYOUT_STORM_COUNT = 20, + LAYOUT_STORM_WINDOW = 2, LAYOUT_ZONE_CHANGE_DELAY = 0.1, LIFECYCLE_SECOND_PASS_DELAY = 0.05, TOSTRING_MAX_DEPTH = 3, @@ -176,12 +180,6 @@ local constants = { -- UI dimension constants POSITION_MODE_EXPLAINER_HEIGHT = 150, - SCROLL_ROW_HEIGHT_COMPACT = 26, - SCROLL_ROW_HEIGHT_WITH_CONTROLS = 34, - SPELL_COLORS_SCROLL_BOTTOM_OFFSET_WITH_SECRET_NAMES = 80, - SPELL_COLORS_SECRET_NAMES_BUTTON_BOTTOM_OFFSET = 8, - SPELL_COLORS_SECRET_NAMES_DESC_BOTTOM_OFFSET = 42, - SPELL_COLORS_SECRET_NAMES_DESC_HEIGHT = 40, VALUE_SLIDER_TIERS = { { ceiling = 200, step = 1 }, @@ -194,6 +192,49 @@ local constants = { }, } +--- Predefined icon stacks resolved at runtime by stackKey. +--- Each entry defines an icon kind and its candidate sources. +local BUILTIN_STACKS = { + trinket1 = { kind = "equipSlot", slotId = 13, label = "Trinket 1" }, + trinket2 = { kind = "equipSlot", slotId = 14, label = "Trinket 2" }, + combatPotions = { kind = "item", ids = constants.COMBAT_POTIONS, label = "Combat Potions" }, + healthPotions = { kind = "item", ids = constants.HEALTH_POTIONS, label = "Health Potions" }, + healthstones = { kind = "item", ids = constants.HEALTHSTONES, label = "Healthstones" }, +} + +--- Default display order for builtin stack keys (matches default viewers.utility order). +local BUILTIN_STACK_ORDER = { "trinket1", "trinket2", "combatPotions", "healthPotions", "healthstones" } + +--- Racial ability lookup keyed by UnitRace("player") raceFileName. +--- One primary active racial per race. +local RACIAL_ABILITIES = { + Human = { spellId = 59752 }, -- Every Man for Himself + Orc = { spellId = 33697 }, -- Blood Fury + Dwarf = { spellId = 20594 }, -- Stoneform + NightElf = { spellId = 58984 }, -- Shadowmeld + Scourge = { spellId = 7744 }, -- Will of the Forsaken + Tauren = { spellId = 20549 }, -- War Stomp + Gnome = { spellId = 20589 }, -- Escape Artist + Troll = { spellId = 26297 }, -- Berserking + Goblin = { spellId = 69070 }, -- Rocket Barrage + BloodElf = { spellId = 28730 }, -- Arcane Torrent + Draenei = { spellId = 59542 }, -- Gift of the Naaru + Worgen = { spellId = 68992 }, -- Darkflight + Pandaren = { spellId = 107079 }, -- Quaking Palm + Nightborne = { spellId = 260364 }, -- Arcane Pulse + HighmountainTauren = { spellId = 255654 }, -- Bull Rush + VoidElf = { spellId = 256948 }, -- Spatial Rift + LightforgedDraenei = { spellId = 255647 }, -- Light's Judgment + ZandalariTroll = { spellId = 291944 }, -- Regeneratin' + KulTiran = { spellId = 287712 }, -- Haymaker + DarkIronDwarf = { spellId = 265221 }, -- Fireblood + Vulpera = { spellId = 312411 }, -- Bag of Tricks + MagharOrc = { spellId = 274738 }, -- Ancestral Call + Mechagnome = { spellId = 312924 }, -- Hyper Organic Light Originator + Dracthyr = { spellIds = { 357214, 368970 } }, -- Tail Swipe + EarthenDwarf = { spellId = 436717 }, -- Azerite Surge +} + local BLIZZARD_FRAMES = { "EssentialCooldownViewer", "UtilityCooldownViewer", @@ -235,8 +276,9 @@ local moduleConfigKeys = { [constants.POWERBAR] = "powerBar", [constants.RESOURCEBAR] = "resourceBar", [constants.RUNEBAR] = "runeBar", - [constants.BUFFBARS] = "buffBars", - [constants.ITEMICONS] = "itemIcons", + [constants.BUFFBARS] = constants.SCOPE_BUFFBARS, + [constants.EXTERNALBARS] = constants.SCOPE_EXTERNALBARS, + [constants.EXTRAICONS] = "extraIcons", } --- Returns the profile config key for a module name. @@ -245,11 +287,27 @@ function constants.ConfigKeyForModule(name) return moduleConfigKeys[name] or (name:sub(1, 1):lower() .. name:sub(2)) end -local chainOrder = { constants.POWERBAR, constants.RESOURCEBAR, constants.RUNEBAR, constants.BUFFBARS } +local chainOrder = { + constants.POWERBAR, + constants.RESOURCEBAR, + constants.RUNEBAR, + constants.BUFFBARS, + constants.EXTERNALBARS, +} constants.CHAIN_ORDER = chainOrder -constants.MODULE_ORDER = { constants.POWERBAR, constants.RESOURCEBAR, constants.RUNEBAR, constants.BUFFBARS, constants.ITEMICONS } +constants.MODULE_ORDER = { + constants.POWERBAR, + constants.RESOURCEBAR, + constants.RUNEBAR, + constants.BUFFBARS, + constants.EXTERNALBARS, + constants.EXTRAICONS, +} constants.MODULE_CONFIG_KEYS = moduleConfigKeys constants.BLIZZARD_FRAMES = BLIZZARD_FRAMES +constants.BUILTIN_STACKS = BUILTIN_STACKS +constants.BUILTIN_STACK_ORDER = BUILTIN_STACK_ORDER +constants.RACIAL_ABILITIES = RACIAL_ABILITIES constants.RESOURCEBAR_CASTABLE_MAX_COLOR_SPELLS = resourceBarCastableMaxColorSpells constants.CLASS_COLORS = CLASS_COLORS constants.RESOURCEBAR_MAX_COLOR_TYPES = resourceBarMaxColorTypes diff --git a/Defaults.lua b/Defaults.lua index 19549d70..4394d7a2 100644 --- a/Defaults.lua +++ b/Defaults.lua @@ -15,6 +15,10 @@ local _, ns = ... ---@field x number X offset from anchor. ---@field y number Y offset from anchor. +---@alias ns.Constants.ANCHORMODE_CHAIN "chain" +---@alias ns.Constants.ANCHORMODE_DETACHED "detached" +---@alias ns.Constants.ANCHORMODE_FREE "free" + ---@class ECM_BarConfigBase Shared bar layout configuration. ---@field enabled boolean Whether the bar is enabled. ---@field editModePositions table|nil Per-layout positions saved via Edit Mode. @@ -51,6 +55,7 @@ local _, ns = ... ---@class ECM_GlobalConfig Global configuration. ---@field debug boolean Whether debug logging is enabled. +---@field errorLogging boolean Whether targeted error logging is enabled. ---@field hideWhenMounted boolean Whether to hide when mounted or in a vehicle. ---@field hideOutOfCombatInRestAreas boolean Whether to hide out of combat in rest areas. ---@field updateFrequency number Update frequency in seconds. @@ -85,7 +90,7 @@ local _, ns = ... ---@field byCooldownID table>> Per-cooldownID colors by class/spec/cooldownID. ---@field byTexture table>> Per-texture colors by class/spec/textureId. ---@field cache table>> Cached bar metadata by class/spec/index. ----@field defaultColor ECM_Color Default color for buff bars. +---@field defaultColor ECM_Color Default color when no per-spell override applies. ---@class ECM_BuffBarsConfig Buff bars configuration. ---@field enabled boolean Whether buff bars are enabled. @@ -99,13 +104,37 @@ local _, ns = ... ---@field fontSize number|nil Font size override for aura bar text. ---@field colors ECM_SpellColorsConfig Per-spell color settings. ----@class ECM_ItemIconsConfig Item icons configuration. ----@field enabled boolean Whether item icons are enabled. ----@field showTrinket1 boolean Whether to show trinket slot 1 (if on-use). ----@field showTrinket2 boolean Whether to show trinket slot 2 (if on-use). ----@field showCombatPotion boolean Whether to show combat potions. ----@field showHealthPotion boolean Whether to show health potions. ----@field showHealthstone boolean Whether to show healthstone. +---@class ECM_ExternalBarsConfig External cooldown bars configuration. +---@field enabled boolean Whether external cooldown bars are enabled. +---@field hideOriginalIcons boolean Whether Blizzard's original external cooldown icons are hidden. +---@field anchorMode ns.Constants.ANCHORMODE_CHAIN|ns.Constants.ANCHORMODE_DETACHED|ns.Constants.ANCHORMODE_FREE|nil Anchor behavior for external cooldown bars. +---@field editModePositions table|nil Per-layout positions saved via Edit Mode. +---@field width number|nil Bar width override. +---@field height number|nil Bar height override. +---@field verticalSpacing number|nil Vertical gap between bars (pixels). +---@field showIcon boolean|nil Whether to show external cooldown icons. +---@field showSpellName boolean|nil Whether to show spell names. +---@field showDuration boolean|nil Whether to show durations. +---@field overrideFont boolean|nil Whether external cooldown bars override global font settings. +---@field font string|nil Font face override for bar text. +---@field fontSize number|nil Font size override for bar text. +---@field colors ECM_SpellColorsConfig Per-spell color settings. + +---@class ECM_ExtraIconEntry +---@field stackKey string|nil Built-in stack key resolved via `BUILTIN_STACKS`. +---@field kind string|nil Entry kind for custom or racial rows. +---@field ids table|nil Entry spell/item priority list. +---@field slotId number|nil Slot ID for equip-slot entries. +---@field disabled boolean|nil When true, the entry stays in settings but is skipped at runtime. + +---@class ECM_ExtraIconsConfig Extra icons configuration. +---@field enabled boolean Whether extra icons are enabled. +---@field showStackCount boolean Whether to show item stack counts. +---@field showCharges boolean Whether to show spell charges. +---@field overrideFont boolean|nil Whether stack/charge counts override global font settings. +---@field font string|nil Font face override for stack/charge counts. +---@field fontSize number|nil Font size override for stack/charge counts. +---@field viewers table Per-viewer ordered icon lists. ---@class ECM_TickMark Tick mark definition. ---@field value number Tick mark value. @@ -131,7 +160,8 @@ local _, ns = ... ---@field resourceBar ECM_ResourceBarConfig Resource bar settings. ---@field runeBar ECM_RuneBarConfig Rune bar settings. ---@field buffBars ECM_BuffBarsConfig Buff bars configuration. ----@field itemIcons ECM_ItemIconsConfig Item icons configuration. +---@field externalBars ECM_ExternalBarsConfig External cooldown bars configuration. +---@field extraIcons ECM_ExtraIconsConfig Extra icons configuration. local C = ns.Constants @@ -149,6 +179,7 @@ local defaults = { schemaVersion = C.CURRENT_SCHEMA_VERSION, global = { debug = false, + errorLogging = true, debugToChat = false, releasePopupSeenVersion = "", hideWhenMounted = true, @@ -273,13 +304,42 @@ local defaults = { defaultColor = { r = 228 / 255, g = 233 / 255, b = 235 / 255, a = 1 }, }, }, - itemIcons = { + externalBars = { + enabled = false, + hideOriginalIcons = false, + anchorMode = C.ANCHORMODE_CHAIN, + editModePositions = {}, + width = C.DEFAULT_BAR_WIDTH, + height = 0, + verticalSpacing = 0, + showIcon = true, + showSpellName = true, + showDuration = true, + overrideFont = false, + colors = { + byName = {}, + bySpellID = {}, + byCooldownID = {}, + byTexture = {}, + cache = {}, + defaultColor = { r = 0.40, g = 0.78, b = 0.95, a = 1 }, + }, + }, + extraIcons = { enabled = true, - showTrinket1 = true, - showTrinket2 = true, - showCombatPotion = true, - showHealthPotion = true, - showHealthstone = true, + showStackCount = true, + showCharges = true, + overrideFont = false, + viewers = { + utility = { + { stackKey = "trinket1" }, + { stackKey = "trinket2" }, + { stackKey = "combatPotions" }, + { stackKey = "healthPotions" }, + { stackKey = "healthstones" }, + }, + main = {}, + }, }, }, } diff --git a/ECM.lua b/ECM.lua index 150e298b..2f0f0702 100644 --- a/ECM.lua +++ b/ECM.lua @@ -36,6 +36,12 @@ function ns.IsDebugEnabled() return gc and gc.debug end +--- Returns whether targeted error logging is enabled. +function ns.IsErrorLoggingEnabled() + local gc = ns.GetGlobalConfig() + return not gc or gc.errorLogging ~= false +end + --- Returns whether the player is a Death Knight. function ns.IsDeathKnight() local _, class = UnitClass("player") @@ -43,9 +49,7 @@ function ns.IsDeathKnight() end local function getAddonVersion() - if C_AddOns and type(C_AddOns.GetAddOnMetadata) == "function" then - return C_AddOns.GetAddOnMetadata(ADDON_NAME, C.ADDON_METADATA_VERSION_KEY) - end + return C_AddOns.GetAddOnMetadata(ADDON_NAME, C.ADDON_METADATA_VERSION_KEY) end local function safeStrTostring(x) @@ -120,6 +124,42 @@ ns.Print = LibConsole:NewPrinter(function(message) print(ns.ColorUtil.Sparkle(L["ADDON_ABRV"] .. ":") .. " " .. message) end) +function ns.ErrorLog(module, message, data) + if not ns.IsErrorLoggingEnabled() then + return + end + + local messageStr = ns.ToString(message) + local coloredPrefix = "|cff" .. C.ERROR_COLOR .. "[" .. L["ADDON_ABRV"] .. " Error" + .. (module and (" " .. module) or "") .. "]|r " + + print(coloredPrefix .. messageStr) + + if DevTool and DevTool.AddData then + pcall(DevTool.AddData, DevTool, { + module = module or "nil", + message = messageStr, + timestamp = GetTime(), + data = data and ns.ToString(data), + }, coloredPrefix .. messageStr) + end +end + +function ns.ErrorLogOnce(module, key, message, data) + if not ns.IsErrorLoggingEnabled() then + return + end + + mod._errorLogOnceKeys = mod._errorLogOnceKeys or {} + local onceKey = (module or "nil") .. ":" .. ns.ToString(key) + if mod._errorLogOnceKeys[onceKey] then + return + end + + mod._errorLogOnceKeys[onceKey] = true + ns.ErrorLog(module, message, data) +end + function ns.Log(module, message, data) if not ns.IsDebugEnabled() then return @@ -143,6 +183,45 @@ function ns.Log(module, message, data) end end +local function getSecureVariableStatus(owner, key) + local ok, secure, taint + if key == nil then + ok, secure, taint = pcall(_G.issecurevariable, owner) + else + ok, secure, taint = pcall(_G.issecurevariable, owner, key) + end + if not ok then + return nil + end + return secure, taint +end + +function ns._CheckChatTaint(reason) + local secure, taint = getSecureVariableStatus("ChatFrameUtil") + if secure == false then + ns.ErrorLogOnce("Taint", "ChatFrameUtil", "ChatFrameUtil is tainted", { + reason = reason, + source = taint or "unknown", + }) + end + + secure, taint = getSecureVariableStatus(_G.ChatFrameUtil, "SetLastTellTarget") + if secure == false then + ns.ErrorLogOnce("Taint", "ChatFrameUtil.SetLastTellTarget", "ChatFrameUtil.SetLastTellTarget is tainted", { + reason = reason, + source = taint or "unknown", + }) + end + + secure, taint = getSecureVariableStatus(_G.ChatFrameMixin, "MessageEventHandler") + if secure == false then + ns.ErrorLogOnce("Taint", "ChatFrameMixin.MessageEventHandler", "ChatFrameMixin.MessageEventHandler is tainted", { + reason = reason, + source = taint or "unknown", + }) + end +end + --- Shows a confirmation popup and reloads the UI on accept. --- ReloadUI is blocked in combat. ---@param text string @@ -186,12 +265,15 @@ end function mod:ChatCommand(input) local cmd, arg = (input or ""):lower():match("^%s*(%S*)%s*(.-)%s*$") - if cmd == "help" then + if cmd == "help" or cmd == "h" then ns.Print(L["CMD_HELP_CLEARSEEN"]) ns.Print(L["CMD_HELP_DEBUG"]) ns.Print(L["CMD_HELP_EVENTS"]) + ns.Print(L["CMD_HELP_EVENTS_RESET"]) ns.Print(L["CMD_HELP_HELP"]) ns.Print(L["CMD_HELP_MIGRATION"]) + ns.Print(L["CMD_HELP_MIGRATION_LOG"]) + ns.Print(L["CMD_HELP_MIGRATION_ROLLBACK"]) ns.Print(L["CMD_HELP_OPTIONS"]) ns.Print(L["CMD_HELP_REFRESH"]) return @@ -253,6 +335,7 @@ function mod:ChatCommand(input) local optionsModule = self:GetModule("Options", true) if optionsModule then optionsModule:OpenOptions() + ns._CheckChatTaint("ChatCommand:options") end return end @@ -287,6 +370,11 @@ function mod:ChatCommand(input) if cmd == "clearseen" then gc.releasePopupSeenVersion = nil ns.Print(L["SEEN_CLEARED"]) + if InCombatLockdown() then + ns.Print(L["RELOAD_BLOCKED_COMBAT"]) + return + end + ReloadUI() return end end @@ -368,7 +456,7 @@ function mod:OnInitialize() ns.Migration.FlushLog() - -- Register bundled font with LibSharedMedia if present. + -- Register bundled fonts with LibSharedMedia if LSM then pcall( LSM.Register, @@ -377,6 +465,13 @@ function mod:OnInitialize() "Expressway", "Interface\\AddOns\\EnhancedCooldownManager\\media\\Fonts\\Expressway.ttf" ) + pcall( + LSM.Register, + LSM, + "font", + "Cabin", + "Interface\\AddOns\\EnhancedCooldownManager\\media\\Fonts\\Cabin.ttf" + ) end local chatHandler = function(input) mod:ChatCommand(input) end @@ -417,6 +512,7 @@ function mod:OnEnable() end self:ShowReleasePopup() + ns._CheckChatTaint("OnEnable") end --- Re-evaluates module enable/disable states after a profile change and refreshes layout. diff --git a/EnhancedCooldownManager.toc b/EnhancedCooldownManager.toc index 423298c2..36832c0c 100644 --- a/EnhancedCooldownManager.toc +++ b/EnhancedCooldownManager.toc @@ -1,12 +1,13 @@ -## Interface: 120000, 120001, 120005, 110207 +## Interface: 120000, 120001, 120005 ## Title: Enhanced Cooldown Manager |cff9c9c9cby|r |cffa855f7A|r|cff7a84f7r|r|cff4cc9f0g|r|cff22c55ei|r -## Notes: Standalone resource bars anchored to Blizzard's Cooldown Manager. +## Notes: Add resource, power and buff bars, new spells and item icons to the built-in Cooldown Manager. ## Author: Argi -## Version: v0.7.8 +## Version: v0.8.0 ## SavedVariables: EnhancedCooldownManagerDB -## OptionalDeps: Ace3, LibSharedMedia-3.0 ## Category-enUS: User Interface ## IconTexture: Interface\AddOns\EnhancedCooldownManager\Media\icon +## X-Curse-Project-ID: 1427906 +## X-WoWI-ID: 27051 ## X-Wago-ID: qGZOwbNd Libs\LibStub\LibStub.lua @@ -20,7 +21,7 @@ Libs\LibSerialize\lib.xml Libs\LibDeflate\lib.xml Libs\LibEditMode\embed.xml Libs\LibSharedMedia-3.0\lib.xml -Libs\LibSettingsBuilder\LibSettingsBuilder.lua +Libs\LibSettingsBuilder\embed.xml Libs\LibLSMSettingsWidgets\LibLSMSettingsWidgets.xml Constants.lua @@ -35,25 +36,31 @@ Migration.lua ImportExport.lua BarMixin.lua +BarStyle.lua ECM.lua Runtime.lua UI\Dialogs.lua Modules\BuffBars.lua +Modules\ExternalBars.lua Modules\PowerBar.lua Modules\ResourceBar.lua Modules\RuneBar.lua -Modules\ItemIcons.lua +Modules\ExtraIcons.lua UI\OptionUtil.lua +UI\AboutOptions.lua UI\Options.lua UI\PowerBarTickMarksOptions.lua UI\GeneralOptions.lua +UI\AdvancedOptions.lua UI\LayoutOptions.lua UI\PowerBarOptions.lua UI\ResourceBarOptions.lua UI\RuneBarOptions.lua UI\ProfileOptions.lua -UI\ItemIconsOptions.lua +UI\ExtraIconsOptions.lua +UI\SpellColorsPage.lua UI\BuffBarsOptions.lua +UI\ExternalBarsOptions.lua diff --git a/ImportExport.lua b/ImportExport.lua index 4a789df9..e9d2fb1f 100644 --- a/ImportExport.lua +++ b/ImportExport.lua @@ -133,7 +133,7 @@ end function ImportExport.ExportCurrentProfile() local db = ns.Addon.db if not db or not db.profile then - return nil, L["IMPORT_NO_PROFILE"] + return nil, L["EXPORT_NO_PROFILE"] end local exportData = prepareProfileForExport(db.profile) @@ -166,12 +166,12 @@ end ---@return string|nil errorMessage Error message if apply failed function ImportExport.ApplyImportData(data) if not data or not data.profile then - return false, L["IMPORT_INVALID_DATA"] + return false, L["IMPORT_NO_PROFILE_DATA"] end local db = ns.Addon.db if not db or not db.profile then - return false, L["IMPORT_NO_ACTIVE_PROFILE"] + return false, L["IMPORT_NO_PROFILE"] end -- Preserve the cache if it exists (deep copy to avoid shared references) diff --git a/Libs/LibEvent/LibEvent.lua b/Libs/LibEvent/LibEvent.lua index 0d4d1738..0c804c35 100644 --- a/Libs/LibEvent/LibEvent.lua +++ b/Libs/LibEvent/LibEvent.lua @@ -3,7 +3,7 @@ -- Licensed under the GNU General Public License v3.0 ---@class LibEvent ----@field embeds table, _stats: table }> Stores embedded event instances by target table. +---@field embeds table, _stats: table|nil }> Stores embedded event instances by target table. local MAJOR, MINOR = "LibEvent-1.0", 3 local LibEvent = LibStub:NewLibrary(MAJOR, MINOR) @@ -17,6 +17,9 @@ local pairs = pairs local type = type local wipe = wipe +local METRICS_DEBUG_ENABLED = false +local EMPTY_STATS = {} + LibEvent.embeds = LibEvent.embeds or {} local function getInstance(target) @@ -89,27 +92,30 @@ function LibEvent:UnregisterAllEvents() end end ----Gets the event invocation stats for this embedded target. ----@return table A table mapping event names to their fire counts. +---Gets event invocation stats when metrics are enabled. +---@return table A table mapping event names to their fire counts, or an empty table when metrics are disabled. function LibEvent:GetEventStats() - return getInstance(self)._stats + return getInstance(self)._stats or EMPTY_STATS end ----Resets the event invocation stats for this embedded target. +---Resets event invocation stats when metrics are enabled. function LibEvent:ResetEventStats() - wipe(getInstance(self)._stats) + local stats = getInstance(self)._stats + if stats then wipe(stats) end end local function createInstance(target) local instance = LibEvent.embeds[target] if type(instance) ~= "table" then - instance = { _events = {}, _stats = {} } - else - -- Preserve existing events and stats on re-embed (library upgrade) - instance._events = instance._events or {} - instance._stats = instance._stats or {} + instance = { _events = {} } + end + + if METRICS_DEBUG_ENABLED and not instance._stats then + instance._stats = {} end + instance._events = instance._events or {} + instance.frame = instance.frame or CreateFrame("Frame") -- Dispatch without snapshot: use index-based iteration that tolerates @@ -120,7 +126,9 @@ local function createInstance(target) if not cbs then return end - instance._stats[event] = (instance._stats[event] or 0) + 1 + if METRICS_DEBUG_ENABLED then + instance._stats[event] = (instance._stats[event] or 0) + 1 + end instance._dispatching = true local i = 1 while i <= #cbs do diff --git a/Libs/LibEvent/README.md b/Libs/LibEvent/README.md index ef71e1e9..07185eb2 100644 --- a/Libs/LibEvent/README.md +++ b/Libs/LibEvent/README.md @@ -9,7 +9,7 @@ Distributed via [LibStub](https://www.wowace.com/projects/libstub). - Embed into any table to give it event registration capabilities. - Zero-allocation dispatch loop — no snapshot copies per fire. - Idempotent embedding — safe to re-embed on library upgrades. -- Per-event callback stats via `GetEventStats` / `ResetEventStats`. +- Metrics hooks via `GetEventStats` / `ResetEventStats`; metrics are disabled by default, so these APIs return no counts in normal runtime. ## Quick start @@ -33,8 +33,8 @@ end) | `UnregisterEvent(event, callback)` | Remove a specific callback. | | `UnregisterAllEvents()` | Remove all callbacks and unregister the hidden frame. | | `Fire(event, ...)` | Manually fire an event on the target. | -| `GetEventStats()` | Returns a table of event → fire-count. | -| `ResetEventStats()` | Clears all accumulated stats. | +| `GetEventStats()` | Returns event fire counts when metrics are enabled, otherwise an empty table. | +| `ResetEventStats()` | Clears accumulated stats when metrics are enabled. | ## Testing diff --git a/Libs/LibEvent/Tests/LibEvent_spec.lua b/Libs/LibEvent/Tests/LibEvent_spec.lua index db40d3c6..b2edff1a 100644 --- a/Libs/LibEvent/Tests/LibEvent_spec.lua +++ b/Libs/LibEvent/Tests/LibEvent_spec.lua @@ -351,13 +351,13 @@ describe("LibEvent", function() assert.same({ "stable" }, calls) end) - it("initializes _stats as an empty table", function() + it("does not initialize _stats when metrics debug is disabled", function() local target = {} LibEvent:Embed(target) - assert.same({}, LibEvent.embeds[target]._stats) + assert.is_nil(LibEvent.embeds[target]._stats) end) - it("increments _stats on each event fire", function() + it("does not increment _stats when metrics debug is disabled", function() local target = { TEST_EVENT = function() end } LibEvent:Embed(target) @@ -366,43 +366,11 @@ describe("LibEvent", function() LibEvent.embeds[target].frame.onEvent(nil, "TEST_EVENT") LibEvent.embeds[target].frame.onEvent(nil, "TEST_EVENT") - assert.are.equal(3, LibEvent.embeds[target]._stats.TEST_EVENT) - end) - - it("tracks stats independently per event", function() - local target = { - EVENT_A = function() end, - EVENT_B = function() end, - } - LibEvent:Embed(target) - - target:RegisterEvent("EVENT_A", target.EVENT_A) - target:RegisterEvent("EVENT_B", target.EVENT_B) - LibEvent.embeds[target].frame.onEvent(nil, "EVENT_A") - LibEvent.embeds[target].frame.onEvent(nil, "EVENT_A") - LibEvent.embeds[target].frame.onEvent(nil, "EVENT_B") - - assert.are.equal(2, LibEvent.embeds[target]._stats.EVENT_A) - assert.are.equal(1, LibEvent.embeds[target]._stats.EVENT_B) - end) - - it("tracks stats independently per target", function() - local first = { TEST_EVENT = function() end } - local second = { TEST_EVENT = function() end } - LibEvent:Embed(first) - LibEvent:Embed(second) - - first:RegisterEvent("TEST_EVENT", first.TEST_EVENT) - second:RegisterEvent("TEST_EVENT", second.TEST_EVENT) - LibEvent.embeds[first].frame.onEvent(nil, "TEST_EVENT") - LibEvent.embeds[second].frame.onEvent(nil, "TEST_EVENT") - LibEvent.embeds[second].frame.onEvent(nil, "TEST_EVENT") - - assert.are.equal(1, LibEvent.embeds[first]._stats.TEST_EVENT) - assert.are.equal(2, LibEvent.embeds[second]._stats.TEST_EVENT) + assert.is_nil(LibEvent.embeds[target]._stats) + assert.same({}, target:GetEventStats()) end) - it("GetEventStats returns the target's _stats table", function() + it("GetEventStats returns an empty table when metrics debug is disabled", function() local target = { TEST_EVENT = function() end } LibEvent:Embed(target) @@ -410,11 +378,11 @@ describe("LibEvent", function() LibEvent.embeds[target].frame.onEvent(nil, "TEST_EVENT") local stats = target:GetEventStats() - assert.are.equal(LibEvent.embeds[target]._stats, stats) - assert.are.equal(1, stats.TEST_EVENT) + assert.same({}, stats) + assert.is_nil(stats.TEST_EVENT) end) - it("ResetEventStats clears all counters for the target", function() + it("ResetEventStats is a no-op when metrics debug is disabled", function() local target = { EVENT_A = function() end, EVENT_B = function() end, @@ -431,23 +399,6 @@ describe("LibEvent", function() assert.same({}, target:GetEventStats()) end) - it("ResetEventStats does not affect other targets", function() - local first = { TEST_EVENT = function() end } - local second = { TEST_EVENT = function() end } - LibEvent:Embed(first) - LibEvent:Embed(second) - - first:RegisterEvent("TEST_EVENT", first.TEST_EVENT) - second:RegisterEvent("TEST_EVENT", second.TEST_EVENT) - LibEvent.embeds[first].frame.onEvent(nil, "TEST_EVENT") - LibEvent.embeds[second].frame.onEvent(nil, "TEST_EVENT") - - first:ResetEventStats() - - assert.same({}, first:GetEventStats()) - assert.are.equal(1, second:GetEventStats().TEST_EVENT) - end) - it("does not increment stats when no callbacks are registered for the event", function() local target = {} LibEvent:Embed(target) diff --git a/Libs/LibLSMSettingsWidgets/LibLSMSettingsWidgets.lua b/Libs/LibLSMSettingsWidgets/LibLSMSettingsWidgets.lua index 36018eab..98ee8394 100644 --- a/Libs/LibLSMSettingsWidgets/LibLSMSettingsWidgets.lua +++ b/Libs/LibLSMSettingsWidgets/LibLSMSettingsWidgets.lua @@ -1,7 +1,7 @@ -- LibLSMSettingsWidgets: LibSharedMedia picker widgets for the WoW Settings API. -- Provides font and texture picker templates with live previews. -local MAJOR, MINOR = "LibLSMSettingsWidgets-1.0", 1 +local MAJOR, MINOR = "LibLSMSettingsWidgets-1.0", 2 local lib = LibStub:NewLibrary(MAJOR, MINOR) if not lib then return end @@ -92,7 +92,7 @@ local function initPicker(self, initializer) SettingsListElementMixin.Init(self, initializer) local data = initializer:GetData() or {} - self.setting = initializer:GetSetting() or data.setting + self.setting = data.setting or (initializer.GetSetting and initializer:GetSetting()) or nil if data.name and self.Text then self.Text:SetText(data.name) diff --git a/Libs/LibLSMSettingsWidgets/Tests/LibLSMSettingsWidgets_spec.lua b/Libs/LibLSMSettingsWidgets/Tests/LibLSMSettingsWidgets_spec.lua index 832563ef..a909f1d0 100644 --- a/Libs/LibLSMSettingsWidgets/Tests/LibLSMSettingsWidgets_spec.lua +++ b/Libs/LibLSMSettingsWidgets/Tests/LibLSMSettingsWidgets_spec.lua @@ -88,10 +88,14 @@ describe("LibLSMSettingsWidgets", function() GetValue = function() return "TestFont" end, SetValue = function() end, } + local staleSetting = { + GetValue = function() return "chain" end, + SetValue = function() end, + } local initializer = { GetData = function() return { name = "Test", setting = setting } end, - GetSetting = function() return setting end, + GetSetting = function() return staleSetting end, } local picker = { @@ -120,6 +124,8 @@ describe("LibLSMSettingsWidgets", function() picker.SetEnabled = mixin.SetEnabled mixin.Init(picker, initializer) + assert.are.same(setting, picker.setting) + -- Init should have bridged SetEnabled onto the initializer assert.is_function(initializer.SetEnabled) diff --git a/Libs/LibSettingsBuilder/CompositeControls/Groups.lua b/Libs/LibSettingsBuilder/CompositeControls/Groups.lua new file mode 100644 index 00000000..843eddde --- /dev/null +++ b/Libs/LibSettingsBuilder/CompositeControls/Groups.lua @@ -0,0 +1,161 @@ +-- Enhanced Cooldown Manager addon for World of Warcraft +-- Author: Argium +-- Licensed under the GNU General Public License v3.0 + +local MAJOR = "LibSettingsBuilder-1.0" +local lib = LibStub(MAJOR, true) +if not lib or not lib._loadState or not lib._loadState.open then + return +end + +local internal = lib._internal + +function lib.HeightOverrideSlider(self, sectionPath, spec) + spec = spec or {} + local childSpec = { + path = sectionPath .. ".height", + name = spec.name or "Height Override", + tooltip = spec.tooltip or "Override the default bar height. Set to 0 to use the global default.", + min = spec.min or 0, + max = spec.max or 40, + step = spec.step or 1, + getTransform = function(value) + return value or 0 + end, + setTransform = function(value) + return value > 0 and value or nil + end, + } + internal.propagateModifiers(self, childSpec, spec) + return lib.Slider(self, childSpec) +end + +--- Font override group. +--- Optional spec fields: +--- fontValues function() -> table (choices for the dropdown) +--- fontFallback function() -> string (fallback font name) +--- fontSizeFallback function() -> number (fallback font size) +--- fontTemplate string (custom template for the font picker) +function lib.FontOverrideGroup(self, sectionPath, spec) + spec = spec or {} + local overridePath = sectionPath .. ".overrideFont" + + local enabledSpec = { + path = overridePath, + name = spec.enabledName or "Override font", + tooltip = spec.enabledTooltip or "Override the global font settings for this module.", + getTransform = function(value) + return value == true + end, + } + internal.propagateModifiers(self, enabledSpec, spec) + local enabledInit, enabledSetting = lib.Checkbox(self, enabledSpec) + + local outerDisabled = spec.disabled + local function isOverrideDisabled() + if outerDisabled and outerDisabled() then + return true + end + return not enabledSetting:GetValue() + end + + local fontSpec = { + path = sectionPath .. ".font", + name = spec.fontName or "Font", + tooltip = spec.fontTooltip, + values = spec.fontValues, + disabled = isOverrideDisabled, + getTransform = function(value) + if value then + return value + end + if spec.fontFallback then + return spec.fontFallback() + end + return nil + end, + } + internal.propagateModifiers(self, fontSpec, spec) + + local fontInit + if spec.fontTemplate then + fontSpec.template = spec.fontTemplate + fontInit = lib.Custom(self, fontSpec) + else + fontInit = lib.Dropdown(self, fontSpec) + end + + local sizeSpec = { + path = sectionPath .. ".fontSize", + name = spec.sizeName or "Font Size", + tooltip = spec.sizeTooltip, + min = spec.sizeMin or 6, + max = spec.sizeMax or 32, + step = spec.sizeStep or 1, + disabled = isOverrideDisabled, + getTransform = function(value) + if value then + return value + end + if spec.fontSizeFallback then + return spec.fontSizeFallback() + end + return 11 + end, + } + internal.propagateModifiers(self, sizeSpec, spec) + local sizeInit = lib.Slider(self, sizeSpec) + + return { + enabledInit = enabledInit, + enabledSetting = enabledSetting, + fontInit = fontInit, + sizeInit = sizeInit, + } +end + +function lib.BorderGroup(self, borderPath, spec) + spec = spec or {} + + local enabledSpec = { + path = borderPath .. ".enabled", + name = spec.enabledName or "Show border", + tooltip = spec.enabledTooltip, + } + internal.propagateModifiers(self, enabledSpec, spec) + local enabledInit, enabledSetting = lib.Checkbox(self, enabledSpec) + + local thicknessSpec = { + path = borderPath .. ".thickness", + name = spec.thicknessName or "Border width", + tooltip = spec.thicknessTooltip, + min = spec.thicknessMin or 1, + max = spec.thicknessMax or 10, + step = spec.thicknessStep or 1, + _parentInitializer = enabledInit, + _parentPredicate = function() + return enabledSetting:GetValue() + end, + } + internal.propagateModifiers(self, thicknessSpec, spec) + local thicknessInit = lib.Slider(self, thicknessSpec) + + local colorSpec = { + path = borderPath .. ".color", + name = spec.colorName or "Border color", + tooltip = spec.colorTooltip, + _parentInitializer = enabledInit, + _parentPredicate = function() + return enabledSetting:GetValue() + end, + } + internal.propagateModifiers(self, colorSpec, spec) + local colorInit = lib.Color(self, colorSpec) + + return { + enabledInit = enabledInit, + enabledSetting = enabledSetting, + thicknessInit = thicknessInit, + colorInit = colorInit, + } +end diff --git a/Libs/LibSettingsBuilder/CompositeControls/Lists.lua b/Libs/LibSettingsBuilder/CompositeControls/Lists.lua new file mode 100644 index 00000000..1a1e89c8 --- /dev/null +++ b/Libs/LibSettingsBuilder/CompositeControls/Lists.lua @@ -0,0 +1,35 @@ +-- Enhanced Cooldown Manager addon for World of Warcraft +-- Author: Argium +-- Licensed under the GNU General Public License v3.0 + +local MAJOR = "LibSettingsBuilder-1.0" +local lib = LibStub(MAJOR, true) +if not lib or not lib._loadState or not lib._loadState.open then + return +end + +local internal = lib._internal + +local function buildControlList(builder, basePath, defs, spec, methodName) + local results = {} + spec = spec or {} + for _, def in ipairs(defs) do + local childSpec = { + path = basePath .. "." .. tostring(def.key), + name = def.name, + tooltip = def.tooltip, + } + internal.propagateModifiers(builder, childSpec, spec) + local initializer, setting = lib[methodName](builder, childSpec) + results[#results + 1] = { key = def.key, initializer = initializer, setting = setting } + end + return results +end + +function lib.ColorPickerList(self, basePath, defs, spec) + return buildControlList(self, basePath, defs, spec, "Color") +end + +function lib.CheckboxList(self, basePath, defs, spec) + return buildControlList(self, basePath, defs, spec, "Checkbox") +end diff --git a/Libs/LibSettingsBuilder/Controls/Base.lua b/Libs/LibSettingsBuilder/Controls/Base.lua new file mode 100644 index 00000000..3be0749c --- /dev/null +++ b/Libs/LibSettingsBuilder/Controls/Base.lua @@ -0,0 +1,219 @@ +-- Enhanced Cooldown Manager addon for World of Warcraft +-- Author: Argium +-- Licensed under the GNU General Public License v3.0 + +local MAJOR = "LibSettingsBuilder-1.0" +local lib = LibStub(MAJOR, true) +if not lib or not lib._loadState or not lib._loadState.open then + return +end + +local internal = lib._internal +local createCustomListRowInitializer = internal.createCustomListRowInitializer +local getOrderedValueEntries = internal.getOrderedValueEntries +local getSettingVariable = internal.getSettingVariable +local applyInputRowEnabledState = internal.applyInputRowEnabledState +local applyInputRowFrame = internal.applyInputRowFrame +local cancelInputPreviewTimer = internal.cancelInputPreviewTimer +function lib.Checkbox(self, spec) + internal.validateSpecFields(self, "checkbox", spec) + local setting, category = internal.makeProxySetting(self, spec, Settings.VarType.Boolean, false) + local initializer = Settings.CreateCheckbox(category, setting, spec.tooltip) + internal.applyModifiers(self, initializer, spec) + return initializer, setting +end + +function lib.Slider(self, spec) + internal.validateSpecFields(self, "slider", spec) + local setting, category = internal.makeProxySetting(self, spec, Settings.VarType.Number, 0) + + local options = Settings.CreateSliderOptions(spec.min, spec.max, spec.step or 1) + options:SetLabelFormatter(MinimalSliderWithSteppersMixin.Label.Right, spec.formatter or internal.defaultSliderFormatter) + + local initializer = Settings.CreateSlider(category, setting, options, spec.tooltip) + internal.applyModifiers(self, initializer, spec) + + return initializer, setting +end + +function lib.Dropdown(self, spec) + internal.validateSpecFields(self, "dropdown", spec) + + local binding = internal.resolveBinding(self, spec) + local defaultValue = binding.default + if spec.getTransform then + defaultValue = spec.getTransform(defaultValue) + end + + local varType = spec.varType + or (type(defaultValue) == "number" and Settings.VarType.Number) + or Settings.VarType.String + + local setting, category = internal.makeProxySetting(self, spec, varType, "", binding) + local function optionsGenerator() + local container = Settings.CreateControlTextContainer() + local values = type(spec.values) == "function" and spec.values() or spec.values + if values then + for _, entry in ipairs(getOrderedValueEntries(values)) do + container:Add(entry.value, entry.label) + end + end + return container:GetData() + end + setting._optionsGen = optionsGenerator + + local initializer = Settings.CreateDropdown(category, setting, optionsGenerator, spec.tooltip) + initializer._lsbData = { + _lsbKind = "dropdown", + setting = setting, + values = spec.values, + name = spec.name, + tooltip = spec.tooltip, + } + if spec.scrollHeight then + initializer._lsbData._lsbKind = "scrollDropdown" + initializer._lsbData.scrollHeight = spec.scrollHeight + initializer:SetSetting(setting) + initializer._lsbRefreshFrame = function(frame) + if frame and frame.RefreshDropdownText then + frame:RefreshDropdownText() + end + end + internal.registerCategoryRefreshable(self, category, initializer) + end + + if not initializer:GetSetting() then + initializer:SetSetting(setting) + end + + if type(spec.values) == "function" and not initializer._lsbRefreshFrame then + initializer._lsbRefreshFrame = function(frame) + if frame and frame.InitDropdown and frame.lsbData and frame.lsbData._lsbKind == "scrollDropdown" then + frame:InitDropdown(initializer) + elseif frame and frame.RefreshDropdownText then + frame:RefreshDropdownText() + elseif frame and frame.SetValue and setting.GetValue then + frame:SetValue(setting:GetValue()) + end + end + internal.registerCategoryRefreshable(self, category, initializer) + end + + internal.applyModifiers(self, initializer, spec) + + return initializer, setting +end + +function lib.Color(self, spec) + internal.validateSpecFields(self, "color", spec) + + local variable = internal.makeVarName(self, spec) + local category = internal.resolveCategory(self, spec) + local binding = internal.resolveBinding(self, spec) + + local function getter() + return internal.colorTableToHex(self, binding.get()) + end + + local settingRef + local function setter(hexValue) + local color = CreateColorFromHexString(hexValue) + local value = { r = color.r, g = color.g, b = color.b, a = color.a } + binding.set(value) + internal.postSet(self, spec, value, settingRef) + end + + local defaultHex = internal.colorTableToHex(self, binding.default or {}) + local setting = Settings.RegisterProxySetting( + category, + variable, + Settings.VarType.String, + spec.name, + defaultHex, + getter, + setter + ) + settingRef = setting + + local initializer = Settings.CreateColorSwatch(category, setting, spec.tooltip) + internal.applyModifiers(self, initializer, spec) + + return initializer, setting +end + +function lib.Input(self, spec) + internal.validateSpecFields(self, "input", spec) + + local setting, category = internal.makeProxySetting(self, spec, Settings.VarType.String, "") + local data = { + debounce = spec.debounce, + maxLetters = spec.maxLetters, + name = spec.name, + numeric = spec.numeric, + onTextChanged = spec.onTextChanged, + resolveText = spec.resolveText, + setting = setting, + settingVariable = getSettingVariable(setting), + tooltip = spec.tooltip, + width = spec.width, + } + + local extent = spec.resolveText and 46 or 26 + local initializer = createCustomListRowInitializer("SettingsListElementTemplate", data, extent, applyInputRowFrame) + local originalInitFrame = initializer.InitFrame + local originalResetter = initializer.Resetter + + initializer._lsbEnabled = true + initializer.SetEnabled = function(controlInitializer, enabled) + controlInitializer._lsbEnabled = enabled + if controlInitializer._lsbActiveFrame then + applyInputRowEnabledState(controlInitializer._lsbActiveFrame, enabled) + end + end + + initializer.InitFrame = function(controlInitializer, frame) + controlInitializer._lsbActiveFrame = frame + originalInitFrame(controlInitializer, frame) + applyInputRowEnabledState(frame, controlInitializer._lsbEnabled ~= false) + end + + initializer.Resetter = function(controlInitializer, frame) + cancelInputPreviewTimer(frame) + if frame and frame._lsbInputEditBox then + frame._lsbInputEditBox:ClearFocus() + frame._lsbInputEditBox._lsbOwnerFrame = nil + end + frame._lsbInputData = nil + frame._lsbInputSetting = nil + if controlInitializer._lsbActiveFrame == frame then + controlInitializer._lsbActiveFrame = nil + end + originalResetter(controlInitializer, frame) + end + + Settings.RegisterInitializer(category, initializer) + internal.applyModifiers(self, initializer, spec) + + return initializer, setting +end + +--- Creates a proxy setting backed by a custom frame template. +--- The template's Init receives initializer data containing {setting, name, tooltip}. +function lib.Custom(self, spec) + internal.validateSpecFields(self, "custom", spec) + assert(spec.template, "Custom: spec.template is required") + + local setting, category = internal.makeProxySetting(self, spec, spec.varType or Settings.VarType.String, "") + local initializer = Settings.CreateElementInitializer(spec.template, { + name = spec.name, + setting = setting, + tooltip = spec.tooltip, + }) + + initializer:SetSetting(setting) + + Settings.RegisterInitializer(category, initializer) + internal.applyModifiers(self, initializer, spec) + + return initializer, setting +end diff --git a/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua new file mode 100644 index 00000000..b7c6e341 --- /dev/null +++ b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua @@ -0,0 +1,993 @@ +-- Enhanced Cooldown Manager addon for World of Warcraft +-- Author: Argium +-- Licensed under the GNU General Public License v3.0 + +local MAJOR = "LibSettingsBuilder-1.0" +local lib = LibStub(MAJOR, true) +if not lib or not lib._loadState or not lib._loadState.open then + return +end + +local ADD = _G.ADD +local REMOVE = _G.REMOVE + +local internal = lib._internal +local SECTION_HEADER_HEIGHT = 50 +local applyActionButtonTextures = internal.applyActionButtonTextures +local configureInlineSlider = internal.configureInlineSlider +local evaluateStaticOrFunction = internal.evaluateStaticOrFunction +local setGameTooltipText = internal.setGameTooltipText +local setSimpleTooltip = internal.setSimpleTooltip +local setTextureValue = internal.setTextureValue +local showFrame = internal.showFrame + +local DISABLED_ROW_ALPHA = 0.5 +local DEFAULT_LABEL_COLOR = { 1, 1, 1, 1 } + +local function preventMouseClickPropagation(frame) + if not frame then + return + end + frame:SetPropagateMouseClicks(false) + local children = { frame:GetChildren() } + for i = 1, #children do + preventMouseClickPropagation(children[i]) + end +end + +local function getFontObjectTextColor(fontObject) + if type(fontObject) == "string" then + fontObject = _G[fontObject] + end + if fontObject and fontObject.GetTextColor then + local r, g, b, a = fontObject:GetTextColor() + if r then + return r, g, b, a + end + end + + return DEFAULT_LABEL_COLOR[1], DEFAULT_LABEL_COLOR[2], DEFAULT_LABEL_COLOR[3], DEFAULT_LABEL_COLOR[4] +end + +local function applyCollectionRowStyle(row, item) + local disabled = item and item.disabled == true + local alpha = item and item.alpha or (disabled and DISABLED_ROW_ALPHA or 1) + local labelFontObject = item and item.labelFontObject or (disabled and _G.GameFontDisable or _G.GameFontNormal) + local iconDesaturated = item and item.iconDesaturated + if iconDesaturated == nil then + iconDesaturated = disabled + end + + if row._label and labelFontObject then + row._label:SetFontObject(labelFontObject) + end + if row._label then + if item and item.labelColor then + row._label:SetTextColor( + item.labelColor[1] or 1, + item.labelColor[2] or 1, + item.labelColor[3] or 1, + item.labelColor[4] or 1 + ) + else + row._label:SetTextColor(getFontObjectTextColor(labelFontObject)) + end + end + if row._label then + row._label:SetAlpha(alpha) + end + if row._icon then + row._icon:SetAlpha(alpha) + end + if row._icon then + row._icon:SetDesaturated(iconDesaturated == true) + end + if row._icon then + local color = item and item.iconVertexColor + if color then + row._icon:SetVertexColor(color[1] or 1, color[2] or 1, color[3] or 1, color[4] or 1) + else + row._icon:SetVertexColor(1, 1, 1, 1) + end + end +end + +local function setCollectionRowHighlight(row, shown) + local highlight = row and row._highlight + if shown then + if highlight then + highlight:Show() + end + elseif highlight then + highlight:Hide() + end +end + +local function getLabelHitBoxWidth(label) + local textWidth = label and label.GetStringWidth and label:GetStringWidth() or nil + local labelWidth = label and label.GetWidth and label:GetWidth() or nil + if textWidth and labelWidth and labelWidth > 0 then + textWidth = math.min(textWidth, labelWidth) + end + return math.max(1, math.ceil(textWidth or labelWidth or 1)) +end + +local function updateActionRowTooltipOwner(row) + local owner = row._tooltipOwner + if not owner then + return + end + + owner:ClearAllPoints() + owner:SetPoint("LEFT", row._label, "LEFT", 0, 0) + owner:SetSize(getLabelHitBoxWidth(row._label), row:GetHeight() or 20) +end + +local function bindCollectionRowTooltip(row, item) + if not row then + return + end + + local label = row._label + local tooltipOwner = row._tooltipOwner or label + row:EnableMouse(item ~= nil) + + row:SetScript("OnEnter", nil) + row:SetScript("OnLeave", nil) + if label then + label:SetScript("OnEnter", nil) + label:SetScript("OnLeave", nil) + end + if label then + label:EnableMouse(false) + end + if tooltipOwner and tooltipOwner ~= label then + tooltipOwner:SetScript("OnEnter", nil) + tooltipOwner:SetScript("OnLeave", nil) + tooltipOwner:EnableMouse(false) + tooltipOwner:Hide() + end + + if not item then + return + end + + row:SetScript("OnEnter", function(self) + setCollectionRowHighlight(self, true) + end) + row:SetScript("OnLeave", function(self) + setCollectionRowHighlight(self, false) + end) + + if not tooltipOwner or (not item.onEnter and not item.tooltip) then + return + end + + tooltipOwner:EnableMouse(true) + tooltipOwner:Show() + tooltipOwner:SetScript("OnEnter", function(self) + setCollectionRowHighlight(row, true) + if item.onEnter then + item.onEnter(self, item) + elseif GameTooltip then + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:ClearLines() + setGameTooltipText(item.tooltip, true) + GameTooltip:Show() + end + end) + tooltipOwner:SetScript("OnLeave", function(self) + setCollectionRowHighlight(row, false) + if item.onLeave then + item.onLeave(self, item) + elseif GameTooltip_Hide then + GameTooltip_Hide() + end + end) +end + +local function ensureHighlight(row) + if row._highlight then + return row._highlight + end + + local highlight = row:CreateTexture(nil, "BACKGROUND") + highlight:SetAllPoints() + highlight:SetColorTexture(1, 1, 1, 0.08) + highlight:Hide() + row._highlight = highlight + return highlight +end + +local DEFAULT_SWATCH_CENTER_X = internal.defaultSwatchCenterX or -73 + +local function ensureSwatchCollectionRow(row) + if row._lsbSwatchRow then + return + end + + row._lsbSwatchRow = true + row:SetHeight(26) + ensureHighlight(row) + + row._icon = row:CreateTexture(nil, "ARTWORK") + row._icon:SetPoint("LEFT", 0, 0) + row._icon:SetSize(16, 16) + row._icon:Hide() + + row._label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal") + row._label:SetPoint("LEFT", row, "LEFT", 0, 0) + row._label:SetJustifyH("LEFT") + row._label:SetWordWrap(false) + + row._swatch = internal.createColorSwatch(row) + row._swatch:SetPoint("LEFT", row, "CENTER", DEFAULT_SWATCH_CENTER_X, 0) +end + +local function refreshSwatchCollectionRow(row, item) + ensureSwatchCollectionRow(row) + + if item.icon then + setTextureValue(row._icon, item.icon) + row._icon:Show() + row._label:ClearAllPoints() + row._label:SetPoint("LEFT", row._icon, "RIGHT", 6, 0) + else + setTextureValue(row._icon, nil) + row._icon:Hide() + row._label:ClearAllPoints() + row._label:SetPoint("LEFT", row, "LEFT", 0, 0) + end + row._label:SetPoint("RIGHT", row._swatch, "LEFT", -8, 0) + + row._label:SetText(item.label or "") + applyCollectionRowStyle(row, item) + bindCollectionRowTooltip(row, item) + + local color = item.color or {} + local colorValue = color.value or color + row._swatch:SetColorRGB(colorValue.r or 1, colorValue.g or 1, colorValue.b or 1) + setSimpleTooltip(row._swatch, item.swatchTooltip or color.tooltip) + row._swatch:SetScript("OnClick", function() + local onClick = color.onClick or item.onColorClick + if onClick then + onClick(item, row) + end + end) + local enabled = evaluateStaticOrFunction(item.enabled, item, row) ~= false + and evaluateStaticOrFunction(color.enabled, item, row) ~= false + row._swatch:SetEnabled(enabled) +end + +local function ensureEditorCollectionRow(row) + if row._lsbEditorRow then + return + end + + row._lsbEditorRow = true + row:SetHeight(34) + ensureHighlight(row) + + row._label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal") + row._label:SetPoint("LEFT", row, "LEFT", 10, 0) + row._label:SetWidth(70) + row._label:SetJustifyH("LEFT") + + row._fieldWidgets = {} + row._swatch = internal.createColorSwatch(row) + row._removeButton = CreateFrame("Button", nil, row, "UIPanelButtonTemplate") + preventMouseClickPropagation(row._removeButton) + row._removeButton:RegisterForClicks("LeftButtonUp") + row._removeButton:SetSize(70, 22) +end + +local function ensureEditorFieldWidgets(row, index) + local widgets = row._fieldWidgets[index] + if widgets then + return widgets + end + + local slider = CreateFrame("Slider", nil, row, "MinimalSliderWithSteppersTemplate") + preventMouseClickPropagation(slider) + local valueText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + valueText:SetJustifyH("LEFT") + + widgets = { + slider = slider, + valueText = valueText, + } + row._fieldWidgets[index] = widgets + return widgets +end + +local function refreshEditorCollectionRow(row, item) + ensureEditorCollectionRow(row) + + row._label:SetText(item.label or "") + applyCollectionRowStyle(row, item) + bindCollectionRowTooltip(row, nil) + + local previousValueText = nil + local fields = item.fields or {} + + for i = 1, #fields do + local field = fields[i] + local widgets = ensureEditorFieldWidgets(row, i) + local slider = widgets.slider + local valueText = widgets.valueText + local minValue, maxValue, step = field.min or 0, field.max or 1, field.step or 1 + + if field.getRange then + local nextMin, nextMax, nextStep = field.getRange(item, field.value) + if nextMin ~= nil then + minValue = nextMin + end + if nextMax ~= nil then + maxValue = nextMax + end + if nextStep ~= nil then + step = nextStep + end + end + + field.min = minValue + field.max = maxValue + field.step = step + + slider:ClearAllPoints() + if previousValueText then + slider:SetPoint("LEFT", previousValueText, "RIGHT", field.gap or 12, 0) + else + slider:SetPoint("LEFT", row._label, "RIGHT", 8, 0) + end + slider:SetWidth(field.sliderWidth or 120) + + valueText:ClearAllPoints() + valueText:SetPoint("LEFT", slider, "RIGHT", 6, 0) + valueText:SetWidth(field.valueWidth or 40) + + local rangeResolver + if field.getRange then + rangeResolver = function(targetValue) + return field.getRange(item, targetValue) + end + end + + configureInlineSlider(slider, valueText, field, function(rounded) + if row._lsbRefreshing then + return + end + if field.onValueChanged then + field.onValueChanged(rounded, item, row) + end + end, rangeResolver) + preventMouseClickPropagation(slider) + + previousValueText = valueText + end + + local color = item.color or {} + row._swatch:ClearAllPoints() + if previousValueText then + row._swatch:SetPoint("LEFT", previousValueText, "RIGHT", 10, 0) + else + row._swatch:SetPoint("LEFT", row._label, "RIGHT", 10, 0) + end + + row._removeButton:ClearAllPoints() + row._removeButton:SetPoint("LEFT", row._swatch, "RIGHT", 8, 0) + row._removeButton:SetSize((item.remove and item.remove.width) or 70, 22) + row._removeButton:SetText((item.remove and item.remove.text) or REMOVE or "Remove") + row._removeButton:SetScript("OnClick", function() + if item.remove and item.remove.onClick then + item.remove.onClick(item, row) + end + end) + row._removeButton:SetEnabled(item.remove == nil or item.remove.enabled ~= false) + setSimpleTooltip(row._removeButton, item.remove and item.remove.tooltip) + + row._lsbRefreshing = true + for i = 1, #fields do + local field = fields[i] + local widgets = row._fieldWidgets[i] + widgets.slider._lsbMinValue = field.min or 0 + widgets.slider._lsbMaxValue = field.max or 1 + widgets.slider._lsbStep = field.step or 1 + widgets.slider:SetValue(field.value or 0) + widgets.valueText:SetText(tostring(field.value or 0)) + end + row._lsbRefreshing = nil + + row._swatch:SetColorRGB((color.value and color.value.r) or 1, (color.value and color.value.g) or 1, (color.value and color.value.b) or 1) + row._swatch:SetScript("OnClick", function() + if color.onClick then + color.onClick(item, row) + end + end) + setSimpleTooltip(row._swatch, color.tooltip) + row._swatch:SetEnabled(color.enabled ~= false) +end + +local ACTION_BUTTON_ORDER = { "up", "down", "move", "delete" } +local ACTION_BUTTON_SPACING = 2 +local DISABLED_ACTION_ICON_COLOR = { 0.55, 0.55, 0.55, 1 } + +local function ensureActionButtonIcon(button) + if button._lsbActionIcon then + return button._lsbActionIcon + end + + local icon = button:CreateTexture(nil, "ARTWORK") + icon:SetPoint("CENTER", button, "CENTER", 0, 0) + icon:Hide() + button._lsbActionIcon = icon + return icon +end + +local function applyActionButtonIcon(button, action, enabled) + local icon = button._lsbActionIcon + local iconTexture = action and action.iconTexture + if not iconTexture then + if icon then + setTextureValue(icon, nil) + icon:SetDesaturated(false) + icon:SetVertexColor(1, 1, 1, 1) + icon:Hide() + end + return + end + + local disabled = enabled == false + icon = ensureActionButtonIcon(button) + icon:ClearAllPoints() + icon:SetPoint("CENTER", button, "CENTER", 0, 0) + icon:SetSize(action.iconSize or 16, action.iconSize or 16) + icon:SetAlpha(disabled and (action.disabledIconAlpha or action.iconAlpha or 1) or (action.iconAlpha or 1)) + icon:SetDesaturated(disabled) + if disabled then + icon:SetVertexColor( + DISABLED_ACTION_ICON_COLOR[1], + DISABLED_ACTION_ICON_COLOR[2], + DISABLED_ACTION_ICON_COLOR[3], + DISABLED_ACTION_ICON_COLOR[4] + ) + else + icon:SetVertexColor(1, 1, 1, 1) + end + setTextureValue(icon, iconTexture) + icon:Show() + + button:SetText("") +end + +local function applyActionButtonState(button, enabled) + local interactive = enabled ~= false + button:EnableMouse(interactive) + button:UnlockHighlight() + + local highlight = button:GetHighlightTexture() + if highlight then + if interactive then + highlight:SetAlpha(button._lsbDisabledHighlightAlpha or 1) + button._lsbDisabledHighlightAlpha = nil + else + if button._lsbDisabledHighlightAlpha == nil then + button._lsbDisabledHighlightAlpha = highlight:GetAlpha() + end + highlight:SetAlpha(0) + end + end +end + +local function resetActionButton(button) + button:ClearAllPoints() + button:SetScript("OnClick", nil) + button:SetScript("OnEnter", nil) + button:SetScript("OnLeave", nil) + button:Hide() +end + +local function ensureActionsCollectionRow(row) + if row._lsbActionsRow then + return + end + + row._lsbActionsRow = true + row:SetHeight(26) + ensureHighlight(row) + + row._icon = row:CreateTexture(nil, "ARTWORK") + row._icon:SetPoint("LEFT", 0, 0) + row._icon:SetSize(20, 20) + + row._label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal") + row._label:SetPoint("LEFT", row._icon, "RIGHT", 6, 0) + row._label:SetJustifyH("LEFT") + row._label:SetWordWrap(false) + row._tooltipOwner = CreateFrame("Frame", nil, row) + row._tooltipOwner:Hide() + + row._buttons = {} + row._textureButtons = {} + for _, key in ipairs(ACTION_BUTTON_ORDER) do + local button = CreateFrame("Button", nil, row, "UIPanelButtonTemplate") + button:RegisterForClicks("LeftButtonUp") + row._buttons[key] = button + + button = CreateFrame("Button", nil, row) + button:RegisterForClicks("LeftButtonUp") + row._textureButtons[key] = button + end +end + +local function refreshActionsCollectionRow(row, item) + ensureActionsCollectionRow(row) + + row._label:SetText(item.label or "") + setTextureValue(row._icon, item.icon or 134400) + applyCollectionRowStyle(row, item) + + local anchor = nil + for _, key in ipairs(ACTION_BUTTON_ORDER) do + local templateButton = row._buttons[key] + local textureButton = row._textureButtons[key] + local action = item.actions and item.actions[key] or nil + + resetActionButton(templateButton) + resetActionButton(textureButton) + + if action and not evaluateStaticOrFunction(action.hidden, action, row, item) then + local button = action.buttonTextures and textureButton or templateButton + if not anchor then + button:SetPoint("RIGHT", row, "RIGHT", -ACTION_BUTTON_SPACING, 0) + else + button:SetPoint("RIGHT", anchor, "LEFT", -ACTION_BUTTON_SPACING, 0) + end + button:SetSize(action.width or 26, action.height or 22) + local enabled = evaluateStaticOrFunction(action.enabled, action, row, item) + if enabled == nil then + enabled = true + end + applyActionButtonTextures(button, action, enabled) + applyActionButtonIcon(button, action, enabled) + button:SetEnabled(enabled) + applyActionButtonState(button, enabled) + setSimpleTooltip(button, enabled ~= false and evaluateStaticOrFunction(action.tooltip, action, row, item) or nil) + button:SetScript("OnClick", function() + if action.onClick then + action.onClick(item, row, action) + end + end) + button:Show() + anchor = button + end + end + + if anchor then + row._label:ClearAllPoints() + row._label:SetPoint("LEFT", row._icon, "RIGHT", 6, 0) + row._label:SetPoint("RIGHT", anchor, "LEFT", -6, 0) + else + row._label:ClearAllPoints() + row._label:SetPoint("LEFT", row._icon, "RIGHT", 6, 0) + row._label:SetPoint("RIGHT", row, "RIGHT", -6, 0) + end + updateActionRowTooltipOwner(row) + bindCollectionRowTooltip(row, item) +end + +local function ensureModeInputRow(row) + if row._lsbModeInputRow then + return + end + + row._lsbModeInputRow = true + row:SetHeight(28) + + row._background = row:CreateTexture(nil, "BACKGROUND") + row._background:SetColorTexture(1, 1, 1, 0.05) + row._background:SetPoint("TOPLEFT", row, "TOPLEFT", -4, 2) + row._background:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", 4, -2) + + row._modeButton = CreateFrame("Button", nil, row, "UIPanelButtonTemplate") + row._modeButton:SetPoint("LEFT", row, "LEFT", 0, 0) + row._modeButton:SetSize(58, 22) + + row._editBox = CreateFrame("EditBox", nil, row, "InputBoxTemplate") + row._editBox:SetPoint("LEFT", row._modeButton, "RIGHT", 6, 0) + row._editBox:SetSize(120, 20) + row._editBox:SetAutoFocus(false) + row._editBox:SetNumeric(true) + row._editBox:SetMaxLetters(10) + row._editBox:SetTextInsets(6, 6, 0, 0) + + row._placeholder = row._editBox:CreateFontString(nil, "OVERLAY", "GameFontDisable") + row._placeholder:SetPoint("LEFT", row._editBox, "LEFT", 6, 0) + row._placeholder:SetPoint("RIGHT", row._editBox, "RIGHT", -6, 0) + row._placeholder:SetJustifyH("LEFT") + row._placeholder:SetWordWrap(false) + + row._previewIcon = row:CreateTexture(nil, "ARTWORK") + row._previewIcon:SetPoint("LEFT", row._editBox, "RIGHT", 8, 0) + row._previewIcon:SetSize(16, 16) + row._previewIcon:Hide() + + row._previewLabel = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + row._previewLabel:SetPoint("LEFT", row._previewIcon, "RIGHT", 4, 0) + row._previewLabel:SetJustifyH("LEFT") + row._previewLabel:SetWordWrap(false) + row._previewLabel:Hide() + + row._submitButton = CreateFrame("Button", nil, row, "UIPanelButtonTemplate") + row._submitButton:SetPoint("RIGHT", row, "RIGHT", 0, 0) + row._submitButton:SetSize(44, 22) + row._submitButton:SetText(ADD or "Add") + + row._previewLabel:SetPoint("RIGHT", row._submitButton, "LEFT", -6, 0) + + row._editBox:SetScript("OnEditFocusGained", function() + row._lsbHasFocus = true + if row._lsbTrailerRefresh then + row._lsbTrailerRefresh(row) + end + end) + row._editBox:SetScript("OnEditFocusLost", function() + row._lsbHasFocus = false + if row._lsbTrailerRefresh then + row._lsbTrailerRefresh(row) + end + end) + row._editBox:SetScript("OnTextChanged", function(self) + if row._lsbSyncingText then + return + end + local trailer = row._lsbTrailerData + if trailer and trailer.onTextChanged then + trailer.onTextChanged(self:GetText() or "", trailer, row, row._lsbSectionData) + end + if row._lsbTrailerRefresh then + row._lsbTrailerRefresh(row) + end + end) + row._editBox:SetScript("OnEnterPressed", function() + local trailer = row._lsbTrailerData + if trailer and trailer.onSubmit then + local disabled = evaluateStaticOrFunction(trailer.disabled, trailer, row, row._lsbSectionData) == true + local submitEnabled = evaluateStaticOrFunction(trailer.submitEnabled, trailer, row, row._lsbSectionData) + if disabled or submitEnabled == false then + return + end + + local keepFocus = trailer.onSubmit(trailer, row, row._lsbSectionData) + if keepFocus then + row._editBox:SetFocus() + row._editBox:HighlightText() + end + end + end) + row._editBox:SetScript("OnTabPressed", function() + local trailer = row._lsbTrailerData + local keepFocus = nil + if trailer and trailer.onTabPressed then + keepFocus = trailer.onTabPressed(trailer, row, row._lsbSectionData) + end + if row._lsbTrailerRefresh then + row._lsbTrailerRefresh(row) + end + if keepFocus then + row._editBox:SetFocus() + row._editBox:HighlightText() + end + end) + row._editBox:SetScript("OnEscapePressed", function(self) + local trailer = row._lsbTrailerData + if trailer and trailer.onEscapePressed then + trailer.onEscapePressed(trailer, row, row._lsbSectionData) + end + self:ClearFocus() + row._lsbHasFocus = false + if row._lsbTrailerRefresh then + row._lsbTrailerRefresh(row) + end + end) +end + +local function getModeInputTrailerValue(trailer, key, row, sectionData) + return evaluateStaticOrFunction(trailer and trailer[key], trailer, row, sectionData) +end + +local function refreshModeInputRow(row, trailer, sectionData) + ensureModeInputRow(row) + + row._lsbTrailerData = trailer + row._lsbSectionData = sectionData + + row._lsbTrailerRefresh = function(activeRow) + local currentTrailer = activeRow._lsbTrailerData or {} + local activeSectionData = activeRow._lsbSectionData + local disabled = getModeInputTrailerValue(currentTrailer, "disabled", activeRow, activeSectionData) == true + local modeEnabled = getModeInputTrailerValue(currentTrailer, "modeEnabled", activeRow, activeSectionData) + local inputEnabled = getModeInputTrailerValue(currentTrailer, "inputEnabled", activeRow, activeSectionData) + local submitEnabled = getModeInputTrailerValue(currentTrailer, "submitEnabled", activeRow, activeSectionData) + local modeText = getModeInputTrailerValue(currentTrailer, "modeText", activeRow, activeSectionData) + local modeTooltip = getModeInputTrailerValue(currentTrailer, "modeTooltip", activeRow, activeSectionData) + local text = getModeInputTrailerValue(currentTrailer, "inputText", activeRow, activeSectionData) or "" + local placeholder = getModeInputTrailerValue(currentTrailer, "placeholder", activeRow, activeSectionData) + local previewIcon = getModeInputTrailerValue(currentTrailer, "previewIcon", activeRow, activeSectionData) + local previewText = getModeInputTrailerValue(currentTrailer, "previewText", activeRow, activeSectionData) + local submitText = getModeInputTrailerValue(currentTrailer, "submitText", activeRow, activeSectionData) + local submitTooltip = getModeInputTrailerValue(currentTrailer, "submitTooltip", activeRow, activeSectionData) + local canSubmit = not disabled and submitEnabled ~= false + + activeRow._modeButton:SetText(modeText or "") + setSimpleTooltip(activeRow._modeButton, modeTooltip) + activeRow._modeButton:SetScript("OnClick", function() + if currentTrailer.onToggleMode then + currentTrailer.onToggleMode(currentTrailer, activeRow, activeRow._lsbSectionData) + end + if activeRow._lsbTrailerRefresh then + activeRow._lsbTrailerRefresh(activeRow) + end + end) + activeRow._modeButton:SetEnabled(not disabled and modeEnabled ~= false) + + if activeRow._editBox:GetText() ~= text then + activeRow._lsbSyncingText = true + activeRow._editBox:SetText(text) + activeRow._lsbSyncingText = nil + end + activeRow._editBox:SetEnabled(not disabled and inputEnabled ~= false) + + activeRow._placeholder:SetText(placeholder or "") + if activeRow._lsbHasFocus or text ~= "" then + activeRow._placeholder:Hide() + else + activeRow._placeholder:Show() + end + + if previewIcon then + setTextureValue(activeRow._previewIcon, previewIcon) + activeRow._previewIcon:Show() + else + setTextureValue(activeRow._previewIcon, nil) + activeRow._previewIcon:Hide() + end + + if previewText and previewText ~= "" then + activeRow._previewLabel:SetText(previewText) + activeRow._previewLabel:Show() + else + activeRow._previewLabel:SetText("") + activeRow._previewLabel:Hide() + end + + activeRow._submitButton:SetText(submitText or ADD or "Add") + setSimpleTooltip(activeRow._submitButton, submitTooltip) + activeRow._submitButton:SetScript("OnClick", function() + if currentTrailer.onSubmit and canSubmit then + local keepFocus = currentTrailer.onSubmit(currentTrailer, activeRow, activeRow._lsbSectionData) + if keepFocus then + activeRow._editBox:SetFocus() + activeRow._editBox:HighlightText() + end + end + end) + activeRow._submitButton:SetEnabled(canSubmit) + end + + row._lsbTrailerRefresh(row) +end + +local function ensureCollectionContent(frame) + if frame._lsbCollectionContent then + showFrame(frame._lsbCollectionContent) + return frame._lsbCollectionContent + end + + local content = CreateFrame("Frame", nil, frame) + content:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0) + content:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, 0) + content:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, 0) + content:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0) + frame._lsbCollectionContent = content + return content +end + +local function ensureFlatCollectionWidgets(frame, data) + if frame._lsbCollectionScrollBox then + showFrame(frame._lsbCollectionScrollBox) + showFrame(frame._lsbCollectionScrollBar) + return + end + + local insetLeft = data.insetLeft or 37 + local insetTop = data.insetTop or 0 + local insetBottom = data.insetBottom or 10 + + local scrollBox = CreateFrame("Frame", nil, frame, "WowScrollBoxList") + scrollBox:SetPoint("TOPLEFT", frame, "TOPLEFT", insetLeft, insetTop) + scrollBox:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -30, insetBottom) + + local scrollBar = CreateFrame("EventFrame", nil, frame, "MinimalScrollBar") + scrollBar:SetPoint("TOPLEFT", scrollBox, "TOPRIGHT", 5, 0) + scrollBar:SetPoint("BOTTOMLEFT", scrollBox, "BOTTOMRIGHT", 5, 0) + + local view = CreateScrollBoxListLinearView() + view:SetElementExtent(data.rowHeight or 26) + view:SetElementInitializer("Frame", function(rowFrame, rowData) + local preset = rowData.preset or data.preset + if preset == "swatch" then + refreshSwatchCollectionRow(rowFrame, rowData.item) + elseif preset == "editor" then + refreshEditorCollectionRow(rowFrame, rowData.item) + end + end) + + local dataProvider = CreateDataProvider() + ScrollUtil.InitScrollBoxListWithScrollBar(scrollBox, scrollBar, view) + scrollBox:SetDataProvider(dataProvider) + + frame._lsbCollectionScrollBox = scrollBox + frame._lsbCollectionScrollBar = scrollBar + frame._lsbCollectionView = view + frame._lsbCollectionDataProvider = dataProvider +end + +local function refreshFlatCollection(frame, data) + ensureFlatCollectionWidgets(frame, data) + + local scrollBox = frame._lsbCollectionScrollBox + local dataProvider = frame._lsbCollectionDataProvider + local items = data.items and data.items(frame) or {} + + if dataProvider and dataProvider.Flush then + dataProvider:Flush() + end + + for _, item in ipairs(items or {}) do + if dataProvider and dataProvider.Insert then + dataProvider:Insert({ + preset = data.preset, + item = item, + }) + end + end + + if scrollBox and scrollBox.SetDataProvider then + scrollBox:SetDataProvider(dataProvider) + end +end + +local function ensureSectionHeaderRow(content, headers, sectionKey, title) + local row = headers[sectionKey] + if row then + return row + end + + row = CreateFrame("Frame", nil, content) + row:SetHeight(SECTION_HEADER_HEIGHT) + row._title = internal.createHeaderTitle(row, title) + headers[sectionKey] = row + return row +end + +local function ensureSectionEmptyLabel(content, labels, sectionKey) + local label = labels[sectionKey] + if label then + return label + end + + label = content:CreateFontString(nil, "OVERLAY", "GameFontDisable") + label:SetJustifyH("LEFT") + labels[sectionKey] = label + return label +end + +local function refreshSectionedCollection(frame, data) + local content = ensureCollectionContent(frame) + local sections = data.sections and data.sections(frame) or {} + local headers = frame._lsbSectionHeaders or {} + local rowPools = frame._lsbSectionRowPools or {} + local emptyLabels = frame._lsbSectionEmptyLabels or {} + local trailerRows = frame._lsbSectionTrailerRows or {} + local y = 0 + local insetLeft = data.insetLeft or 37 + local insetRight = data.insetRight or 20 + local rowSpacing = data.rowSpacing or 4 + local sectionSpacing = data.sectionSpacing or 12 + + frame._lsbSectionHeaders = headers + frame._lsbSectionRowPools = rowPools + frame._lsbSectionEmptyLabels = emptyLabels + frame._lsbSectionTrailerRows = trailerRows + + for _, pool in pairs(rowPools) do + for _, row in ipairs(pool) do + row:Hide() + end + end + for _, row in pairs(headers) do + row:Hide() + end + for _, label in pairs(emptyLabels) do + label:Hide() + end + for _, trailer in pairs(trailerRows) do + trailer:Hide() + end + + for _, section in ipairs(sections) do + local sectionKey = section.key or section.name or tostring(_) + local titleText = section.title or section.name or "" + local header = ensureSectionHeaderRow(content, headers, sectionKey, titleText) + header._title:SetText(titleText) + header:ClearAllPoints() + header:SetPoint("TOPLEFT", content, "TOPLEFT", 0, y) + header:SetPoint("RIGHT", content, "RIGHT", 0, 0) + header:Show() + y = y - (section.headerHeight or SECTION_HEADER_HEIGHT) + + local items = section.items or {} + local pool = rowPools[sectionKey] or {} + rowPools[sectionKey] = pool + + if #items == 0 and section.emptyText then + local label = ensureSectionEmptyLabel(content, emptyLabels, sectionKey) + label:SetText(section.emptyText) + label:ClearAllPoints() + label:SetPoint("TOPLEFT", content, "TOPLEFT", insetLeft, y) + label:Show() + y = y - ((section.emptyHeight or 26) + rowSpacing) + end + + for index, item in ipairs(items) do + local row = pool[index] + if not row then + row = CreateFrame("Frame", nil, content) + pool[index] = row + end + + refreshActionsCollectionRow(row, item) + row:ClearAllPoints() + row:SetPoint("TOPLEFT", content, "TOPLEFT", insetLeft, y) + row:SetPoint("RIGHT", content, "RIGHT", -insetRight, 0) + row:Show() + y = y - ((section.rowHeight or 26) + rowSpacing) + end + + local footer = section.footer + local footerType = footer and (footer.type or footer.preset) + if footerType == "modeInput" then + y = y - (section.footerSpacing or data.footerSpacing or 0) + local trailerRow = trailerRows[sectionKey] + if not trailerRow then + trailerRow = CreateFrame("Frame", nil, content) + trailerRows[sectionKey] = trailerRow + end + + footer.preset = footer.preset or footerType + refreshModeInputRow(trailerRow, footer, section) + trailerRow:ClearAllPoints() + trailerRow:SetPoint("TOPLEFT", content, "TOPLEFT", insetLeft, y) + trailerRow:SetPoint("RIGHT", content, "RIGHT", -insetRight, 0) + trailerRow:Show() + y = y - (section.footerHeight or 28) + end + + y = y - (section.spacingAfter or sectionSpacing) + end +end + +local function applyCollectionFrame(frame, data, initializer) + frame.OnDefault = data.onDefault + frame._lsbCollectionData = data + frame._lsbCollectionInitializer = initializer + + if data.sections then + refreshSectionedCollection(frame, data) + else + refreshFlatCollection(frame, data) + end +end + +internal.applyCollectionFrame = applyCollectionFrame diff --git a/Libs/LibSettingsBuilder/Controls/Collections.lua b/Libs/LibSettingsBuilder/Controls/Collections.lua new file mode 100644 index 00000000..657998a3 --- /dev/null +++ b/Libs/LibSettingsBuilder/Controls/Collections.lua @@ -0,0 +1,60 @@ +-- Enhanced Cooldown Manager addon for World of Warcraft +-- Author: Argium +-- Licensed under the GNU General Public License v3.0 + +local MAJOR = "LibSettingsBuilder-1.0" +local lib = LibStub(MAJOR, true) +if not lib or not lib._loadState or not lib._loadState.open then + return +end + +local internal = lib._internal +local applyCollectionFrame = internal.applyCollectionFrame +local createCustomListRowInitializer = internal.createCustomListRowInitializer +local copyMixin = internal.copyMixin +function internal.createCollectionInitializer(self, spec, errorPrefix) + assert(spec.height, errorPrefix .. ": spec.height is required") + + local category = internal.resolveCategory(self, spec) + local data = copyMixin({}, spec) + if data.variant and data.preset == nil then + data.preset = data.variant + end + + local initializer = createCustomListRowInitializer("SettingsListElementTemplate", data, spec.height, applyCollectionFrame) + + initializer._lsbEnabled = true + initializer.SetEnabled = function(controlInitializer, enabled) + controlInitializer._lsbEnabled = enabled + local activeFrame = controlInitializer._lsbActiveFrame + if activeFrame then + applyCollectionFrame(activeFrame, data, controlInitializer) + activeFrame:SetAlpha(enabled and 1 or 0.5) + if enabled == false then + internal.setCanvasInteractive(self, activeFrame, false) + end + end + end + + initializer._lsbRefreshFrame = function(frame) + initializer._lsbActiveFrame = frame + initializer:SetEnabled(initializer._lsbEnabled ~= false) + end + + Settings.RegisterInitializer(category, initializer) + internal.registerCategoryRefreshable(self, category, initializer) + internal.applyModifiers(self, initializer, spec) + + return initializer +end + +function lib.List(self, spec) + assert(spec.items, "List: spec.items is required") + assert(not spec.sections, "List: spec.sections is not supported") + return internal.createCollectionInitializer(self, spec, "List") +end + +function lib.SectionList(self, spec) + assert(spec.sections, "SectionList: spec.sections is required") + return internal.createCollectionInitializer(self, spec, "SectionList") +end diff --git a/Libs/LibSettingsBuilder/Controls/Rows.lua b/Libs/LibSettingsBuilder/Controls/Rows.lua new file mode 100644 index 00000000..8292f086 --- /dev/null +++ b/Libs/LibSettingsBuilder/Controls/Rows.lua @@ -0,0 +1,162 @@ +-- Enhanced Cooldown Manager addon for World of Warcraft +-- Author: Argium +-- Licensed under the GNU General Public License v3.0 + +local MAJOR = "LibSettingsBuilder-1.0" +local lib = LibStub(MAJOR, true) +if not lib or not lib._loadState or not lib._loadState.open then + return +end + +local internal = lib._internal +local applyCanvasState = internal.applyCanvasState +local applyEmbedCanvasFrame = internal.applyEmbedCanvasFrame +local applyHeaderFrame = internal.applyHeaderFrame +local applyInfoRowFrame = internal.applyInfoRowFrame +local applySubheaderFrame = internal.applySubheaderFrame +local copyMixin = internal.copyMixin +local createCustomListRowInitializer = internal.createCustomListRowInitializer +local hideHeaderActionButtons = internal.hideHeaderActionButtons +function internal.addLayoutInitializer(self, spec, initializer, refreshable) + local category = internal.resolveCategory(self, spec) + self._layouts[category]:AddInitializer(initializer) + if refreshable then + internal.registerCategoryRefreshable(self, category, initializer) + end + internal.applyModifiers(self, initializer, spec) + return initializer, category +end + +function lib.Header(self, textOrSpec, category) + local spec = type(textOrSpec) == "table" and textOrSpec or { + name = textOrSpec, + category = category, + } + + assert(not spec.actions, "Header: use PageActions for page header buttons") + local initializer = CreateSettingsListSectionHeaderInitializer(spec.name) + return internal.addLayoutInitializer(self, spec, initializer) +end + +function lib.PageActions(self, spec) + assert(spec.actions, "PageActions: spec.actions is required") + + local category = internal.resolveCategory(self, spec) + local categoryName = self._subcategoryNames[category] + or (category == self._rootCategory and self._rootCategoryName) + or "" + local attachToCategoryHeader = spec.attachToCategoryHeader ~= false + local hideTitle = spec.hideTitle + if hideTitle == nil then + hideTitle = attachToCategoryHeader + end + local initializer = createCustomListRowInitializer("SettingsListElementTemplate", { + _lsbKind = "pageActions", + name = spec.name or categoryName, + actions = spec.actions, + hideTitle = hideTitle, + attachToCategoryHeader = attachToCategoryHeader, + }, spec.height or (attachToCategoryHeader and 1 or 28), applyHeaderFrame) + + initializer._lsbEnabled = true + initializer.SetEnabled = function(controlInitializer, enabled) + controlInitializer._lsbEnabled = enabled + local activeFrame = controlInitializer._lsbActiveFrame + if activeFrame then + applyCanvasState(self, activeFrame, enabled) + end + end + + initializer._lsbRefreshFrame = function(frame) + applyHeaderFrame(frame, initializer:GetData()) + initializer:SetEnabled(initializer._lsbEnabled ~= false) + end + initializer._lsbResetFrame = hideHeaderActionButtons + return internal.addLayoutInitializer(self, spec, initializer, true) +end + +function lib.Subheader(self, spec) + local initializer = createCustomListRowInitializer("SettingsListElementTemplate", { + _lsbKind = "subheader", + name = spec.name, + }, 28, applySubheaderFrame) + return internal.addLayoutInitializer(self, spec, initializer) +end + +function lib.InfoRow(self, spec) + local initializer = createCustomListRowInitializer("SettingsListElementTemplate", { + _lsbKind = "infoRow", + name = spec.name, + value = spec.value, + wide = spec.wide, + multiline = spec.multiline, + }, spec.height or 26, applyInfoRowFrame) + initializer._lsbRefreshFrame = function(frame) + applyInfoRowFrame(frame, initializer:GetData()) + end + return internal.addLayoutInitializer(self, spec, initializer, type(spec.value) == "function" or type(spec.name) == "function") +end + +function lib.EmbedCanvas(self, canvas, height, spec) + spec = spec or {} + + local modifiers = copyMixin({}, spec) + modifiers.canvas = canvas + + local initializer = createCustomListRowInitializer("SettingsListElementTemplate", { + _lsbKind = "embedCanvas", + canvas = canvas, + }, height or canvas:GetHeight(), applyEmbedCanvasFrame) + + Settings.RegisterInitializer(internal.resolveCategory(self, spec), initializer) + internal.applyModifiers(self, initializer, modifiers) + + return initializer +end + +function internal.ensureConfirmDialog(self) + if self._confirmDialogName then + return self._confirmDialogName + end + + self._confirmDialogName = self._config.varPrefix .. "_" .. MAJOR:gsub("[%-%.]", "_") .. "_SettingsConfirm" + if not StaticPopupDialogs[self._confirmDialogName] then + StaticPopupDialogs[self._confirmDialogName] = { + text = "%s", + button1 = YES, + button2 = NO, + OnAccept = function(_, data) + if data and data.onAccept then + data.onAccept() + end + end, + timeout = 0, + whileDead = true, + hideOnEscape = true, + } + end + + return self._confirmDialogName +end + +function lib.Button(self, spec) + local callbackContext = internal.createCallbackContext(self, spec) + local onClick = spec.onClick + if spec.confirm then + local confirmDialogName = internal.ensureConfirmDialog(self) + local confirmText = type(spec.confirm) == "string" and spec.confirm or "Are you sure?" + local originalClick = onClick + onClick = function(ctx) + StaticPopup_Show(confirmDialogName, confirmText, nil, { + onAccept = function() + originalClick(ctx) + end, + }) + end + end + + local initializer = CreateSettingsButtonInitializer(spec.name, spec.buttonText or spec.name, function() + onClick(callbackContext) + end, spec.tooltip, true) + return internal.addLayoutInitializer(self, spec, initializer) +end diff --git a/Libs/LibSettingsBuilder/Core.lua b/Libs/LibSettingsBuilder/Core.lua new file mode 100644 index 00000000..a119f2e8 --- /dev/null +++ b/Libs/LibSettingsBuilder/Core.lua @@ -0,0 +1,1164 @@ +-- Enhanced Cooldown Manager addon for World of Warcraft +-- Author: Argium +-- Licensed under the GNU General Public License v3.0 + +-- LibSettingsBuilder: A standalone path-based settings builder for the +-- World of Warcraft Settings API. Provides proxy controls, composite groups +-- and utility helpers. + +--- Row or builder-level change hook fired after a value is written. +---@alias LibSettingsBuilderChangedCallback fun(ctx: LibSettingsBuilderCallbackContext, value: any) + +--- Row-local post-set hook fired before `config.onChanged`. +---@alias LibSettingsBuilderRowSetCallback fun(ctx: LibSettingsBuilderCallbackContext, value: any) + +--- Page lifecycle hook fired when Blizzard shows or hides a registered page. +---@alias LibSettingsBuilderPageLifecycleCallback fun() + +--- Custom nested-path getter used by path-bound rows. +---@alias LibSettingsBuilderGetNestedValue fun(tbl: table, path: string): any + +--- Custom nested-path setter used by path-bound rows. +---@alias LibSettingsBuilderSetNestedValue fun(tbl: table, path: string, value: any) + +--- Callback context passed to row callbacks and `config.onChanged`. +---@class LibSettingsBuilderCallbackContext +---@field builder LibSettingsBuilderRuntime Gets the runtime instance that owns the registered page tree. +---@field category table Gets the Blizzard Settings category backing the active row. +---@field key string|number|nil Gets the handler-mode key for rows registered through `key`. +---@field page LibSettingsBuilderPageHandle|nil Gets the registered page handle that owns the row, when available. +---@field path string|nil Gets the resolved path used by path-bound rows. +---@field setting table|nil Gets the proxy setting object for persisted row kinds. +---@field spec LibSettingsBuilderRowConfig Gets the normalized row spec that triggered the callback. + +--- Root registration config passed to `LSB.New(...)`. +--- Example (root page): +--- local lsb = LSB.New({ +--- name = "My Addon", +--- onChanged = function(ctx) MyAddon:Refresh() end, +--- page = { +--- key = "about", +--- rows = { { type = "info", name = "Version", value = AddOnVersion } }, +--- }, +--- }) +---@class LibSettingsBuilderConfig +---@field name string|nil Gets the root category display name. +---@field onChanged LibSettingsBuilderChangedCallback Gets the callback fired after a row setter completes. +---@field store table|(fun(): table)|nil Gets the store table or lazy provider used by path-bound rows. +---@field defaults table|(fun(): table)|nil Gets the defaults table or lazy provider used by path-bound rows. +---@field defaultsConfirmation fun(pageName: string, onAccept: fun())|nil Gets the optional confirmation hook shown before any category-header `Defaults` reset. +---@field getNestedValue LibSettingsBuilderGetNestedValue|nil Gets the custom nested-path reader used by path-bound rows. +---@field setNestedValue LibSettingsBuilderSetNestedValue|nil Gets the custom nested-path writer used by path-bound rows. +---@field page LibSettingsBuilderPageConfig|nil Gets the optional root-owned page definition. +---@field sections LibSettingsBuilderSectionConfig[]|nil Gets the optional section definitions registered under the root category. + +local MAJOR, MINOR = "LibSettingsBuilder-1.0", 6 +local lib = LibStub:NewLibrary(MAJOR, MINOR) +if not lib then + return +end + +lib._loadState = { open = true } +lib._internal = {} +lib._pageLifecycleCallbacks = {} +lib._pageLifecycleHooked = false + +local internal = lib._internal + +--- Returns Blizzard's category-header `Defaults` button if the SettingsPanel +--- has been created and the settings list is available. +local function getCategoryDefaultsButton() + local settingsList = SettingsPanel and SettingsPanel.GetSettingsList and SettingsPanel:GetSettingsList() + local header = settingsList and settingsList.Header + return header and header.DefaultsButton or nil +end + +--- Replaces the category-header `Defaults` button click handler with `onClick` +--- and forces it enabled (or evaluates `enabledPredicate` when supplied) for as +--- long as the override is active. Returns a restore function the caller must +--- invoke when the page is hidden so other categories keep Blizzard's default +--- behavior. +function internal.installCategoryDefaultsOverride(onClick, enabledPredicate, confirmDefaults, pageName) + local button = getCategoryDefaultsButton() + if not button then + return function() end + end + + local originalOnClick = button:GetScript("OnClick") + local originalEnabled = button:IsEnabled() + + local function applyEnabled() + if enabledPredicate then + button:SetEnabled(enabledPredicate() and true or false) + elseif not onClick then + button:SetEnabled(originalEnabled) + else + button:SetEnabled(true) + end + end + + button:SetScript("OnClick", function(self) + if enabledPredicate and not enabledPredicate() then + return + end + + local function reset() + if onClick then + onClick() + applyEnabled() + elseif originalOnClick then + originalOnClick(self) + end + end + + if confirmDefaults then + confirmDefaults(pageName, reset) + else + reset() + end + end) + applyEnabled() + + return function() + if button:GetScript("OnClick") then + button:SetScript("OnClick", originalOnClick) + end + button:SetEnabled(originalEnabled) + end +end + +--- Installs one-time hooks on SettingsPanel to fire page-level onShow/onHide +--- callbacks registered through the root/section/page API. Defers automatically if +--- SettingsPanel has not been created yet (Blizzard_Settings loads on demand). +local function installPageLifecycleHooks() if lib._pageLifecycleHooked then + return + end + + if type(SettingsPanel) ~= "table" or type(SettingsPanel.DisplayCategory) ~= "function" then + -- SettingsPanel not yet loaded; listen for ADDON_LOADED to retry. + if lib._pageLifecycleDeferred or type(CreateFrame) ~= "function" then + return + end + lib._pageLifecycleDeferred = true + local f = CreateFrame("Frame") + f:RegisterEvent("ADDON_LOADED") + f:SetScript("OnEvent", function(self) + if type(SettingsPanel) == "table" and type(SettingsPanel.DisplayCategory) == "function" then + self:UnregisterAllEvents() + installPageLifecycleHooks() + end + end) + return + end + + lib._pageLifecycleHooked = true + + -- DisplayCategory fires for both sidebar clicks and OpenToCategory. + -- Retrieve the active category via GetCurrentCategory inside the hook. + hooksecurefunc(SettingsPanel, "DisplayCategory", function(panel) + local category = panel.GetCurrentCategory and panel:GetCurrentCategory() or nil + local old = lib._activeLifecycleCategory + if old == category then + return + end + + if old then + local cbs = lib._pageLifecycleCallbacks[old] + if cbs then + if cbs._defaultsRestore then + cbs._defaultsRestore() + cbs._defaultsRestore = nil + end + if cbs.onHide then + cbs.onHide() + end + end + end + + lib._activeLifecycleCategory = category + if category then + local cbs = lib._pageLifecycleCallbacks[category] + if cbs then + if cbs.onDefault or cbs.confirmDefaults then + cbs._defaultsRestore = internal.installCategoryDefaultsOverride(cbs.onDefault, cbs.onDefaultEnabled, cbs.confirmDefaults, cbs.pageName) + end + if cbs.onShow then + cbs.onShow() + end + end + end + end) + + SettingsPanel:HookScript("OnHide", function() + local active = lib._activeLifecycleCategory + if active then + local cbs = lib._pageLifecycleCallbacks[active] + if cbs then + if cbs._defaultsRestore then + cbs._defaultsRestore() + cbs._defaultsRestore = nil + end + if cbs.onHide then + cbs.onHide() + end + end + end + lib._activeLifecycleCategory = nil + end) +end + +local function copyMixin(target, source) + for key, value in pairs(source) do + target[key] = value + end + return target +end + +local function setInitializerExtent(initializer, extent) + if initializer.SetExtent then + return initializer:SetExtent(extent) + end + initializer.GetExtent = function() + return extent + end +end + +local function getInitializerData(initializer) + return initializer and (initializer._lsbData or (initializer.GetData and initializer:GetData())) or nil +end + +local function getSettingVariable(setting) + return setting and (setting._lsbVariable or setting._variable) +end + +local function registerValueChangedCallback(frame, variable, callback, owner) + local handles = frame and frame.cbrHandles + if variable and handles and handles.SetOnValueChangedCallback then + handles:SetOnValueChangedCallback(variable, callback, owner or frame) + end +end + +local function makeStableSortKey(value) + local valueType = type(value) + if valueType == "number" then + return "1:" .. string.format("%020.10f", value) + end + if valueType == "boolean" then + return value and "2:true" or "2:false" + end + return valueType .. ":" .. tostring(value):lower() +end + +local function getOrderedValueEntries(values) + local entries = {} + if not values then + return entries + end + + for value, label in pairs(values) do + entries[#entries + 1] = { + value = value, + label = label, + labelSortKey = tostring(label):lower(), + valueSortKey = makeStableSortKey(value), + } + end + + table.sort(entries, function(left, right) + if left.labelSortKey == right.labelSortKey then + return left.valueSortKey < right.valueSortKey + end + return left.labelSortKey < right.labelSortKey + end) + + return entries +end + +local function showFrame(frame) + if frame and frame.Show then + frame:Show() + end +end + +local function setTextureValue(texture, value) + if not texture or not texture.SetTexture then + return + end + + if value == nil then + texture:SetTexture(nil) + return + end + + if type(value) == "number" and texture.SetToFileData then + texture:SetToFileData(value) + return + end + + texture:SetTexture(value) +end + +local DEFAULT_ACTION_BUTTON_HIGHLIGHT = "Interface\\Buttons\\ButtonHilight-Square" +local DEFAULT_ACTION_BUTTON_DISABLED_ALPHA = 0.4 +local DEFAULT_SWATCH_CENTER_X = -73 + +local function getButtonTextureValue(button, getterName) + local getter = button and button[getterName] + if type(getter) ~= "function" then + return nil + end + + local texture = getter(button) + if texture and texture.GetTexture then + return texture:GetTexture() + end + + return texture +end + +local function ensureActionButtonTextureDefaults(button) + if button._lsbActionButtonTextureDefaults then + return button._lsbActionButtonTextureDefaults + end + + local defaults = { + disabled = getButtonTextureValue(button, "GetDisabledTexture"), + highlight = getButtonTextureValue(button, "GetHighlightTexture"), + normal = getButtonTextureValue(button, "GetNormalTexture"), + pushed = getButtonTextureValue(button, "GetPushedTexture"), + } + + button._lsbActionButtonTextureDefaults = defaults + return defaults +end + +local function setButtonTextureState(button, setterName, getterName, value, blendMode, alpha) + local setter = button and button[setterName] + if type(setter) ~= "function" then + return + end + + if blendMode ~= nil then + setter(button, value, blendMode) + else + setter(button, value) + end + + local getter = button and button[getterName] + if type(getter) ~= "function" then + return + end + + local texture = getter(button) + if not texture then + return + end + + if texture.ClearAllPoints then + texture:ClearAllPoints() + end + if texture.SetAllPoints then + texture:SetAllPoints(button) + end + if alpha ~= nil and texture.SetAlpha then + texture:SetAlpha(alpha) + end +end + +local function applyActionButtonTextures(button, action, enabled) + if not button then + return + end + + local defaults = ensureActionButtonTextureDefaults(button) + local textures = action and action.buttonTextures + + button:SetText(textures and textures.normal and "" or (action and action.text or "")) + + if textures and textures.normal then + setButtonTextureState(button, "SetNormalTexture", "GetNormalTexture", textures.normal, nil, 1) + setButtonTextureState(button, "SetPushedTexture", "GetPushedTexture", textures.pushed or textures.normal, nil, 1) + setButtonTextureState(button, "SetDisabledTexture", "GetDisabledTexture", textures.disabled or textures.normal, nil, 1) + + local highlight = textures.highlight + if highlight == nil then + highlight = DEFAULT_ACTION_BUTTON_HIGHLIGHT + end + setButtonTextureState( + button, + "SetHighlightTexture", + "GetHighlightTexture", + highlight, + highlight and "ADD" or nil, + textures.highlightAlpha or 0.25 + ) + + button:SetAlpha(enabled == false and (textures.disabledAlpha or DEFAULT_ACTION_BUTTON_DISABLED_ALPHA) or 1) + + button._lsbUsesActionButtonTextures = true + return + end + + if button._lsbUsesActionButtonTextures then + setButtonTextureState(button, "SetNormalTexture", "GetNormalTexture", defaults.normal) + setButtonTextureState(button, "SetPushedTexture", "GetPushedTexture", defaults.pushed) + setButtonTextureState(button, "SetDisabledTexture", "GetDisabledTexture", defaults.disabled) + setButtonTextureState(button, "SetHighlightTexture", "GetHighlightTexture", defaults.highlight) + button._lsbUsesActionButtonTextures = nil + end + + button:SetAlpha(1) +end + +local function setGameTooltipText(text, wrap) + GameTooltip:SetText(text, 1, 1, 1, 1, wrap == true) +end + +local function setSimpleTooltip(owner, text) + if not owner then + return + end + + owner:SetScript("OnEnter", nil) + owner:SetScript("OnLeave", nil) + + if not text or text == "" then + return + end + + owner:SetScript("OnEnter", function(self) + if not GameTooltip then + return + end + + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:ClearLines() + setGameTooltipText(text, true) + GameTooltip:Show() + end) + owner:SetScript("OnLeave", function() + if GameTooltip_Hide then + GameTooltip_Hide() + end + end) +end + +local function evaluateStaticOrFunction(value, ...) + if type(value) == "function" then + return value(...) + end + return value +end + +local function createTitle(parent, template, x, y, text, fontObject) + local title = parent:CreateFontString(nil, "OVERLAY", template) + title:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y) + title:SetJustifyH("LEFT") + title:SetJustifyV("TOP") + if fontObject then + title:SetFontObject(fontObject) + end + if text ~= nil then + title:SetText(text) + end + title:Show() + return title +end + +local function createSubheaderTitle(parent, text) + return createTitle(parent, "GameFontNormalSmall", 35, -8, text) +end + +local function createHeaderTitle(parent, text) + return createTitle(parent, "GameFontHighlightLarge", 7, -16, text) +end + +-------------------------------------------------------------------------------- +-- CanvasLayout: Vertical stacking engine for canvas subcategory pages. +-- Replicates Blizzard's Settings panel positioning so canvas pages are +-- visually indistinguishable from vertical-layout pages. +-- +-- Measurements from Blizzard_SettingControls.xml/.lua: +-- Element height: 26 (all control types) +-- Section header: 45 (GameFontHighlightLarge at TOPLEFT 7, -16) +-- Label left offset: indent + 37 +-- Label right bound: CENTER - 85 +-- Control anchor: CENTER - 80 (checkbox, slider, color swatch) +-- Button anchor: CENTER - 40 (width 200) +-- Indent per level: 15 +-------------------------------------------------------------------------------- + +internal.CanvasLayoutDefaults = { + elementHeight = 26, + headerHeight = 50, + labelX = 37, + controlCenterX = -80, + buttonCenterX = -40, + buttonWidth = 200, + sliderWidth = 250, + swatchCenterX = DEFAULT_SWATCH_CENTER_X, + verifiedPatch = "Retail 12.0/12.1", +} +local CanvasLayout = {} +internal.CanvasLayout = CanvasLayout + +local function getCanvasLayoutMetrics(layout) + return layout._metrics or internal.CanvasLayoutDefaults +end + +function CanvasLayout:_Advance(h) + self.yPos = self.yPos - h +end + +function CanvasLayout:_CreateRow(h) + local metrics = getCanvasLayoutMetrics(self) + h = h or metrics.elementHeight + local row = CreateFrame("Frame", nil, self.frame) + row:SetPoint("TOPLEFT", 0, self.yPos) + row:SetPoint("RIGHT") + row:SetHeight(h) + self.elements[#self.elements + 1] = row + self:_Advance(h) + return row +end + +function CanvasLayout:_AddLabel(row, text, fontObject) + local metrics = getCanvasLayoutMetrics(self) + local label = row:CreateFontString(nil, "OVERLAY", fontObject or "GameFontNormal") + label:SetPoint("LEFT", metrics.labelX, 0) + label:SetPoint("RIGHT", row, "CENTER", -85, 0) + label:SetJustifyH("LEFT") + label:SetWordWrap(false) + label:SetText(text) + row._label = label + return label +end + +--- Add a page header using Blizzard's SettingsListTemplate.Header. +--- Provides Title, Options_HorizontalDivider, and DefaultsButton. +---@return Frame row (row._title, row._defaultsButton exposed) +function CanvasLayout:AddHeader(text) + local metrics = getCanvasLayoutMetrics(self) + local row = self:_CreateRow(metrics.headerHeight) + local settingsList = CreateFrame("Frame", nil, row, "SettingsListTemplate") + settingsList:SetAllPoints(row) + settingsList.ScrollBox:Hide() + settingsList.ScrollBar:Hide() + settingsList.Header.Title:SetText(text) + row._title = settingsList.Header.Title + row._defaultsButton = settingsList.Header.DefaultsButton + return row +end + +--- Add vertical spacing. +function CanvasLayout:AddSpacer(height) + self:_Advance(height) +end + +--- Add a description / informational text row. +function CanvasLayout:AddDescription(text, fontObject) + local metrics = getCanvasLayoutMetrics(self) + local row = self:_CreateRow() + local label = row:CreateFontString(nil, "OVERLAY", fontObject or "GameFontNormal") + label:SetPoint("LEFT", metrics.labelX, 0) + label:SetPoint("RIGHT", row, "RIGHT", -10, 0) + label:SetJustifyH("LEFT") + label:SetText(text) + row._text = label + return row +end + +--- Add a color swatch row (label + clickable swatch). +---@return Frame row, Button swatch +function CanvasLayout:AddColorSwatch(labelText) + local metrics = getCanvasLayoutMetrics(self) + local row = self:_CreateRow() + self:_AddLabel(row, labelText) + local swatch = internal.createColorSwatch(row) + swatch:SetPoint("LEFT", row, "CENTER", metrics.swatchCenterX, 0) + row._swatch = swatch + return row, swatch +end + +--- Add a slider row (label + MinimalSliderWithSteppers). +---@return Frame row, Slider slider, FontString valueText +function CanvasLayout:AddSlider(labelText, min, max, step) + local metrics = getCanvasLayoutMetrics(self) + local row = self:_CreateRow() + self:_AddLabel(row, labelText) + local slider = CreateFrame("Slider", nil, row, "MinimalSliderWithSteppersTemplate") + slider:SetWidth(metrics.sliderWidth) + slider:SetPoint("LEFT", row, "CENTER", metrics.controlCenterX, 3) + slider:SetMinMaxValues(min, max) + slider:SetValueStep(step or 1) + slider:SetObeyStepOnDrag(true) + local valueText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + valueText:SetPoint("LEFT", slider, "RIGHT", 8, 0) + valueText:SetWidth(40) + valueText:SetJustifyH("LEFT") + row._slider = slider + row._valueText = valueText + return row, slider, valueText +end + +--- Add a button row (label + UIPanelButton). +---@return Frame row, Button button +function CanvasLayout:AddButton(labelText, buttonText) + local metrics = getCanvasLayoutMetrics(self) + local row = self:_CreateRow() + self:_AddLabel(row, labelText) + local button = CreateFrame("Button", nil, row, "UIPanelButtonTemplate") + button:SetSize(metrics.buttonWidth, 26) + button:SetPoint("LEFT", row, "CENTER", metrics.buttonCenterX, 0) + button:SetText(buttonText) + row._button = button + return row, button +end + +--- Add a scroll list that fills the remaining vertical space. +---@return Frame scrollBox, EventFrame scrollBar, table view +function CanvasLayout:AddScrollList(elementExtent) + local metrics = getCanvasLayoutMetrics(self) + local scrollBox = CreateFrame("Frame", nil, self.frame, "WowScrollBoxList") + scrollBox:SetPoint("TOPLEFT", metrics.labelX, self.yPos) + scrollBox:SetPoint("BOTTOMRIGHT", -30, 10) + local scrollBar = CreateFrame("EventFrame", nil, self.frame, "MinimalScrollBar") + scrollBar:SetPoint("TOPLEFT", scrollBox, "TOPRIGHT", 5, 0) + scrollBar:SetPoint("BOTTOMLEFT", scrollBox, "BOTTOMRIGHT", 5, 0) + local view = CreateScrollBoxListLinearView() + view:SetElementExtent(elementExtent) + ScrollUtil.InitScrollBoxListWithScrollBar(scrollBox, scrollBar, view) + return scrollBox, scrollBar, view +end + +-------------------------------------------------------------------------------- +-- Static utilities (shared across all instances) +-------------------------------------------------------------------------------- + +--- Create a color swatch button using Blizzard's SettingsColorSwatchTemplate. +--- Inherits ColorSwatchTemplate (SwatchBg/InnerBorder/Color layers) and +--- SettingsColorSwatchMixin (hover effects, color picker integration). +---@param parent Frame +---@return Button swatch (swatch._tex points to swatch.Color for backward compat) +local function createColorSwatch(parent) + local swatch = CreateFrame("Button", nil, parent, "SettingsColorSwatchTemplate") + swatch._tex = swatch.Color + swatch:EnableMouse(true) + swatch:RegisterForClicks("LeftButtonUp", "RightButtonUp") + swatch:SetPropagateMouseClicks(false) + return swatch +end +internal.createColorSwatch = createColorSwatch + +-------------------------------------------------------------------------------- +-- Path accessors: built-in dot-path resolution with numeric key support +-------------------------------------------------------------------------------- + +local function defaultGetNestedValue(tbl, path) + local current = tbl + for segment in path:gmatch("[^.]+") do + if type(current) ~= "table" then + return nil + end + local val = current[segment] + if val == nil then + local num = tonumber(segment) + if num then + val = current[num] + end + end + current = val + end + return current +end + +local function defaultSetNestedValue(tbl, path, value) + local current, lastKey = tbl, nil + for segment in path:gmatch("[^.]+") do + if lastKey then + local resolved = lastKey + local existing = current[lastKey] + if existing == nil then + local num = tonumber(lastKey) + if num and current[num] ~= nil then + resolved = num + existing = current[num] + end + end + if type(existing) ~= "table" then + existing = {} + current[resolved] = existing + end + current = existing + end + lastKey = segment + end + assert(lastKey, "defaultSetNestedValue: path is required") + local resolved = lastKey + if current[lastKey] == nil then + local num = tonumber(lastKey) + if num then + resolved = num + end + end + current[resolved] = value +end + +local function createStoreAdapter(config) + local getNested = config.getNestedValue or defaultGetNestedValue + local setNested = config.setNestedValue or defaultSetNestedValue + + return { + resolve = function(self, path) + return { + get = function() + return getNested(config.getStore(), path) + end, + set = function(value) + setNested(config.getStore(), path, value) + end, + default = getNested(config.getDefaults(), path), + } + end, + read = function(self, path) + return getNested(config.getStore(), path) + end, + } +end + +local function defaultSliderFormatter(value) + return value == math.floor(value) and tostring(math.floor(value)) or string.format("%.1f", value) +end + +local function makeVarPrefixFromName(name) + local words = {} + for word in tostring(name or ""):gmatch("[A-Za-z0-9]+") do + words[#words + 1] = word + end + + local prefix = "" + if #words > 1 then + for _, word in ipairs(words) do + prefix = prefix .. word:sub(1, 1):upper() + end + elseif words[1] then + prefix = words[1]:upper():gsub("[^A-Z0-9]", "") + end + + if prefix == "" then + prefix = "LSB" + end + + return prefix +end + +local MODIFIER_KEYS = { "category", "disabled", "hidden", "layout" } + +local COMMON_SPEC_FIELDS = { + path = true, + name = true, + tooltip = true, + category = true, + onSet = true, + getTransform = true, + setTransform = true, + disabled = true, + hidden = true, + layout = true, + type = true, + desc = true, + get = true, + set = true, + key = true, + default = true, +} + +local EXTRA_FIELDS_BY_TYPE = { + checkbox = {}, + slider = { min = true, max = true, step = true, formatter = true }, + dropdown = { values = true, scrollHeight = true }, + color = {}, + input = { + debounce = true, + maxLetters = true, + numeric = true, + onTextChanged = true, + resolveText = true, + width = true, + }, + custom = { template = true, varType = true }, +} + +function internal.makeVarName(self, spec) + local id = spec.key or spec.path + return self._config.varPrefix .. "_" .. tostring(id):gsub("%.", "_") +end + +---@param self LibSettingsBuilderRuntime +---@param spec LibSettingsBuilderRowConfig|table +function internal.createCallbackContext(self, spec, setting) + return { + builder = self, + category = internal.resolveCategory(self, spec), + key = spec.key, + page = spec._page and spec._page._handle, + path = spec.path, + setting = setting, + spec = spec, + } +end + +function internal.resolveCategory(self, spec) + return spec.category or self._currentSubcategory or self._rootCategory +end + +function internal.registerCategoryRefreshable(self, category, initializer) + if not category or not initializer then + return + end + + local refreshables = self._categoryRefreshables[category] + if not refreshables then + refreshables = {} + self._categoryRefreshables[category] = refreshables + end + + for _, existing in ipairs(refreshables) do + if existing == initializer then + return + end + end + + refreshables[#refreshables + 1] = initializer +end + +function internal.postSet(self, spec, value, setting) + local ctx = internal.createCallbackContext(self, spec, setting) + if spec.onSet then + spec.onSet(ctx, value) + end + self._config.onChanged(ctx, value) + internal.reevaluateReactiveControls(self) +end + +function internal.resolveBinding(self, spec) + local hasPath = spec.path ~= nil + local hasHandler = spec.get ~= nil or spec.set ~= nil + + assert(not (hasPath and hasHandler), "spec cannot have both path and get/set") + + if hasHandler then + assert(spec.get, "handler mode requires get") + assert(spec.set, "handler mode requires set") + assert(spec.key, "handler mode requires key") + return { get = spec.get, set = spec.set, default = spec.default } + end + + assert(hasPath, "spec must have either path or get/set") + assert(self._adapter, "path mode requires store/defaults on the builder") + + local binding = self._adapter:resolve(spec.path) + if spec.default ~= nil then + binding.default = spec.default + end + return binding +end + +function internal.makeProxySetting(self, spec, varType, defaultFallback, binding) + local variable = internal.makeVarName(self, spec) + local category = internal.resolveCategory(self, spec) + local setting + + binding = binding or internal.resolveBinding(self, spec) + + local function getter() + local value = binding.get() + if spec.getTransform then + value = spec.getTransform(value) + end + return value + end + + local function applyValue(value) + if spec.setTransform then + value = spec.setTransform(value) + end + binding.set(value) + return value + end + + local function setter(value) + value = applyValue(value) + internal.postSet(self, spec, value, setting) + end + + local function setValueNoCallback(_, value) + value = applyValue(value) + self._config.onChanged(internal.createCallbackContext(self, spec, setting), value) + internal.reevaluateReactiveControls(self) + end + + local defaultValue = binding.default + if spec.getTransform then + defaultValue = spec.getTransform(defaultValue) + end + if defaultValue == nil then + defaultValue = defaultFallback + end + + setting = Settings.RegisterProxySetting(category, variable, varType, spec.name, defaultValue, getter, setter) + setting.SetValueNoCallback = setValueNoCallback + setting._lsbVariable = variable + + return setting, category +end + +function internal.propagateModifiers(self, target, source) + for _, key in ipairs(MODIFIER_KEYS) do + if target[key] == nil then + target[key] = source[key] + end + end +end + +function internal.validateSpecFields(self, controlType, spec) + if not LSB_DEBUG then + return + end + + local allowed = EXTRA_FIELDS_BY_TYPE[controlType] + if not allowed then + return + end + + for key in pairs(spec) do + if not COMMON_SPEC_FIELDS[key] and not allowed[key] then + print( + "|cffFF8800LibSettingsBuilder WARNING:|r Unknown spec field '" + .. tostring(key) + .. "' on " + .. controlType + .. " control '" + .. tostring(spec.name or spec.path) + .. "'" + ) + end + end +end + +function internal.setCanvasInteractive(self, frame, enabled) + if frame.SetEnabled then + frame:SetEnabled(enabled) + end + if frame.EnableMouse then + frame:EnableMouse(enabled) + end + if frame.GetChildren then + local children = { frame:GetChildren() } + for i = 1, #children do + internal.setCanvasInteractive(self, children[i], enabled) + end + end +end + +function internal.isParentEnabled(self, spec) + if not spec._parentInitializer then + return true + end + if spec._parentPredicate then + return spec._parentPredicate() + end + if not spec._parentInitializer.GetSetting then + return true + end + + local setting = spec._parentInitializer:GetSetting() + if not setting then + return true + end + + return setting:GetValue() +end + +function internal.isControlEnabled(self, spec) + if spec.disabled and spec.disabled() then + return false + end + return internal.isParentEnabled(self, spec) +end + +function internal.applyCanvasState(self, canvas, enabled) + canvas:SetAlpha(enabled and 1 or 0.5) + internal.setCanvasInteractive(self, canvas, enabled) +end + +function internal.reevaluateReactiveControls(self) + local panel = SettingsPanel + if panel and panel:IsShown() then + local settingsList = panel:GetSettingsList() + if settingsList and settingsList.ScrollBox then + settingsList.ScrollBox:ForEachFrame(function(frame) + if frame.EvaluateState then + frame:EvaluateState() + end + end) + end + end + + for _, entry in ipairs(self._reactiveControls) do + local spec = entry[2] + if spec.canvas then + internal.applyCanvasState(self, spec.canvas, internal.isControlEnabled(self, spec)) + end + end +end + +function internal.applyEnabledState(self, initializer, spec) + local enabled = internal.isControlEnabled(self, spec) + if initializer.SetEnabled then + initializer:SetEnabled(enabled) + end + if spec.canvas then + internal.applyCanvasState(self, spec.canvas, enabled) + end + return enabled +end + +function internal.applyModifiers(self, initializer, spec) + if not initializer then + return + end + + if spec.disabled or spec.canvas or spec._parentInitializer then + initializer:AddModifyPredicate(function() + return internal.applyEnabledState(self, initializer, spec) + end) + internal.applyEnabledState(self, initializer, spec) + end + + if spec._parentInitializer then + initializer:SetParentInitializer(spec._parentInitializer, function() + return internal.isParentEnabled(self, spec) + end) + end + + if spec.hidden then + initializer:AddShownPredicate(function() + return not spec.hidden() + end) + end + + if spec.canvas then + self._reactiveControls[#self._reactiveControls + 1] = { initializer, spec } + end +end + +function internal.colorTableToHex(self, tbl) + if not tbl then + return "FFFFFFFF" + end + return string.format( + "%02X%02X%02X%02X", + math.floor((tbl.a or 1) * 255 + 0.5), + math.floor((tbl.r or 1) * 255 + 0.5), + math.floor((tbl.g or 1) * 255 + 0.5), + math.floor((tbl.b or 1) * 255 + 0.5) + ) +end + +function internal.storeCategory(self, name, category, layout) + self._subcategories[name] = category + self._subcategoryNames[category] = name + self._layouts[category] = layout + return category +end + +internal.defaultSliderFormatter = defaultSliderFormatter + +--- Creates a LibSettingsBuilder runtime instance and optionally registers the full declarative tree. +--- `config.onChanged(ctx, value)` runs after any row-local `onSet(ctx, value)` hook. +--- Path-bound rows resolve against `config.store` / `config.defaults`; handler-bound rows use row-local `get`, `set`, and `key` callbacks. +---@overload fun(config: LibSettingsBuilderConfig): LibSettingsBuilderRuntime +---@param selfOrConfig LibSettingsBuilderConfig|table +---@param maybeConfig LibSettingsBuilderConfig|nil +---@return LibSettingsBuilderRuntime lsb +function lib.New(selfOrConfig, maybeConfig) + local config = maybeConfig or selfOrConfig + assert(type(config) == "table", "LibSettingsBuilder.New: config table is required") + + assert(config.varPrefix == nil, "LibSettingsBuilder: varPrefix is not part of the v2 config") + assert(config.pathAdapter == nil, "LibSettingsBuilder: pathAdapter is not part of the v2 config") + assert(config.compositeDefaults == nil, "LibSettingsBuilder: compositeDefaults is not part of the v2 config") + config.varPrefix = makeVarPrefixFromName(config.name) + assert(config.onChanged, "LibSettingsBuilder: onChanged is required") + + local adapter + if config.store ~= nil then + local getStore = type(config.store) == "function" and config.store or function() + return config.store + end + local getDefaults = type(config.defaults) == "function" and config.defaults or function() + return config.defaults + end + adapter = createStoreAdapter({ + getDefaults = getDefaults, + getNestedValue = config.getNestedValue, + getStore = getStore, + setNestedValue = config.setNestedValue, + }) + end + + local lsb = setmetatable({ + _config = config, + _adapter = adapter, + _rootCategory = nil, + _rootCategoryName = nil, + _rootRegistered = nil, + _registeredRootPage = nil, + _currentSubcategory = nil, + _subcategories = {}, + _subcategoryNames = {}, + _layouts = {}, + _reactiveControls = {}, + _categoryRefreshables = {}, + _pages = {}, + _pageList = {}, + _sectionList = {}, + _sections = {}, + _nextRootPageSequence = 0, + _nextSectionSequence = 0, + name = nil, + }, { __index = lib._publicApi or lib }) + + if config.name ~= nil then + lsb:_initializeRoot(config.name) + end + + if config.page or config.sections then + lsb:_registerTree({ + page = config.page, + sections = config.sections, + }) + end + + return lsb +end + +-- Export local functions to internal for cross-file access +internal.installPageLifecycleHooks = installPageLifecycleHooks +internal.copyMixin = copyMixin +internal.setInitializerExtent = setInitializerExtent +internal.getInitializerData = getInitializerData +internal.getSettingVariable = getSettingVariable +internal.registerValueChangedCallback = registerValueChangedCallback +internal.getOrderedValueEntries = getOrderedValueEntries +internal.showFrame = showFrame +internal.setTextureValue = setTextureValue +internal.setGameTooltipText = setGameTooltipText +internal.setSimpleTooltip = setSimpleTooltip +internal.applyActionButtonTextures = applyActionButtonTextures +internal.evaluateStaticOrFunction = evaluateStaticOrFunction +internal.getCanvasLayoutMetrics = getCanvasLayoutMetrics +internal.defaultSwatchCenterX = DEFAULT_SWATCH_CENTER_X +internal.createHeaderTitle = createHeaderTitle +internal.createSubheaderTitle = createSubheaderTitle diff --git a/Libs/LibSettingsBuilder/LibSettingsBuilder.lua b/Libs/LibSettingsBuilder/LibSettingsBuilder.lua deleted file mode 100644 index 5df2377a..00000000 --- a/Libs/LibSettingsBuilder/LibSettingsBuilder.lua +++ /dev/null @@ -1,1900 +0,0 @@ --- Enhanced Cooldown Manager addon for World of Warcraft --- Author: Argium --- Licensed under the GNU General Public License v3.0 - --- LibSettingsBuilder: A standalone path-based settings builder for the --- World of Warcraft Settings API. Provides proxy controls, composite groups --- and utility helpers. - -local MAJOR, MINOR = "LibSettingsBuilder-1.0", 1 -local lib = LibStub:NewLibrary(MAJOR, MINOR) -if not lib then - return -end - -lib.EMBED_CANVAS_TEMPLATE = "SettingsListElementTemplate" -lib.SUBHEADER_TEMPLATE = "SettingsListElementTemplate" -lib.INFOROW_TEMPLATE = "SettingsListElementTemplate" -lib.SCROLL_DROPDOWN_TEMPLATE = "SettingsDropdownControlTemplate" - -lib._pageLifecycleCallbacks = lib._pageLifecycleCallbacks or {} -lib._pageLifecycleHooked = lib._pageLifecycleHooked or false - ---- Installs one-time hooks on SettingsPanel to fire page-level onShow/onHide ---- callbacks registered via RegisterFromTable. Defers automatically if ---- SettingsPanel has not been created yet (Blizzard_Settings loads on demand). -local function installPageLifecycleHooks() - if lib._pageLifecycleHooked then - return - end - - if type(SettingsPanel) ~= "table" or type(SettingsPanel.DisplayCategory) ~= "function" then - -- SettingsPanel not yet loaded; listen for ADDON_LOADED to retry. - if lib._pageLifecycleDeferred or type(CreateFrame) ~= "function" then - return - end - lib._pageLifecycleDeferred = true - local f = CreateFrame("Frame") - f:RegisterEvent("ADDON_LOADED") - f:SetScript("OnEvent", function(self) - if type(SettingsPanel) == "table" and type(SettingsPanel.DisplayCategory) == "function" then - self:UnregisterAllEvents() - installPageLifecycleHooks() - end - end) - return - end - - lib._pageLifecycleHooked = true - - -- DisplayCategory fires for both sidebar clicks and OpenToCategory. - -- Retrieve the active category via GetCurrentCategory inside the hook. - hooksecurefunc(SettingsPanel, "DisplayCategory", function(panel) - local category = panel.GetCurrentCategory and panel:GetCurrentCategory() or nil - local old = lib._activeLifecycleCategory - if old == category then - return - end - - if old then - local cbs = lib._pageLifecycleCallbacks[old] - if cbs and cbs.onHide then - cbs.onHide() - end - end - - lib._activeLifecycleCategory = category - if category then - local cbs = lib._pageLifecycleCallbacks[category] - if cbs and cbs.onShow then - cbs.onShow() - end - end - end) - - SettingsPanel:HookScript("OnHide", function() - local active = lib._activeLifecycleCategory - if active then - local cbs = lib._pageLifecycleCallbacks[active] - if cbs and cbs.onHide then - cbs.onHide() - end - end - lib._activeLifecycleCategory = nil - end) -end - -local listElementKeysToHide = { "_lsbSubheaderTitle", "_lsbInfoTitle", "_lsbInfoValue", "_lsbCanvas" } - -local function copyMixin(target, source) - for key, value in pairs(source) do - target[key] = value - end - return target -end - -local function setInitializerExtent(initializer, extent) - if initializer.SetExtent then - initializer:SetExtent(extent) - return - end - - initializer.GetExtent = function() - return extent - end -end - -local function getInitializerData(initializer) - if initializer and initializer.GetData then - return initializer:GetData() - end -end - -local function makeStableSortKey(value) - local valueType = type(value) - if valueType == "number" then - return "1:" .. string.format("%020.10f", value) - end - if valueType == "boolean" then - return value and "2:true" or "2:false" - end - return valueType .. ":" .. tostring(value):lower() -end - -local function getOrderedValueEntries(values) - local entries = {} - if not values then - return entries - end - - for value, label in pairs(values) do - entries[#entries + 1] = { - value = value, - label = label, - labelSortKey = tostring(label):lower(), - valueSortKey = makeStableSortKey(value), - } - end - - table.sort(entries, function(left, right) - if left.labelSortKey == right.labelSortKey then - return left.valueSortKey < right.valueSortKey - end - return left.labelSortKey < right.labelSortKey - end) - - return entries -end - -local function resetListElement(frame) - for _, key in ipairs(listElementKeysToHide) do - local region = frame[key] - if region then - region:Hide() - end - end -end - -local function ensureSubheaderTitle(frame) - if frame._lsbSubheaderTitle then - return frame._lsbSubheaderTitle - end - - local title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") - title:SetPoint("TOPLEFT", 35, -8) - title:SetJustifyH("LEFT") - title:SetJustifyV("TOP") - frame._lsbSubheaderTitle = title - frame.Title = title - return title -end - -local function ensureInfoRowWidgets(frame) - if frame._lsbInfoTitle and frame._lsbInfoValue then - return frame._lsbInfoTitle, frame._lsbInfoValue - end - - local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal") - title:SetPoint("LEFT", 37, 0) - title:SetPoint("RIGHT", frame, "CENTER", -85, 0) - title:SetJustifyH("LEFT") - - local value = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight") - value:SetPoint("LEFT", frame, "CENTER", -80, 0) - value:SetJustifyH("LEFT") - - frame._lsbInfoTitle = title - frame._lsbInfoValue = value - frame.Title = title - frame.Value = value - - return title, value -end - -local function applySubheaderFrame(frame, data) - local title = ensureSubheaderTitle(frame) - title:SetFontObject(GameFontHighlight) - title:SetText(data.name) - title:Show() -end - -local function applyInfoRowFrame(frame, data) - local title, value = ensureInfoRowWidgets(frame) - title:SetText(data.name) - value:SetText(data.value) - title:Show() - value:Show() -end - -local function applyEmbedCanvasFrame(frame, data, initializer) - local canvas = data.canvas - if not canvas then - return - end - - frame._lsbCanvas = canvas - canvas:SetParent(frame) - canvas:ClearAllPoints() - canvas:SetPoint("TOPLEFT", 0, 0) - canvas:SetPoint("TOPRIGHT", 0, 0) - canvas:SetHeight(initializer:GetExtent()) - canvas:Show() -end - -local function ensureListElementCallbackHandles(frame) - if frame.cbrHandles or not (Settings and Settings.CreateCallbackHandleContainer) then - return - end - - frame.cbrHandles = Settings.CreateCallbackHandleContainer() -end - -local function initializerShouldShow(initializer) - if initializer and initializer.ShouldShow then - return initializer:ShouldShow() - end - - if initializer and initializer._shownPredicates then - for _, predicate in ipairs(initializer._shownPredicates) do - if not predicate() then - return false - end - end - end - - return true -end - -local function createCustomListRowInitializer(template, data, extent, initFrame) - local initializer = Settings.CreateElementInitializer(template, data) - setInitializerExtent(initializer, extent) - - initializer.InitFrame = function(self, frame) - ensureListElementCallbackHandles(frame) - - frame.data = self.data - if frame.Text then - frame.Text:SetText("") - end - if frame.NewFeature then - frame.NewFeature:Hide() - end - - resetListElement(frame) - initFrame(frame, self.data, self) - - if not frame._lsbHasCustomEvaluateState then - frame.EvaluateState = function(control) - local currentInitializer = control.GetElementData and control:GetElementData() - or control._lsbInitializer - control:SetShown(initializerShouldShow(currentInitializer)) - end - frame._lsbHasCustomEvaluateState = true - end - - frame._lsbInitializer = self - frame:EvaluateState() - end - - initializer.Resetter = function(self, frame) - if frame.cbrHandles and frame.cbrHandles.Unregister then - frame.cbrHandles:Unregister() - end - if frame.Text then - frame.Text:SetText("") - end - if frame.NewFeature then - frame.NewFeature:Hide() - end - if frame._lsbCanvas then - frame._lsbCanvas:Hide() - end - - resetListElement(frame) - frame.data = nil - frame._lsbInitializer = nil - end - - return initializer -end - -local ScrollDropdownMethods = {} - -function ScrollDropdownMethods:GetSetting() - if self.initializer and self.initializer.GetSetting then - return self.initializer:GetSetting() - end - return self.lsbData and self.lsbData.setting or nil -end - -function ScrollDropdownMethods:RefreshDropdownText(value) - local dropdown = self.Control and self.Control.Dropdown - if not dropdown then - return - end - - local setting = self:GetSetting() - local currentValue = value - if currentValue == nil and setting and setting.GetValue then - currentValue = setting:GetValue() - end - - local values = self.lsbData and self.lsbData.values - if type(values) == "function" then - values = values() - end - local text = values and values[currentValue] or tostring(currentValue or "") - - if dropdown.OverrideText then - dropdown:OverrideText(text) - elseif dropdown.SetText then - dropdown:SetText(text) - end -end - -function ScrollDropdownMethods:SetValue(value) - self:RefreshDropdownText(value) -end - -function ScrollDropdownMethods:InitDropdown() - local setting = self:GetSetting() - local data = self.lsbData or {} - local scrollHeight = data.scrollHeight or 200 - - local dropdown = self.Control and self.Control.Dropdown - if not dropdown or not setting then - return - end - - dropdown:SetupMenu(function(_, rootDescription) - rootDescription:SetScrollMode(scrollHeight) - - local values = data.values - if type(values) == "function" then - values = values() - end - if not values then - return - end - - for _, entry in ipairs(getOrderedValueEntries(values)) do - rootDescription:CreateRadio(entry.label, function() - return setting:GetValue() == entry.value - end, function() - setting:SetValue(entry.value) - self:RefreshDropdownText(entry.value) - end, entry.value) - end - end) - - self:RefreshDropdownText() -end - -local function configureScrollDropdownFrame(frame, initializer) - if not frame._lsbOriginalSetValue then - frame._lsbOriginalSetValue = frame.SetValue - end - - copyMixin(frame, ScrollDropdownMethods) - frame.initializer = initializer - frame.lsbData = initializer:GetData() or {} - frame:InitDropdown() -end - -if not lib._scrollDropdownHookInstalled and hooksecurefunc and SettingsDropdownControlMixin then - hooksecurefunc(SettingsDropdownControlMixin, "Init", function(frame, initializer) - local data = getInitializerData(initializer) - if not data or data._lsbKind ~= "scrollDropdown" then - if frame._lsbOriginalSetValue then - frame.SetValue = frame._lsbOriginalSetValue - end - frame.initializer = initializer - frame.lsbData = nil - return - end - - configureScrollDropdownFrame(frame, initializer) - end) - - lib._scrollDropdownHookInstalled = true -end - --------------------------------------------------------------------------------- --- CanvasLayout: Vertical stacking engine for canvas subcategory pages. --- Replicates Blizzard's Settings panel positioning so canvas pages are --- visually indistinguishable from vertical-layout pages. --- --- Measurements from Blizzard_SettingControls.xml/.lua: --- Element height: 26 (all control types) --- Section header: 45 (GameFontHighlightLarge at TOPLEFT 7, -16) --- Label left offset: indent + 37 --- Label right bound: CENTER - 85 --- Control anchor: CENTER - 80 (checkbox, slider, color swatch) --- Button anchor: CENTER - 40 (width 200) --- Indent per level: 15 --------------------------------------------------------------------------------- - -lib.CanvasLayoutDefaults = lib.CanvasLayoutDefaults - or { - elementHeight = 26, - headerHeight = 50, - labelX = 37, - controlCenterX = -80, - buttonCenterX = -40, - buttonWidth = 200, - sliderWidth = 250, - swatchCenterX = -73, - verifiedPatch = "Retail 12.0/12.1", - } - -local CanvasLayout = {} -lib.CanvasLayout = CanvasLayout - -local function getCanvasLayoutMetrics(layout) - return layout._metrics or lib.CanvasLayoutDefaults -end - -function CanvasLayout:_Advance(h) - self.yPos = self.yPos - h -end - -function CanvasLayout:_CreateRow(h) - local metrics = getCanvasLayoutMetrics(self) - h = h or metrics.elementHeight - local row = CreateFrame("Frame", nil, self.frame) - row:SetPoint("TOPLEFT", 0, self.yPos) - row:SetPoint("RIGHT") - row:SetHeight(h) - self.elements[#self.elements + 1] = row - self:_Advance(h) - return row -end - -function CanvasLayout:_AddLabel(row, text, fontObject) - local metrics = getCanvasLayoutMetrics(self) - local label = row:CreateFontString(nil, "OVERLAY", fontObject or "GameFontNormal") - label:SetPoint("LEFT", metrics.labelX, 0) - label:SetPoint("RIGHT", row, "CENTER", -85, 0) - label:SetJustifyH("LEFT") - label:SetWordWrap(false) - label:SetText(text) - row._label = label - return label -end - ---- Add a page header using Blizzard's SettingsListTemplate.Header. ---- Provides Title, Options_HorizontalDivider, and DefaultsButton. ----@return Frame row (row._title, row._defaultsButton exposed) -function CanvasLayout:AddHeader(text) - local metrics = getCanvasLayoutMetrics(self) - local row = self:_CreateRow(metrics.headerHeight) - local settingsList = CreateFrame("Frame", nil, row, "SettingsListTemplate") - settingsList:SetAllPoints(row) - settingsList.ScrollBox:Hide() - settingsList.ScrollBar:Hide() - settingsList.Header.Title:SetText(text) - row._title = settingsList.Header.Title - row._defaultsButton = settingsList.Header.DefaultsButton - return row -end - ---- Add vertical spacing. -function CanvasLayout:AddSpacer(height) - self:_Advance(height) -end - ---- Add a description / informational text row. -function CanvasLayout:AddDescription(text, fontObject) - local metrics = getCanvasLayoutMetrics(self) - local row = self:_CreateRow() - local label = row:CreateFontString(nil, "OVERLAY", fontObject or "GameFontNormal") - label:SetPoint("LEFT", metrics.labelX, 0) - label:SetPoint("RIGHT", row, "RIGHT", -10, 0) - label:SetJustifyH("LEFT") - label:SetText(text) - row._text = label - return row -end - ---- Add a color swatch row (label + clickable swatch). ----@return Frame row, Button swatch -function CanvasLayout:AddColorSwatch(labelText) - local metrics = getCanvasLayoutMetrics(self) - local row = self:_CreateRow() - self:_AddLabel(row, labelText) - local swatch = lib.CreateColorSwatch(row) - swatch:SetPoint("LEFT", row, "CENTER", metrics.swatchCenterX, 0) - row._swatch = swatch - return row, swatch -end - ---- Add a slider row (label + MinimalSliderWithSteppers). ----@return Frame row, Slider slider, FontString valueText -function CanvasLayout:AddSlider(labelText, min, max, step) - local metrics = getCanvasLayoutMetrics(self) - local row = self:_CreateRow() - self:_AddLabel(row, labelText) - local slider = CreateFrame("Slider", nil, row, "MinimalSliderWithSteppersTemplate") - slider:SetWidth(metrics.sliderWidth) - slider:SetPoint("LEFT", row, "CENTER", metrics.controlCenterX, 3) - slider:SetMinMaxValues(min, max) - slider:SetValueStep(step or 1) - slider:SetObeyStepOnDrag(true) - local valueText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") - valueText:SetPoint("LEFT", slider, "RIGHT", 8, 0) - valueText:SetWidth(40) - valueText:SetJustifyH("LEFT") - row._slider = slider - row._valueText = valueText - return row, slider, valueText -end - ---- Add a button row (label + UIPanelButton). ----@return Frame row, Button button -function CanvasLayout:AddButton(labelText, buttonText) - local metrics = getCanvasLayoutMetrics(self) - local row = self:_CreateRow() - self:_AddLabel(row, labelText) - local button = CreateFrame("Button", nil, row, "UIPanelButtonTemplate") - button:SetSize(metrics.buttonWidth, 26) - button:SetPoint("LEFT", row, "CENTER", metrics.buttonCenterX, 0) - button:SetText(buttonText) - row._button = button - return row, button -end - ---- Add a scroll list that fills the remaining vertical space. ----@return Frame scrollBox, EventFrame scrollBar, table view -function CanvasLayout:AddScrollList(elementExtent) - local metrics = getCanvasLayoutMetrics(self) - local scrollBox = CreateFrame("Frame", nil, self.frame, "WowScrollBoxList") - scrollBox:SetPoint("TOPLEFT", metrics.labelX, self.yPos) - scrollBox:SetPoint("BOTTOMRIGHT", -30, 10) - local scrollBar = CreateFrame("EventFrame", nil, self.frame, "MinimalScrollBar") - scrollBar:SetPoint("TOPLEFT", scrollBox, "TOPRIGHT", 5, 0) - scrollBar:SetPoint("BOTTOMLEFT", scrollBox, "BOTTOMRIGHT", 5, 0) - local view = CreateScrollBoxListLinearView() - view:SetElementExtent(elementExtent) - ScrollUtil.InitScrollBoxListWithScrollBar(scrollBox, scrollBar, view) - return scrollBox, scrollBar, view -end - --------------------------------------------------------------------------------- --- Static utilities (shared across all instances) --------------------------------------------------------------------------------- - ---- Create a color swatch button using Blizzard's SettingsColorSwatchTemplate. ---- Inherits ColorSwatchTemplate (SwatchBg/InnerBorder/Color layers) and ---- SettingsColorSwatchMixin (hover effects, color picker integration). ----@param parent Frame ----@return Button swatch (swatch._tex points to swatch.Color for backward compat) -function lib.CreateColorSwatch(parent) - local swatch = CreateFrame("Button", nil, parent, "SettingsColorSwatchTemplate") - swatch._tex = swatch.Color - return swatch -end - --------------------------------------------------------------------------------- --- Slider editable-value hook (global, runs once per lib version) --------------------------------------------------------------------------------- - -if not lib._sliderHookInstalled then - local function setupSliderEditableValue() - if not SettingsSliderControlMixin then - return - end - - local function findValueLabel(sliderWithSteppers) - if sliderWithSteppers._label then - return sliderWithSteppers._label - end - if sliderWithSteppers.RightText then - return sliderWithSteppers.RightText - end - if sliderWithSteppers.Label then - return sliderWithSteppers.Label - end - for i = 1, select("#", sliderWithSteppers:GetRegions()) do - local region = select(i, sliderWithSteppers:GetRegions()) - if region and region:IsObjectType("FontString") then - return region - end - end - return nil - end - - local function getSliderValueText(self) - local setting = self and self._lsbCurrentSetting - if not setting or not setting.GetValue then - return "" - end - return tostring(setting:GetValue()) - end - - local function hideSliderEditBox(self) - local editBox = self and self._lsbEditBox - local valueLabel = self and self._lsbValueLabel - if not editBox or not valueLabel then - return - end - editBox:ClearFocus() - editBox:Hide() - valueLabel:Show() - end - - local function applySliderEditValue(self) - local editBox = self and self._lsbEditBox - local setting = self and self._lsbCurrentSetting - local sliderWithSteppers = self and self.SliderWithSteppers - if not editBox or not setting or not sliderWithSteppers or not sliderWithSteppers.Slider then - hideSliderEditBox(self) - return - end - - local num = tonumber(editBox:GetText()) - if num then - local slider = sliderWithSteppers.Slider - local min, max = slider:GetMinMaxValues() - num = math.max(min, math.min(max, num)) - local step = slider:GetValueStep() - if step and step > 0 then - num = math.floor(num / step + 0.5) * step - end - setting:SetValue(num) - end - - hideSliderEditBox(self) - end - - local function anchorSliderValueButton(self) - local valueLabel = self and self._lsbValueLabel - local valueButton = self and self._lsbValueButton - if not valueLabel or not valueButton then - return - end - - if valueButton.ClearAllPoints then - valueButton:ClearAllPoints() - end - valueButton:SetAllPoints(valueLabel) - end - - hooksecurefunc(SettingsSliderControlMixin, "Init", function(self, initializer) - local sliderWithSteppers = self.SliderWithSteppers - if not sliderWithSteppers then - return - end - - local valueLabel = findValueLabel(sliderWithSteppers) - if not valueLabel then - return - end - - self._lsbCurrentSetting = initializer:GetSetting() - self._lsbValueLabel = valueLabel - - if not self._lsbValueButton then - local btn = CreateFrame("Button", nil, sliderWithSteppers) - btn:RegisterForClicks("LeftButtonDown") - self._lsbValueButton = btn - - local editBox = CreateFrame("EditBox", nil, sliderWithSteppers, "InputBoxTemplate") - editBox:SetAutoFocus(false) - editBox:SetNumeric(false) - editBox:SetSize(50, 20) - editBox:SetPoint("CENTER", valueLabel, "CENTER") - editBox:SetJustifyH("CENTER") - editBox:Hide() - self._lsbEditBox = editBox - - editBox:SetScript("OnEnterPressed", function() - applySliderEditValue(self) - end) - editBox:SetScript("OnEscapePressed", function() - hideSliderEditBox(self) - end) - editBox:SetScript("OnEditFocusLost", function() - hideSliderEditBox(self) - end) - - btn:SetScript("OnClick", function() - local setting = self._lsbCurrentSetting - local currentValueLabel = self._lsbValueLabel - if not setting or not currentValueLabel then - return - end - - anchorSliderValueButton(self) - editBox:SetText(getSliderValueText(self)) - currentValueLabel:Hide() - editBox:Show() - editBox:SetFocus() - editBox:HighlightText() - end) - end - - anchorSliderValueButton(self) - - if self._lsbEditBox and self._lsbEditBox.ClearFocus then - self._lsbEditBox:ClearFocus() - self._lsbEditBox:Hide() - end - valueLabel:Show() - end) - end - - setupSliderEditableValue() - lib._sliderHookInstalled = true -end - --------------------------------------------------------------------------------- --- Path accessors: built-in dot-path resolution with numeric key support --------------------------------------------------------------------------------- - -local function defaultGetNestedValue(tbl, path) - local current = tbl - for segment in path:gmatch("[^.]+") do - if type(current) ~= "table" then - return nil - end - local val = current[segment] - if val == nil then - local num = tonumber(segment) - if num then - val = current[num] - end - end - current = val - end - return current -end - -local function defaultSetNestedValue(tbl, path, value) - local current, lastKey = tbl, nil - for segment in path:gmatch("[^.]+") do - if lastKey then - local resolved = lastKey - if current[lastKey] == nil then - local num = tonumber(lastKey) - if num and current[num] ~= nil then - resolved = num - end - end - if current[resolved] == nil then - current[resolved] = {} - end - current = current[resolved] - end - lastKey = segment - end - local resolved = lastKey - if current[lastKey] == nil then - local num = tonumber(lastKey) - if num then - resolved = num - end - end - current[resolved] = value -end - ---- Creates a path adapter for resolving dot-delimited paths to get/set/default ---- bindings. Built-in accessors handle numeric path segments (e.g. "colors.0"). ----@param config table ---- Required: getStore (function() -> table), getDefaults (function() -> table) ---- Optional: getNestedValue, setNestedValue (custom path accessors) ----@return table adapter with :resolve(path) and :read(path) methods -function lib.PathAdapter(config) - assert(config.getStore, "PathAdapter: getStore is required") - assert(config.getDefaults, "PathAdapter: getDefaults is required") - - local getNested = config.getNestedValue or defaultGetNestedValue - local setNested = config.setNestedValue or defaultSetNestedValue - - return { - resolve = function(self, path) - return { - get = function() - return getNested(config.getStore(), path) - end, - set = function(value) - setNested(config.getStore(), path, value) - end, - default = getNested(config.getDefaults(), path), - } - end, - read = function(self, path) - return getNested(config.getStore(), path) - end, - } -end - --------------------------------------------------------------------------------- --- Factory --------------------------------------------------------------------------------- - ---- Create a new SettingsBuilder instance. ----@param config table ---- Required fields: ---- varPrefix string e.g. "ECM" ---- onChanged function(spec, value) called after each setter ---- Optional fields: ---- pathAdapter table PathAdapter instance for path-based controls ---- compositeDefaults table keyed by composite function name ----@return table builder instance with the full SB API -function lib:New(config) - assert(config.varPrefix, "LibSettingsBuilder: varPrefix is required") - assert(config.onChanged, "LibSettingsBuilder: onChanged is required") - - local SB = {} - SB._rootCategory = nil - SB._rootCategoryName = nil - SB._currentSubcategory = nil - SB._subcategories = {} - SB._subcategoryNames = {} - SB._layouts = {} - SB._firstHeaderAdded = {} - SB._reactiveControls = {} - - SB.EMBED_CANVAS_TEMPLATE = lib.EMBED_CANVAS_TEMPLATE - SB.SUBHEADER_TEMPLATE = lib.SUBHEADER_TEMPLATE - SB.INFOROW_TEMPLATE = lib.INFOROW_TEMPLATE - SB.SCROLL_DROPDOWN_TEMPLATE = lib.SCROLL_DROPDOWN_TEMPLATE - - ---------------------------------------------------------------------------- - -- Internal helpers - ---------------------------------------------------------------------------- - - local function defaultSliderFormatter(value) - return value == math.floor(value) and tostring(math.floor(value)) or string.format("%.1f", value) - end - - local adapter = config.pathAdapter - - local function makeVarName(spec) - local id = spec.key or spec.path - return config.varPrefix .. "_" .. id:gsub("%.", "_") - end - - local function resolveCategory(spec) - return spec.category or SB._currentSubcategory or SB._rootCategory - end - - local reevaluateReactiveControls - - local function postSet(spec, value, setting) - if spec.onSet then - spec.onSet(value, setting) - end - config.onChanged(spec, value) - reevaluateReactiveControls() - end - - --- Resolves a spec into a binding with get/set/default. - --- Handler mode: spec provides explicit get, set, key, and default. - --- Path mode: spec provides a path string; the pathAdapter generates get/set/default. - local function resolveBinding(spec) - local hasPath = spec.path ~= nil - local hasHandler = spec.get ~= nil or spec.set ~= nil - - assert(not (hasPath and hasHandler), "spec cannot have both path and get/set") - - if hasHandler then - assert(spec.get, "handler mode requires get") - assert(spec.set, "handler mode requires set") - assert(spec.key, "handler mode requires key") - return { get = spec.get, set = spec.set, default = spec.default } - end - - assert(hasPath, "spec must have either path or get/set") - assert(adapter, "path mode requires a pathAdapter on the builder") - - local binding = adapter:resolve(spec.path) - if spec.default ~= nil then - binding.default = spec.default - end - return binding - end - - --- Consolidates the getter/setter/default/transform/register boilerplate - --- shared by Checkbox, Slider, Dropdown, and Custom. - local function makeProxySetting(spec, varType, defaultFallback, binding) - local variable = makeVarName(spec) - local cat = resolveCategory(spec) - local setting - - binding = binding or resolveBinding(spec) - - local function getter() - local val = binding.get() - if spec.getTransform then - val = spec.getTransform(val) - end - return val - end - - local function applyValue(value) - if spec.setTransform then - value = spec.setTransform(value) - end - binding.set(value) - return value - end - - local function setter(value) - value = applyValue(value) - postSet(spec, value, setting) - end - - local function setValueNoCallback(_, value) - value = applyValue(value) - config.onChanged(spec, value) - reevaluateReactiveControls() - end - - local default = binding.default - if spec.getTransform then - default = spec.getTransform(default) - end - - if default == nil then - default = defaultFallback - end - - setting = Settings.RegisterProxySetting( - cat, - variable, - varType, - spec.name, - default, - getter, - setter - ) - setting.SetValueNoCallback = setValueNoCallback - - return setting, cat - end - - --- Copies inherited modifier keys from a composite spec onto a child spec - --- when the child hasn't set them explicitly. - local MODIFIER_KEYS = { "category", "parent", "parentCheck", "disabled", "hidden", "layout" } - local function propagateModifiers(target, source) - for _, key in ipairs(MODIFIER_KEYS) do - if target[key] == nil then - target[key] = source[key] - end - end - end - - --- Merges compositeDefaults for the given composite function name onto spec. - --- Spec values win over defaults. - local function mergeCompositeDefaults(functionName, spec) - local defaults = config.compositeDefaults and config.compositeDefaults[functionName] - if not defaults then - return spec or {} - end - local merged = {} - for k, v in pairs(defaults) do - merged[k] = v - end - if spec then - for k, v in pairs(spec) do - merged[k] = v - end - end - return merged - end - - ---------------------------------------------------------------------------- - -- Debug spec validation (active only when LSB_DEBUG is truthy) - ---------------------------------------------------------------------------- - - local COMMON_SPEC_FIELDS = { - path = true, - name = true, - tooltip = true, - category = true, - onSet = true, - getTransform = true, - setTransform = true, - parent = true, - parentCheck = true, - disabled = true, - hidden = true, - layout = true, - type = true, - desc = true, - get = true, - set = true, - key = true, - default = true, - } - - local EXTRA_FIELDS_BY_TYPE = { - checkbox = {}, - slider = { min = true, max = true, step = true, formatter = true }, - dropdown = { values = true, scrollHeight = true }, - color = {}, - custom = { template = true, varType = true }, - } - - local function validateSpecFields(controlType, spec) - if not LSB_DEBUG then - return - end - local allowed = EXTRA_FIELDS_BY_TYPE[controlType] - if not allowed then - return - end - for key in pairs(spec) do - if not COMMON_SPEC_FIELDS[key] and not allowed[key] then - print( - "|cffFF8800LibSettingsBuilder WARNING:|r Unknown spec field '" - .. tostring(key) - .. "' on " - .. controlType - .. " control '" - .. tostring(spec.name or spec.path) - .. "'" - ) - end - end - end - - local function setCanvasInteractive(frame, enabled) - if frame.SetEnabled then - frame:SetEnabled(enabled) - end - if frame.EnableMouse then - frame:EnableMouse(enabled) - end - if frame.GetChildren then - local children = { frame:GetChildren() } - for i = 1, #children do - setCanvasInteractive(children[i], enabled) - end - end - end - - local function isParentEnabled(spec) - if not spec.parent then - return true - end - - if spec.parentCheck then - return spec.parentCheck() - end - - if not spec.parent.GetSetting then - return true - end - local setting = spec.parent:GetSetting() - if not setting then - return true - end - return setting:GetValue() - end - - local function isControlEnabled(spec) - if spec.disabled and spec.disabled() then - return false - end - return isParentEnabled(spec) - end - - local function applyCanvasState(canvas, enabled) - if canvas.SetAlpha then - canvas:SetAlpha(enabled and 1 or 0.5) - end - setCanvasInteractive(canvas, enabled) - end - - reevaluateReactiveControls = function() - -- Force the WoW settings panel to re-evaluate visible control states. - local panel = SettingsPanel - if panel and panel:IsShown() then - local settingsList = panel:GetSettingsList() - if settingsList and settingsList.ScrollBox then - settingsList.ScrollBox:ForEachFrame(function(frame) - if frame.EvaluateState then - frame:EvaluateState() - end - end) - end - end - - -- Canvas controls aren't part of the settings list, handle directly - for _, entry in ipairs(SB._reactiveControls) do - local s = entry[2] - if s.canvas then - applyCanvasState(s.canvas, isControlEnabled(s)) - end - end - end - - local function applyEnabledState(initializer, spec) - local enabled = isControlEnabled(spec) - if initializer.SetEnabled then - initializer:SetEnabled(enabled) - end - if spec.canvas then - applyCanvasState(spec.canvas, enabled) - end - return enabled - end - - local function applyModifiers(initializer, spec) - if not initializer then - return - end - - if spec.disabled or spec.canvas or spec.parent then - initializer:AddModifyPredicate(function() - return applyEnabledState(initializer, spec) - end) - applyEnabledState(initializer, spec) - end - - if spec.parent then - local predicate = function() - return isParentEnabled(spec) - end - initializer:SetParentInitializer(spec.parent, predicate) - end - - if spec.hidden then - initializer:AddShownPredicate(function() - return not spec.hidden() - end) - end - - if spec.canvas then - SB._reactiveControls[#SB._reactiveControls + 1] = { initializer, spec } - end - end - - local function colorTableToHex(tbl) - if not tbl then - return "FFFFFFFF" - end - return string.format( - "%02X%02X%02X%02X", - math.floor((tbl.a or 1) * 255 + 0.5), - math.floor((tbl.r or 1) * 255 + 0.5), - math.floor((tbl.g or 1) * 255 + 0.5), - math.floor((tbl.b or 1) * 255 + 0.5) - ) - end - - ---------------------------------------------------------------------------- - -- Category management - ---------------------------------------------------------------------------- - - function SB.CreateRootCategory(name) - local category, layout = Settings.RegisterVerticalLayoutCategory(name) - SB._rootCategory = category - SB._rootCategoryName = name - SB._layouts[category] = layout - SB._currentSubcategory = nil - SB._firstHeaderAdded = {} - return category - end - - function SB.CreateSubcategory(name) - local subcategory, layout = Settings.RegisterVerticalLayoutSubcategory(SB._rootCategory, name) - SB._subcategories[name] = subcategory - SB._subcategoryNames[subcategory] = name - SB._layouts[subcategory] = layout - SB._currentSubcategory = subcategory - return subcategory - end - - function SB.CreateCanvasSubcategory(frame, name, parentCategory) - local parent = parentCategory or SB._rootCategory - local subcategory, layout = Settings.RegisterCanvasLayoutSubcategory(parent, frame, name) - SB._subcategories[name] = subcategory - SB._layouts[subcategory] = layout - return subcategory - end - - --- Creates a canvas subcategory with a CanvasLayout engine attached. - --- Returns a layout object with AddHeader, AddDescription, AddSlider, - --- AddColorSwatch, AddButton, AddScrollList methods that position - --- controls to match Blizzard's vertical-layout settings pages. - ---@param name string Subcategory display name. - ---@param parentCategory? table Parent category (defaults to root). - ---@return table layout CanvasLayout instance (layout.frame for the raw frame). - function SB.CreateCanvasLayout(name, parentCategory) - local frame = CreateFrame("Frame", nil) - SB.CreateCanvasSubcategory(frame, name, parentCategory) - local metrics = copyMixin({}, lib.CanvasLayoutDefaults) - local layout = setmetatable({ - frame = frame, - yPos = 0, - elements = {}, - _metrics = metrics, - }, { __index = lib.CanvasLayout }) - return layout - end - - function SB.SetCanvasLayoutDefaults(overrides) - if not overrides then - return lib.CanvasLayoutDefaults - end - - for key, value in pairs(overrides) do - lib.CanvasLayoutDefaults[key] = value - end - - return lib.CanvasLayoutDefaults - end - - function SB.ConfigureCanvasLayout(layout, overrides) - assert(layout, "ConfigureCanvasLayout: layout is required") - if not overrides then - return getCanvasLayoutMetrics(layout) - end - - layout._metrics = copyMixin(copyMixin({}, lib.CanvasLayoutDefaults), overrides) - return layout._metrics - end - - --- Static color swatch factory, forwarded from lib for convenience. - SB.CreateColorSwatch = lib.CreateColorSwatch - - function SB.RegisterCategories() - if SB._rootCategory then - Settings.RegisterAddOnCategory(SB._rootCategory) - end - end - - function SB.GetRootCategoryID() - return SB._rootCategory and SB._rootCategory:GetID() - end - - function SB.GetSubcategoryID(name) - local category = SB._subcategories[name] - return category and category:GetID() - end - - function SB.GetRootCategory() - return SB._rootCategory - end - - function SB.GetSubcategory(name) - return SB._subcategories[name] - end - - function SB.HasCategory(category) - return category ~= nil and SB._layouts[category] ~= nil - end - - ---------------------------------------------------------------------------- - -- Proxy controls - ---------------------------------------------------------------------------- - - function SB.Checkbox(spec) - validateSpecFields("checkbox", spec) - local setting, cat = makeProxySetting(spec, Settings.VarType.Boolean, false) - local initializer = Settings.CreateCheckbox(cat, setting, spec.tooltip) - applyModifiers(initializer, spec) - return initializer, setting - end - - function SB.Slider(spec) - validateSpecFields("slider", spec) - local setting, cat = makeProxySetting(spec, Settings.VarType.Number, 0) - - local options = Settings.CreateSliderOptions(spec.min, spec.max, spec.step or 1) - options:SetLabelFormatter(MinimalSliderWithSteppersMixin.Label.Right, spec.formatter or defaultSliderFormatter) - - local initializer = Settings.CreateSlider(cat, setting, options, spec.tooltip) - applyModifiers(initializer, spec) - - return initializer, setting - end - - function SB.Dropdown(spec) - validateSpecFields("dropdown", spec) - local binding = resolveBinding(spec) - local cat = resolveCategory(spec) - - local default = binding.default - if spec.getTransform then - default = spec.getTransform(default) - end - - local varType = spec.varType - or (type(default) == "number" and Settings.VarType.Number) - or Settings.VarType.String - - local setting = makeProxySetting(spec, varType, "", binding) - - if spec.scrollHeight then - local initializer = Settings.CreateElementInitializer(lib.SCROLL_DROPDOWN_TEMPLATE, { - _lsbKind = "scrollDropdown", - setting = setting, - values = spec.values, - scrollHeight = spec.scrollHeight, - name = spec.name, - tooltip = spec.tooltip, - }) - if initializer.SetSetting then - initializer:SetSetting(setting) - end - Settings.RegisterInitializer(cat, initializer) - applyModifiers(initializer, spec) - return initializer, setting - end - - local function optionsGenerator() - local container = Settings.CreateControlTextContainer() - local values = type(spec.values) == "function" and spec.values() or spec.values - if values then - for _, entry in ipairs(getOrderedValueEntries(values)) do - container:Add(entry.value, entry.label) - end - end - return container:GetData() - end - - local initializer = Settings.CreateDropdown(cat, setting, optionsGenerator, spec.tooltip) - applyModifiers(initializer, spec) - - return initializer, setting - end - - function SB.Color(spec) - validateSpecFields("color", spec) - local variable = makeVarName(spec) - local cat = resolveCategory(spec) - local binding = resolveBinding(spec) - - local function getter() - local tbl = binding.get() - return colorTableToHex(tbl) - end - - local settingRef - - local function setter(hexValue) - local color = CreateColorFromHexString(hexValue) - local tbl = { r = color.r, g = color.g, b = color.b, a = color.a } - binding.set(tbl) - postSet(spec, tbl, settingRef) - end - - local defaultTbl = binding.default or {} - local defaultHex = colorTableToHex(defaultTbl) - - local setting = - Settings.RegisterProxySetting(cat, variable, Settings.VarType.String, spec.name, defaultHex, getter, setter) - settingRef = setting - - local initializer = Settings.CreateColorSwatch(cat, setting, spec.tooltip) - applyModifiers(initializer, spec) - - return initializer, setting - end - - --- Creates a proxy setting backed by a custom frame template. - --- The template's Init receives initializer data containing {setting, name, tooltip}. - function SB.Custom(spec) - validateSpecFields("custom", spec) - assert(spec.template, "Custom: spec.template is required") - local setting, cat = makeProxySetting(spec, spec.varType or Settings.VarType.String, "") - - local initializer = - Settings.CreateElementInitializer(spec.template, { name = spec.name, tooltip = spec.tooltip }) - - if initializer.SetSetting then - initializer:SetSetting(setting) - end - - Settings.RegisterInitializer(cat, initializer) - applyModifiers(initializer, spec) - - return initializer, setting - end - - --- Unified proxy control dispatch table. - local DISPATCH = { - checkbox = "Checkbox", - slider = "Slider", - dropdown = "Dropdown", - color = "Color", - custom = "Custom", - } - - function SB.Control(spec) - local fn = SB[DISPATCH[spec.type]] - assert(fn, "Control: unknown type '" .. tostring(spec.type) .. "'") - return fn(spec) - end - - ---------------------------------------------------------------------------- - -- Composite builders - ---------------------------------------------------------------------------- - - function SB.HeightOverrideSlider(sectionPath, spec) - spec = spec or {} - local childSpec = { - path = sectionPath .. ".height", - name = spec.name or "Height Override", - tooltip = spec.tooltip or "Override the default bar height. Set to 0 to use the global default.", - min = spec.min or 0, - max = spec.max or 40, - step = spec.step or 1, - getTransform = function(value) - return value or 0 - end, - setTransform = function(value) - return value > 0 and value or nil - end, - } - propagateModifiers(childSpec, spec) - return SB.Slider(childSpec) - end - - --- Font override group. - --- Optional spec fields: - --- fontValues function() -> table (choices for the dropdown) - --- fontFallback function() -> string (fallback font name) - --- fontSizeFallback function() -> number (fallback font size) - --- fontTemplate string (custom template for the font picker) - function SB.FontOverrideGroup(sectionPath, spec) - spec = mergeCompositeDefaults("FontOverrideGroup", spec) - local overridePath = sectionPath .. ".overrideFont" - - local enabledSpec = { - path = overridePath, - name = spec.enabledName or "Override font", - tooltip = spec.enabledTooltip or "Override the global font settings for this module.", - getTransform = function(value) - return value == true - end, - } - propagateModifiers(enabledSpec, spec) - local enabledInit, enabledSetting = SB.Checkbox(enabledSpec) - - -- Children stay visible but disabled when override is off. - -- The font picker's SetEnabled hides the preview automatically. - local outerDisabled = spec.disabled - local function isOverrideDisabled() - if outerDisabled and outerDisabled() then - return true - end - return not enabledSetting:GetValue() - end - - local fontSpec = { - path = sectionPath .. ".font", - name = spec.fontName or "Font", - tooltip = spec.fontTooltip, - values = spec.fontValues, - disabled = isOverrideDisabled, - getTransform = function(value) - if value then - return value - end - if spec.fontFallback then - return spec.fontFallback() - end - return nil - end, - } - propagateModifiers(fontSpec, spec) - - local fontInit - if spec.fontTemplate then - fontSpec.template = spec.fontTemplate - fontInit = SB.Custom(fontSpec) - else - fontInit = SB.Dropdown(fontSpec) - end - - local sizeSpec = { - path = sectionPath .. ".fontSize", - name = spec.sizeName or "Font Size", - tooltip = spec.sizeTooltip, - min = spec.sizeMin or 6, - max = spec.sizeMax or 32, - step = spec.sizeStep or 1, - disabled = isOverrideDisabled, - getTransform = function(value) - if value then - return value - end - if spec.fontSizeFallback then - return spec.fontSizeFallback() - end - return 11 - end, - } - propagateModifiers(sizeSpec, spec) - local sizeInit = SB.Slider(sizeSpec) - - return { - enabledInit = enabledInit, - enabledSetting = enabledSetting, - fontInit = fontInit, - sizeInit = sizeInit, - } - end - - function SB.BorderGroup(borderPath, spec) - spec = spec or {} - - local enabledSpec = { - path = borderPath .. ".enabled", - name = spec.enabledName or "Show border", - tooltip = spec.enabledTooltip, - } - propagateModifiers(enabledSpec, spec) - local enabledInit, enabledSetting = SB.Checkbox(enabledSpec) - - local thicknessSpec = { - path = borderPath .. ".thickness", - name = spec.thicknessName or "Border width", - tooltip = spec.thicknessTooltip, - min = spec.thicknessMin or 1, - max = spec.thicknessMax or 10, - step = spec.thicknessStep or 1, - parent = enabledInit, - parentCheck = function() - return enabledSetting:GetValue() - end, - } - propagateModifiers(thicknessSpec, spec) - local thicknessInit = SB.Slider(thicknessSpec) - - local colorSpec = { - path = borderPath .. ".color", - name = spec.colorName or "Border color", - tooltip = spec.colorTooltip, - parent = enabledInit, - parentCheck = function() - return enabledSetting:GetValue() - end, - } - propagateModifiers(colorSpec, spec) - local colorInit = SB.Color(colorSpec) - - return { - enabledInit = enabledInit, - enabledSetting = enabledSetting, - thicknessInit = thicknessInit, - colorInit = colorInit, - } - end - - function SB.ColorPickerList(basePath, defs, spec) - spec = spec or {} - local results = {} - - for _, def in ipairs(defs) do - local childSpec = { - path = basePath .. "." .. tostring(def.key), - name = def.name, - tooltip = def.tooltip, - } - propagateModifiers(childSpec, spec) - local init, setting = SB.Color(childSpec) - results[#results + 1] = { key = def.key, initializer = init, setting = setting } - end - - return results - end - - function SB.CheckboxList(basePath, defs, spec) - spec = spec or {} - local results = {} - - for _, def in ipairs(defs) do - local childSpec = { - path = basePath .. "." .. tostring(def.key), - name = def.name, - tooltip = def.tooltip, - } - propagateModifiers(childSpec, spec) - local init, setting = SB.Checkbox(childSpec) - results[#results + 1] = { key = def.key, initializer = init, setting = setting } - end - - return results - end - - ---------------------------------------------------------------------------- - -- Utility helpers - ---------------------------------------------------------------------------- - - function SB.Header(text, category) - local cat = category or SB._currentSubcategory or SB._rootCategory - - if not SB._firstHeaderAdded[cat] then - SB._firstHeaderAdded[cat] = true - local catName = SB._subcategoryNames[cat] or (cat == SB._rootCategory and SB._rootCategoryName) - if catName and text == catName then - return nil - end - end - - local layout = SB._layouts[cat] - local initializer = CreateSettingsListSectionHeaderInitializer(text) - layout:AddInitializer(initializer) - return initializer - end - - function SB.Subheader(spec) - local cat = resolveCategory(spec) - local layout = SB._layouts[cat] - local initializer = createCustomListRowInitializer(lib.SUBHEADER_TEMPLATE, { - _lsbKind = "subheader", - name = spec.name, - }, 28, applySubheaderFrame) - layout:AddInitializer(initializer) - applyModifiers(initializer, spec) - return initializer - end - - function SB.InfoRow(spec) - local cat = resolveCategory(spec) - local layout = SB._layouts[cat] - local initializer = createCustomListRowInitializer(lib.INFOROW_TEMPLATE, { - _lsbKind = "infoRow", - name = spec.name, - value = spec.value, - }, 26, applyInfoRowFrame) - layout:AddInitializer(initializer) - applyModifiers(initializer, spec) - return initializer - end - - function SB.EmbedCanvas(canvas, height, spec) - spec = spec or {} - local cat = spec.category or SB._currentSubcategory or SB._rootCategory - - local modifiers = {} - for k, v in pairs(spec) do - modifiers[k] = v - end - modifiers.canvas = canvas - - local initializer = createCustomListRowInitializer(lib.EMBED_CANVAS_TEMPLATE, { - _lsbKind = "embedCanvas", - canvas = canvas, - }, height or canvas:GetHeight(), applyEmbedCanvasFrame) - - Settings.RegisterInitializer(cat, initializer) - applyModifiers(initializer, modifiers) - - return initializer - end - - -- Make CONFIRM_DIALOG_NAME unique per instance to prevent single-pop collisions - local CONFIRM_DIALOG_NAME = config.varPrefix .. "_" .. MAJOR:gsub("[%-%.]", "_") .. "_SettingsConfirm" - StaticPopupDialogs[CONFIRM_DIALOG_NAME] = { - text = "%s", - button1 = YES, - button2 = NO, - OnAccept = function(_, data) - if data and data.onAccept then - data.onAccept() - end - end, - timeout = 0, - whileDead = true, - hideOnEscape = true, - } - - function SB.Button(spec) - local cat = spec.category or SB._currentSubcategory or SB._rootCategory - - local onClick = spec.onClick - if spec.confirm then - local confirmText = type(spec.confirm) == "string" and spec.confirm or "Are you sure?" - local originalClick = onClick - onClick = function() - StaticPopup_Show(CONFIRM_DIALOG_NAME, confirmText, nil, { onAccept = originalClick }) - end - end - - local layout = SB._layouts[cat] - local initializer = - CreateSettingsButtonInitializer(spec.name, spec.buttonText or spec.name, onClick, spec.tooltip, true) - layout:AddInitializer(initializer) - applyModifiers(initializer, spec) - - return initializer - end - - ---------------------------------------------------------------------------- - -- Table-driven registration (AceConfig-inspired) - ---------------------------------------------------------------------------- - - local TYPE_ALIASES = { - toggle = "checkbox", - range = "slider", - select = "dropdown", - execute = "button", - description = "subheader", - } - - local SPEC_EXCLUDE = { type = true, order = true, defs = true, label = true, condition = true } - - -- Composite type dispatch: returns init, setting from a composite builder - local COMPOSITE_DISPATCH = { - border = function(path, spec) - local r = SB.BorderGroup(path, spec) - return r.enabledInit, r.enabledSetting - end, - fontOverride = function(path, spec) - local r = SB.FontOverrideGroup(path, spec) - return r.enabledInit, r.enabledSetting - end, - heightOverride = function(path, spec) - return SB.HeightOverrideSlider(path, spec) - end, - } - - --- Walks an AceConfig-inspired option table and calls the imperative API. - --- Top-level `onShow`/`onHide` callbacks fire when the page is selected or - --- navigated away from (via SettingsPanel.SelectCategory hook). - function SB.RegisterFromTable(tbl) - assert(tbl.name, "RegisterFromTable: tbl.name is required") - - if tbl.rootCategory then - SB._currentSubcategory = SB._rootCategory - else - SB.CreateSubcategory(tbl.name) - end - - if tbl.onShow or tbl.onHide then - lib._pageLifecycleCallbacks[SB._currentSubcategory] = { - onShow = tbl.onShow, - onHide = tbl.onHide, - } - installPageLifecycleHooks() - end - - local groupPath = tbl.path or "" - - local function resolvePath(entryPath) - if not entryPath then - return groupPath - end - if entryPath:find("%.") or groupPath == "" then - return entryPath - end - return groupPath .. "." .. entryPath - end - - if not tbl.args then - return - end - - -- Sort entries by order field (stable: secondary key breaks ties) - local sorted = {} - for key, entry in pairs(tbl.args) do - sorted[#sorted + 1] = { key = key, entry = entry } - end - table.sort(sorted, function(a, b) - local oa, ob = a.entry.order or 100, b.entry.order or 100 - if oa ~= ob then - return oa < ob - end - return a.key < b.key - end) - - local created = {} - - for _, item in ipairs(sorted) do - local entryKey = item.key - local entry = item.entry - local entryType = TYPE_ALIASES[entry.type] or entry.type - - -- Evaluate condition (skip entry if false) - local condition = entry.condition - local shouldProcess = condition == nil - or (type(condition) == "function" and condition()) - or (type(condition) ~= "function" and condition) - - if shouldProcess then - local spec = {} - for k, v in pairs(entry) do - if not SPEC_EXCLUDE[k] then - spec[k] = v - end - end - - if spec.desc and not spec.tooltip then - spec.tooltip = spec.desc - spec.desc = nil - end - - if tbl.disabled and spec.disabled == nil then - spec.disabled = tbl.disabled - end - if tbl.hidden and spec.hidden == nil then - spec.hidden = tbl.hidden - end - - -- Resolve parent string references - if type(spec.parent) == "string" then - local ref = created[spec.parent] - assert(ref, "RegisterFromTable: parent '" .. spec.parent .. "' not found (misspelled or forward-referenced?)") - spec.parent = ref.initializer - if spec.parentCheck == "checked" then - local s = ref.setting - spec.parentCheck = function() - return s:GetValue() - end - elseif spec.parentCheck == "notChecked" then - local s = ref.setting - spec.parentCheck = function() - return not s:GetValue() - end - end - end - - local init, setting - - if entryType == "header" then - init = SB.Header(spec.name) - elseif entryType == "subheader" then - init = SB.Subheader(spec) - elseif entryType == "info" then - init = SB.InfoRow(spec) - elseif entryType == "button" then - init = SB.Button(spec) - elseif entryType == "canvas" then - init = SB.EmbedCanvas(entry.canvas, entry.height, spec) - elseif entryType == "colorList" then - local defs = entry.defs or {} - if entry.label then - local labelInit = - SB.Subheader({ name = entry.label, disabled = spec.disabled, hidden = spec.hidden }) - spec.parent = spec.parent or labelInit - end - local results = SB.ColorPickerList(resolvePath(entry.path), defs, spec) - if results[1] then - init, setting = results[1].initializer, results[1].setting - end - elseif entryType == "toggleList" then - local defs = entry.defs or {} - if entry.label then - local labelInit = - SB.Subheader({ name = entry.label, disabled = spec.disabled, hidden = spec.hidden }) - spec.parent = spec.parent or labelInit - end - local results = SB.CheckboxList(resolvePath(entry.path), defs, spec) - if results[1] then - init, setting = results[1].initializer, results[1].setting - end - elseif COMPOSITE_DISPATCH[entryType] then - init, setting = COMPOSITE_DISPATCH[entryType](resolvePath(entry.path), spec) - elseif DISPATCH[entryType] then - -- Path mode: resolve path from group prefix - if not spec.get then - spec.path = resolvePath(entry.path or spec.path) - end - -- Handler mode: fall back to entry key as spec.key if not set - if spec.get and not spec.key then - spec.key = entryKey - end - spec.type = entryType - init, setting = SB.Control(spec) - end - - created[entryKey] = { initializer = init, setting = setting } - end - end - end - - function SB.RegisterSection(nsTable, key, section) - nsTable.OptionsSections = nsTable.OptionsSections or {} - nsTable.OptionsSections[key] = section - return section - end - - return SB -end diff --git a/Libs/LibSettingsBuilder/Primitives/BlizzardControls.lua b/Libs/LibSettingsBuilder/Primitives/BlizzardControls.lua new file mode 100644 index 00000000..f54b77e1 --- /dev/null +++ b/Libs/LibSettingsBuilder/Primitives/BlizzardControls.lua @@ -0,0 +1,441 @@ +-- Enhanced Cooldown Manager addon for World of Warcraft +-- Author: Argium +-- Licensed under the GNU General Public License v3.0 + +local MAJOR = "LibSettingsBuilder-1.0" +local lib = LibStub(MAJOR, true) +if not lib or not lib._loadState or not lib._loadState.open then + return +end + +local internal = lib._internal +local copyMixin = internal.copyMixin +local getInitializerData = internal.getInitializerData +local getOrderedValueEntries = internal.getOrderedValueEntries + +local DropdownMethods = {} + +function DropdownMethods:GetSetting() + if self.lsbData and self.lsbData.setting then + return self.lsbData.setting + end + if self.initializer and self.initializer.GetSetting then + return self.initializer:GetSetting() + end + return nil +end + +function DropdownMethods:RefreshDropdownText(value) + local dropdown = self.Control and self.Control.Dropdown + if not dropdown then + return + end + + local setting = self:GetSetting() + local currentValue = value + if currentValue == nil and setting and setting.GetValue then + currentValue = setting:GetValue() + end + + local values = self.lsbData and self.lsbData.values + if type(values) == "function" then + values = values() + end + local text = values and values[currentValue] or tostring(currentValue or "") + + if dropdown.OverrideText then + dropdown:OverrideText(text) + elseif dropdown.SetText then + dropdown:SetText(text) + end +end + +function DropdownMethods:SetValue(value) + if self._lsbOriginalSetValue then + self:_lsbOriginalSetValue(value) + end + self:RefreshDropdownText(value) +end + +function DropdownMethods:InitDropdown() + local setting = self:GetSetting() + local data = self.lsbData or {} + local scrollHeight = data.scrollHeight or 200 + + local dropdown = self.Control and self.Control.Dropdown + if not dropdown or not setting then + return + end + + dropdown:SetupMenu(function(_, rootDescription) + rootDescription:SetScrollMode(scrollHeight) + + local values = data.values + if type(values) == "function" then + values = values() + end + if not values then + return + end + + for _, entry in ipairs(getOrderedValueEntries(values)) do + rootDescription:CreateRadio(entry.label, function() + return setting:GetValue() == entry.value + end, function() + setting:SetValue(entry.value) + self:RefreshDropdownText(entry.value) + end, entry.value) + end + end) + + self:RefreshDropdownText() +end + +local function configureDropdownFrame(frame, initializer, data) + if not frame._lsbOriginalSetValue then + frame._lsbOriginalSetValue = frame.SetValue + end + + copyMixin(frame, DropdownMethods) + frame.initializer = initializer + frame.lsbData = data or {} + initializer._lsbActiveFrame = frame + if frame.lsbData._lsbKind == "scrollDropdown" then + frame:InitDropdown() + else + frame:RefreshDropdownText() + end +end + +if not lib._scrollDropdownHookInstalled and hooksecurefunc and SettingsDropdownControlMixin then + hooksecurefunc(SettingsDropdownControlMixin, "Init", function(frame, initializer) + local data = getInitializerData(initializer) + if not data or (data._lsbKind ~= "dropdown" and data._lsbKind ~= "scrollDropdown") then + if frame._lsbOriginalSetValue then + frame.SetValue = frame._lsbOriginalSetValue + end + frame.initializer = initializer + frame.lsbData = nil + return + end + + configureDropdownFrame(frame, initializer, data) + end) + + lib._scrollDropdownHookInstalled = true +end + +local function roundSliderValue(value, step, minValue, maxValue) + local actualStep = step or 1 + local baseValue = minValue or 0 + local rounded = math.floor(((value - baseValue) / actualStep) + 0.5) * actualStep + baseValue + if minValue then + rounded = math.max(minValue, rounded) + end + if maxValue then + rounded = math.min(maxValue, rounded) + end + return rounded +end + +local function getSliderStepCount(minValue, maxValue, step) + return math.max(1, math.floor(((maxValue - minValue) / (step or 1)) + 0.5)) +end + +local function createInlineSliderFormatters() + if not MinimalSliderWithSteppersMixin or not MinimalSliderWithSteppersMixin.Label then + return nil + end + + return { + [MinimalSliderWithSteppersMixin.Label.Right] = function() + return "" + end, + } +end + +local function attachInlineSliderEditor(slider, textLabel, editBoxWidth) + if slider._lsbValueButton then + return + end + + local function hideEditBox() + if slider._lsbEditBox then + slider._lsbEditBox:ClearFocus() + end + if slider._lsbEditBox then + slider._lsbEditBox:Hide() + end + if textLabel then + textLabel:Show() + end + end + + local function applyEditBoxValue() + local editBox = slider._lsbEditBox + local enteredValue = editBox and tonumber(editBox:GetText()) + if enteredValue then + local minValue = slider._lsbMinValue or 0 + local maxValue = slider._lsbMaxValue + if slider._lsbRangeResolver then + local nextMin, nextMax, nextStep = slider._lsbRangeResolver(enteredValue) + if nextMin ~= nil then + minValue = nextMin + end + if nextMax ~= nil then + maxValue = nextMax + end + if nextStep ~= nil then + slider._lsbStep = nextStep + end + if maxValue ~= nil then + slider._lsbMaxValue = maxValue + end + slider._lsbMinValue = minValue + end + + slider:SetValue(roundSliderValue(enteredValue, slider._lsbStep, minValue, maxValue)) + end + hideEditBox() + end + + local valueButton = CreateFrame("Button", nil, slider) + valueButton:RegisterForClicks("LeftButtonDown") + valueButton:SetPropagateMouseClicks(false) + valueButton:SetAllPoints(textLabel) + slider._lsbValueButton = valueButton + + local editBox = CreateFrame("EditBox", nil, slider, "InputBoxTemplate") + editBox:SetAutoFocus(false) + editBox:SetNumeric(false) + editBox:SetSize(editBoxWidth or 50, 20) + editBox:SetPoint("CENTER", textLabel, "CENTER") + editBox:SetJustifyH("CENTER") + editBox:Hide() + slider._lsbEditBox = editBox + + editBox:SetScript("OnEnterPressed", applyEditBoxValue) + editBox:SetScript("OnEscapePressed", hideEditBox) + editBox:SetScript("OnEditFocusLost", hideEditBox) + + valueButton:SetScript("OnClick", function() + editBox:SetText(textLabel and textLabel.GetText and textLabel:GetText() or "") + if textLabel and textLabel.Hide then + textLabel:Hide() + end + editBox:Show() + editBox:SetFocus() + editBox:HighlightText() + end) +end + +local function configureInlineSlider(slider, textLabel, field, onValueChanged, rangeResolver) + local minValue = field.min or 0 + local maxValue = field.max or 1 + local step = field.step or 1 + + slider._lsbOnValueChanged = onValueChanged + slider._lsbMinValue = minValue + slider._lsbMaxValue = maxValue + slider._lsbStep = step + slider._lsbRangeResolver = rangeResolver or field.getRange + + if slider.MinText then + slider.MinText:Hide() + end + if slider.MaxText then + slider.MaxText:Hide() + end + if slider.RightText then + slider.RightText:Hide() + end + + if slider.Init then + local wasSuppressed = slider._lsbSuppressValueChanged + slider._lsbSuppressValueChanged = true + local ok, err = pcall(function() + slider:Init(field.value or minValue, minValue, maxValue, getSliderStepCount(minValue, maxValue, step), createInlineSliderFormatters()) + if slider.Slider then + slider.Slider:SetValueStep(step) + end + end) + slider._lsbSuppressValueChanged = wasSuppressed + if not ok then + error(err, 0) + end + else + slider:SetMinMaxValues(minValue, maxValue) + slider:SetValueStep(step) + slider:SetObeyStepOnDrag(true) + end + + attachInlineSliderEditor(slider, textLabel, field.editWidth or 50) + + if not slider._lsbValueChangedBound then + local function handleValueChanged(_, value) + local rounded = roundSliderValue(value, slider._lsbStep, slider._lsbMinValue, slider._lsbMaxValue) + if textLabel then + textLabel:SetText(tostring(rounded)) + end + if not slider._lsbSuppressValueChanged and slider._lsbOnValueChanged then + slider._lsbOnValueChanged(rounded) + end + end + + if slider.RegisterCallback and MinimalSliderWithSteppersMixin and MinimalSliderWithSteppersMixin.Event then + slider:RegisterCallback(MinimalSliderWithSteppersMixin.Event.OnValueChanged, handleValueChanged, slider) + else + slider:HookScript("OnValueChanged", handleValueChanged) + end + slider._lsbValueChangedBound = true + end +end + +internal.configureInlineSlider = configureInlineSlider + +if not lib._sliderHookInstalled then + local function setupSliderEditableValue() + if not SettingsSliderControlMixin then + return + end + + local function findValueLabel(sliderWithSteppers) + if sliderWithSteppers._label then + return sliderWithSteppers._label + end + if sliderWithSteppers.RightText then + return sliderWithSteppers.RightText + end + if sliderWithSteppers.Label then + return sliderWithSteppers.Label + end + for i = 1, select("#", sliderWithSteppers:GetRegions()) do + local region = select(i, sliderWithSteppers:GetRegions()) + if region and region:IsObjectType("FontString") then + return region + end + end + return nil + end + + local function getSliderValueText(self) + local setting = self and self._lsbCurrentSetting + if not setting or not setting.GetValue then + return "" + end + return tostring(setting:GetValue()) + end + + local function hideSliderEditBox(self) + local editBox = self and self._lsbEditBox + local valueLabel = self and self._lsbValueLabel + if not editBox or not valueLabel then + return + end + editBox:ClearFocus() + editBox:Hide() + valueLabel:Show() + end + + local function applySliderEditValue(self) + local editBox = self and self._lsbEditBox + local setting = self and self._lsbCurrentSetting + local sliderWithSteppers = self and self.SliderWithSteppers + if not editBox or not setting or not sliderWithSteppers or not sliderWithSteppers.Slider then + hideSliderEditBox(self) + return + end + + local num = tonumber(editBox:GetText()) + if num then + local slider = sliderWithSteppers.Slider + local min, max = slider:GetMinMaxValues() + num = math.max(min, math.min(max, num)) + local step = slider:GetValueStep() + if step and step > 0 then + num = math.floor(num / step + 0.5) * step + end + setting:SetValue(num) + end + + hideSliderEditBox(self) + end + + local function anchorSliderValueButton(self) + local valueLabel = self and self._lsbValueLabel + local valueButton = self and self._lsbValueButton + if not valueLabel or not valueButton then + return + end + + valueButton:ClearAllPoints() + valueButton:SetAllPoints(valueLabel) + end + + hooksecurefunc(SettingsSliderControlMixin, "Init", function(self, initializer) + local sliderWithSteppers = self.SliderWithSteppers + if not sliderWithSteppers then + return + end + + local valueLabel = findValueLabel(sliderWithSteppers) + if not valueLabel then + return + end + + self._lsbCurrentSetting = initializer:GetSetting() + self._lsbValueLabel = valueLabel + + if not self._lsbValueButton then + local btn = CreateFrame("Button", nil, sliderWithSteppers) + btn:RegisterForClicks("LeftButtonDown") + self._lsbValueButton = btn + + local editBox = CreateFrame("EditBox", nil, sliderWithSteppers, "InputBoxTemplate") + editBox:SetAutoFocus(false) + editBox:SetNumeric(false) + editBox:SetSize(50, 20) + editBox:SetPoint("CENTER", valueLabel, "CENTER") + editBox:SetJustifyH("CENTER") + editBox:Hide() + self._lsbEditBox = editBox + + editBox:SetScript("OnEnterPressed", function() + applySliderEditValue(self) + end) + editBox:SetScript("OnEscapePressed", function() + hideSliderEditBox(self) + end) + editBox:SetScript("OnEditFocusLost", function() + hideSliderEditBox(self) + end) + + btn:SetScript("OnClick", function() + local setting = self._lsbCurrentSetting + local currentValueLabel = self._lsbValueLabel + if not setting or not currentValueLabel then + return + end + + anchorSliderValueButton(self) + editBox:SetText(getSliderValueText(self)) + currentValueLabel:Hide() + editBox:Show() + editBox:SetFocus() + editBox:HighlightText() + end) + end + + anchorSliderValueButton(self) + + if self._lsbEditBox then + self._lsbEditBox:ClearFocus() + self._lsbEditBox:Hide() + end + valueLabel:Show() + end) + end + + setupSliderEditableValue() + lib._sliderHookInstalled = true +end diff --git a/Libs/LibSettingsBuilder/Primitives/Layout.lua b/Libs/LibSettingsBuilder/Primitives/Layout.lua new file mode 100644 index 00000000..25b10e08 --- /dev/null +++ b/Libs/LibSettingsBuilder/Primitives/Layout.lua @@ -0,0 +1,53 @@ +-- Enhanced Cooldown Manager addon for World of Warcraft +-- Author: Argium +-- Licensed under the GNU General Public License v3.0 + +local MAJOR = "LibSettingsBuilder-1.0" +local lib = LibStub(MAJOR, true) +if not lib or not lib._loadState or not lib._loadState.open then + return +end + +local internal = lib._internal +local copyMixin = internal.copyMixin + +function internal.createRootCategory(self, name) + local category, layout = Settings.RegisterVerticalLayoutCategory(name) + self._rootCategory = category + self._rootCategoryName = name + self._layouts[category] = layout + self._currentSubcategory = nil + return category +end + +function internal.createSubcategory(self, name, parentCategory) + local parent = parentCategory or self._rootCategory + local subcategory, layout = Settings.RegisterVerticalLayoutSubcategory(parent, name) + self._currentSubcategory = internal.storeCategory(self, name, subcategory, layout) + return subcategory +end + +function internal.createCanvasSubcategory(self, frame, name, parentCategory) + local parent = parentCategory or self._rootCategory + local subcategory, layout = Settings.RegisterCanvasLayoutSubcategory(parent, frame, name) + return internal.storeCategory(self, name, subcategory, layout) +end + +--- Creates a canvas subcategory with a CanvasLayout engine attached. +--- Returns a layout object with AddHeader, AddDescription, AddSlider, +--- AddColorSwatch, AddButton, AddScrollList methods that position +--- controls to match Blizzard's vertical-layout settings pages. +---@param name string Subcategory display name. +---@param parentCategory? table Parent category (defaults to root). +---@return table layout CanvasLayout instance (layout.frame for the raw frame). +function internal.createCanvasLayout(self, name, parentCategory) + local frame = CreateFrame("Frame", nil) + internal.createCanvasSubcategory(self, frame, name, parentCategory) + local metrics = copyMixin({}, internal.CanvasLayoutDefaults) + return setmetatable({ + frame = frame, + yPos = 0, + elements = {}, + _metrics = metrics, + }, { __index = internal.CanvasLayout }) +end diff --git a/Libs/LibSettingsBuilder/Primitives/Rows.lua b/Libs/LibSettingsBuilder/Primitives/Rows.lua new file mode 100644 index 00000000..f9ecab01 --- /dev/null +++ b/Libs/LibSettingsBuilder/Primitives/Rows.lua @@ -0,0 +1,584 @@ +-- Enhanced Cooldown Manager addon for World of Warcraft +-- Author: Argium +-- Licensed under the GNU General Public License v3.0 + +local MAJOR = "LibSettingsBuilder-1.0" +local lib = LibStub(MAJOR, true) +if not lib or not lib._loadState or not lib._loadState.open then + return +end + +local internal = lib._internal +local evaluateStaticOrFunction = internal.evaluateStaticOrFunction +local registerValueChangedCallback = internal.registerValueChangedCallback +local setInitializerExtent = internal.setInitializerExtent + +local listElementKeysToHide = { + "_lsbSubheaderTitle", + "_lsbInfoTitle", + "_lsbInfoValue", + "_lsbCanvas", + "_lsbInputTitle", + "_lsbInputEditBox", + "_lsbInputPreview", +} + +local function resetListElement(frame) + for _, key in ipairs(listElementKeysToHide) do + local region = frame[key] + if region then + region:Hide() + end + end +end + +local function hideListElementObjects(frame, getterName) + if not frame or not frame[getterName] then + return + end + + local objects = { frame[getterName](frame) } + for i = 1, #objects do + local object = objects[i] + if object and object.Hide then + object:Hide() + end + end +end + +local function resetPlainListElementFrame(frame) + hideListElementObjects(frame, "GetChildren") + hideListElementObjects(frame, "GetRegions") + resetListElement(frame) +end + +local function ensureSubheaderTitle(frame) + if frame._lsbSubheaderTitle then + return frame._lsbSubheaderTitle + end + + local title = internal.createSubheaderTitle(frame) + frame._lsbSubheaderTitle = title + frame.Title = title + return title +end + +local function ensureInfoRowWidgets(frame) + if frame._lsbInfoTitle and frame._lsbInfoValue then + return frame._lsbInfoTitle, frame._lsbInfoValue + end + + local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal") + title:SetPoint("LEFT", 37, 0) + title:SetPoint("RIGHT", frame, "CENTER", -85, 0) + title:SetJustifyH("LEFT") + + local value = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight") + value:SetPoint("LEFT", frame, "CENTER", -80, 0) + value:SetJustifyH("LEFT") + + frame._lsbInfoTitle = title + frame._lsbInfoValue = value + frame.Title = title + frame.Value = value + + return title, value +end + +local function ensureHeaderRowWidgets(frame) + if frame._lsbHeaderTitle then + return frame + end + + frame._lsbHeaderTitle = internal.createHeaderTitle(frame) + frame._lsbHeaderActionButtons = frame._lsbHeaderActionButtons or {} + + return frame +end + +local function getSettingsListHeader() + local settingsList = SettingsPanel and SettingsPanel.GetSettingsList and SettingsPanel:GetSettingsList() + return settingsList and settingsList.Header or nil +end + +local function hideHeaderActionButtons(frame) + for _, button in ipairs(frame._lsbHeaderActionButtons or {}) do + button:SetScript("OnClick", nil) + button:SetScript("OnEnter", nil) + button:SetScript("OnLeave", nil) + button:Hide() + end +end + +local function applyHeaderActionButtons(frame, actions, actionParent, rightAnchor) + ensureHeaderRowWidgets(frame) + local buttons = frame._lsbHeaderActionButtons + local anchor = nil + local visibleCount = 0 + + actionParent = actionParent or frame + hideHeaderActionButtons(frame) + + for _, action in ipairs(actions or {}) do + if not evaluateStaticOrFunction(action.hidden, action, frame) then + visibleCount = visibleCount + 1 + + local button = buttons[visibleCount] + if button and button._lsbActionParent ~= actionParent then + button:Hide() + button = nil + end + if not button then + button = CreateFrame("Button", nil, actionParent, "UIPanelButtonTemplate") + button._lsbActionParent = actionParent + buttons[visibleCount] = button + end + + button:ClearAllPoints() + if anchor then + button:SetPoint("RIGHT", anchor, "LEFT", -8, 0) + elseif rightAnchor then + button:SetPoint("RIGHT", rightAnchor, "LEFT", -8, 0) + else + button:SetPoint("RIGHT", actionParent, "RIGHT", -20, 0) + end + button:SetSize(action.width or 100, action.height or 22) + button:SetText(action.text or action.name or "") + local enabled = evaluateStaticOrFunction(action.enabled, action, frame) + if enabled == nil then + enabled = true + end + button:SetEnabled(enabled) + internal.setSimpleTooltip(button, evaluateStaticOrFunction(action.tooltip, action, frame)) + button:SetScript("OnClick", function() + if action.onClick then + action.onClick(action, frame) + end + end) + button:Show() + anchor = button + end + end +end + +local function applySubheaderFrame(frame, data) + local title = ensureSubheaderTitle(frame) + title:SetText(data.name) + title:Show() +end + +local function applyHeaderFrame(frame, data) + ensureHeaderRowWidgets(frame) + local settingsHeader = data.attachToCategoryHeader and getSettingsListHeader() or nil + local actionParent = settingsHeader or frame + local rightAnchor = settingsHeader and settingsHeader.DefaultsButton or nil + + if frame._lsbHeaderTitle then + frame._lsbHeaderTitle:ClearAllPoints() + frame._lsbHeaderTitle:SetPoint("TOPLEFT", frame, "TOPLEFT", 7, -16) + frame._lsbHeaderTitle:SetPoint("RIGHT", frame, "RIGHT", -20, 0) + frame._lsbHeaderTitle:SetText(data.name or "") + end + + applyHeaderActionButtons(frame, data.actions, actionParent, rightAnchor) + + if frame._lsbHeaderTitle then + if data.hideTitle then + frame._lsbHeaderTitle:Hide() + return + end + + local titleRight = -20 + local buttons = frame._lsbHeaderActionButtons or {} + for i = 1, #buttons do + local button = buttons[i] + if button and button.IsShown and button:IsShown() then + frame._lsbHeaderTitle:ClearAllPoints() + frame._lsbHeaderTitle:SetPoint("TOPLEFT", frame, "TOPLEFT", 7, -16) + frame._lsbHeaderTitle:SetPoint("RIGHT", button, "LEFT", -12, 0) + titleRight = nil + break + end + end + if titleRight then + frame._lsbHeaderTitle:SetPoint("RIGHT", frame, "RIGHT", titleRight, 0) + end + frame._lsbHeaderTitle:Show() + end +end + +local function applyInfoRowFrame(frame, data) + local title, value = ensureInfoRowWidgets(frame) + local name = evaluateStaticOrFunction(data.name, frame, data) + local resolvedValue = evaluateStaticOrFunction(data.value, frame, data) + local isWide = data.wide == true or name == nil or name == "" + local isMultiline = data.multiline == true + + title:ClearAllPoints() + value:ClearAllPoints() + + if isWide then + title:SetText("") + title:Hide() + value:SetPoint("TOPLEFT", frame, "TOPLEFT", 37, isMultiline and -4 or 0) + value:SetPoint("RIGHT", frame, "RIGHT", -20, 0) + else + title:SetText(name or "") + title:SetPoint("LEFT", 37, 0) + title:SetPoint("RIGHT", frame, "CENTER", -85, 0) + title:Show() + value:SetPoint("LEFT", frame, "CENTER", -80, 0) + value:SetPoint("RIGHT", frame, "RIGHT", -20, 0) + end + + value:SetWordWrap(isMultiline) + value:SetJustifyV(isMultiline and "TOP" or "MIDDLE") + value:SetJustifyH("LEFT") + value:SetText(resolvedValue or "") + if not isWide then + title:Show() + end + value:Show() +end + +local function ensureInputRowWidgets(frame) + if frame._lsbInputTitle and frame._lsbInputEditBox and frame._lsbInputPreview then + return frame._lsbInputTitle, frame._lsbInputEditBox, frame._lsbInputPreview + end + + local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal") + title:SetJustifyH("LEFT") + title:SetWordWrap(false) + + local editBox = CreateFrame("EditBox", nil, frame, "InputBoxTemplate") + editBox:SetAutoFocus(false) + + local preview = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + preview:SetJustifyH("LEFT") + preview:SetJustifyV("TOP") + preview:SetWordWrap(false) + preview:Hide() + + frame._lsbInputTitle = title + frame._lsbInputEditBox = editBox + frame._lsbInputPreview = preview + frame.Title = title + frame.EditBox = editBox + frame.Preview = preview + + return title, editBox, preview +end + +local function setInputPreviewText(frame, text) + local preview = frame._lsbInputPreview + if not preview then + return + end + + text = text and tostring(text) or "" + preview:SetText(text) + if text ~= "" then + preview:Show() + else + preview:Hide() + end +end + +local function cancelInputPreviewTimer(frame) + local timer = frame and frame._lsbInputPreviewTimer + if timer and timer.Cancel then + timer:Cancel() + end + if frame then + frame._lsbInputPreviewTimer = nil + end +end + +local function syncInputRowText(frame, value) + local editBox = frame and frame._lsbInputEditBox + if not editBox then + return + end + + value = value == nil and "" or tostring(value) + if editBox.GetText and editBox:GetText() == value then + return + end + + frame._lsbUpdatingInputText = true + editBox:SetText(value) + frame._lsbUpdatingInputText = nil +end + +local function resolveInputPreview(frame) + local data = frame and frame._lsbInputData + local setting = frame and frame._lsbInputSetting + if not data or not data.resolveText then + setInputPreviewText(frame, nil) + return + end + + local value = setting and setting.GetValue and setting:GetValue() or nil + setInputPreviewText(frame, data.resolveText(value, setting, frame)) +end + +local function scheduleInputPreview(frame, immediate) + cancelInputPreviewTimer(frame) + + local data = frame and frame._lsbInputData + if not data or not data.resolveText then + setInputPreviewText(frame, nil) + return + end + + local delay = immediate and 0 or (data.debounce or 0) + if delay > 0 and C_Timer and C_Timer.NewTimer then + frame._lsbInputPreviewTimer = C_Timer.NewTimer(delay, function() + frame._lsbInputPreviewTimer = nil + resolveInputPreview(frame) + end) + return + end + + resolveInputPreview(frame) +end + +local function applyInputRowEnabledState(frame, enabled) + if not frame then + return + end + + frame:SetAlpha(enabled and 1 or 0.5) + + local editBox = frame._lsbInputEditBox + if not editBox then + return + end + + editBox:SetEnabled(enabled) + editBox:EnableMouse(enabled) +end + +local function applyInputRowFrame(frame, data) + local title, editBox, preview = ensureInputRowWidgets(frame) + local hasPreview = data.resolveText ~= nil + + title:ClearAllPoints() + title:SetPoint(hasPreview and "TOPLEFT" or "LEFT", frame, hasPreview and "TOPLEFT" or "LEFT", 37, hasPreview and -6 or 0) + title:SetPoint("RIGHT", frame, "CENTER", -85, 0) + title:SetJustifyV(hasPreview and "TOP" or "MIDDLE") + title:SetText(data.name) + title:Show() + + editBox:ClearAllPoints() + editBox:SetPoint(hasPreview and "TOPLEFT" or "LEFT", frame, "CENTER", -80, hasPreview and -2 or 0) + editBox:SetSize(data.width or 140, 20) + editBox:SetNumeric(data.numeric == true) + if data.maxLetters then + editBox:SetMaxLetters(data.maxLetters) + end + editBox:SetTextInsets(6, 6, 0, 0) + editBox:Show() + + preview:ClearAllPoints() + preview:SetPoint("TOPLEFT", editBox, "BOTTOMLEFT", 0, -3) + preview:SetPoint("RIGHT", frame, "RIGHT", -20, 0) + if hasPreview then + preview:Show() + else + preview:Hide() + end + + frame._lsbInputData = data + frame._lsbInputSetting = data.setting + editBox._lsbOwnerFrame = frame + + if not editBox._lsbInputScriptsBound then + editBox:SetScript("OnTextChanged", function(self) + local owner = self._lsbOwnerFrame + if not owner or owner._lsbUpdatingInputText then + return + end + + local setting = owner._lsbInputSetting + local text = self:GetText() or "" + if setting and setting.SetValue then + setting:SetValue(text) + end + + local inputData = owner._lsbInputData + if inputData and inputData.onTextChanged then + inputData.onTextChanged(text, setting, owner) + end + + scheduleInputPreview(owner, false) + end) + editBox:SetScript("OnEnterPressed", function(self) + self:ClearFocus() + end) + editBox:SetScript("OnEscapePressed", function(self) + local owner = self._lsbOwnerFrame + if owner then + local setting = owner._lsbInputSetting + local value = setting and setting.GetValue and setting:GetValue() or "" + syncInputRowText(owner, value) + scheduleInputPreview(owner, true) + end + self:ClearFocus() + end) + editBox._lsbInputScriptsBound = true + end + + syncInputRowText(frame, data.setting and data.setting.GetValue and data.setting:GetValue() or "") + + local ownVariable = data.settingVariable + registerValueChangedCallback(frame, ownVariable, function() + local currentSetting = frame._lsbInputSetting + local value = currentSetting and currentSetting.GetValue and currentSetting:GetValue() or "" + syncInputRowText(frame, value) + end, frame) + + if data.watchVariables then + for _, variable in ipairs(data.watchVariables) do + if variable ~= ownVariable then + registerValueChangedCallback(frame, variable, function() + scheduleInputPreview(frame, true) + end, frame) + end + end + end + + scheduleInputPreview(frame, true) +end + +local function applyEmbedCanvasFrame(frame, data, initializer) + local canvas = data.canvas + if not canvas then + return + end + + frame._lsbCanvas = canvas + canvas:SetParent(frame) + canvas:ClearAllPoints() + canvas:SetPoint("TOPLEFT", 0, 0) + canvas:SetPoint("TOPRIGHT", 0, 0) + canvas:SetHeight(initializer:GetExtent()) + canvas:Show() +end + +local function ensureListElementCallbackHandles(frame) + if frame.cbrHandles or not (Settings and Settings.CreateCallbackHandleContainer) then + return + end + + frame.cbrHandles = Settings.CreateCallbackHandleContainer() +end + +local function initializerShouldShow(initializer) + if initializer and initializer.ShouldShow then + return initializer:ShouldShow() + end + + if initializer and initializer._shownPredicates then + for _, predicate in ipairs(initializer._shownPredicates) do + if not predicate() then + return false + end + end + end + + return true +end + +local function initializerIsEnabled(initializer) + if initializer and initializer.EvaluateModifyPredicates then + return initializer:EvaluateModifyPredicates() + end + + if initializer and initializer._modifyPredicates then + for _, predicate in ipairs(initializer._modifyPredicates) do + if not predicate() then + return false + end + end + end + + return true +end + +local function createCustomListRowInitializer(template, data, extent, initFrame) + local initializer = Settings.CreateElementInitializer(template, data) + setInitializerExtent(initializer, extent) + + initializer.InitFrame = function(self, frame) + ensureListElementCallbackHandles(frame) + + frame.data = self.data + if frame.Text then + frame.Text:SetText("") + end + if frame.NewFeature then + frame.NewFeature:Hide() + end + + resetPlainListElementFrame(frame) + initFrame(frame, self.data, self) + self._lsbActiveFrame = frame + + if not frame._lsbHasCustomEvaluateState then + frame.EvaluateState = function(control) + local currentInitializer = control.GetElementData and control:GetElementData() + or control._lsbInitializer + if currentInitializer then + currentInitializer:SetEnabled(initializerIsEnabled(currentInitializer)) + end + control:SetShown(initializerShouldShow(currentInitializer)) + end + frame._lsbHasCustomEvaluateState = true + end + + frame._lsbInitializer = self + frame:EvaluateState() + end + + initializer.Resetter = function(self, frame) + if frame.cbrHandles and frame.cbrHandles.Unregister then + frame.cbrHandles:Unregister() + end + if frame.Text then + frame.Text:SetText("") + end + if frame.NewFeature then + frame.NewFeature:Hide() + end + -- Canvas rows are recycled by Blizzard's list view. Hide and detach the + -- previous row-owned canvas here so it cannot remain visible on the + -- wrong page when the frame is reused for a different initializer. + if frame._lsbCanvas then + frame._lsbCanvas:Hide() + frame._lsbCanvas = nil + end + if self._lsbActiveFrame == frame then + self._lsbActiveFrame = nil + end + if self._lsbResetFrame then + self._lsbResetFrame(frame, self) + end + + resetPlainListElementFrame(frame) + frame.data = nil + frame._lsbInitializer = nil + end + + return initializer +end + +internal.hideHeaderActionButtons = hideHeaderActionButtons +internal.applyHeaderFrame = applyHeaderFrame +internal.applySubheaderFrame = applySubheaderFrame +internal.applyInfoRowFrame = applyInfoRowFrame +internal.applyInputRowFrame = applyInputRowFrame +internal.applyInputRowEnabledState = applyInputRowEnabledState +internal.cancelInputPreviewTimer = cancelInputPreviewTimer +internal.applyEmbedCanvasFrame = applyEmbedCanvasFrame +internal.createCustomListRowInitializer = createCustomListRowInitializer diff --git a/Libs/LibSettingsBuilder/README.md b/Libs/LibSettingsBuilder/README.md index a1b220f7..eccb08d9 100644 --- a/Libs/LibSettingsBuilder/README.md +++ b/Libs/LibSettingsBuilder/README.md @@ -6,71 +6,184 @@ It supports: - path-based bindings for AceDB-style profile tables, - handler-mode bindings for arbitrary storage, +- built-in text input rows with optional debounced preview resolution, +- first-class dynamic lists and sectioned editors, - composite builders for common settings groups, -- canvas layout helpers for more complex pages, +- canonical row types for headers, subheaders, info rows, buttons, canvases, and page actions, +- XML/template-backed custom controls when a built-in row is not enough, +- page-owned refresh hooks for out-of-band state changes, - deterministic dropdown ordering, - clickable slider value editing. Distributed via [LibStub](https://www.wowace.com/projects/libstub). +## Public surface + +The documented public surface is the current declarative API: + +- factory: `LSB.New(config)` +- runtime lookups: `lsb:GetSection(sectionKey)`, `lsb:GetRootPage()`, `lsb:GetPage(sectionKey, pageKey)`, `lsb:HasCategory(category)` +- page handle methods: `page:GetId()`, `page:Refresh()` +- registration root: `config.page` plus `config.sections` +- canonical registration schema: raw row tables in `rows = { ... }` + +The runtime returned by `LSB.New(...)` is intentionally narrow. Builder/helper constructors are not exposed on `lsb` instances, and deprecated transition namespaces like `LSBDeprecated` are not part of the documented public API. + ## At a glance -| Need | LibSettingsBuilder | -|---|---| -| Standard settings pages | `RegisterFromTable(...)` | -| Fine-grained control | imperative `SB.Checkbox(...)`, `SB.Slider(...)`, etc. | -| Existing AceDB profiles | `PathAdapter(...)` | -| Custom storage | handler mode with `get` / `set` / `key` | -| Reusable settings groups | border, font override, positioning composites | -| Custom settings pages | `CreateCanvasLayout(...)` | +| Need | LibSettingsBuilder | +| ------------------------------- | ---------------------------------------------------------------------------------------------------- | +| Standard settings pages | `LSB.New({ name = ..., page = ..., sections = ... })` | +| Root-owned landing page | `page = { key = ..., rows = ... }` inside the root spec | +| Dynamic refresh | lookup the registered page with `lsb:GetRootPage()` / `lsb:GetPage(...)`, then call `page:Refresh()` | +| Existing AceDB profiles | `store = db.profile`, `defaults = defaults.profile` | +| Custom storage | handler mode with `get` / `set` / `key` (or `id`) | +| Text entry / numeric ID fields | `type = "input"` | +| Dynamic editors / ordered lists | `type = "list"` or `type = "sectionList"` | +| Reusable settings groups | border, font override, and height override composites | +| XML-backed bespoke widgets | `type = "custom"` | +| Force visible rows to refresh | `page:Refresh()` | ## Quick start ```lua local LSB = LibStub("LibSettingsBuilder-1.0") -local SB = LSB:New({ - pathAdapter = LSB.PathAdapter({ - getStore = function() - return MyAddonDB.profile - end, - getDefaults = function() - return MyAddonDefaults.profile - end, - }), - varPrefix = "MYADDON", - onChanged = function() +local lsb = LSB.New({ + name = "My Addon", + store = MyAddonDB.profile, + defaults = MyAddonDefaults.profile, + onChanged = function(ctx) MyAddon:Refresh() end, -}) - -SB.CreateRootCategory("My Addon") - -SB.RegisterFromTable({ - name = "General", - path = "general", - args = { - enabled = { - type = "toggle", - path = "enabled", - name = "Enable", - order = 1, + page = { + key = "about", + rows = { + { + type = "info", + name = "Version", + value = "1.0.0", + }, }, - opacity = { - type = "range", - path = "opacity", - name = "Opacity", - min = 0, - max = 100, - step = 1, - order = 2, + }, + sections = { + { + key = "general", + name = "General", + path = "general", + pages = { + { + key = "main", + rows = { + { + type = "checkbox", + path = "enabled", + name = "Enable", + }, + { + type = "slider", + path = "opacity", + name = "Opacity", + min = 0, + max = 100, + step = 1, + }, + }, + }, + }, }, }, }) +``` + +For a registered category tree, `name` and `onChanged` are required. `store` enables path-bound rows, and `defaults` supplies their default values. + +## Canonical row types + +Declarative pages accept canonical row types only. + +| Type | Meaning | +| ---------------- | ----------------------------------------------------------------- | +| `checkbox` | Boolean proxy setting | +| `slider` | Numeric proxy setting | +| `dropdown` | Deterministic menu proxy setting | +| `input` | Built-in text input row with optional preview / debounce support | +| `color` | Color swatch proxy setting | +| `button` | Button row | +| `header` | Blizzard-style section header | +| `subheader` | Secondary text row | +| `info` | Left-label / right-value informational row | +| `canvas` | Embedded frame row for canvas content | +| `pageActions` | Right-aligned page-header action row | +| `list` | First-class dynamic flat list widget | +| `sectionList` | First-class dynamic grouped list widget | +| `custom` | Proxy setting backed by a custom XML template | +| `colorList` | Expands `defs` into multiple color swatches | +| `checkboxList` | Expands `defs` into multiple checkboxes | +| `border` | Composite group for border enable / width / color | +| `fontOverride` | Composite group for override toggle, font picker, and size slider | +| `heightOverride` | Composite slider with nil/zero transforms | + +## Input rows + +`input` is the newest built-in control type. It is intended for cases where you want a normal settings row layout, but need text entry instead of a dropdown or slider. + +Supported `input` spec fields include the standard binding/modifier fields plus: + +- `numeric = true` — sets the edit box to numeric-only mode. +- `maxLetters` — limits input length. +- `width` — overrides the edit box width (default `140`). +- `debounce` — delays preview refresh by N seconds. +- `resolveText(value, setting, frame)` — returns the preview text shown under the edit box. +- `onTextChanged(text, setting, frame)` — optional hook fired after the new text is written. + +Example: -SB.RegisterCategories() +```lua +spellId = { + type = "input", + name = "Spell ID", + key = "draftSpellId", + numeric = true, + maxLetters = 10, + debounce = 1, + get = function() + return draft.spellIdText + end, + set = function(value) + draft.spellIdText = value or "" + end, + resolveText = function(value) + local id = tonumber(value) + return id and C_Spell.GetSpellName(id) or nil + end, +} ``` +## Implementation notes + +The library has three main implementation paths: + +- **Proxy controls** — `checkbox`, `slider`, `dropdown`, `color`, `input`, and `custom` all go through the same proxy-setting pipeline. That means path mode and handler mode work consistently across them. +- **Layout rows** — `header`, `subheader`, `info`, `button`, `canvas`, and `pageActions` are initializer/layout helpers rather than persisted settings. +- **Composite rows** — `border`, `fontOverride`, `heightOverride`, `colorList`, and `checkboxList` expand into multiple child controls. + +The recommended author-facing registration model is declarative: export plain page/section spec tables and pass the assembled tree to `LSB.New({ ... })`. Deprecated non-declarative page-construction APIs have been removed. + +Use `pageActions` for right-aligned page buttons. Use `list` and `sectionList` for dynamic editors, and keep `canvas` / `custom` as escape hatches for truly bespoke frames. Canvas rows stay on the existing lifecycle path, so page switches continue to reuse the same proven frame handling. + +`input` specifically is implemented as a built-in custom list row using `SettingsListElementTemplate`, with an `InputBoxTemplate` edit box anchored in the standard left-label / right-control layout. It does **not** need a separate XML template the way `custom` controls do. + +Under the hood, an input row: + +1. creates a normal proxy setting via `Settings.RegisterProxySetting`, +2. writes the current edit-box text back through that setting on `OnTextChanged`, +3. optionally debounces preview work through `C_Timer.NewTimer`, +4. refreshes the preview immediately when watched settings change via callback handles, and +5. reuses the same enabled / hidden / parent modifier system as the other built-in controls. + +That keeps `input` aligned with the rest of the builder instead of turning it into a one-off control with different binding behavior. + ## Documentation - [Installation & Compatibility](docs/INSTALLATION.md) @@ -93,7 +206,9 @@ The `.busted` config defines the `libsettingsbuilder` task pointing at this libr - Embed the library inside your addon's `Libs/` folder. - Load `LibStub` before `LibSettingsBuilder`. -- Canvas layout spacing can be tuned globally or per layout. +- Load `Libs\LibSettingsBuilder\embed.xml` rather than the individual library Lua files. +- Prefer a single `LSB.New({ name = ..., onChanged = ..., page = ..., sections = { ... } })` call and keep page handles only for later `page:Refresh()` calls. +- `page:Refresh()` is the intended way to refresh dynamic info rows, dropdown options, and dynamic list rows after profile mutations, async item loads, or other out-of-band changes. - Slider value editing and scroll dropdown support are implemented through Settings UI integration hooks. ## License diff --git a/Libs/LibSettingsBuilder/Tests/Builder_spec.lua b/Libs/LibSettingsBuilder/Tests/Builder_spec.lua new file mode 100644 index 00000000..30a1526a --- /dev/null +++ b/Libs/LibSettingsBuilder/Tests/Builder_spec.lua @@ -0,0 +1,378 @@ +-- Enhanced Cooldown Manager addon for World of Warcraft +-- Author: Argium +-- Licensed under the GNU General Public License v3.0 + +local TestHelpers = + assert(loadfile("Tests/TestHelpers.lua") or loadfile("TestHelpers.lua"), "Unable to load Tests/TestHelpers.lua")() + +describe("LibSettingsBuilder Builder", function() + local originalGlobals + + setup(function() + originalGlobals = TestHelpers.CaptureGlobals({ + "LibStub", + "Settings", + "CreateFrame", + "hooksecurefunc", + "SettingsDropdownControlMixin", + "SettingsSliderControlMixin", + "SettingsListElementMixin", + }) + end) + + teardown(function() + TestHelpers.RestoreGlobals(originalGlobals) + end) + + before_each(function() + TestHelpers.SetupLibStub() + TestHelpers.SetupSettingsStubs() + TestHelpers.LoadLibSettingsBuilder() + end) + + local function createBuilder(config) + local lsb = LibStub("LibSettingsBuilder-1.0") + local profile = { + general = { + enabled = true, + height = nil, + }, + } + local defaults = { + general = { + enabled = false, + height = 12, + }, + } + + return lsb.New({ + name = "Builder Spec", + store = function() + return profile + end, + defaults = function() + return defaults + end, + onChanged = function() end, + page = config and config.page or nil, + sections = config and config.sections or nil, + }), profile, defaults + end + + it("registers root and section pages through LSB.New", function() + local sb = createBuilder({ + page = { + key = "about", + rows = { + { type = "info", name = "Version", value = "1.0" }, + }, + }, + sections = { + { + key = "general", + name = "General", + pages = { + { + key = "main", + rows = { + { type = "checkbox", path = "general.enabled", name = "Enabled" }, + }, + }, + }, + }, + }, + }) + + local rootPage = assert(sb:GetRootPage()) + local generalPage = assert(sb:GetPage("general", "main")) + + assert.are.equal("Builder Spec", rootPage:GetId()) + assert.are.equal("Builder Spec.General", generalPage:GetId()) + assert.are.equal("general", assert(sb:GetSection("general")).key) + assert.is_true(sb:HasCategory(rootPage._category)) + assert.is_true(sb:HasCategory(generalPage._category)) + end) + + it("returns nil for missing section-page lookups", function() + local sb = createBuilder({ + sections = { + { + key = "general", + name = "General", + pages = { + { + key = "main", + rows = { + { type = "info", name = "Version", value = "1.0" }, + }, + }, + }, + }, + }, + }) + + assert.is_nil(sb:GetPage("general")) + assert.is_nil(sb:GetPage("missing", "main")) + assert.is_nil(sb:GetPage("general", "missing")) + end) + + it("uses the section category for an unnamed page in a multi-page section", function() + local sb = createBuilder({ + sections = { + { + key = "power", + name = "Power", + pages = { + { + key = "main", + rows = { + { type = "info", name = "Enabled", value = "Yes" }, + }, + }, + { + key = "ticks", + name = "Ticks", + rows = { + { type = "info", name = "Count", value = "0" }, + }, + }, + }, + }, + }, + }) + + local mainPage = assert(sb:GetPage("power", "main")) + local ticksPage = assert(sb:GetPage("power", "ticks")) + + assert.are.equal("Builder Spec.Power", mainPage:GetId()) + assert.are.equal("Builder Spec.Power.Ticks", ticksPage:GetId()) + assert.are.equal(mainPage._category, ticksPage._category._parent) + end) + + it("registers root-bound composite rows from an empty path", function() + local sb, profile = createBuilder({ + sections = { + { + key = "general", + name = "General", + pages = { + { + key = "main", + rows = { + { type = "heightOverride", path = "", disabled = false }, + }, + }, + }, + }, + }, + }) + + local settings = TestHelpers.CollectSettings(function() + TestHelpers.RegisterSectionSpec(sb, { + key = "generalTwo", + name = "General Two", + path = "general", + pages = { + { + key = "main", + rows = { + { type = "heightOverride", path = "", disabled = false }, + }, + }, + }, + }) + end) + + assert.is_not_nil(settings["BS_generalTwo_height"] or settings["BS_general_height"]) + assert.is_nil(profile.general.height) + end) + + it("rejects deprecated desc fields at registration time", function() + local ok, err = pcall(function() + createBuilder({ + sections = { + { + key = "general", + name = "General", + pages = { + { + key = "main", + rows = { + { type = "checkbox", path = "general.enabled", name = "Enabled", desc = "Old tooltip" }, + }, + }, + }, + }, + }, + }) + end) + + assert.is_false(ok) + assert.is_truthy(tostring(err):find("deprecated field 'desc'", 1, true)) + end) + + it("rejects removed condition fields at registration time", function() + local ok, err = pcall(function() + createBuilder({ + sections = { + { + key = "general", + name = "General", + pages = { + { + key = "main", + rows = { + { + type = "checkbox", + path = "general.enabled", + name = "Enabled", + condition = function() + return true + end, + }, + }, + }, + }, + }, + }, + }) + end) + + assert.is_false(ok) + assert.is_truthy(tostring(err):find("removed field 'condition'", 1, true)) + end) + + it("keeps page handles limited to the v2 public surface", function() + local sb = createBuilder({ + sections = { + { + key = "general", + name = "General", + pages = { + { + key = "main", + rows = { + { type = "info", name = "Version", value = "1.0" }, + }, + }, + }, + }, + }, + }) + local page = assert(sb:GetPage("general", "main")) + + assert.is_function(page.GetId) + assert.is_function(page.Refresh) + assert.is_nil(page.GetID) + assert.is_nil(page.RegisterRows) + assert.is_nil(page.Checkbox) + assert.is_nil(page.List) + end) + + it("returns an lsb instance with only the public API on its prototype", function() + local sb = createBuilder({ + sections = { + { + key = "general", + name = "General", + pages = { + { + key = "main", + rows = { { type = "info", name = "Version", value = "1.0" } }, + }, + }, + }, + }, + }) + + -- Public API accessible via narrow prototype + assert.is_function(sb.GetSection) + assert.is_function(sb.GetRootPage) + assert.is_function(sb.GetPage) + assert.is_function(sb.HasCategory) + + -- Internal row-builder methods not on the public prototype + assert.is_nil(sb.Checkbox) + assert.is_nil(sb.Slider) + assert.is_nil(sb.BorderGroup) + assert.is_nil(sb.Control) + assert.is_nil(sb.EmbedCanvas) + + -- Instance state is raw on the table + assert.is_table(rawget(sb, "_sections")) + assert.is_table(rawget(sb, "_layouts")) + end) + + it("returns plain page handles with methods directly on the table", function() + local sb = createBuilder({ + sections = { + { + key = "general", + name = "General", + pages = { + { + key = "main", + rows = { { type = "info", name = "Version", value = "1.0" } }, + }, + }, + }, + }, + }) + local page = assert(sb:GetPage("general", "main")) + + -- Methods are directly on the handle, not via metatable + assert.is_function(rawget(page, "GetId")) + assert.is_function(rawget(page, "Refresh")) + -- _category is kept for HasCategory use + assert.is_not_nil(rawget(page, "_category")) + + -- Internal page state is not on the handle + assert.is_nil(page._operations) + assert.is_nil(page._rowIDs) + assert.is_nil(page._registered) + assert.is_nil(page._builder) + assert.is_nil(page._root) + end) + + it("registers declarative pageActions rows", function() + local sb = createBuilder({ + sections = { + { + key = "general", + name = "General", + pages = { + { + key = "main", + name = "Main", + rows = { + { + id = "actions", + type = "pageActions", + name = "Spell Colors", + attachToCategoryHeader = false, + hideTitle = false, + actions = { + { + text = "Reset", + onClick = function() end, + }, + }, + }, + }, + }, + }, + }, + }, + }) + local page = assert(sb:GetPage("general", "main")) + local initializers = assert(page._category:GetLayout())._initializers + local initializer = assert(initializers[1]) + local data = initializer:GetData() + + assert.are.equal("pageActions", data._lsbKind) + assert.are.equal("Spell Colors", data.name) + assert.is_false(data.attachToCategoryHeader) + assert.is_false(data.hideTitle) + assert.are.equal("Reset", data.actions[1].text) + end) +end) diff --git a/Libs/LibSettingsBuilder/Tests/Collections_spec.lua b/Libs/LibSettingsBuilder/Tests/Collections_spec.lua new file mode 100644 index 00000000..6cd2182f --- /dev/null +++ b/Libs/LibSettingsBuilder/Tests/Collections_spec.lua @@ -0,0 +1,676 @@ +-- Enhanced Cooldown Manager addon for World of Warcraft +-- Author: Argium +-- Licensed under the GNU General Public License v3.0 + +local TestHelpers = + assert(loadfile("Tests/TestHelpers.lua") or loadfile("TestHelpers.lua"), "Unable to load Tests/TestHelpers.lua")() + +describe("LibSettingsBuilder Collections", function() + local originalGlobals + + setup(function() + originalGlobals = TestHelpers.CaptureGlobals({ + "LibStub", + "Settings", + "CreateFrame", + "hooksecurefunc", + "SettingsDropdownControlMixin", + "SettingsSliderControlMixin", + "SettingsListElementMixin", + "CreateDataProvider", + "CreateScrollBoxListLinearView", + "GameFontDisable", + "GameFontNormal", + "ScrollUtil", + }) + end) + + teardown(function() + TestHelpers.RestoreGlobals(originalGlobals) + end) + + before_each(function() + TestHelpers.SetupLibStub() + TestHelpers.SetupSettingsStubs() + _G.hooksecurefunc = function() end + _G.SettingsListElementMixin = {} + _G.SettingsDropdownControlMixin = {} + _G.SettingsSliderControlMixin = {} + _G.GameFontDisable = { + GetTextColor = function() + return 0.5, 0.5, 0.5, 1 + end, + } + _G.GameFontNormal = { + GetTextColor = function() + return 1, 0.82, 0, 1 + end, + } + _G.CreateFrame = function() + return TestHelpers.makeFrame() + end + _G.CreateDataProvider = function() + return { + Flush = function(self) + self.items = {} + end, + Insert = function(self, item) + self.items = self.items or {} + self.items[#self.items + 1] = item + end, + } + end + _G.CreateScrollBoxListLinearView = function() + return { + SetElementExtent = function() end, + SetElementInitializer = function(self, _, fn) + self._initializer = fn + end, + } + end + _G.ScrollUtil = { + InitScrollBoxListWithScrollBar = function(scrollBox, _, view) + scrollBox._scrollView = view + end, + } + TestHelpers.LoadLibSettingsBuilder() + end) + + local function makeCollectionControl(clickedButtons) + local control = TestHelpers.makeFrame() + control._children = {} + local callbacks = {} + local textColor = { 1, 1, 1, 1 } + local function fireValueChanged(self, value) + for _, callback in ipairs(callbacks.OnValueChanged or {}) do + callback.fn(callback.owner or self, value) + end + end + control.SetShown = function(self, shown) + if shown then + self:Show() + else + self:Hide() + end + end + control.GetChildren = function(self) + return (table.unpack or unpack)(self._children) + end + control.SetText = function(self, text) + self._text = text + end + control.GetText = function(self) + return self._text or "" + end + control.SetTexture = function(self, textureValue) + self._texture = textureValue + end + control.GetTexture = function(self) + return self._texture + end + control.GetStringWidth = function(self) + return #(self._text or "") * 5 + end + control.SetFontObject = function(self, fontObject) + self._fontObject = fontObject + end + control.SetTextColor = function(_, r, g, b, a) + textColor = { r, g, b, a or 1 } + end + control.GetTextColor = function() + return textColor[1], textColor[2], textColor[3], textColor[4] + end + control.SetWordWrap = function() end + control.SetJustifyH = function() end + control.SetJustifyV = function() end + control.SetAutoFocus = function() end + control.SetNumeric = function() end + control.SetMaxLetters = function() end + control.SetTextInsets = function() end + control.SetFocus = function() end + control.HighlightText = function() end + control.SetEnabled = function(self, enabled) + self._enabled = enabled + end + control.EnableMouse = function(self, enabled) + self._mouseEnabled = enabled + end + control.RegisterForClicks = function(self, ...) + self._registeredClicks = { ... } + if clickedButtons then + clickedButtons[#clickedButtons + 1] = self + end + end + control.SetPropagateMouseClicks = function(self, propagate) + self._propagateMouseClicks = propagate + end + control.SetMinMaxValues = function(self, minValue, maxValue) + self._minValue = minValue + self._maxValue = maxValue + end + control.SetValueStep = function(self, step) + self._valueStep = step + end + control.SetObeyStepOnDrag = function(self, obey) + self._obeyStepOnDrag = obey + end + control.RegisterCallback = function(self, event, fn, owner) + callbacks[event] = callbacks[event] or {} + callbacks[event][#callbacks[event] + 1] = { fn = fn, owner = owner } + end + control.Init = function(self, initialValue, minValue, maxValue) + self._value = initialValue + self._minValue = minValue + self._maxValue = maxValue + fireValueChanged(self, initialValue) + end + control.SetValue = function(self, value) + self._value = value + fireValueChanged(self, value) + end + control.Slider = { + SetValueStep = function(_, step) + control._valueStep = step + end, + } + control.SetColorRGB = function(self, r, g, b) + self._color = { r, g, b } + end + control.SetDataProvider = function(self, dataProvider) + self._dataProvider = dataProvider + if self._scrollView and self._scrollView._initializer then + self._rows = self._rows or {} + for index, item in ipairs(dataProvider.items or {}) do + local row = self._rows[index] or makeCollectionControl(clickedButtons) + self._rows[index] = row + if not row._testParented then + self._children[#self._children + 1] = row + row._testParented = true + end + self._scrollView._initializer(row, item) + end + end + end + control.CreateFontString = function() + return makeCollectionControl(clickedButtons) + end + control.CreateTexture = function() + local texture = makeCollectionControl(clickedButtons) + texture.SetDesaturated = function(self, desaturated) + self._desaturated = desaturated + end + texture.SetVertexColor = function(self, r, g, b, a) + self._vertexColor = { r, g, b, a } + end + return texture + end + + local function setButtonTexture(self, key, textureValue) + self[key] = self[key] or makeCollectionControl(clickedButtons) + self[key]:SetTexture(textureValue) + end + control.SetNormalTexture = function(self, textureValue) + setButtonTexture(self, "_normalTexture", textureValue) + end + control.GetNormalTexture = function(self) + return self._normalTexture + end + control.SetPushedTexture = function(self, textureValue) + setButtonTexture(self, "_pushedTexture", textureValue) + end + control.GetPushedTexture = function(self) + return self._pushedTexture + end + control.SetDisabledTexture = function(self, textureValue) + setButtonTexture(self, "_disabledTexture", textureValue) + end + control.GetDisabledTexture = function(self) + return self._disabledTexture + end + control.SetHighlightTexture = function(self, textureValue) + setButtonTexture(self, "_highlightTexture", textureValue) + end + control.GetHighlightTexture = function(self) + return self._highlightTexture + end + return control + end + + it("creates first-class list and sectionList initializers from raw row specs", function() + local lsb = LibStub("LibSettingsBuilder-1.0") + local SB = lsb.New({ + name = "Collections", + store = function() + return { root = {} } + end, + defaults = function() + return { root = {} } + end, + onChanged = function() end, + sections = { + { + key = "rows", + name = "Rows", + pages = { + { + key = "main", + rows = { + { + id = "listRow", + type = "list", + height = 120, + items = function() + return {} + end, + variant = "swatch", + }, + { + id = "sectionRow", + type = "sectionList", + height = 120, + sections = function() + return {} + end, + }, + }, + }, + }, + }, + }, + }) + local page = SB:GetPage("rows", "main") + local initializers = page._category:GetLayout()._initializers + local listInit = initializers[1] + local sectionInit = initializers[2] + + assert.are.equal("SettingsListElementTemplate", listInit._template) + assert.are.equal("SettingsListElementTemplate", sectionInit._template) + assert.is_function(page.Refresh) + end) + + it("registers section-list row action buttons for mouse-up clicks", function() + local clickedButtons = {} + + _G.CreateFrame = function() + return makeCollectionControl(clickedButtons) + end + + local lsb = LibStub("LibSettingsBuilder-1.0") + lsb._internal.applyCollectionFrame(makeCollectionControl(clickedButtons), { + sections = function() + return { + { + key = "utility", + title = "Utility", + items = { + { + label = "Shadowmeld", + actions = { + delete = { + text = "Remove", + }, + }, + }, + }, + }, + } + end, + }) + + assert.is_true(#clickedButtons > 0) + for _, button in ipairs(clickedButtons) do + assert.are.same({ "LeftButtonUp" }, button._registeredClicks) + end + end) + + it("resets reused section-list row visuals from disabled to enabled", function() + _G.CreateFrame = function() + return makeCollectionControl() + end + + local sections = { + { + key = "utility", + title = "Utility", + items = { + { + label = "Shadowmeld", + icon = 58984, + tooltip = "Add Shadowmeld", + disabled = true, + actions = { + delete = { + buttonTextures = { normal = "add", disabled = "add-disabled" }, + enabled = false, + tooltip = "Add", + }, + }, + }, + }, + }, + } + local data = { + sections = function() + return sections + end, + } + local host = makeCollectionControl() + local lsb = LibStub("LibSettingsBuilder-1.0") + + lsb._internal.applyCollectionFrame(host, data) + local row = assert(host._lsbSectionRowPools.utility[1]) + assert.are.same({ 0.5, 0.5, 0.5, 1 }, { row._label:GetTextColor() }) + assert.are.equal(0.5, row._label:GetAlpha()) + assert.are.equal(0.4, row._textureButtons.delete:GetAlpha()) + assert.is_nil(row._textureButtons.delete:GetScript("OnEnter")) + + sections[1].items[1] = { + label = "Shadowmeld", + icon = 58984, + tooltip = "Remove Shadowmeld", + disabled = false, + actions = { + delete = { + buttonTextures = { normal = "remove", disabled = "remove-disabled" }, + enabled = true, + tooltip = "Remove", + }, + }, + } + lsb._internal.applyCollectionFrame(host, data) + + assert.are.same({ 1, 0.82, 0, 1 }, { row._label:GetTextColor() }) + assert.are.equal(1, row._label:GetAlpha()) + assert.are.equal(1, row._textureButtons.delete:GetAlpha()) + assert.are.equal(1, row._textureButtons.delete:GetNormalTexture():GetAlpha()) + assert.are.equal(row._label:GetStringWidth(), row._tooltipOwner:GetWidth()) + row._tooltipOwner:GetScript("OnEnter")(row._tooltipOwner) + assert.is_true(row._highlight:IsShown()) + assert.is_function(row._textureButtons.delete:GetScript("OnEnter")) + end) + + it("prevents editor row controls from selecting the host settings row", function() + _G.CreateFrame = function() + return makeCollectionControl() + end + + local item = { + label = "Tick 1", + fields = { + { + value = 50, + min = 1, + max = 100, + step = 1, + }, + }, + color = { + value = { r = 1, g = 1, b = 1, a = 1 }, + }, + remove = { + text = "Remove", + }, + } + local host = makeCollectionControl() + local lsb = LibStub("LibSettingsBuilder-1.0") + + lsb._internal.applyCollectionFrame(host, { + preset = "editor", + rowHeight = 34, + items = function() + return { item } + end, + }) + + local row = makeCollectionControl() + host._lsbCollectionView._initializer(row, assert(host._lsbCollectionDataProvider.items[1])) + + local slider = row._fieldWidgets[1].slider + assert.is_false(row._mouseEnabled) + assert.is_nil(row:GetScript("OnEnter")) + assert.is_nil(row:GetScript("OnLeave")) + assert.is_false(slider._propagateMouseClicks) + assert.is_false(slider._lsbValueButton._propagateMouseClicks) + assert.is_false(row._swatch._propagateMouseClicks) + assert.is_false(row._removeButton._propagateMouseClicks) + assert.are.same({ "LeftButtonUp" }, row._removeButton._registeredClicks) + end) + + it("keeps editor slider callbacks current across recycled row refreshes", function() + _G.CreateFrame = function() + return makeCollectionControl() + end + + local calls = {} + local items = { + { + label = "Tick 1", + fields = { + { + value = 10, + min = 1, + max = 100, + step = 1, + onValueChanged = function(value) + calls[#calls + 1] = "first:" .. value + end, + }, + }, + }, + } + local host = makeCollectionControl() + local lsb = LibStub("LibSettingsBuilder-1.0") + local data = { + preset = "editor", + rowHeight = 34, + items = function() + return items + end, + } + + lsb._internal.applyCollectionFrame(host, data) + items = { + { + label = "Tick 2", + fields = { + { + value = 20, + min = 1, + max = 100, + step = 1, + onValueChanged = function(value) + calls[#calls + 1] = "second:" .. value + end, + }, + }, + }, + } + lsb._internal.applyCollectionFrame(host, data) + + host._lsbCollectionScrollBox._rows[1]._fieldWidgets[1].slider:SetValue(42) + + assert.are.same({ "second:42" }, calls) + end) + + it("resolves editor slider text entry ranges against the current item", function() + _G.CreateFrame = function() + return makeCollectionControl() + end + + local resolvedItem + local host = makeCollectionControl() + local lsb = LibStub("LibSettingsBuilder-1.0") + local item = { + label = "Tick 1", + fields = { + { + value = 50, + min = 1, + max = 100, + step = 1, + getRange = function(currentItem, targetValue) + resolvedItem = currentItem + return 1, targetValue, 5 + end, + }, + }, + } + + lsb._internal.applyCollectionFrame(host, { + preset = "editor", + rowHeight = 34, + items = function() + return { item } + end, + }) + + local minValue, maxValue, step = host._lsbCollectionScrollBox._rows[1]._fieldWidgets[1].slider._lsbRangeResolver(500) + + assert.are.equal(item, resolvedItem) + assert.are.equal(1, minValue) + assert.are.equal(500, maxValue) + assert.are.equal(5, step) + end) + + it("does not re-enable editor row mouse targets during initializer state evaluation", function() + _G.CreateFrame = function(_, _, parent) + local frame = makeCollectionControl() + if parent and parent._children then + parent._children[#parent._children + 1] = frame + end + return frame + end + + local lsb = LibStub("LibSettingsBuilder-1.0") + local SB = lsb.New({ + name = "Collections", + store = function() + return { root = {} } + end, + defaults = function() + return { root = {} } + end, + onChanged = function() end, + sections = { + { + key = "rows", + name = "Rows", + pages = { + { + key = "main", + rows = { + { + id = "listRow", + type = "list", + height = 120, + variant = "editor", + items = function() + return { + { + label = "Tick 1", + fields = { + { value = 50, min = 1, max = 100, step = 1 }, + }, + color = { + value = { r = 1, g = 1, b = 1, a = 1 }, + }, + remove = { + text = "Remove", + }, + }, + } + end, + }, + }, + }, + }, + }, + }, + }) + local initializer = SB:GetPage("rows", "main")._category:GetLayout()._initializers[1] + local host = makeCollectionControl() + + initializer:InitFrame(host) + + local row = host._lsbCollectionScrollBox._rows[1] + local slider = row._fieldWidgets[1].slider + assert.is_false(row._mouseEnabled) + assert.is_false(slider._propagateMouseClicks) + assert.is_false(slider._lsbValueButton._propagateMouseClicks) + assert.is_false(row._removeButton._propagateMouseClicks) + assert.is_true(host:IsShown()) + assert.are.equal(1, host:GetAlpha()) + end) + + it("keeps mode-input submit disabled until the footer reports a valid value", function() + _G.CreateFrame = function() + return makeCollectionControl() + end + + local valid = false + local submitCalls = 0 + local data = { + sections = function() + return { + { + key = "utility", + title = "Utility", + footer = { + type = "modeInput", + modeText = "Spell", + inputText = function() + return valid and "12345" or "" + end, + submitText = "Add", + submitEnabled = function() + return valid + end, + onSubmit = function() + submitCalls = submitCalls + 1 + end, + }, + }, + } + end, + } + local host = makeCollectionControl() + local lsb = LibStub("LibSettingsBuilder-1.0") + + lsb._internal.applyCollectionFrame(host, data) + local footer = assert(host._lsbSectionTrailerRows.utility) + assert.is_false(footer._submitButton._enabled) + footer._submitButton:GetScript("OnClick")() + assert.are.equal(0, submitCalls) + + valid = true + lsb._internal.applyCollectionFrame(host, data) + assert.is_true(footer._submitButton._enabled) + footer._submitButton:GetScript("OnClick")() + assert.are.equal(1, submitCalls) + end) + + it("prevents embedded color swatch clicks from selecting the host settings row", function() + local created + _G.CreateFrame = function() + created = { + EnableMouse = function(self, enabled) + self._mouseEnabled = enabled + end, + RegisterForClicks = function(self, ...) + self._registeredClicks = { ... } + end, + SetPropagateMouseClicks = function(self, propagate) + self._propagateMouseClicks = propagate + end, + } + return created + end + + local lsb = LibStub("LibSettingsBuilder-1.0") + local swatch = lsb._internal.createColorSwatch(TestHelpers.makeFrame()) + + assert.are.equal(created, swatch) + assert.is_true(swatch._mouseEnabled) + assert.is_false(swatch._propagateMouseClicks) + end) +end) diff --git a/Libs/LibSettingsBuilder/Tests/Controls_spec.lua b/Libs/LibSettingsBuilder/Tests/Controls_spec.lua new file mode 100644 index 00000000..e540c451 --- /dev/null +++ b/Libs/LibSettingsBuilder/Tests/Controls_spec.lua @@ -0,0 +1,227 @@ +-- Enhanced Cooldown Manager addon for World of Warcraft +-- Author: Argium +-- Licensed under the GNU General Public License v3.0 + +local TestHelpers = + assert(loadfile("Tests/TestHelpers.lua") or loadfile("TestHelpers.lua"), "Unable to load Tests/TestHelpers.lua")() + +describe("LibSettingsBuilder Controls", function() + local originalGlobals + + local function createScriptableFrame() + local frame = TestHelpers.makeFrame() + frame._text = "" + frame._focused = false + frame.RegisterForClicks = function(self, ...) + self._registeredClicks = { ... } + end + frame.SetAutoFocus = function() end + frame.SetNumeric = function() end + frame.SetJustifyH = function() end + frame.SetSize = function(self, width, height) + self:SetWidth(width) + self:SetHeight(height) + end + frame.SetText = function(self, text) + self._text = text + end + frame.GetText = function(self) + return self._text + end + frame.SetFocus = function(self) + self._focused = true + end + frame.ClearFocus = function(self) + self._focused = false + end + frame.HighlightText = function(self) + self._highlighted = true + end + return frame + end + + setup(function() + originalGlobals = TestHelpers.CaptureGlobals({ + "LibStub", + "Settings", + "CreateFrame", + "hooksecurefunc", + "SettingsDropdownControlMixin", + "SettingsSliderControlMixin", + "SettingsListElementMixin", + "MinimalSliderWithSteppersMixin", + }) + end) + + teardown(function() + TestHelpers.RestoreGlobals(originalGlobals) + end) + + it("installs dropdown and slider hooks when the mixins are available before load", function() + local hooks = {} + + TestHelpers.SetupLibStub() + TestHelpers.SetupSettingsStubs() + _G.hooksecurefunc = function(target, method, fn) + hooks[target] = hooks[target] or {} + hooks[target][method] = fn + end + _G.SettingsListElementMixin = {} + _G.SettingsDropdownControlMixin = {} + _G.SettingsSliderControlMixin = {} + _G.CreateFrame = function() + return createScriptableFrame() + end + + TestHelpers.LoadLibSettingsBuilder() + + assert.is_function(hooks[_G.SettingsDropdownControlMixin].Init) + assert.is_function(hooks[_G.SettingsSliderControlMixin].Init) + end) + + it("refreshes standard dropdown text through the label map after Blizzard init", function() + local dropdownHook + + TestHelpers.SetupLibStub() + TestHelpers.SetupSettingsStubs() + _G.hooksecurefunc = function(target, method, fn) + if target == _G.SettingsDropdownControlMixin and method == "Init" then + dropdownHook = fn + end + end + _G.SettingsListElementMixin = {} + _G.SettingsDropdownControlMixin = {} + _G.SettingsSliderControlMixin = {} + _G.CreateFrame = function() + return createScriptableFrame() + end + + TestHelpers.LoadLibSettingsBuilder() + + local profile = { general = { mode = "chain" } } + local defaults = { general = { mode = "chain" } } + local originalCreateDropdown = Settings.CreateDropdown + local initializer + rawset(Settings, "CreateDropdown", function(...) + initializer = originalCreateDropdown(...) + return initializer + end) + + local builder = LibStub("LibSettingsBuilder-1.0").New({ + name = "Dropdown Labels", + store = function() + return profile + end, + defaults = function() + return defaults + end, + onChanged = function() end, + sections = { + { + key = "general", + name = "General", + pages = { + { + key = "main", + rows = { + { + type = "dropdown", + path = "mode", + name = "Mode", + values = { + chain = "Attached", + detached = "Detached", + free = "Free", + }, + }, + }, + }, + }, + }, + }, + }) + + assert.is_table(builder:GetPage("general", "main")) + initializer.GetSetting = function() + return { + GetValue = function() + return "OUTLINE" + end, + } + end + local displayedText, originalValue + local frame = { + Control = { + Dropdown = { + OverrideText = function(_, text) + displayedText = text + end, + }, + }, + SetValue = function(_, value) + originalValue = value + end, + } + + dropdownHook(frame, initializer) + + assert.are.equal("Attached", displayedText) + + frame:SetValue("free") + + assert.are.equal("free", originalValue) + assert.are.equal("Free", displayedText) + end) + + it("passes custom row settings through initializer data", function() + TestHelpers.SetupLibStub() + TestHelpers.SetupSettingsStubs() + _G.hooksecurefunc = function() end + _G.SettingsListElementMixin = {} + _G.SettingsDropdownControlMixin = {} + _G.SettingsSliderControlMixin = {} + _G.CreateFrame = function() + return createScriptableFrame() + end + + TestHelpers.LoadLibSettingsBuilder() + + local profile = { general = { font = "Expressway" } } + local defaults = { general = { font = "Expressway" } } + local builder = LibStub("LibSettingsBuilder-1.0").New({ + name = "Custom Setting Data", + store = function() + return profile + end, + defaults = function() + return defaults + end, + onChanged = function() end, + sections = { + { + key = "general", + name = "General", + pages = { + { + key = "main", + rows = { + { + type = "custom", + path = "font", + name = "Font", + template = "TestFontPickerTemplate", + }, + }, + }, + }, + }, + }, + }) + + local initializer = builder:GetPage("general", "main")._category:GetLayout()._initializers[1] + local data = initializer:GetData() + + assert.is_table(data.setting) + assert.are.equal("Expressway", data.setting:GetValue()) + end) +end) diff --git a/Libs/LibSettingsBuilder/Tests/Core_spec.lua b/Libs/LibSettingsBuilder/Tests/Core_spec.lua new file mode 100644 index 00000000..af5ba456 --- /dev/null +++ b/Libs/LibSettingsBuilder/Tests/Core_spec.lua @@ -0,0 +1,248 @@ +-- Enhanced Cooldown Manager addon for World of Warcraft +-- Author: Argium +-- Licensed under the GNU General Public License v3.0 + +local TestHelpers = + assert(loadfile("Tests/TestHelpers.lua") or loadfile("TestHelpers.lua"), "Unable to load Tests/TestHelpers.lua")() + +describe("LibSettingsBuilder Core", function() + local originalGlobals + + setup(function() + originalGlobals = TestHelpers.CaptureGlobals({ + "LibStub", + "Settings", + "CreateFrame", + "hooksecurefunc", + "SettingsDropdownControlMixin", + "SettingsSliderControlMixin", + "SettingsListElementMixin", + }) + end) + + teardown(function() + TestHelpers.RestoreGlobals(originalGlobals) + end) + + before_each(function() + TestHelpers.SetupLibStub() + TestHelpers.SetupSettingsStubs() + TestHelpers.LoadLibSettingsBuilder() + end) + + it("loads the split library through the shared ordered loader", function() + local lsb = LibStub("LibSettingsBuilder-1.0") + assert.is_table(lsb) + assert.is_nil(lsb._loadState.open) + end) + + it("initializes implementation internals on load", function() + local lsb = LibStub("LibSettingsBuilder-1.0") + assert.is_table(lsb._internal) + assert.are.equal(26, lsb._internal.CanvasLayoutDefaults.elementHeight) + assert.is_table(lsb._pageLifecycleCallbacks) + assert.is_false(lsb._pageLifecycleHooked) + end) + + it("exposes only the public API on builder instances", function() + local lsb = LibStub("LibSettingsBuilder-1.0") + local sb = lsb.New({ + name = "Phase 2", + onChanged = function() end, + sections = { + { + key = "general", + name = "General", + pages = { + { + key = "main", + rows = { + { type = "info", name = "Version", value = "1.0" }, + }, + }, + }, + }, + }, + }) + + assert.is_function(sb.GetSection) + assert.is_function(sb.GetRootPage) + assert.is_function(sb.GetPage) + assert.is_function(sb.HasCategory) + + -- Internal builder methods not on the public prototype + assert.is_nil(sb.Control) + assert.is_nil(sb.Checkbox) + assert.is_nil(sb.List) + assert.is_nil(sb.EmbedCanvas) + assert.is_nil(sb.BorderGroup) + end) + + it("store/defaults bindings resolve nested values and defaults", function() + local profile = { + root = { + enabled = true, + }, + } + local defaults = { + root = { + enabled = false, + }, + } + local lsb = LibStub("LibSettingsBuilder-1.0") + local sb = lsb.New({ + name = "Store Binding", + store = function() + return profile + end, + defaults = function() + return defaults + end, + onChanged = function() end, + }) + + local binding = sb._adapter:resolve("root.enabled") + assert.are.equal(true, binding.get()) + assert.are.equal(false, binding.default) + + binding.set(false) + assert.are.equal(false, profile.root.enabled) + end) + + it("registers canonical raw row tables without public helper constructors", function() + local profile = { + general = { + enabled = true, + threshold = 5, + }, + } + local defaults = { + general = { + enabled = false, + threshold = 0, + }, + } + local lsb = LibStub("LibSettingsBuilder-1.0") + local sb = lsb.New({ + name = "Phase 2", + store = function() + return profile + end, + defaults = function() + return defaults + end, + onChanged = function() end, + sections = { + { + key = "general", + name = "General", + pages = { + { + key = "main", + rows = { + { id = "enabled", type = "checkbox", path = "general.enabled", name = "Enable" }, + { + id = "threshold", + type = "slider", + path = "general.threshold", + name = "Threshold", + min = 0, + max = 10, + step = 1, + formatValue = function(value) + return tostring(value) + end, + }, + }, + }, + }, + }, + }, + }) + + assert.has_no.errors(function() + local page = sb:GetPage("general", "main") + assert.is_table(page) + assert.are.equal("Phase 2.General", page:GetId()) + end) + end) + + it("does not require standard initializers to expose SetEnabled", function() + local originalCreateSlider = Settings.CreateSlider + local initializer + + rawset(Settings, "CreateSlider", function(...) + initializer = originalCreateSlider(...) + initializer.SetEnabled = nil + return initializer + end) + + local profile = { general = { threshold = 5 } } + local defaults = { general = { threshold = 0 } } + local lsb = LibStub("LibSettingsBuilder-1.0") + + lsb.New({ + name = "Predicate Only", + store = function() + return profile + end, + defaults = function() + return defaults + end, + onChanged = function() end, + sections = { + { + key = "general", + name = "General", + pages = { + { + key = "main", + rows = { + { + type = "slider", + path = "general.threshold", + name = "Threshold", + min = 0, + max = 10, + disabled = function() + return true + end, + }, + }, + }, + }, + }, + }, + }) + + assert.is_nil(initializer.SetEnabled) + assert.is_false(initializer:EvaluateModifyPredicates()) + end) + + it("fails early when a raw path-bound row is registered without a path adapter", function() + local lsb = LibStub("LibSettingsBuilder-1.0") + local ok, err = pcall(function() + lsb.New({ + name = "Phase 2 Invalid", + onChanged = function() end, + sections = { + { + key = "general", + name = "General", + pages = { + { + key = "main", + rows = { + { type = "checkbox", path = "general.enabled", name = "Enable" }, + }, + }, + }, + }, + }, + }) + end) + + assert.is_false(ok) + assert.is_truthy(tostring(err):find("requires store/defaults on the builder", 1, true)) + end) +end) diff --git a/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua b/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua deleted file mode 100644 index c2a59a3e..00000000 --- a/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua +++ /dev/null @@ -1,2166 +0,0 @@ --- Enhanced Cooldown Manager addon for World of Warcraft --- Author: Argium --- Licensed under the GNU General Public License v3.0 - -local TestHelpers = - assert(loadfile("Tests/TestHelpers.lua") or loadfile("TestHelpers.lua"), "Unable to load Tests/TestHelpers.lua")() - -describe("LibSettingsBuilder", function() - local originalGlobals - local addonNS - local layoutUpdateCalls - local SB - - local function createSB2(varPrefix, categoryName) - local LSB2 = LibStub("LibSettingsBuilder-1.0") - local SB2 = LSB2:New({ - pathAdapter = LSB2.PathAdapter({ - getStore = function() - return addonNS.Addon.db.profile - end, - getDefaults = function() - return addonNS.Addon.db.defaults.profile - end, - getNestedValue = addonNS.OptionUtil.GetNestedValue, - setNestedValue = addonNS.OptionUtil.SetNestedValue, - }), - varPrefix = varPrefix, - onChanged = function() end, - }) - SB2.CreateRootCategory(categoryName or "Test") - return SB2 - end - - local function createSettingsPanelMock() - local frames = {} - local hookScripts = {} - local currentCategory = nil - _G.SettingsPanel = { - IsShown = function() - return true - end, - GetSettingsList = function() - return { - ScrollBox = { - ForEachFrame = function(_, fn) - for _, f in ipairs(frames) do - fn(f) - end - end, - }, - } - end, - SelectCategory = function() end, - DisplayCategory = function(self, cat) - currentCategory = cat or currentCategory - end, - GetCurrentCategory = function() - return currentCategory - end, - SetCurrentCategory = function(_, cat) - currentCategory = cat - end, - HookScript = function(_, event, fn) - hookScripts[event] = hookScripts[event] or {} - hookScripts[event][#hookScripts[event] + 1] = fn - end, - _fireScript = function(event) - for _, fn in ipairs(hookScripts[event] or {}) do - fn(_G.SettingsPanel) - end - end, - } - return frames - end - - local function createScriptableFrame() - local frame = TestHelpers.makeFrame() - frame._scripts = {} - frame._text = "" - frame._focused = false - frame.RegisterEvent = function() end - frame.UnregisterAllEvents = function() end - frame.RegisterForClicks = function(self, ...) - self._registeredClicks = { ... } - end - frame.SetScript = function(self, event, fn) - self._scripts[event] = fn - end - frame.GetScript = function(self, event) - return self._scripts[event] - end - frame.SetAutoFocus = function() end - frame.SetNumeric = function() end - frame.SetJustifyH = function() end - frame.SetSize = function(self, width, height) - self:SetWidth(width) - self:SetHeight(height) - end - frame.SetText = function(self, text) - self._text = text - end - frame.GetText = function(self) - return self._text - end - frame.SetFocus = function(self) - self._focused = true - end - frame.ClearFocus = function(self) - self._focused = false - end - frame.HighlightText = function(self) - self._highlighted = true - end - return frame - end - - local function loadLibraryWithHookStubs() - local hooks = {} - - TestHelpers.SetupLibStub() - TestHelpers.SetupSettingsStubs() - - _G.hooksecurefunc = function(target, method, fn) - hooks[target] = hooks[target] or {} - hooks[target][method] = fn - end - - _G.SettingsListElementMixin = {} - _G.SettingsDropdownControlMixin = {} - _G.SettingsSliderControlMixin = {} - _G.CreateFrame = function(_, _, _, template) - local frame = createScriptableFrame() - frame._template = template - return frame - end - - TestHelpers.LoadChunk("Libs/LibSettingsBuilder/LibSettingsBuilder.lua", "Unable to load LibSettingsBuilder.lua")() - - return hooks, LibStub("LibSettingsBuilder-1.0") - end - - setup(function() - originalGlobals = TestHelpers.CaptureGlobals({ - "ECM_DeepEquals", - "Settings", - "SettingsPanel", - "CreateSettingsListSectionHeaderInitializer", - "CreateSettingsButtonInitializer", - "MinimalSliderWithSteppersMixin", - "CreateColor", - "CreateColorFromHexString", - "CreateFrame", - "hooksecurefunc", - "StaticPopupDialogs", - "StaticPopup_Show", - "YES", - "NO", - "UnitClass", - "GetSpecialization", - "GetSpecializationInfo", - "LibStub", - "CreateFromMixins", - "SettingsListElementInitializer", - "SettingsListElementMixin", - "SettingsDropdownControlMixin", - "SettingsSliderControlMixin", - "GameFontHighlightSmall", - "GameFontNormal", - }) - end) - - teardown(function() - TestHelpers.RestoreGlobals(originalGlobals) - end) - - before_each(function() - layoutUpdateCalls = 0 - - TestHelpers.SetupLibStub() - TestHelpers.SetupSettingsStubs() - - _G.ECM_DeepEquals = TestHelpers.deepEquals - _G.GameFontHighlightSmall = "GameFontHighlightSmall" - _G.GameFontNormal = "GameFontNormal" - - _G.UnitClass = function() - return "Warrior", "WARRIOR", 1 - end - _G.GetSpecialization = function() - return 1 - end - _G.GetSpecializationInfo = function() - return nil, "Arms" - end - - -- Load the library - TestHelpers.LoadChunk("Libs/LibSettingsBuilder/LibSettingsBuilder.lua", "Unable to load LibSettingsBuilder.lua")() - - -- Register LSMW stub - local lsmw = LibStub:NewLibrary("LibLSMSettingsWidgets-1.0", 1) - lsmw.GetFontValues = function() - return { Expressway = "Expressway" } - end - lsmw.GetStatusbarValues = function() - return { Blizzard = "Blizzard" } - end - lsmw.FONT_PICKER_TEMPLATE = "TestFontPickerTemplate" - lsmw.TEXTURE_PICKER_TEMPLATE = "TestTexturePickerTemplate" - - local profileData = { - global = { - hideWhenMounted = true, - value = 5, - mode = "solid", - font = "Global Font", - fontSize = 11, - color = { r = 0.1, g = 0.2, b = 0.3, a = 1 }, - nested = { enabled = true }, - }, - powerBar = { - enabled = true, - height = 10, - overrideFont = false, - border = { - enabled = false, - thickness = 2, - color = { r = 0, g = 0, b = 0, a = 1 }, - }, - anchorMode = 1, - colors = {}, - }, - } - - addonNS = { - Addon = { - db = { - profile = profileData, - defaults = { profile = TestHelpers.deepClone(profileData) }, - }, - NewModule = function(_, name) - return { moduleName = name } - end, - }, - Constants = { - ANCHORMODE_CHAIN = 1, - ANCHORMODE_FREE = 2, - DEFAULT_BAR_WIDTH = 300, - }, - CloneValue = TestHelpers.deepClone, - Runtime = { - ScheduleLayoutUpdate = function() - layoutUpdateCalls = layoutUpdateCalls + 1 - end, - }, - } - - TestHelpers.LoadChunk("UI/OptionUtil.lua", "Unable to load UI/OptionUtil.lua")(nil, addonNS) - TestHelpers.LoadChunk("UI/Options.lua", "Unable to load UI/Options.lua")(nil, addonNS) - - SB = addonNS.SettingsBuilder - SB.CreateRootCategory("TestAddon") - SB.CreateSubcategory("TestSection") - end) - - -- Category lifecycle - it("CreateRootCategory, CreateSubcategory, GetRootCategoryID, GetSubcategoryID", function() - assert.are.equal("TestAddon", SB.GetRootCategoryID()) - assert.is_not_nil(SB.GetSubcategoryID("TestSection")) - assert.is_nil(SB.GetSubcategoryID("MissingSection")) - end) - - it("RegisterCategories does not error", function() - assert.has_no.errors(function() - SB.RegisterCategories() - end) - end) - - it("Setting current subcategory to root allows adding headers there", function() - SB._currentSubcategory = SB._rootCategory - local init = SB.Header("Root Header") - assert.are.equal("header", init._type) - assert.are.equal("Root Header", init._text) - end) - - -- Checkbox - it("Checkbox reads and writes profile value", function() - local _, setting = SB.Checkbox({ - path = "global.hideWhenMounted", - name = "Hide", - }) - - assert.is_true(setting:GetValue()) - - setting:SetValue(false) - assert.is_false(addonNS.Addon.db.profile.global.hideWhenMounted) - assert.are.equal(1, layoutUpdateCalls) - end) - - it("Checkbox onSet callback is invoked on set", function() - local onSetValue - local _, setting = SB.Checkbox({ - path = "global.hideWhenMounted", - name = "Hide", - onSet = function(v) - onSetValue = v - end, - }) - - setting:SetValue(false) - assert.are.equal(false, onSetValue) - end) - - -- Slider - it("Slider reads/writes with getTransform and setTransform", function() - local _, setting = SB.Slider({ - path = "powerBar.height", - name = "Height", - min = 0, - max = 40, - step = 1, - getTransform = function(v) - return v or 0 - end, - setTransform = function(v) - return v > 0 and v or nil - end, - }) - - assert.are.equal(10, setting:GetValue()) - - setting:SetValue(0) - assert.is_nil(addonNS.Addon.db.profile.powerBar.height) - end) - - it("Slider applies default formatter when none specified", function() - local capturedOpts - local settings = Settings - local origCreate = settings.CreateSlider - rawset(settings, "CreateSlider", function(cat, setting, options, tooltip) - capturedOpts = options - return origCreate(cat, setting, options, tooltip) - end) - - SB.Slider({ - path = "global.value", - name = "Value", - min = 0, - max = 10, - step = 1, - }) - - rawset(settings, "CreateSlider", origCreate) - - assert.are.equal(MinimalSliderWithSteppersMixin.Label.Right, capturedOpts._labelFormatterLocation) - -- Default formatter renders integers without decimals - assert.are.equal("5", capturedOpts._labelFormatter(5)) - assert.are.equal("0", capturedOpts._labelFormatter(0)) - -- Default formatter renders fractional values with one decimal - assert.are.equal("2.5", capturedOpts._labelFormatter(2.5)) - end) - - it("Slider uses custom formatter when specified", function() - local capturedOpts - local settings = Settings - local origCreate = settings.CreateSlider - rawset(settings, "CreateSlider", function(cat, setting, options, tooltip) - capturedOpts = options - return origCreate(cat, setting, options, tooltip) - end) - - local customFormatter = function(value) - return value .. "%%" - end - SB.Slider({ - path = "global.value", - name = "Value", - min = 0, - max = 100, - step = 5, - formatter = customFormatter, - }) - - rawset(settings, "CreateSlider", origCreate) - - assert.are.equal(customFormatter, capturedOpts._labelFormatter) - end) - - -- Dropdown - it("Dropdown creates dropdown with values", function() - local _, setting = SB.Dropdown({ - path = "global.mode", - name = "Mode", - values = { solid = "Solid", flat = "Flat" }, - }) - - assert.are.equal("solid", setting:GetValue()) - - setting:SetValue("flat") - assert.are.equal("flat", addonNS.Addon.db.profile.global.mode) - end) - - -- Color - it("Color reads/writes color as AARRGGBB hex", function() - local _, setting = SB.Color({ - path = "global.color", - name = "Color", - }) - - local hex = setting:GetValue() - assert.are.equal("FF1A334D", hex) - - -- Verify round-trip: hex -> table stored in profile - setting:SetValue("FF66809A") - local stored = addonNS.Addon.db.profile.global.color - assert.are.equal(0.4, math.floor(stored.r * 255 + 0.5) / 255) - end) - - -- Control dispatcher - it("Control dispatches to checkbox", function() - local _, setting = SB.Control({ - type = "checkbox", - path = "global.hideWhenMounted", - name = "Hide", - }) - assert.is_true(setting:GetValue()) - end) - - it("Control dispatches to slider", function() - local _, setting = SB.Control({ - type = "slider", - path = "global.value", - name = "Value", - min = 0, - max = 10, - step = 1, - }) - assert.are.equal(5, setting:GetValue()) - end) - - it("Control dispatches to dropdown", function() - local _, setting = SB.Control({ - type = "dropdown", - path = "global.mode", - name = "Mode", - values = { solid = "Solid" }, - }) - assert.are.equal("solid", setting:GetValue()) - end) - - it("Control dispatches to color", function() - local _, setting = SB.Control({ - type = "color", - path = "global.color", - name = "Color", - }) - local hex = setting:GetValue() - assert.are.equal("string", type(hex)) - assert.are.equal(8, #hex) - end) - - it("Control errors on unknown type", function() - assert.has_error(function() - SB.Control({ type = "bogus", path = "x", name = "X" }) - end) - end) - - -- layout=false - it("layout=false skips ScheduleLayoutUpdate", function() - local _, setting = SB.Checkbox({ - path = "global.hideWhenMounted", - name = "Hide", - layout = false, - }) - setting:SetValue(false) - assert.are.equal(0, layoutUpdateCalls) - end) - - -- Header - it("Header adds initializer to current layout", function() - local init = SB.Header("Test Header") - assert.are.equal("header", init._type) - assert.are.equal("Test Header", init._text) - end) - - -- Subheader - it("Subheader adds element initializer with normal font template", function() - local init = SB.Subheader({ name = "Item Quality" }) - assert.are.equal(SB.SUBHEADER_TEMPLATE, init._template) - assert.are.equal("Item Quality", init.data.name) - end) - - it("Subheader respects explicit category via root subcategory", function() - SB._currentSubcategory = SB._rootCategory - local init = SB.Subheader({ name = "Root Sub" }) - assert.are.equal("Root Sub", init.data.name) - end) - - it("Subheader as parent — isParentEnabled returns true", function() - local labelInit = SB.Subheader({ name = "Colors" }) - local childInit = SB.Checkbox({ - path = "global.hideWhenMounted", - name = "Child", - parent = labelInit, - }) - -- Labels have no GetSetting, so isParentEnabled should return true - local enabledPredicate = childInit._modifyPredicates[1] - assert.is_true(enabledPredicate()) - end) - - -- InfoRow - it("InfoRow adds element initializer with template and data", function() - local init = SB.InfoRow({ name = "Author", value = "TestUser" }) - assert.are.equal(SB.INFOROW_TEMPLATE, init._template) - assert.are.equal("Author", init.data.name) - assert.are.equal("TestUser", init.data.value) - end) - - it("InfoRow falls back to GetExtent when SetExtent is unavailable", function() - local settings = Settings - local originalCreateElementInitializer = settings.CreateElementInitializer - rawset(settings, "CreateElementInitializer", function(frameTemplate, data) - local init = originalCreateElementInitializer(frameTemplate, data) - init.SetExtent = nil - return init - end) - - local init = SB.InfoRow({ name = "Author", value = "TestUser" }) - - rawset(settings, "CreateElementInitializer", originalCreateElementInitializer) - - assert.are.equal(26, init:GetExtent()) - end) - - it("InfoRow respects explicit category", function() - SB._currentSubcategory = SB._rootCategory - local init = SB.InfoRow({ name = "Version", value = "1.0" }) - assert.are.equal("Version", init.data.name) - assert.are.equal("1.0", init.data.value) - end) - - it("InfoRow supports hidden modifier", function() - local hidden = true - local init = SB.InfoRow({ - name = "Secret", - value = "x", - hidden = function() - return hidden - end, - }) - assert.is_not_nil(init._shownPredicates) - assert.are.equal(1, #init._shownPredicates) - end) - - it("custom list rows initialize safely without preexisting cbrHandles", function() - local function makeListElementFrame() - local frame = createScriptableFrame() - frame.Text = createScriptableFrame() - frame.NewFeature = createScriptableFrame() - frame.CreateFontString = function() - local fontString = createScriptableFrame() - fontString.SetFontObject = function() end - fontString.SetJustifyH = function() end - fontString.SetJustifyV = function() end - return fontString - end - frame.SetShown = function(self, shown) - self._shown = shown - end - return frame - end - - local subheader = SB.Subheader({ name = "Item Quality" }) - local subheaderFrame = makeListElementFrame() - - assert.has_no.errors(function() - subheader:InitFrame(subheaderFrame) - end) - assert.is_not_nil(subheaderFrame.cbrHandles) - assert.are.equal("Item Quality", subheaderFrame._lsbSubheaderTitle:GetText()) - - subheader:Resetter(subheaderFrame) - assert.is_true(subheaderFrame.cbrHandles._unregistered) - - local canvas = createScriptableFrame() - canvas.SetParent = function(self, parent) - self._parent = parent - end - canvas.GetParent = function(self) - return self._parent - end - local embed = SB.EmbedCanvas(canvas, 120) - local embedFrame = makeListElementFrame() - - assert.has_no.errors(function() - embed:InitFrame(embedFrame) - end) - assert.are.equal(embedFrame, canvas:GetParent()) - assert.are.equal(120, canvas:GetHeight()) - end) - - -- Button - it("Button creates button initializer with onClick", function() - local clicked = false - local init = SB.Button({ - name = "Do it", - buttonText = "Click", - onClick = function() - clicked = true - end, - }) - assert.are.equal("button", init._type) - init._onClick() - assert.is_true(clicked) - end) - - it("Button confirm wraps onClick in StaticPopup", function() - local clicked = false - SB.Button({ - name = "Danger", - buttonText = "Reset", - confirm = "Are you sure?", - onClick = function() - clicked = true - end, - }) - - -- The shared confirm dialog should exist - local dialogName = "ECM_LibSettingsBuilder_1_0_SettingsConfirm" - local dialog = StaticPopupDialogs[dialogName] - assert.is_table(dialog) - - -- Simulate accepting the popup with the data that onClick passes - dialog.OnAccept(nil, { onAccept = function() clicked = true end }) - assert.is_true(clicked) - end) - - it("Button confirm uses a shared dialog with per-button data", function() - local getShownNames = TestHelpers.InstallPopupRecorder() - - local clicked = {} - local resetButton = SB.Button({ - name = "Reset", - confirm = "Reset everything?", - onClick = function() - clicked[#clicked + 1] = "reset" - end, - }) - local deleteButton = SB.Button({ - name = "Delete", - confirm = "Delete profile?", - onClick = function() - clicked[#clicked + 1] = "delete" - end, - }) - - resetButton._onClick() - deleteButton._onClick() - - local shownNames = getShownNames() - - -- Both use the same shared dialog - assert.are.equal(2, #shownNames) - assert.are.equal(shownNames[1], shownNames[2]) - - -- Verify the shared dialog's OnAccept dispatches correctly via data - local dialogName = shownNames[1] - local dialog = StaticPopupDialogs[dialogName] - assert.is_table(dialog) - - dialog.OnAccept(nil, { onAccept = function() clicked[#clicked + 1] = "reset" end }) - dialog.OnAccept(nil, { onAccept = function() clicked[#clicked + 1] = "delete" end }) - assert.are.same({ "reset", "delete" }, clicked) - end) - - -- ApplyModifiers - it("ApplyModifiers sets parent, disabled, and hidden predicates", function() - local parentInit, _ = SB.Checkbox({ - path = "global.nested.enabled", - name = "Parent", - }) - local childInit, _ = SB.Checkbox({ - path = "global.hideWhenMounted", - name = "Child", - parent = parentInit, - parentCheck = function() - return true - end, - disabled = function() - return true - end, - hidden = function() - return false - end, - }) - - assert.are.equal(parentInit, childInit._parentInit) - assert.are.equal(1, #childInit._modifyPredicates) - assert.are.equal(1, #childInit._shownPredicates) - end) - - it("Parent-controlled dropdown is disabled when parent is unchecked", function() - local parentInit, parentSetting = SB.Checkbox({ - path = "global.nested.enabled", - name = "Parent", - }) - - local childInit = SB.Dropdown({ - path = "global.mode", - name = "Child", - values = { solid = "Solid", flat = "Flat" }, - parent = parentInit, - parentCheck = function() - return parentSetting:GetValue() - end, - }) - - local enabledPredicate = childInit._modifyPredicates[1] - assert.is_true(enabledPredicate()) - - parentSetting:SetValue(false) - assert.is_false(enabledPredicate()) - end) - - it("Parent-controlled custom picker is disabled when parent is unchecked", function() - local parentInit, parentSetting = SB.Checkbox({ - path = "global.nested.enabled", - name = "Parent", - }) - - local customEnabled - local settings = Settings - local originalCreateElementInitializer = settings.CreateElementInitializer - rawset(settings, "CreateElementInitializer", function(frameTemplate, data) - local init = originalCreateElementInitializer(frameTemplate, data) - init.SetEnabled = function(_, enabled) - customEnabled = enabled - end - return init - end) - - local childInit = SB.Custom({ - path = "global.font", - name = "Custom picker", - template = "TestTexturePickerTemplate", - parent = parentInit, - parentCheck = function() - return parentSetting:GetValue() - end, - }) - - rawset(settings, "CreateElementInitializer", originalCreateElementInitializer) - - local enabledPredicate = childInit._modifyPredicates[1] - assert.is_true(customEnabled) - assert.is_true(enabledPredicate()) - - parentSetting:SetValue(false) - assert.is_false(enabledPredicate()) - assert.is_false(customEnabled) - end) - - -- Reactive disabled predicate - it("disabled predicate re-evaluates when another setting changes", function() - local frames = createSettingsPanelMock() - - local _, enabledSetting = SB.Checkbox({ - path = "powerBar.enabled", - name = "Enable", - }) - - local childInit - local controlEnabled - local settings = Settings - local origCreateCheckbox = settings.CreateCheckbox - rawset(settings, "CreateCheckbox", function(cat, setting, tooltip) - local init = origCreateCheckbox(cat, setting, tooltip) - childInit = init - return init - end) - - SB.Checkbox({ - path = "powerBar.showText", - name = "Show text", - disabled = function() - return not addonNS.Addon.db.profile.powerBar.enabled - end, - }) - - rawset(settings, "CreateCheckbox", origCreateCheckbox) - - -- Simulate a rendered frame for the child control - frames[1] = { - GetElementData = function() - return childInit - end, - IsEnabled = function(self) - return self:GetElementData():EvaluateModifyPredicates() - end, - EvaluateState = function(self) - controlEnabled = self:IsEnabled() - end, - SetShown = function() end, - } - -- Verify initial state - frames[1]:EvaluateState() - assert.is_true(controlEnabled) - - enabledSetting:SetValue(false) - assert.is_false(controlEnabled) - - enabledSetting:SetValue(true) - assert.is_true(controlEnabled) - - _G.SettingsPanel = nil - end) - - -- Reactive hidden predicate - it("hidden predicate re-evaluates when another setting changes", function() - local frames = createSettingsPanelMock() - - local _, toggleSetting = SB.Checkbox({ - path = "powerBar.enabled", - name = "Enable", - }) - - local childInit - local settings = Settings - local origCreateCheckbox = settings.CreateCheckbox - rawset(settings, "CreateCheckbox", function(cat, setting, tooltip) - local init = origCreateCheckbox(cat, setting, tooltip) - childInit = init - return init - end) - - SB.Checkbox({ - path = "powerBar.showText", - name = "Show text", - hidden = function() - return not addonNS.Addon.db.profile.powerBar.enabled - end, - }) - - rawset(settings, "CreateCheckbox", origCreateCheckbox) - - -- Initial state: enabled=true, so hidden()=false → shown - local shownPredicate = childInit._shownPredicates[1] - assert.is_true(shownPredicate()) - - -- Simulate a rendered frame that checks ShouldShow - local frameShown = true - childInit.ShouldShow = function() - return not childInit._shownPredicates[1] or childInit._shownPredicates[1]() - end - frames[1] = { - GetElementData = function() - return childInit - end, - EvaluateState = function(self) - frameShown = self:GetElementData():ShouldShow() - end, - } - frames[1]:EvaluateState() - assert.is_true(frameShown) - - toggleSetting:SetValue(false) - assert.is_false(frameShown) - - toggleSetting:SetValue(true) - assert.is_true(frameShown) - - _G.SettingsPanel = nil - end) - - -- HeightOverrideSlider - it("HeightOverrideSlider transforms nil→0 and 0→nil", function() - local _, setting = SB.HeightOverrideSlider("powerBar") - - assert.are.equal(10, setting:GetValue()) - - setting:SetValue(0) - assert.is_nil(addonNS.Addon.db.profile.powerBar.height) - assert.are.equal(0, setting:GetValue()) - end) - - -- BorderGroup - it("BorderGroup creates enabled, thickness, color controls", function() - local result = SB.BorderGroup("powerBar.border") - assert.is_not_nil(result.enabledInit) - assert.is_not_nil(result.enabledSetting) - assert.is_not_nil(result.thicknessInit) - assert.is_not_nil(result.colorInit) - end) - - -- FontOverrideGroup - it("FontOverrideGroup creates override checkbox, font dropdown, size slider", function() - local result = SB.FontOverrideGroup("powerBar") - assert.is_not_nil(result.enabledInit) - assert.is_not_nil(result.enabledSetting) - assert.is_not_nil(result.fontInit) - assert.is_not_nil(result.sizeInit) - end) - - it("FontOverrideGroup children are disabled when override is unchecked", function() - addonNS.Addon.db.profile.powerBar.overrideFont = false - local result = SB.FontOverrideGroup("powerBar") - - -- Font and size children should have modify predicates (disabled, not hidden) - assert.is_truthy(result.fontInit._modifyPredicates) - assert.is_truthy(result.sizeInit._modifyPredicates) - assert.is_nil(result.fontInit._parentInit) - assert.is_nil(result.sizeInit._parentInit) - - local fontPredicate = result.fontInit._modifyPredicates[1] - local sizePredicate = result.sizeInit._modifyPredicates[1] - - -- Override is false → children disabled - assert.is_false(fontPredicate()) - assert.is_false(sizePredicate()) - - -- Toggle override on → children enabled - result.enabledSetting:SetValue(true) - assert.is_true(fontPredicate()) - assert.is_true(sizePredicate()) - end) - - -- ColorPickerList - it("ColorPickerList creates native color swatch per definition", function() - addonNS.Addon.db.profile.powerBar.colors = { - [0] = { r = 0, g = 0, b = 1, a = 1 }, - } - addonNS.Addon.db.defaults.profile.powerBar.colors = { - [0] = { r = 0, g = 0, b = 1, a = 1 }, - } - - local defs = { - { key = 0, name = "Mana" }, - { key = 1, name = "Rage" }, - } - local results = SB.ColorPickerList("powerBar.colors", defs) - assert.are.equal(2, #results) - assert.are.equal(0, results[1].key) - assert.are.equal(1, results[2].key) - assert.is_not_nil(results[1].initializer) - assert.is_not_nil(results[1].setting) - assert.is_not_nil(results[2].initializer) - assert.is_not_nil(results[2].setting) - end) - - -- RegisterSection - it("RegisterSection stores section in namespace", function() - local ns = {} - local section = { RegisterSettings = function() end } - SB.RegisterSection(ns, "Foo", section) - assert.are.same(section, ns.OptionsSections.Foo) - end) - - -- Built-in path accessors - it("path accessors read and write nested values", function() - local SB2 = createSB2("TEST2", "Test2") - SB2.CreateSubcategory("Sub2") - - local _, setting = SB2.Checkbox({ - path = "global.hideWhenMounted", - name = "Hide", - }) - assert.is_true(setting:GetValue()) - - setting:SetValue(false) - assert.is_false(addonNS.Addon.db.profile.global.hideWhenMounted) - end) - - it("built-in path accessors handle numeric keys", function() - addonNS.Addon.db.profile.powerBar.colors[0] = { r = 0, g = 0, b = 1, a = 1 } - addonNS.Addon.db.defaults.profile.powerBar.colors[0] = { r = 0, g = 0, b = 1, a = 1 } - - local SB2 = createSB2("TEST3", "Test3") - SB2.CreateSubcategory("Sub3") - - local _, setting = SB2.Color({ - path = "powerBar.colors.0", - name = "Mana", - }) - local hex = setting:GetValue() - assert.are.equal("string", type(hex)) - assert.are.equal(8, #hex) - end) - - -- Header "Display" no longer suppressed - it("Header('Display') returns initializer (no longer suppressed)", function() - local init = SB.Header("Display") - assert.are.equal("header", init._type) - assert.are.equal("Display", init._text) - end) - - -- Header first-header suppression - it("Header suppresses first header matching subcategory name", function() - SB.CreateSubcategory("Appearance") - local init = SB.Header("Appearance") - assert.is_nil(init) - - -- Second header with different text is not suppressed - local init2 = SB.Header("Colors") - assert.is_not_nil(init2) - end) - - -- Custom with varType override - it("Custom respects varType override", function() - local capturedVarType - local settings = Settings - local origRegister = settings.RegisterProxySetting - rawset(settings, "RegisterProxySetting", function(cat, variable, varType, name, default, getter, setter) - capturedVarType = varType - return origRegister(cat, variable, varType, name, default, getter, setter) - end) - - SB.Custom({ - path = "global.value", - name = "Custom Numeric", - template = "TestTemplate", - varType = Settings.VarType.Number, - }) - - rawset(settings, "RegisterProxySetting", origRegister) - assert.are.equal(Settings.VarType.Number, capturedVarType) - end) - - -- propagateModifiers with layout - it("propagateModifiers propagates layout=false to composite children", function() - SB.HeightOverrideSlider("powerBar", { layout = false }) - -- Since layout=false is propagated, the onChanged check should skip layout - -- We verify by setting the value and checking layoutUpdateCalls stays 0 - -- (Need to reload to test with onChanged that checks layout) - end) - - -- Spec field validation - it("debug spec validation warns on unknown fields", function() - local warnings = {} - local origPrint = print - _G.print = function(msg) - if type(msg) == "string" and msg:find("LibSettingsBuilder WARNING") then - warnings[#warnings + 1] = msg - end - end - _G.LSB_DEBUG = true - - SB.Checkbox({ - path = "global.hideWhenMounted", - name = "Test", - bogusField = true, - }) - - _G.LSB_DEBUG = nil - _G.print = origPrint - - assert.is_true(#warnings > 0) - assert.is_truthy(warnings[1]:find("bogusField")) - end) - - it("debug spec validation is silent when LSB_DEBUG is off", function() - local warnings = {} - local origPrint = print - _G.print = function(msg) - if type(msg) == "string" and msg:find("LibSettingsBuilder WARNING") then - warnings[#warnings + 1] = msg - end - end - _G.LSB_DEBUG = nil - - SB.Checkbox({ - path = "global.hideWhenMounted", - name = "Test", - bogusField = true, - }) - - _G.print = origPrint - assert.are.equal(0, #warnings) - end) - - -- Dropdown with scrollHeight - it("Dropdown with scrollHeight uses scroll template", function() - local capturedTemplate - local settings = Settings - local origCreateElementInitializer = settings.CreateElementInitializer - rawset(settings, "CreateElementInitializer", function(template, data) - capturedTemplate = template - return origCreateElementInitializer(template, data) - end) - - local _, setting = SB.Dropdown({ - path = "global.mode", - name = "Scrollable Mode", - values = { solid = "Solid", flat = "Flat" }, - scrollHeight = 300, - }) - - rawset(settings, "CreateElementInitializer", origCreateElementInitializer) - - assert.are.equal(SB.SCROLL_DROPDOWN_TEMPLATE, capturedTemplate) - assert.are.equal("solid", setting:GetValue()) - - setting:SetValue("flat") - assert.are.equal("flat", addonNS.Addon.db.profile.global.mode) - end) - - it("Dropdown without scrollHeight uses standard dropdown", function() - local capturedTemplate = nil - local settings = Settings - local origCreateElementInitializer = settings.CreateElementInitializer - rawset(settings, "CreateElementInitializer", function(template, data) - capturedTemplate = template - return origCreateElementInitializer(template, data) - end) - - SB.Dropdown({ - path = "global.mode", - name = "Standard Mode", - values = { solid = "Solid", flat = "Flat" }, - }) - - rawset(settings, "CreateElementInitializer", origCreateElementInitializer) - - -- Standard path uses Settings.CreateDropdown, not CreateElementInitializer - -- with the scroll template - assert.is_not_equal(SB.SCROLL_DROPDOWN_TEMPLATE, capturedTemplate) - end) - - it("Dropdown options are added in deterministic label order", function() - local init = SB.Dropdown({ - path = "global.mode", - name = "Standard Mode", - values = { - gamma = "Gamma", - alpha = "Alpha", - beta = "Beta", - }, - }) - - local options = init._optionsGen() - assert.are.same({ "Alpha", "Beta", "Gamma" }, { - options[1].label, - options[2].label, - options[3].label, - }) - assert.are.same({ "alpha", "beta", "gamma" }, { - options[1].value, - options[2].value, - options[3].value, - }) - end) - - it("scroll dropdown menu options are added in deterministic label order", function() - local hooks = select(1, loadLibraryWithHookStubs()) - local initHook = hooks[_G.SettingsDropdownControlMixin].Init - - local currentValue = "beta" - local setting = { - GetValue = function() - return currentValue - end, - SetValue = function(_, value) - currentValue = value - end, - } - - local dropdown = { - SetupMenu = function(self, builder) - self._builder = builder - end, - OverrideText = function(self, text) - self._text = text - end, - } - local frame = { - Control = { Dropdown = dropdown }, - SetValue = function() end, - } - local initializer = { - GetData = function() - return { - _lsbKind = "scrollDropdown", - setting = setting, - values = { - gamma = "Gamma", - alpha = "Alpha", - beta = "Beta", - }, - scrollHeight = 240, - } - end, - GetSetting = function() - return setting - end, - } - - initHook(frame, initializer) - - local orderedLabels = {} - local rootDescription = { - SetScrollMode = function(_, value) - orderedLabels.scrollHeight = value - end, - CreateRadio = function(_, label) - orderedLabels[#orderedLabels + 1] = label - end, - } - dropdown._builder(nil, rootDescription) - - assert.are.equal("Beta", dropdown._text) - assert.are.equal(240, orderedLabels.scrollHeight) - assert.are.same({ "Alpha", "Beta", "Gamma" }, { - orderedLabels[1], - orderedLabels[2], - orderedLabels[3], - }) - end) - - -- RegisterFromTable - it("RegisterFromTable creates subcategory and controls from table", function() - local SB2 = createSB2("TBL1", "TableTest") - - SB2.RegisterFromTable({ - name = "Test Section", - path = "global", - args = { - header1 = { type = "header", name = "Visibility", order = 1 }, - mounted = { type = "toggle", path = "hideWhenMounted", name = "Hide", order = 2 }, - val = { type = "range", path = "value", name = "Value", min = 0, max = 10, step = 1, order = 3 }, - mode = { type = "select", path = "mode", name = "Mode", values = { solid = "Solid" }, order = 4 }, - }, - }) - - -- Verify subcategory was created - assert.is_not_nil(SB2.GetSubcategoryID("Test Section")) - end) - - it("RegisterFromTable inherits disabled from group", function() - local disabledFn = function() - return true - end - local SB2 = createSB2("TBL2", "InheritTest") - - SB2.RegisterFromTable({ - name = "Inherit Section", - path = "global", - disabled = disabledFn, - args = { - mounted = { type = "toggle", path = "hideWhenMounted", name = "Hide", order = 1 }, - }, - }) - - -- The control should have the disabled predicate applied - -- (We can't directly inspect the predicate, but we verify no error occurs) - assert.is_not_nil(SB2.GetSubcategoryID("Inherit Section")) - end) - - it("RegisterFromTable resolves parent references by key", function() - local SB2 = createSB2("TBL3", "ParentRefTest") - - assert.has_no.errors(function() - SB2.RegisterFromTable({ - name = "Parent Ref Section", - path = "global", - args = { - parentCtrl = { type = "toggle", path = "hideWhenMounted", name = "Parent", order = 1 }, - childCtrl = { - type = "range", - path = "value", - name = "Child", - min = 0, - max = 10, - step = 1, - parent = "parentCtrl", - parentCheck = "checked", - order = 2, - }, - }, - }) - end) - end) - - it("RegisterFromTable supports type aliases", function() - local SB2 = createSB2("TBL4", "AliasTest") - - -- All AceConfig type aliases should work without error - assert.has_no.errors(function() - SB2.RegisterFromTable({ - name = "Alias Section", - path = "global", - args = { - t = { type = "toggle", path = "hideWhenMounted", name = "Toggle", order = 1 }, - r = { type = "range", path = "value", name = "Range", min = 0, max = 10, step = 1, order = 2 }, - s = { type = "select", path = "mode", name = "Select", values = { solid = "Solid" }, order = 3 }, - h = { type = "header", name = "Header", order = 4 }, - d = { type = "description", name = "Desc", order = 5 }, - i = { type = "info", name = "Author", value = "Test", order = 6 }, - }, - }) - end) - end) - - it("RegisterFromTable supports desc as alias for tooltip", function() - local capturedTooltip - local settings = Settings - local origCreateCheckbox = settings.CreateCheckbox - rawset(settings, "CreateCheckbox", function(cat, setting, tooltip) - capturedTooltip = tooltip - return origCreateCheckbox(cat, setting, tooltip) - end) - - local SB2 = createSB2("TBL5", "DescTest") - - SB2.RegisterFromTable({ - name = "Desc Section", - path = "global", - args = { - mounted = { - type = "toggle", - path = "hideWhenMounted", - name = "Hide", - desc = "Hide when on a mount.", - order = 1, - }, - }, - }) - - rawset(settings, "CreateCheckbox", origCreateCheckbox) - assert.are.equal("Hide when on a mount.", capturedTooltip) - end) - - it("RegisterFromTable path prefixing works", function() - local SB2 = createSB2("TBL7", "PrefixTest") - - SB2.RegisterFromTable({ - name = "Prefix Section", - path = "powerBar", - args = { - enabled = { type = "toggle", path = "enabled", name = "Enabled", order = 1 }, - }, - }) - - -- The checkbox should read from powerBar.enabled - assert.is_true(addonNS.Addon.db.profile.powerBar.enabled) - end) - - -- RegisterFromTable condition support - it("RegisterFromTable condition=false skips entry", function() - local headerCreated = false - local origHeader = CreateSettingsListSectionHeaderInitializer - _G.CreateSettingsListSectionHeaderInitializer = function(text) - if text == "Should Not Appear" then - headerCreated = true - end - return origHeader(text) - end - - local SB2 = createSB2("COND1", "CondTest") - - SB2.RegisterFromTable({ - name = "Cond Section", - path = "global", - args = { - skipped = { - type = "header", - name = "Should Not Appear", - condition = function() - return false - end, - order = 1, - }, - shown = { type = "header", name = "Should Appear", order = 2 }, - }, - }) - - _G.CreateSettingsListSectionHeaderInitializer = origHeader - assert.is_false(headerCreated) - end) - - it("RegisterFromTable condition=true includes entry", function() - local headerCreated = false - local origHeader = CreateSettingsListSectionHeaderInitializer - _G.CreateSettingsListSectionHeaderInitializer = function(text) - if text == "Conditional Header" then - headerCreated = true - end - return origHeader(text) - end - - local SB2 = createSB2("COND2", "CondTest2") - - SB2.RegisterFromTable({ - name = "Cond Section 2", - path = "global", - args = { - shown = { - type = "header", - name = "Conditional Header", - condition = function() - return true - end, - order = 1, - }, - }, - }) - - _G.CreateSettingsListSectionHeaderInitializer = origHeader - assert.is_true(headerCreated) - end) - - it("RegisterFromTable rootCategory=true uses root instead of subcategory", function() - local SB2 = createSB2("ROOT1", "RootTest") - - SB2.RegisterFromTable({ - name = "Root Section", - rootCategory = true, - path = "global", - args = { - mounted = { type = "toggle", path = "hideWhenMounted", name = "Hide", order = 1 }, - }, - }) - - -- rootCategory=true should NOT create a subcategory - assert.is_nil(SB2.GetSubcategoryID("Root Section")) - end) - - it("RegisterFromTable canvas type embeds a canvas frame", function() - local SB2 = createSB2("CANVAS1", "CanvasTest") - - local canvasFrame = { - GetHeight = function() - return 200 - end, - } - - local embeddedCanvas, embeddedHeight - local origEmbed = SB2.EmbedCanvas - SB2.EmbedCanvas = function(canvas, height, spec) - embeddedCanvas = canvas - embeddedHeight = height - return origEmbed(canvas, height, spec) - end - - SB2.RegisterFromTable({ - name = "Canvas Section", - path = "global", - args = { - myCanvas = { type = "canvas", canvas = canvasFrame, height = 400, order = 1 }, - }, - }) - - assert.are.equal(canvasFrame, embeddedCanvas) - assert.are.equal(400, embeddedHeight) - end) - - it("CanvasLayout supports configurable defaults and per-layout overrides", function() - local originalCreateFrame = _G.CreateFrame - _G.CreateFrame = function(_, _, _, template) - local frame = TestHelpers.makeFrame({ height = 0, width = 0 }) - frame._template = template - frame.SetSize = function(self, width, height) - self:SetWidth(width) - self:SetHeight(height) - end - frame.SetText = function(self, text) - self._text = text - end - frame.CreateFontString = function() - local fontString = TestHelpers.makeFrame() - fontString.SetText = function(self, text) - self._text = text - end - fontString.SetFontObject = function() end - fontString.SetWordWrap = function() end - fontString.SetJustifyH = function() end - fontString.SetJustifyV = function() end - return fontString - end - return frame - end - - local originalDefaults = TestHelpers.deepClone(SB.SetCanvasLayoutDefaults()) - SB.SetCanvasLayoutDefaults({ elementHeight = 30 }) - - local defaultLayout = SB.CreateCanvasLayout("Canvas Defaults") - local defaultRow = defaultLayout:AddDescription("Uses updated defaults") - assert.are.equal(30, defaultRow:GetHeight()) - - local customLayout = SB.CreateCanvasLayout("Canvas Custom") - SB.ConfigureCanvasLayout(customLayout, { - elementHeight = 42, - labelX = 20, - buttonCenterX = -10, - buttonWidth = 180, - }) - - local row, button = customLayout:AddButton("Action", "Run") - - assert.are.equal(42, row:GetHeight()) - TestHelpers.assertAnchor(row._label, 1, "LEFT", 20, 0, 0, 0) - TestHelpers.assertAnchor(button, 1, "LEFT", row, "CENTER", -10, 0) - assert.are.equal(180, button:GetWidth()) - - SB.SetCanvasLayoutDefaults(originalDefaults) - _G.CreateFrame = originalCreateFrame - end) - - it("onSet receives setting as second parameter", function() - local receivedSetting - local receivedValue - - local _, setting = SB.Checkbox({ - path = "global.hideWhenMounted", - name = "Test onSet", - onSet = function(value, s) - receivedValue = value - receivedSetting = s - end, - }) - - -- Trigger the setter via SetValue which calls the proxy setter → postSet → onSet - setting:SetValue(false) - assert.are.equal(false, receivedValue) - assert.are.equal(setting, receivedSetting) - end) - - -- PathAdapter - describe("PathAdapter", function() - it("resolve returns get/set/default for nested path", function() - local LSB = LibStub("LibSettingsBuilder-1.0") - local pa = LSB.PathAdapter({ - getStore = function() - return addonNS.Addon.db.profile - end, - getDefaults = function() - return addonNS.Addon.db.defaults.profile - end, - }) - - local binding = pa:resolve("global.hideWhenMounted") - assert.is_function(binding.get) - assert.is_function(binding.set) - assert.are.equal(true, binding.default) - assert.are.equal(true, binding.get()) - - binding.set(false) - assert.are.equal(false, addonNS.Addon.db.profile.global.hideWhenMounted) - end) - - it("read returns nested value", function() - local LSB = LibStub("LibSettingsBuilder-1.0") - local pa = LSB.PathAdapter({ - getStore = function() - return addonNS.Addon.db.profile - end, - getDefaults = function() - return addonNS.Addon.db.defaults.profile - end, - }) - - assert.are.equal(5, pa:read("global.value")) - end) - - it("falls back to nil when defaults table missing", function() - local LSB = LibStub("LibSettingsBuilder-1.0") - local pa = LSB.PathAdapter({ - getStore = function() - return addonNS.Addon.db.profile - end, - getDefaults = function() - return nil - end, - }) - - local binding = pa:resolve("global.hideWhenMounted") - assert.is_nil(binding.default) - end) - end) - - -- Handler mode - describe("handler mode", function() - it("Checkbox with get/set/key works without pathAdapter", function() - local LSB = LibStub("LibSettingsBuilder-1.0") - local SBH = LSB:New({ - varPrefix = "Handler", - onChanged = function() end, - }) - SBH.CreateRootCategory("HandlerTest") - SBH.CreateSubcategory("HandlerSection") - - local store = { myVal = true } - local _, setting = SBH.Checkbox({ - get = function() - return store.myVal - end, - set = function(v) - store.myVal = v - end, - key = "myVal", - default = true, - name = "Handler Checkbox", - }) - - assert.are.equal(true, setting:GetValue()) - setting:SetValue(false) - assert.are.equal(false, store.myVal) - end) - - it("Slider with get/set/key and transforms", function() - local LSB = LibStub("LibSettingsBuilder-1.0") - local SBH = LSB:New({ - varPrefix = "Handler", - onChanged = function() end, - }) - SBH.CreateRootCategory("HandlerTest2") - SBH.CreateSubcategory("HandlerSection2") - - local store = { scale = 0.75 } - local _, setting = SBH.Slider({ - get = function() - return store.scale - end, - set = function(v) - store.scale = v - end, - key = "scale", - default = 1.0, - name = "Handler Slider", - min = 0, - max = 2, - step = 0.01, - getTransform = function(v) - return v * 100 - end, - setTransform = function(v) - return v / 100 - end, - }) - - assert.are.equal(75, setting:GetValue()) - setting:SetValue(50) - assert.are.equal(0.5, store.scale) - end) - - it("errors when spec has both path and get", function() - assert.has.errors(function() - SB.Checkbox({ - path = "global.hideWhenMounted", - get = function() - return true - end, - set = function() end, - key = "x", - name = "Bad Spec", - }) - end) - end) - - it("errors when handler mode missing set", function() - local LSB = LibStub("LibSettingsBuilder-1.0") - local SBH = LSB:New({ varPrefix = "H", onChanged = function() end }) - SBH.CreateRootCategory("HErr") - SBH.CreateSubcategory("HErrS") - - assert.has.errors(function() - SBH.Checkbox({ - get = function() - return true - end, - key = "x", - name = "Missing Set", - }) - end) - end) - - it("errors when handler mode missing key", function() - local LSB = LibStub("LibSettingsBuilder-1.0") - local SBH = LSB:New({ varPrefix = "H2", onChanged = function() end }) - SBH.CreateRootCategory("HErr2") - SBH.CreateSubcategory("HErrS2") - - assert.has.errors(function() - SBH.Checkbox({ - get = function() - return true - end, - set = function() end, - name = "Missing Key", - }) - end) - end) - - it("path mode errors without pathAdapter", function() - local LSB = LibStub("LibSettingsBuilder-1.0") - local SBH = LSB:New({ varPrefix = "NP", onChanged = function() end }) - SBH.CreateRootCategory("NoPath") - SBH.CreateSubcategory("NoPathS") - - assert.has.errors(function() - SBH.Checkbox({ - path = "some.path", - name = "No Adapter", - }) - end) - end) - - it("Control dispatches handler-mode checkbox", function() - local LSB = LibStub("LibSettingsBuilder-1.0") - local SBH = LSB:New({ varPrefix = "Disp", onChanged = function() end }) - SBH.CreateRootCategory("DispTest") - SBH.CreateSubcategory("DispSect") - - local store = { flag = false } - local _, setting = SBH.Control({ - type = "checkbox", - get = function() - return store.flag - end, - set = function(v) - store.flag = v - end, - key = "flag", - default = false, - name = "Dispatched Handler", - }) - - assert.are.equal(false, setting:GetValue()) - setting:SetValue(true) - assert.are.equal(true, store.flag) - end) - end) - - describe("slider inline edit hook", function() - it("rebinds the edit box to the current slider setting when frames are reused", function() - local hooks = select(1, loadLibraryWithHookStubs()) - local initHook = hooks[_G.SettingsSliderControlMixin].Init - - local firstValue = 12 - local secondValue = 33 - local firstSetting = { - GetValue = function() - return firstValue - end, - SetValue = function(_, value) - firstValue = value - end, - } - local secondSetting = { - GetValue = function() - return secondValue - end, - SetValue = function(_, value) - secondValue = value - end, - } - - local firstLabel = createScriptableFrame() - firstLabel.IsObjectType = function(_, objectType) - return objectType == "FontString" - end - - local sliderWithSteppers = createScriptableFrame() - sliderWithSteppers.Slider = { - GetMinMaxValues = function() - return 0, 100 - end, - GetValueStep = function() - return 5 - end, - } - sliderWithSteppers.RightText = firstLabel - sliderWithSteppers.GetRegions = function() - return firstLabel - end - - local control = { - SliderWithSteppers = sliderWithSteppers, - } - - initHook(control, { - GetSetting = function() - return firstSetting - end, - }) - - control._lsbValueButton:GetScript("OnClick")() - assert.are.equal("12", control._lsbEditBox:GetText()) - - local secondLabel = createScriptableFrame() - secondLabel.IsObjectType = function(_, objectType) - return objectType == "FontString" - end - sliderWithSteppers.RightText = secondLabel - sliderWithSteppers.GetRegions = function() - return secondLabel - end - - initHook(control, { - GetSetting = function() - return secondSetting - end, - }) - - control._lsbValueButton:GetScript("OnClick")() - assert.are.equal("33", control._lsbEditBox:GetText()) - - control._lsbEditBox:SetText("27") - control._lsbEditBox:GetScript("OnEnterPressed")() - - assert.are.equal(25, secondValue) - assert.are.equal(12, firstValue) - assert.is_true(secondLabel:IsShown()) - assert.is_false(control._lsbEditBox._focused) - end) - end) - - describe("page lifecycle onShow/onHide", function() - local LSB - - before_each(function() - createSettingsPanelMock() - - TestHelpers.SetupLibStub() - TestHelpers.SetupSettingsStubs() - - _G.hooksecurefunc = function(tbl, method, hook) - if type(tbl) == "table" and type(method) == "string" and type(hook) == "function" then - local orig = tbl[method] - if type(orig) == "function" then - tbl[method] = function(...) - orig(...) - hook(...) - end - end - end - end - - _G.SettingsListElementMixin = {} - _G.SettingsDropdownControlMixin = {} - _G.SettingsSliderControlMixin = {} - _G.CreateFrame = function() - return createScriptableFrame() - end - - TestHelpers.LoadChunk( - "Libs/LibSettingsBuilder/LibSettingsBuilder.lua", - "Unable to load LibSettingsBuilder.lua" - )() - LSB = LibStub("LibSettingsBuilder-1.0") - end) - - local function makeSB(prefix) - return LSB:New({ - pathAdapter = LSB.PathAdapter({ - getStore = function() return addonNS.Addon.db.profile end, - getDefaults = function() return addonNS.Addon.db.defaults.profile end, - getNestedValue = addonNS.OptionUtil.GetNestedValue, - setNestedValue = addonNS.OptionUtil.SetNestedValue, - }), - varPrefix = prefix or "T", - onChanged = function() end, - }) - end - - it("stores onShow/onHide callbacks when provided in RegisterFromTable", function() - local sb = makeSB() - sb.CreateRootCategory("Lifecycle") - sb.RegisterFromTable({ - name = "Page1", - onShow = function() end, - onHide = function() end, - args = {}, - }) - local cat = sb._subcategories["Page1"] - assert.is_table(LSB._pageLifecycleCallbacks[cat]) - assert.is_function(LSB._pageLifecycleCallbacks[cat].onShow) - assert.is_function(LSB._pageLifecycleCallbacks[cat].onHide) - end) - - --- Simulates WoW's sidebar navigation: SetCurrentCategory then DisplayCategory. - local function navigateTo(cat) - SettingsPanel:SetCurrentCategory(cat) - SettingsPanel:DisplayCategory(cat) - end - - it("fires onShow when DisplayCategory is called with a tracked category", function() - local sb = makeSB() - sb.CreateRootCategory("Lifecycle") - local showCount = 0 - sb.RegisterFromTable({ - name = "Page1", - onShow = function() showCount = showCount + 1 end, - args = {}, - }) - local cat = sb._subcategories["Page1"] - navigateTo(cat) - assert.are.equal(1, showCount) - end) - - it("fires onHide when switching away from a tracked category", function() - local sb = makeSB() - sb.CreateRootCategory("Lifecycle") - local hideCount = 0 - sb.RegisterFromTable({ - name = "Page1", - onHide = function() hideCount = hideCount + 1 end, - args = {}, - }) - local cat = sb._subcategories["Page1"] - local other = { _name = "Other" } - navigateTo(cat) - navigateTo(other) - assert.are.equal(1, hideCount) - end) - - it("fires onHide when SettingsPanel is hidden", function() - local sb = makeSB() - sb.CreateRootCategory("Lifecycle") - local hideCount = 0 - sb.RegisterFromTable({ - name = "Page1", - onHide = function() hideCount = hideCount + 1 end, - args = {}, - }) - local cat = sb._subcategories["Page1"] - navigateTo(cat) - SettingsPanel._fireScript("OnHide") - assert.are.equal(1, hideCount) - end) - - it("does not fire duplicate onShow when same category re-selected", function() - local sb = makeSB() - sb.CreateRootCategory("Lifecycle") - local showCount = 0 - sb.RegisterFromTable({ - name = "Page1", - onShow = function() showCount = showCount + 1 end, - args = {}, - }) - local cat = sb._subcategories["Page1"] - navigateTo(cat) - navigateTo(cat) - assert.are.equal(1, showCount) - end) - - it("does not fire callbacks for categories without lifecycle hooks", function() - local sb = makeSB() - sb.CreateRootCategory("Lifecycle") - sb.RegisterFromTable({ name = "Plain", args = {} }) - local untracked = sb._subcategories["Plain"] - -- Should not error - navigateTo(untracked) - end) - - it("clears active category on panel hide so next open fires onShow", function() - local sb = makeSB() - sb.CreateRootCategory("Lifecycle") - local showCount = 0 - sb.RegisterFromTable({ - name = "Page1", - onShow = function() showCount = showCount + 1 end, - args = {}, - }) - local cat = sb._subcategories["Page1"] - navigateTo(cat) - SettingsPanel._fireScript("OnHide") - navigateTo(cat) - assert.are.equal(2, showCount) - end) - - it("defers hook installation when SettingsPanel is not yet available", function() - -- Remove SettingsPanel before loading library - _G.SettingsPanel = nil - - TestHelpers.SetupLibStub() - TestHelpers.SetupSettingsStubs() - _G.hooksecurefunc = function(tbl, method, hook) - if type(tbl) == "table" and type(method) == "string" and type(hook) == "function" then - local orig = tbl[method] - if type(orig) == "function" then - tbl[method] = function(...) - orig(...) - hook(...) - end - end - end - end - _G.SettingsListElementMixin = {} - _G.SettingsDropdownControlMixin = {} - _G.SettingsSliderControlMixin = {} - - local deferFrame - _G.CreateFrame = function() - deferFrame = createScriptableFrame() - return deferFrame - end - - TestHelpers.LoadChunk( - "Libs/LibSettingsBuilder/LibSettingsBuilder.lua", - "Unable to load LibSettingsBuilder.lua" - )() - local lsb = LibStub("LibSettingsBuilder-1.0") - - local sb = lsb:New({ - pathAdapter = lsb.PathAdapter({ - getStore = function() return addonNS.Addon.db.profile end, - getDefaults = function() return addonNS.Addon.db.defaults.profile end, - getNestedValue = addonNS.OptionUtil.GetNestedValue, - setNestedValue = addonNS.OptionUtil.SetNestedValue, - }), - varPrefix = "D", - onChanged = function() end, - }) - sb.CreateRootCategory("Deferred") - - local showCount = 0 - sb.RegisterFromTable({ - name = "Page1", - onShow = function() showCount = showCount + 1 end, - args = {}, - }) - - -- Hooks not yet installed — deferred frame should exist - assert.is_table(deferFrame) - assert.is_false(lsb._pageLifecycleHooked) - - -- Simulate Blizzard_Settings loading - createSettingsPanelMock() - deferFrame:GetScript("OnEvent")(deferFrame, "ADDON_LOADED", "Blizzard_Settings") - - assert.is_true(lsb._pageLifecycleHooked) - - -- Hooks should now work - local cat = sb._subcategories["Page1"] - SettingsPanel:SetCurrentCategory(cat) - SettingsPanel:DisplayCategory(cat) - assert.are.equal(1, showCount) - end) - end) - - --------------------------------------------------------------------------- - -- SB.Custom integration: template, setting, and InitFrame pipeline - --------------------------------------------------------------------------- - describe("Custom control integration", function() - it("passes the actual template name to CreateElementInitializer", function() - local capturedTemplate - local settings = Settings - local origCEI = settings.CreateElementInitializer - rawset(settings, "CreateElementInitializer", function(template, data) - capturedTemplate = template - return origCEI(template, data) - end) - - SB.Custom({ - path = "global.font", - name = "Font Picker", - template = "LibLSMSettingsWidgets_FontPickerTemplate", - }) - - rawset(settings, "CreateElementInitializer", origCEI) - assert.are.equal("LibLSMSettingsWidgets_FontPickerTemplate", capturedTemplate) - end) - - it("attaches the setting so InitFrame can retrieve it via GetSetting", function() - local init, setting = SB.Custom({ - path = "global.font", - name = "Font Picker", - template = "LibLSMSettingsWidgets_FontPickerTemplate", - }) - - assert.is_not_nil(init:GetSetting()) - assert.are.equal(setting, init:GetSetting()) - end) - - it("setting reads the current profile value", function() - local _, setting = SB.Custom({ - path = "global.font", - name = "Font Picker", - template = "LibLSMSettingsWidgets_FontPickerTemplate", - }) - - assert.are.equal("Global Font", setting:GetValue()) - end) - - it("setting writes back to the profile", function() - local _, setting = SB.Custom({ - path = "global.font", - name = "Font Picker", - template = "LibLSMSettingsWidgets_FontPickerTemplate", - }) - - setting:SetValue("NewFont") - assert.are.equal("NewFont", addonNS.Addon.db.profile.global.font) - end) - - it("initializer data contains name and tooltip", function() - local init = SB.Custom({ - path = "global.font", - name = "Font Picker", - template = "LibLSMSettingsWidgets_FontPickerTemplate", - tooltip = "Choose a font", - }) - - local data = init:GetData() - assert.are.equal("Font Picker", data.name) - assert.are.equal("Choose a font", data.tooltip) - end) - - it("setting is retrievable so XML mixin Init can access it", function() - local init, setting = SB.Custom({ - path = "global.font", - name = "Font Picker", - template = "LibLSMSettingsWidgets_FontPickerTemplate", - }) - - -- In the real WoW path, the settings framework creates a frame - -- from the XML template (which applies the mixin and fires OnLoad), - -- then calls frame:Init(initializer). The mixin's Init calls - -- initializer:GetSetting() to bind the dropdown. This test verifies - -- that the setting is attached and accessible on the initializer — - -- the critical contract that the XML mixin relies on. - assert.is_not_nil(init:GetSetting()) - assert.are.equal(setting, init:GetSetting()) - assert.are.equal("Global Font", setting:GetValue()) - end) - - it("RegisterFromTable dispatches custom type through SB.Custom", function() - local capturedTemplate - local settings = Settings - local origCEI = settings.CreateElementInitializer - rawset(settings, "CreateElementInitializer", function(template, data) - capturedTemplate = template - return origCEI(template, data) - end) - - SB.RegisterFromTable({ - name = "Test Custom Section", - path = "global", - args = { - testHeader = { type = "header", name = "Appearance", order = 1 }, - fontPicker = { - type = "custom", - path = "font", - name = "Font", - template = "LibLSMSettingsWidgets_FontPickerTemplate", - order = 2, - }, - }, - }) - - rawset(settings, "CreateElementInitializer", origCEI) - assert.are.equal("LibLSMSettingsWidgets_FontPickerTemplate", capturedTemplate) - end) - - it("does not wrap or replace InitFrame on the initializer", function() - local init = SB.Custom({ - path = "global.font", - name = "Font Picker", - template = "LibLSMSettingsWidgets_FontPickerTemplate", - }) - - -- A stock SettingsListElementInitializer from the stub has no - -- InitFrame. If SB.Custom starts injecting one (e.g. for mixin - -- injection), that's a regression — XML templates handle this. - assert.is_nil(init.InitFrame) - end) - end) -end) diff --git a/Libs/LibSettingsBuilder/Utility.lua b/Libs/LibSettingsBuilder/Utility.lua new file mode 100644 index 00000000..55ed75df --- /dev/null +++ b/Libs/LibSettingsBuilder/Utility.lua @@ -0,0 +1,959 @@ +-- Enhanced Cooldown Manager addon for World of Warcraft +-- Author: Argium +-- Licensed under the GNU General Public License v3.0 + +--- No-arg predicate used by declarative `disabled` and `hidden` fields. +---@alias LibSettingsBuilderPredicate boolean|fun(): boolean + +--- Dropdown value source used by `dropdown` rows. +---@alias LibSettingsBuilderDropdownValues table|fun(): table + +--- Inline slider label formatter used by `slider` rows. +---@alias LibSettingsBuilderSliderFormatter fun(value: number): string + +--- Button row callback. +---@alias LibSettingsBuilderButtonClickCallback fun(ctx: LibSettingsBuilderCallbackContext) + +--- Input preview resolver. +---@alias LibSettingsBuilderInputResolveTextCallback fun(value: string, setting: table, frame: Frame): string|nil + +--- Input text-change hook. +---@alias LibSettingsBuilderInputTextChangedCallback fun(text: string, setting: table, frame: Frame) + +--- Page-actions button callback. +---@alias LibSettingsBuilderPageActionClickCallback fun(action: LibSettingsBuilderPageActionConfig, frame: Frame) + +--- Dynamic flat-list provider. +---@alias LibSettingsBuilderListItemsProvider fun(frame: Frame): table[] + +--- Dynamic grouped-list provider. +---@alias LibSettingsBuilderSectionListProvider fun(frame: Frame): table[] + +--- Canonical declarative row kinds accepted by `config.page.rows` and section page rows. +---@alias LibSettingsBuilderRowKind +---| "border" +---| "button" +---| "canvas" +---| "checkbox" +---| "checkboxList" +---| "color" +---| "colorList" +---| "custom" +---| "dropdown" +---| "fontOverride" +---| "header" +---| "heightOverride" +---| "info" +---| "input" +---| "list" +---| "pageActions" +---| "sectionList" +---| "slider" +---| "subheader" + +--- Dynamic list presets supported by `type = "list"` rows. +---@alias LibSettingsBuilderListVariant +---| "editor" +---| "swatch" + +--- Registered section metadata returned by `lsb:GetSection(...)`. +---@class LibSettingsBuilderSectionHandle +---@field key string Gets the stable section key. +---@field name string Gets the section display name. +---@field path string Gets the base path prefix applied to child pages and rows. + +--- Plain page handle returned by `lsb:GetRootPage()` and `lsb:GetPage(...)`. +---@class LibSettingsBuilderPageHandle +---@field GetId fun(self: LibSettingsBuilderPageHandle): string Gets the Blizzard Settings category ID for this registered page. +---@field Refresh fun(self: LibSettingsBuilderPageHandle) Refreshes visible rows and dynamic content for this registered page. + +--- Runtime object returned by `LSB.New(...)`. +---@class LibSettingsBuilderRuntime +---@field GetSection fun(self: LibSettingsBuilderRuntime, key: string): LibSettingsBuilderSectionHandle|nil Gets the registered section metadata by key. +---@field GetRootPage fun(self: LibSettingsBuilderRuntime): LibSettingsBuilderPageHandle|nil Gets the registered root page handle. +---@field GetPage fun(self: LibSettingsBuilderRuntime, sectionKey: string, pageKey: string): LibSettingsBuilderPageHandle|nil Gets the registered section page handle by section and page key. +---@field HasCategory fun(self: LibSettingsBuilderRuntime, category: table|nil): boolean Gets whether this runtime owns the supplied Blizzard Settings category. + +--- Declarative page definition registered under the root category or a section. +---@class LibSettingsBuilderPageConfig +---@field key string Gets the stable page key within its owner. +---@field name string|nil Gets the page display name; defaults to the root or section name when omitted. +---@field path string|nil Gets the optional base path prefix prepended to child path-bound rows. +---@field rows LibSettingsBuilderRowConfig[] Gets the declarative row array registered on the page. +---@field onShow LibSettingsBuilderPageLifecycleCallback|nil Gets the callback fired when Blizzard shows this page. +---@field onHide LibSettingsBuilderPageLifecycleCallback|nil Gets the callback fired when Blizzard hides this page. +---@field onDefault fun()|nil Gets the callback invoked when the user clicks the Blizzard category-header `Defaults` button while this page is active. When supplied, the library replaces the button's default reset behavior for the duration the page is shown. +---@field onDefaultEnabled fun(): boolean|nil Gets the predicate that controls whether the `Defaults` button is enabled while this page is active. Defaults to always-enabled when `onDefault` is supplied. +---@field disabled LibSettingsBuilderPredicate|nil Gets the page-level disabled predicate propagated to child rows. +---@field hidden LibSettingsBuilderPredicate|nil Gets the page-level hidden predicate propagated to child rows. +---@field order number|nil Gets the sort order used when a section declares multiple pages. +---@field useSectionCategory boolean|nil Gets whether a multi-page section page is materialized on the section category instead of under a child category. + +--- Declarative section definition registered under `config.sections`. +--- Example (section page): +--- { +--- key = "general", +--- name = "General", +--- pages = { +--- { +--- key = "main", +--- rows = { { type = "checkbox", path = "enabled", name = "Enable" } }, +--- }, +--- }, +--- } +---@class LibSettingsBuilderSectionConfig +---@field key string Gets the stable section key. +---@field name string Gets the section display name. +---@field path string|nil Gets the optional base path prefix; defaults to `key`. +---@field order number|nil Gets the sort order among sibling sections. +---@field pages LibSettingsBuilderPageConfig[] Gets the page definitions registered under this section. + +--- Shared fields accepted by all declarative row kinds. +---@class LibSettingsBuilderRowBase +---@field type LibSettingsBuilderRowKind Gets the canonical row kind to register. +---@field id string|number|nil Gets the optional per-page row identifier. +---@field name string|nil Gets the primary display label when the row kind uses one. +---@field tooltip string|nil Gets the tooltip text shown for the row or control. +---@field disabled LibSettingsBuilderPredicate|nil Gets the disabled predicate reevaluated during row refreshes. +---@field hidden LibSettingsBuilderPredicate|nil Gets the hidden predicate reevaluated during row refreshes. + +--- Shared binding fields for persisted row kinds. +--- Use either path mode (`path`) or handler mode (`key` + `get` + `set`), never both. +--- Example (path-bound row): +--- { type = "checkbox", path = "general.enabled", name = "Enable" } +--- Example (handler-bound row): +--- { +--- type = "input", +--- key = "draftSpellId", +--- name = "Spell ID", +--- get = function() return draft.spellIdText end, +--- set = function(value) draft.spellIdText = value or "" end, +--- } +---@class LibSettingsBuilderBindableRowBase: LibSettingsBuilderRowBase +---@field path string|nil Gets the dot-path resolved against `config.store` and `config.defaults`. +---@field key string|number|nil Gets the stable handler key used when the row is not path-bound. +---@field default any Gets the default value used when the binding does not provide one. +---@field get (fun(): any)|nil Gets the handler-mode getter callback. +---@field set fun(value: any)|nil Gets the handler-mode setter callback. +---@field getTransform (fun(value: any): any)|nil Gets the read transform applied before the control sees the stored value. +---@field setTransform (fun(value: any): any)|nil Gets the write transform applied before the value is stored. +---@field onSet LibSettingsBuilderRowSetCallback|nil Gets the row-local callback fired before `config.onChanged`. + +--- Shared fields for composite rows that always consume a path prefix. +---@class LibSettingsBuilderPathRowBase: LibSettingsBuilderRowBase +---@field path string Gets the dot-path prefix consumed by this composite row. + +--- Child definition used by `checkboxList` and `colorList` rows. +---@class LibSettingsBuilderCompositeListDef +---@field key string|number Gets the child key appended to the parent row path. +---@field name string Gets the child row label. +---@field tooltip string|nil Gets the child row tooltip. + +--- Action button definition used by `pageActions` rows. +---@class LibSettingsBuilderPageActionConfig +---@field name string|nil Gets the fallback button label when `text` is omitted. +---@field text string|nil Gets the button label. +---@field width number|nil Gets the button width. +---@field height number|nil Gets the button height. +---@field buttonTextures table|nil Gets optional full-button texture states. +---@field iconTexture string|number|nil Gets the optional centered icon texture drawn over the default button chrome. +---@field iconSize number|nil Gets the optional centered icon size. +---@field iconAlpha number|nil Gets the optional enabled icon alpha. +---@field disabledIconAlpha number|nil Gets the optional disabled icon alpha. +---@field enabled boolean|(fun(action: LibSettingsBuilderPageActionConfig, frame: Frame): boolean|nil)|nil Gets the enabled predicate or static enabled flag. +---@field hidden boolean|(fun(action: LibSettingsBuilderPageActionConfig, frame: Frame): boolean|nil)|nil Gets the hidden predicate or static hidden flag. +---@field tooltip string|(fun(action: LibSettingsBuilderPageActionConfig, frame: Frame): string|nil)|nil Gets the tooltip text or tooltip resolver. +---@field onClick LibSettingsBuilderPageActionClickCallback|nil Gets the click callback. + +---@class LibSettingsBuilderCheckboxRowConfig: LibSettingsBuilderBindableRowBase +---@field type "checkbox" Gets the checkbox row kind. + +---@class LibSettingsBuilderSliderRowConfig: LibSettingsBuilderBindableRowBase +---@field type "slider" Gets the slider row kind. +---@field min number Gets the minimum slider value. +---@field max number Gets the maximum slider value. +---@field step number|nil Gets the slider step size. +---@field formatter LibSettingsBuilderSliderFormatter|nil Gets the inline value formatter. + +---@class LibSettingsBuilderDropdownRowConfig: LibSettingsBuilderBindableRowBase +---@field type "dropdown" Gets the dropdown row kind. +---@field values LibSettingsBuilderDropdownValues Gets the dropdown value table or provider. +---@field scrollHeight number|nil Gets the optional scrollable menu height. +---@field varType any Gets the optional `Settings.VarType` override. + +---@class LibSettingsBuilderColorRowConfig: LibSettingsBuilderBindableRowBase +---@field type "color" Gets the color-swatch row kind. + +---@class LibSettingsBuilderInputRowConfig: LibSettingsBuilderBindableRowBase +---@field type "input" Gets the text-input row kind. +---@field debounce number|nil Gets the preview debounce in seconds. +---@field maxLetters number|nil Gets the maximum edit-box length. +---@field numeric boolean|nil Gets whether the edit box only accepts numeric input. +---@field onTextChanged LibSettingsBuilderInputTextChangedCallback|nil Gets the callback fired after the new text is written. +---@field resolveText LibSettingsBuilderInputResolveTextCallback|nil Gets the preview-text resolver shown beneath the edit box. +---@field width number|nil Gets the edit-box width. + +---@class LibSettingsBuilderCustomRowConfig: LibSettingsBuilderBindableRowBase +---@field type "custom" Gets the XML-template-backed custom row kind. +---@field template string Gets the XML template name registered with Blizzard's Settings API. +---@field varType any Gets the optional `Settings.VarType` override. + +---@class LibSettingsBuilderButtonRowConfig: LibSettingsBuilderRowBase +---@field type "button" Gets the button row kind. +---@field buttonText string|nil Gets the button label; defaults to `name`. +---@field confirm boolean|string|nil Gets whether the row shows a confirmation dialog, or the confirmation text to use. +---@field onClick LibSettingsBuilderButtonClickCallback Gets the click callback. + +---@class LibSettingsBuilderHeaderRowConfig: LibSettingsBuilderRowBase +---@field type "header" Gets the header row kind. +---@field name string Gets the header label. + +---@class LibSettingsBuilderSubheaderRowConfig: LibSettingsBuilderRowBase +---@field type "subheader" Gets the subheader row kind. +---@field name string Gets the subheader label. + +---@class LibSettingsBuilderInfoRowConfig: LibSettingsBuilderRowBase +---@field type "info" Gets the informational row kind. +---@field value string|number|boolean|(fun(frame: Frame, data: table): any)|nil Gets the primary value or dynamic value resolver. +---@field values string[]|nil Gets the optional multiline value array, joined with newlines during normalization. +---@field wide boolean|nil Gets whether the value should span the full row without a left label. +---@field multiline boolean|nil Gets whether the value text may wrap across multiple lines. +---@field height number|nil Gets the custom row height. + +---@class LibSettingsBuilderCanvasRowConfig: LibSettingsBuilderRowBase +---@field type "canvas" Gets the embedded-canvas row kind. +---@field canvas Frame Gets the prebuilt frame to embed into the settings page. +---@field height number|nil Gets the embedded row height; defaults to the canvas height. + +---@class LibSettingsBuilderPageActionsRowConfig: LibSettingsBuilderRowBase +---@field type "pageActions" Gets the page-actions row kind. +---@field actions LibSettingsBuilderPageActionConfig[] Gets the action button definitions attached to the page header. +---@field height number|nil Gets the placeholder row height used by the initializer. + +---@class LibSettingsBuilderListRowConfig: LibSettingsBuilderRowBase +---@field type "list" Gets the dynamic flat-list row kind. +---@field height number Gets the total row height reserved for the list widget. +---@field variant LibSettingsBuilderListVariant|nil Gets the built-in list preset applied to item data. +---@field items LibSettingsBuilderListItemsProvider Gets the item provider called during refreshes. + +---@class LibSettingsBuilderSectionListRowConfig: LibSettingsBuilderRowBase +---@field type "sectionList" Gets the dynamic grouped-list row kind. +---@field height number Gets the total row height reserved for the list widget. +---@field sections LibSettingsBuilderSectionListProvider Gets the section provider called during refreshes. + +---@class LibSettingsBuilderCheckboxListRowConfig: LibSettingsBuilderPathRowBase +---@field type "checkboxList" Gets the checkbox-list composite row kind. +---@field defs LibSettingsBuilderCompositeListDef[] Gets the child checkbox definitions. +---@field label string|nil Gets the optional composite subheader label. + +---@class LibSettingsBuilderColorListRowConfig: LibSettingsBuilderPathRowBase +---@field type "colorList" Gets the color-list composite row kind. +---@field defs LibSettingsBuilderCompositeListDef[] Gets the child color definitions. +---@field label string|nil Gets the optional composite subheader label. + +---@class LibSettingsBuilderBorderRowConfig: LibSettingsBuilderPathRowBase +---@field type "border" Gets the border composite row kind. +---@field enabledName string|nil Gets the enable-row label. +---@field enabledTooltip string|nil Gets the enable-row tooltip. +---@field thicknessName string|nil Gets the border-width row label. +---@field thicknessTooltip string|nil Gets the border-width row tooltip. +---@field thicknessMin number|nil Gets the minimum border width. +---@field thicknessMax number|nil Gets the maximum border width. +---@field thicknessStep number|nil Gets the border-width step size. +---@field colorName string|nil Gets the color-row label. +---@field colorTooltip string|nil Gets the color-row tooltip. + +---@class LibSettingsBuilderFontOverrideRowConfig: LibSettingsBuilderPathRowBase +---@field type "fontOverride" Gets the font-override composite row kind. +---@field enabledName string|nil Gets the override toggle label. +---@field enabledTooltip string|nil Gets the override toggle tooltip. +---@field fontName string|nil Gets the font-row label. +---@field fontTooltip string|nil Gets the font-row tooltip. +---@field fontValues (fun(): table)|nil Gets the optional dropdown value provider for the font row. +---@field fontFallback (fun(): string|nil)|nil Gets the fallback font name used when no override is stored. +---@field fontTemplate string|nil Gets the optional custom template used instead of the built-in dropdown. +---@field sizeName string|nil Gets the font-size row label. +---@field sizeTooltip string|nil Gets the font-size row tooltip. +---@field sizeMin number|nil Gets the minimum font size. +---@field sizeMax number|nil Gets the maximum font size. +---@field sizeStep number|nil Gets the font-size step size. +---@field fontSizeFallback (fun(): number|nil)|nil Gets the fallback font size used when no override is stored. + +---@class LibSettingsBuilderHeightOverrideRowConfig: LibSettingsBuilderPathRowBase +---@field type "heightOverride" Gets the height-override composite row kind. +---@field min number|nil Gets the minimum slider value. +---@field max number|nil Gets the maximum slider value. +---@field step number|nil Gets the slider step size. + +---@alias LibSettingsBuilderRowConfig +---| LibSettingsBuilderCheckboxRowConfig +---| LibSettingsBuilderSliderRowConfig +---| LibSettingsBuilderDropdownRowConfig +---| LibSettingsBuilderColorRowConfig +---| LibSettingsBuilderInputRowConfig +---| LibSettingsBuilderCustomRowConfig +---| LibSettingsBuilderButtonRowConfig +---| LibSettingsBuilderHeaderRowConfig +---| LibSettingsBuilderSubheaderRowConfig +---| LibSettingsBuilderInfoRowConfig +---| LibSettingsBuilderCanvasRowConfig +---| LibSettingsBuilderPageActionsRowConfig +---| LibSettingsBuilderListRowConfig +---| LibSettingsBuilderSectionListRowConfig +---| LibSettingsBuilderCheckboxListRowConfig +---| LibSettingsBuilderColorListRowConfig +---| LibSettingsBuilderBorderRowConfig +---| LibSettingsBuilderFontOverrideRowConfig +---| LibSettingsBuilderHeightOverrideRowConfig + +local MAJOR = "LibSettingsBuilder-1.0" +local lib = LibStub(MAJOR, true) +if not lib or not lib._loadState or not lib._loadState.open then + return +end + +local internal = lib._internal +local copyMixin = internal.copyMixin +local installPageLifecycleHooks = internal.installPageLifecycleHooks + +local PROXY_ROW_TYPES = { + checkbox = true, + slider = true, + dropdown = true, + color = true, + input = true, + custom = true, +} + +local COMPOSITE_ROW_TYPES = { + border = true, + checkboxList = true, + colorList = true, + fontOverride = true, + heightOverride = true, +} + +local VALID_ROW_TYPES = { + border = true, + button = true, + canvas = true, + checkbox = true, + checkboxList = true, + color = true, + colorList = true, + custom = true, + dropdown = true, + fontOverride = true, + header = true, + heightOverride = true, + info = true, + input = true, + list = true, + pageActions = true, + sectionList = true, + slider = true, + subheader = true, +} + +local function refreshCategory(builder, category) + if not category then + return + end + + local currentCategory = SettingsPanel and SettingsPanel.GetCurrentCategory and SettingsPanel:GetCurrentCategory() or nil + local isVisible = SettingsPanel and SettingsPanel.IsShown and SettingsPanel:IsShown() and currentCategory == category + + local refreshables = builder._categoryRefreshables[category] or {} + for _, initializer in ipairs(refreshables) do + if initializer._lsbActiveFrame and initializer._lsbRefreshFrame then + initializer._lsbRefreshFrame(initializer._lsbActiveFrame, initializer) + end + end + + if not isVisible then + return + end + + local settingsList = SettingsPanel.GetSettingsList and SettingsPanel:GetSettingsList() + local scrollBox = settingsList and settingsList.ScrollBox + if scrollBox and scrollBox.ForEachFrame then + scrollBox:ForEachFrame(function(frame) + local initializer = frame.GetElementData and frame:GetElementData() or frame._lsbInitializer + if frame.EvaluateState then + frame:EvaluateState() + end + if initializer and initializer._lsbRefreshFrame then + initializer._lsbRefreshFrame(frame, initializer) + end + end) + end +end + +local function resolvePagePath(pagePath, rowPath) + if rowPath == nil or rowPath == "" or rowPath:find("%.") or pagePath == "" then + return (rowPath ~= nil and rowPath ~= "") and rowPath or pagePath + end + return pagePath .. "." .. rowPath +end + +local function assertBooleanOrCallback(sourceName, fieldName, value) + local valueType = type(value) + assert( + value == nil or valueType == "boolean" or valueType == "function", + sourceName .. ": " .. fieldName .. " must be a boolean or function" + ) +end + +local function getRowLabel(row) + return tostring(row.id or row.key or row.path or row.name or row.type) +end + +local function normalizeDeclarativeRowSpec(sourceName, row) + assert(type(row) == "table", sourceName .. ": each row must be a table") + + local spec = copyMixin({}, row) + assert(spec.desc == nil, sourceName .. ": row '" .. getRowLabel(spec) .. "' uses deprecated field 'desc'; use 'tooltip'") + assert(spec.condition == nil, sourceName .. ": row '" .. getRowLabel(spec) .. "' uses removed field 'condition'") + assert(spec.parent == nil, sourceName .. ": row '" .. getRowLabel(spec) .. "' uses removed field 'parent'") + assert(spec.parentCheck == nil, sourceName .. ": row '" .. getRowLabel(spec) .. "' uses removed field 'parentCheck'") + + local rowType = spec.type + assert(type(rowType) == "string" and VALID_ROW_TYPES[rowType], sourceName .. ": unknown row type '" .. tostring(rowType) .. "'") + + if rowType == "button" and spec.buttonText == nil and spec.value ~= nil then + spec.buttonText = spec.value + end + spec.value = rowType == "button" and nil or spec.value + + if rowType == "dropdown" and spec.scrollHeight == nil and spec.maxScrollDisplayHeight ~= nil then + spec.scrollHeight = spec.maxScrollDisplayHeight + end + spec.maxScrollDisplayHeight = nil + + if rowType == "info" and spec.values ~= nil then + assert(spec.value == nil, sourceName .. ": info row '" .. getRowLabel(spec) .. "' cannot define both value and values") + assert(type(spec.values) == "table", sourceName .. ": info row '" .. getRowLabel(spec) .. "' values must be a table") + spec.value = table.concat(spec.values, "\n") + spec.multiline = true + spec.values = nil + end + + if rowType == "input" and spec.debounce == nil and spec.debounceMilliseconds ~= nil then + spec.debounce = spec.debounceMilliseconds / 1000 + end + spec.debounceMilliseconds = nil + + if rowType == "slider" and spec.formatter == nil and spec.formatValue ~= nil then + spec.formatter = spec.formatValue + end + spec.formatValue = nil + + spec.id = row.id + + return spec +end + +local function validateDeclarativeRow(sourceName, builder, row) + local rowType = row.type + local rowLabel = getRowLabel(row) + local hasHandler = row.get ~= nil or row.set ~= nil + + assertBooleanOrCallback(sourceName, "disabled", row.disabled) + assertBooleanOrCallback(sourceName, "hidden", row.hidden) + + if PROXY_ROW_TYPES[rowType] then + if hasHandler then + assert(row.get, sourceName .. ": handler-mode row '" .. rowLabel .. "' requires get") + assert(row.set, sourceName .. ": handler-mode row '" .. rowLabel .. "' requires set") + assert(row.key or row.id, sourceName .. ": handler-mode row '" .. rowLabel .. "' requires key or id") + else + assert(row.path ~= nil, sourceName .. ": path-bound row '" .. rowLabel .. "' requires path") + assert(builder._adapter, sourceName .. ": path-bound row '" .. rowLabel .. "' requires store/defaults on the builder") + end + end + + if rowType == "button" then + assert(type(row.onClick) == "function", sourceName .. ": button row '" .. rowLabel .. "' requires onClick") + elseif rowType == "canvas" then + assert(row.canvas, sourceName .. ": canvas row '" .. rowLabel .. "' requires canvas") + elseif rowType == "custom" then + assert(row.template, sourceName .. ": custom row '" .. rowLabel .. "' requires template") + elseif rowType == "dropdown" then + assert(row.values ~= nil, sourceName .. ": dropdown row '" .. rowLabel .. "' requires values") + elseif rowType == "list" then + assert(type(row.items) == "function", sourceName .. ": list row '" .. rowLabel .. "' requires items") + assert(row.height, sourceName .. ": list row '" .. rowLabel .. "' requires height") + elseif rowType == "pageActions" then + assert(type(row.actions) == "table", sourceName .. ": pageActions row '" .. rowLabel .. "' requires actions") + elseif rowType == "sectionList" then + assert(type(row.sections) == "function", sourceName .. ": sectionList row '" .. rowLabel .. "' requires sections") + assert(row.height, sourceName .. ": sectionList row '" .. rowLabel .. "' requires height") + elseif rowType == "slider" then + assert(row.min ~= nil, sourceName .. ": slider row '" .. rowLabel .. "' requires min") + assert(row.max ~= nil, sourceName .. ": slider row '" .. rowLabel .. "' requires max") + elseif COMPOSITE_ROW_TYPES[rowType] then + assert(row.path ~= nil, sourceName .. ": composite row '" .. rowLabel .. "' requires path") + if rowType == "checkboxList" or rowType == "colorList" then + assert(type(row.defs) == "table", sourceName .. ": composite row '" .. rowLabel .. "' requires defs") + end + elseif rowType == "info" then + assert( + row.value ~= nil or row.values ~= nil or row.name ~= nil, + sourceName .. ": info row '" .. rowLabel .. "' requires value, values, or name" + ) + elseif rowType == "header" or rowType == "subheader" then + assert(row.name ~= nil, sourceName .. ": " .. rowType .. " row '" .. rowLabel .. "' requires name") + end +end + +local function validateDeclarativeRows(sourceName, builder, rows, seenRowIDs) + assert(type(rows) == "table", sourceName .. ": rows must be a table") + + for _, row in ipairs(rows) do + local normalized = normalizeDeclarativeRowSpec(sourceName, row) + local rowID = normalized.id + if rowID ~= nil then + assert(not seenRowIDs[rowID], sourceName .. ": duplicate row id '" .. tostring(rowID) .. "'") + seenRowIDs[rowID] = true + end + validateDeclarativeRow(sourceName, builder, normalized) + end +end + +local function validatePageDefinition(sourceName, pageDef) + assert(type(pageDef) == "table", sourceName .. ": page definition must be a table") + assert(pageDef.key, sourceName .. ": page definition requires key") + assert(type(pageDef.rows) == "table", sourceName .. ": page definition requires rows") +end + +local function registerLabeledList(page, spec, builderMethod) + local builder = page._builder + if spec.label then + local labelInit = lib.Subheader(builder, { + name = spec.label, + disabled = spec.disabled, + hidden = spec.hidden, + category = page._category, + }) + spec._parentInitializer = spec._parentInitializer or labelInit + end + + local results = builderMethod(builder, resolvePagePath(page.path or "", spec.path), spec.defs or {}, spec) + return results[1] and results[1].initializer, results[1] and results[1].setting +end + +local function registerDeclarativeRow(sourceName, page, row, created) + local spec = normalizeDeclarativeRowSpec(sourceName, row) + local rowType = spec.type + + local builder = page._builder + if page.disabled and spec.disabled == nil then + spec.disabled = page.disabled + end + if page.hidden and spec.hidden == nil then + spec.hidden = page.hidden + end + if spec.category == nil then + spec.category = page._category + end + + spec._page = page + + local initializer, setting + local path = resolvePagePath(page.path or "", spec.path) + if rowType == "button" then + initializer = lib.Button(builder, spec) + elseif rowType == "canvas" then + initializer = lib.EmbedCanvas(builder, spec.canvas, spec.height, spec) + elseif rowType == "checkboxList" then + initializer, setting = registerLabeledList(page, spec, lib.CheckboxList) + elseif rowType == "colorList" then + initializer, setting = registerLabeledList(page, spec, lib.ColorPickerList) + elseif rowType == "header" then + initializer = lib.Header(builder, spec) + elseif rowType == "info" then + initializer = lib.InfoRow(builder, spec) + elseif rowType == "list" then + initializer = lib.List(builder, spec) + elseif rowType == "pageActions" then + initializer = lib.PageActions(builder, spec) + elseif rowType == "sectionList" then + initializer = lib.SectionList(builder, spec) + elseif rowType == "subheader" then + initializer = lib.Subheader(builder, spec) + elseif rowType == "border" then + local result = lib.BorderGroup(builder, path, spec) + initializer, setting = result.enabledInit, result.enabledSetting + elseif rowType == "fontOverride" then + local result = lib.FontOverrideGroup(builder, path, spec) + initializer, setting = result.enabledInit, result.enabledSetting + elseif rowType == "heightOverride" then + initializer, setting = lib.HeightOverrideSlider(builder, path, spec) + elseif PROXY_ROW_TYPES[rowType] then + if not spec.get then + spec.path = path + elseif not spec.key then + spec.key = row.id + end + if spec.get and not spec.key then + error(sourceName .. ": handler-mode row '" .. tostring(row.id or spec.name) .. "' requires key or id") + end + if rowType == "checkbox" then + initializer, setting = lib.Checkbox(builder, spec) + elseif rowType == "slider" then + initializer, setting = lib.Slider(builder, spec) + elseif rowType == "dropdown" then + initializer, setting = lib.Dropdown(builder, spec) + elseif rowType == "color" then + initializer, setting = lib.Color(builder, spec) + elseif rowType == "input" then + initializer, setting = lib.Input(builder, spec) + elseif rowType == "custom" then + initializer, setting = lib.Custom(builder, spec) + end + else + error(sourceName .. ": unknown row type '" .. tostring(rowType) .. "'") + end + + if row.id then + created[row.id] = { initializer = initializer, setting = setting } + end +end + +local function createManagedSubcategory(builder, name, parentCategory) + local previous = builder._currentSubcategory + local category = internal.createSubcategory(builder, name, parentCategory) + builder._currentSubcategory = previous + return category +end + +local function assertRootConfigured(root, sourceName) + assert(root._category, sourceName .. ": builder was created without config.name") +end + +local function sortByOrder(items) + table.sort(items, function(left, right) + local leftOrder = left.order or left._sequence + local rightOrder = right.order or right._sequence + if leftOrder == rightOrder then + return left._sequence < right._sequence + end + return leftOrder < rightOrder + end) + return items +end + +local function assertPageMutable(page, sourceName) + assert(not page._registered, sourceName .. ": page is already registered") + if page._section then + assert(not page._section._registered, sourceName .. ": section is already registered") + end +end + +local function bindPageLifecycle(page) + local confirmDefaults = page._builder._config.defaultsConfirmation + if page._onShow or page._onHide or page._onDefault or confirmDefaults then + lib._pageLifecycleCallbacks[page._category] = { + onShow = page._onShow, + onHide = page._onHide, + onDefault = page._onDefault, + onDefaultEnabled = page._onDefaultEnabled, + confirmDefaults = confirmDefaults, + pageName = page._name, + } + installPageLifecycleHooks() + end +end + +local function queuePageOperation(page, sourceName, fn) + assertPageMutable(page, sourceName) + page._operations[#page._operations + 1] = fn +end + +local function materializePage(page, category) + assert(not page._registered, "materializePage: page is already registered") + page._category = category + bindPageLifecycle(page) + + -- Create the handle before row operations so ctx.page is available in callbacks + -- registered during those operations (e.g. onClick, onSet). + page._handle = { + _category = page._category, + GetId = function(_) + return page._category:GetID() + end, + Refresh = function(_) + refreshCategory(page._builder, page._category) + end, + } + + local created = {} + for _, operation in ipairs(page._operations) do + operation(created) + end + + page._registered = true + return page +end + +local function appendDeclarativeRows(page, sourceName, rows) + validateDeclarativeRows(sourceName, page._builder, rows, page._rowIDs) + queuePageOperation(page, sourceName, function(created) + for _, row in ipairs(rows) do + registerDeclarativeRow(sourceName, page, row, created) + end + end) + return page +end + +local function createPage(owner, key, rows, opts) + assert(key, "CreatePage: key is required") + + opts = opts or {} + local ownerPath = owner.path or "" + local page = { + _builder = owner._builder or owner, + _root = owner._root or owner, + _section = owner._root and owner or nil, + _key = key, + _name = opts.name, + _onShow = opts.onShow, + _onHide = opts.onHide, + _onDefault = opts.onDefault, + _onDefaultEnabled = opts.onDefaultEnabled, + _operations = {}, + _rowIDs = {}, + _registered = false, + _useSectionCategory = opts.useSectionCategory == true, + disabled = opts.disabled, + hidden = opts.hidden, + key = key, + name = opts.name, + order = opts.order, + path = opts.path ~= nil and opts.path or ownerPath, + } + + if rows then + appendDeclarativeRows(page, "CreatePage", rows) + end + + return page +end + +local function createSectionPage(section, key, rows, opts) + assert(not section._registered, "createSectionPage: section is already registered") + assert(key, "createSectionPage: key is required") + assert(not section._pages[key], "createSectionPage: duplicate page key '" .. tostring(key) .. "'") + + section._nextPageSequence = section._nextPageSequence + 1 + local page = createPage(section, key, rows, opts) + page._sequence = section._nextPageSequence + section._pages[key] = page + section._pageList[#section._pageList + 1] = page + return page +end + +local function registerRootPage(root, page) + assert(not page._section, "registerRootPage: only root-owned pages can be registered directly") + assert(not page._registered, "registerRootPage: page is already registered") + assert( + not root._registeredRootPage or root._registeredRootPage == page, + "registerRootPage: root already has a registered page" + ) + root._registeredRootPage = page + materializePage(page, root._category) + return page +end + +local function registerSection(section) + assert(not section._registered, "registerSection: section is already registered") + assert(#section._pageList > 0, "registerSection: section must contain at least one page") + + local builder = section._builder + local nested = #section._pageList > 1 + local orderedPages = {} + local sectionCategoryPage + for i = 1, #section._pageList do + orderedPages[i] = section._pageList[i] + end + sortByOrder(orderedPages) + + if nested then + for _, page in ipairs(orderedPages) do + if page._useSectionCategory then + assert(not sectionCategoryPage, "registerSection: only one nested page can use the section category") + sectionCategoryPage = page + end + end + end + + if nested then + section._category = createManagedSubcategory(builder, section.name, section._root._category) + end + + for _, page in ipairs(orderedPages) do + if nested then + assert(page.name and page.name ~= "", "registerSection: nested pages require spec.name") + if page == sectionCategoryPage then + materializePage(page, section._category) + else + materializePage(page, createManagedSubcategory(builder, page.name, section._category)) + end + else + materializePage(page, createManagedSubcategory(builder, section.name, section._root._category)) + end + end + + section._registered = true + return section +end + +local function createSection(root, key, name, opts) + assert(key, "createSection: key is required") + assert(name, "createSection: name is required") + assert(not root._sections[key], "createSection: duplicate section key '" .. tostring(key) .. "'") + + opts = opts or {} + root._nextSectionSequence = root._nextSectionSequence + 1 + local section = { + _builder = root, + _root = root, + _pages = {}, + _pageList = {}, + _nextPageSequence = 0, + _registered = false, + _sequence = root._nextSectionSequence, + key = key, + name = name, + order = opts.order, + path = opts.path ~= nil and opts.path or key, + } + + root._sections[key] = section + root._sectionList[#root._sectionList + 1] = section + return section +end + +local function createRootPage(root, key, rows, opts) + assert(key, "createRootPage: key is required") + assert(not root._pages[key], "createRootPage: duplicate root page key '" .. tostring(key) .. "'") + + local page = createPage(root, key, rows, opts) + page._sequence = root._nextRootPageSequence + 1 + root._nextRootPageSequence = page._sequence + root._pages[key] = page + root._pageList[#root._pageList + 1] = page + return page +end + +--- Gets the registered section metadata by key. +---@param key string +---@return LibSettingsBuilderSectionHandle|nil section +function lib:GetSection(key) + return self._sections[key] +end + +--- Gets the registered root page handle. +---@return LibSettingsBuilderPageHandle|nil page +function lib:GetRootPage() + local page = self._registeredRootPage + return page and page._handle or nil +end + +--- Gets the registered section page handle by section and page key. +---@param sectionKey string +---@param pageKey string +---@return LibSettingsBuilderPageHandle|nil page +function lib:GetPage(sectionKey, pageKey) + if pageKey == nil then + return nil + end + + local section = self._sections[sectionKey] + local page = section and section._pages[pageKey] or nil + return page and page._handle or nil +end + +--- Gets whether this runtime owns the supplied Blizzard Settings category. +---@param category table|nil +---@return boolean owned +function lib:HasCategory(category) + return category ~= nil and self._layouts[category] ~= nil +end + +local function registerPageDefinition(owner, pageDef, defaultName) + validatePageDefinition("registerPageDefinition", pageDef) + + local creator = owner._root and createSectionPage or createRootPage + return creator(owner, pageDef.key, pageDef.rows, { + name = pageDef.name or defaultName, + onShow = pageDef.onShow, + onHide = pageDef.onHide, + onDefault = pageDef.onDefault, + onDefaultEnabled = pageDef.onDefaultEnabled, + disabled = pageDef.disabled, + hidden = pageDef.hidden, + order = pageDef.order, + path = pageDef.path, + useSectionCategory = pageDef.useSectionCategory or (owner._root ~= nil and pageDef.name == nil), + }) +end + +function internal.registerTree(self, spec) + assertRootConfigured(self, "Register") + assert(type(spec) == "table", "Register: spec must be a table") + assert(spec.page or spec.sections, "Register: spec requires page or sections") + + if spec.page then + registerRootPage(self, registerPageDefinition(self, spec.page, self.name)) + end + + for _, sectionDef in ipairs(spec.sections or {}) do + assert(type(sectionDef) == "table", "Register: each section definition must be a table") + assert(sectionDef.key, "Register: each section requires a key") + assert(sectionDef.name, "Register: each section requires a name") + + local section = createSection(self, sectionDef.key, sectionDef.name, { + order = sectionDef.order, + path = sectionDef.path, + }) + + assert(type(sectionDef.pages) == "table", "Register: each section requires a pages array") + for _, pageDef in ipairs(sectionDef.pages) do + registerPageDefinition(section, pageDef, sectionDef.name) + end + + registerSection(section) + end + + return self +end + +function internal.initializeRoot(self, name) + if not self._rootCategory then + assert(name, "_initializeRoot: name is required") + internal.createRootCategory(self, name) + elseif name and self._rootCategoryName ~= name then + error("_initializeRoot: root already exists with name '" .. tostring(self._rootCategoryName) .. "'") + end + + if not self._rootRegistered and self._rootCategory then + Settings.RegisterAddOnCategory(self._rootCategory) + self._rootRegistered = true + end + + self._category = self._rootCategory + self.name = self._rootCategoryName + return self +end + +lib._publicApi = { + GetSection = lib.GetSection, + GetRootPage = lib.GetRootPage, + GetPage = lib.GetPage, + HasCategory = lib.HasCategory, + _registerTree = internal.registerTree, + _initializeRoot = internal.initializeRoot, +} + +lib._loadState.open = nil diff --git a/Libs/LibSettingsBuilder/docs/API_REFERENCE.md b/Libs/LibSettingsBuilder/docs/API_REFERENCE.md index c3d12c8c..6fbbbdde 100644 --- a/Libs/LibSettingsBuilder/docs/API_REFERENCE.md +++ b/Libs/LibSettingsBuilder/docs/API_REFERENCE.md @@ -8,79 +8,146 @@ - [Migration Guide](MIGRATION_GUIDE.md) - [Troubleshooting](TROUBLESHOOTING.md) +## Current public surface + +`LibSettingsBuilder` is centered on declarative registration through `LSB.New({ ... })`. + +Documented surface: + +- `LSB.New(config)` +- `lsb:GetSection(sectionKey)` +- `lsb:GetRootPage()` +- `lsb:GetPage(sectionKey, pageKey)` +- `lsb:HasCategory(category)` +- `page:GetId()` +- `page:Refresh()` +- `config.page` and `config.sections` +- raw row tables in `rows = { ... }` + +The runtime returned by `LSB.New(...)` is intentionally narrow. Row helper constructors are not available on `lsb` instances, and deprecated transition namespaces like `LSBDeprecated` are not part of the documented public API. + +The declarative loader still normalizes a small compatibility subset of older field names: + +- `button.value` → `buttonText` +- `slider.formatValue` → `formatter` +- `dropdown.maxScrollDisplayHeight` → `scrollHeight` +- `input.debounceMilliseconds` → `debounce` (seconds) +- `info.values` → newline-joined `value` plus `multiline = true` + +Removed fields such as `desc`, `condition`, `parent`, and `parentCheck` error at registration time. + ## Factory -### `LSB:New(config)` +### `LSB.New(config)` Required fields: -- `varPrefix` -- `onChanged(spec, value)` +- `onChanged(ctx, value)` + +Conditionally required fields: + +- `name` — required when registering `page` or `sections` Optional fields: -- `pathAdapter` -- `compositeDefaults` +- `store` — table or function returning the live store used by path-bound rows +- `defaults` — table or function returning default values for path-bound rows +- `getNestedValue` +- `setNestedValue` +- `page` +- `sections` + +Returns an `lsb` runtime instance bound to one category tree. + +## Registration tree -Returns a builder instance referred to as `SB` in the examples below. +`LSB.New(config)` accepts and registers the full declarative tree. -## Path adapters +Supported fields: -### `LSB.PathAdapter(config)` +- `config.page` — optional root-owned landing page definition +- `config.sections` — optional array of section definitions -Required: +Root page definition fields: -- `getStore()` -- `getDefaults()` +- `key` +- `rows` +- `name` (optional; defaults to the root name) +- `onShow` +- `onHide` +- `disabled` +- `hidden` +- `order` +- `path` -Optional: +Section definition fields: + +- `key` +- `name` +- `path` (defaults to `key`) +- `order` +- `pages` + +Page definition fields inside `pages`: + +- `key` +- `name` (optional; in a multi-page section, omitting it makes that page use the visible section category) +- `path` +- `rows` +- `onShow` +- `onHide` +- `disabled` +- `hidden` +- `order` +- `useSectionCategory` (optional explicit form of the omitted-`name` multi-page behavior) -- `getNestedValue(tbl, path)` -- `setNestedValue(tbl, path, value)` +Notes: -Methods: +- single-page sections flatten to a single leaf by default, +- multi-page sections create a visible section node automatically, +- one multi-page section page can live directly on that section node; named pages are registered below it. +- page `path` prefixes child `path` fields that do not already contain dots, +- page-level `disabled` and `hidden` values propagate to child rows unless a row overrides them. -- `adapter:resolve(path)` → `{ get, set, default }` -- `adapter:read(path)` → current value +Declarative root registration is the only supported page-construction API. -The built-in path helpers support numeric segments like `colors.0`. +### Lookup and page operations -## Category helpers +- `lsb:GetSection(key)` — registered section metadata or `nil` +- `lsb:GetRootPage()` — root page handle or `nil` +- `lsb:GetPage(sectionKey, pageKey)` — section page handle or `nil` +- `lsb:HasCategory(category)` — whether the category belongs to this runtime +- `page:GetId()` — Blizzard Settings category ID +- `page:Refresh()` — refreshes visible rows and registered dynamic content -- `SB.CreateRootCategory(name)` -- `SB.CreateSubcategory(name)` -- `SB.CreateCanvasSubcategory(frame, name[, parentCategory])` -- `SB.CreateCanvasLayout(name[, parentCategory])` -- `SB.RegisterCategories()` -- `SB.GetRootCategoryID()` -- `SB.GetSubcategoryID(name)` +Page handles are plain runtime lookup objects, not mutable builders. -## Controls +## Declarative rows -All controls support either: +Persisted rows support either: - **path mode** with `spec.path`, or -- **handler mode** with `spec.get`, `spec.set`, and `spec.key`. +- **handler mode** with `spec.get`, `spec.set`, and `spec.key` or `spec.id`. Common spec fields: +- `id` - `name` - `tooltip` - `default` -- `category` - `disabled` - `hidden` -- `parent` -- `parentCheck` - `getTransform` - `setTransform` - `onSet` -### `SB.Checkbox(spec)` +Use `tooltip`, not `desc`. + +### `checkbox` row Creates a boolean checkbox. -### `SB.Slider(spec)` +### `slider` row Additional fields: @@ -91,122 +158,323 @@ Additional fields: Slider values are editable inline through the displayed value label. -### `SB.Dropdown(spec)` +### `dropdown` row Additional fields: - `values` - `scrollHeight` +- `varType` Dropdown values are emitted in deterministic order to keep menus stable between sessions. -### `SB.Color(spec)` +`maxScrollDisplayHeight` is still normalized to `scrollHeight` for compatibility. + +### `color` row Reads and writes `{ r, g, b, a }` tables through a hex proxy value. -### `SB.Custom(spec)` +### `input` row + +Creates a text input row using the standard settings-row layout. + +Additional fields: + +- `numeric` +- `maxLetters` +- `width` +- `debounce` +- `resolveText(value, setting, frame)` +- `onTextChanged(text, setting, frame)` + +Notes: + +- the edit box writes through the same proxy-setting pipeline as the other built-in controls, +- `resolveText` enables an optional preview line below the edit box, +- `debounce` delays preview recomputation through `C_Timer.NewTimer`. + +`debounceMilliseconds` is still normalized to `debounce / 1000` for compatibility. + +### `custom` row Additional fields: - `template` - `varType` -### `SB.Control(spec)` +Notes: + +- use this for XML-backed widgets that are not covered by the built-in controls, +- the template must already be loaded by the time you register settings, +- unlike `input`, `custom` does not create its frame structure in Lua. + +### `button` row + +Additional fields: + +- `buttonText` +- `confirm` +- `onClick` + +Notes: + +- `onClick` is required, +- `confirm = true` uses the default `"Are you sure?"` prompt, +- `confirm = "..."` uses your custom confirm text, +- `value` is still normalized to `buttonText` for compatibility. + +### `header` row + +Use for a Blizzard-style section header. + +Required fields: + +- `name` + +Notes: + +- page header buttons belong in a separate `pageActions` row, not on the `header` row. + +### `subheader` row + +Use for smaller secondary section text. + +Required fields: + +- `name` + +### `info` row + +Displays left-label / right-value informational text. + +Additional fields: + +- `value` +- `values` +- `wide` +- `multiline` +- `height` + +Notes: + +- `value` may be a static value or a function, +- `name` may also be a function for dynamic labels, +- `wide = true` hides the left label and lets the value span the row, +- `values = { ... }` is normalized to a newline-joined `value` and sets `multiline = true`. + +### `canvas` row + +Embeds a prebuilt frame into the settings page. + +Additional fields: + +- `canvas` +- `height` + +Notes: + +- `canvas` is required, +- `height` defaults to `canvas:GetHeight()`. + +### `pageActions` row + +Renders right-aligned page-header action buttons. + +Additional fields: + +- `actions` +- `height` + +Action fields: + +- `name` +- `text` +- `width` +- `height` +- `enabled` +- `hidden` +- `tooltip` +- `onClick` + +Notes: + +- `actions` is required, +- `enabled`, `hidden`, and `tooltip` may be static values or functions evaluated during refreshes. + +### `list` row + +Creates a first-class dynamic flat list row backed by the normal settings list. + +Required fields: + +- `height` +- `items(frame)` + +Flat-list fields: + +- `variant = "swatch"` or `variant = "editor"` +- `rowHeight` +- `insetLeft` +- `insetTop` +- `insetBottom` + +Notes: + +- the row's `variant` becomes the default preset for returned items, +- `swatch` rows support label/icon/swatch style entries, +- `editor` rows support label + slider field(s), optional swatch, and a remove button. + +### `sectionList` row + +Creates a first-class grouped dynamic list row backed by the normal settings list. + +Required fields: + +- `height` +- `sections(frame)` → section list + +Section-level fields commonly used by the built-in renderer: + +- `key` +- `name` +- `title` +- `items` +- `emptyText` +- `headerHeight` +- `emptyHeight` +- `rowHeight` +- `footer` +- `footerHeight` +- `spacingAfter` + +Supported list variants: + +- `swatch` — label/icon plus color swatch rows +- `editor` — label plus one or more slider fields, optional swatch, and remove button +- section items use the built-in action-row layout (`up`, `down`, `move`, `delete`) +- section action buttons may use text, `iconTexture`, or `buttonTextures = { normal, pushed?, disabled?, highlight?, highlightAlpha?, disabledAlpha? }` +- section trailers support `type = "modeInput"` for toggle + input + preview + submit rows + Mode-input trailer display fields may be static values or functions that are re-evaluated during in-place row refreshes. + +## Composite row types + +These row kinds expand into multiple child rows during registration. + +### `heightOverride` + +Fields: + +- `path` +- `name` +- `tooltip` +- `min` +- `max` +- `step` + +Notes: + +- stores `nil` when the slider is set to `0`, +- reads `nil` back as `0`. + +### `fontOverride` + +Fields: -Dispatches to the correct control factory using `spec.type`. +- `path` +- `enabledName` +- `enabledTooltip` +- `fontName` +- `fontTooltip` +- `fontValues` +- `fontFallback` +- `fontTemplate` +- `sizeName` +- `sizeTooltip` +- `sizeMin` +- `sizeMax` +- `sizeStep` +- `fontSizeFallback` -## Composite builders +Notes: -- `SB.HeightOverrideSlider(sectionPath[, spec])` -- `SB.FontOverrideGroup(sectionPath[, spec])` -- `SB.BorderGroup(borderPath[, spec])` -- `SB.ColorPickerList(basePath, defs[, spec])` -- `SB.CheckboxList(basePath, defs[, spec])` -- `SB.PositioningGroup(configPath, spec)` +- expands to an override checkbox, a font selector, and a size slider, +- when `fontTemplate` is present, the font selector uses `type = "custom"` instead of the built-in dropdown. -## Utility helpers +### `border` -- `SB.Header(text[, category])` -- `SB.Subheader(spec)` -- `SB.InfoRow(spec)` -- `SB.EmbedCanvas(canvas, height[, spec])` -- `SB.Button(spec)` -- `SB.RegisterSection(nsTable, key, section)` +Fields: -`SB.Button` supports `confirm = true` or a custom confirm string. Confirm dialogs are registered per button to avoid cross-button collisions. +- `path` +- `enabledName` +- `enabledTooltip` +- `thicknessName` +- `thicknessTooltip` +- `thicknessMin` +- `thicknessMax` +- `thicknessStep` +- `colorName` +- `colorTooltip` -## Table-driven registration +Notes: -### `SB.RegisterFromTable(tbl)` +- expands to an enable checkbox, a width slider, and a color swatch. -Supported standard types: +### `colorList` / `checkboxList` -- `checkbox` / `toggle` -- `slider` / `range` -- `dropdown` / `select` +Required fields: + +- `defs` + +Fields: + +- `path` +- `defs = { { key, name, tooltip? }, ... }` +- `label` + +Notes: + +- `defs` is required for both composite row types, +- `label`, when present, inserts a subheader above the generated child rows. + +## Declarative page rows + +Supported canonical row types: + +- `checkbox` +- `slider` +- `dropdown` +- `input` - `color` - `custom` -- `button` / `execute` +- `button` - `header` -- `subheader` / `description` +- `subheader` - `info` - `canvas` +- `pageActions` +- `list` +- `sectionList` Supported composite types: -- `positioning` - `border` - `fontOverride` - `heightOverride` - `colorList` -- `toggleList` - -## Canvas layout helpers - -`CreateCanvasLayout` returns a layout object with these methods: - -- `AddHeader(text)` -- `AddSpacer(height)` -- `AddDescription(text[, fontObject])` -- `AddColorSwatch(label)` -- `AddSlider(label, min, max[, step])` -- `AddButton(label, buttonText)` -- `AddScrollList(elementExtent)` +- `checkboxList` -### Canvas layout configuration +Declarative pages are normally supplied through `LSB.New({ page = ..., sections = { ... } })`, either as a root page definition or through section `rows` / `pages` definitions. -Library defaults live on `LSB.CanvasLayoutDefaults` and can be adjusted globally or per layout. +## Implementation model -#### `SB.SetCanvasLayoutDefaults(overrides)` +The library has three main families of row builders: -Merges overrides into the shared defaults table. +- **proxy controls** — persisted values backed by `Settings.RegisterProxySetting` (`checkbox`, `slider`, `dropdown`, `color`, `input`, `custom`), +- **layout rows** — structural/display rows without stored values (`header`, `subheader`, `info`, `button`, `canvas`, `pageActions`), +- **composites** — helpers that emit multiple child rows (`border`, `fontOverride`, `heightOverride`, `colorList`, `checkboxList`). -#### `SB.ConfigureCanvasLayout(layout, overrides)` - -Clones the shared defaults and applies overrides only to the supplied layout. - -Useful fields include: - -- `elementHeight` -- `headerHeight` -- `labelX` -- `controlCenterX` -- `buttonCenterX` -- `buttonWidth` -- `sliderWidth` -- `swatchCenterX` -- `verifiedPatch` - -Example: - -```lua -local layout = SB.CreateCanvasLayout("Spell Colors") -SB.ConfigureCanvasLayout(layout, { - elementHeight = 30, - labelX = 42, - buttonWidth = 220, -}) -``` +`input` is implemented as a built-in custom list row on `SettingsListElementTemplate`. It creates an `InputBoxTemplate` edit box at runtime, subscribes to watched proxy settings through callback handles, and optionally debounces preview refreshes. That gives it built-in-row behavior without requiring a separate XML template. +`canvas` rows stay on the current lifecycle path. The documented public canvas API is the `canvas` row type; older canvas-layout helpers live under internal implementation details and are not part of the public surface documented here. ## Debugging diff --git a/Libs/LibSettingsBuilder/docs/INSTALLATION.md b/Libs/LibSettingsBuilder/docs/INSTALLATION.md index 01fb23c2..afcc0a12 100644 --- a/Libs/LibSettingsBuilder/docs/INSTALLATION.md +++ b/Libs/LibSettingsBuilder/docs/INSTALLATION.md @@ -4,7 +4,7 @@ - [README](../README.md) — overview and quick links - [Quick Start](QUICK_START.md) — common setup patterns -- [API Reference](API_REFERENCE.md) — builder, controls, composites, canvas helpers +- [API Reference](API_REFERENCE.md) — public surface, row types, page registration, compatibility notes - [Migration Guide](MIGRATION_GUIDE.md) — moving from AceConfig/AceGUI - [Troubleshooting](TROUBLESHOOTING.md) — common issues and fixes @@ -21,14 +21,15 @@ Include the library in your addon's TOC or load-on-demand manifest. ```toc Libs\LibStub\LibStub.lua -Libs\LibSettingsBuilder\LibSettingsBuilder.lua +Libs\LibSettingsBuilder\embed.xml ``` Recommended pattern for addon authors: 1. Ship the library inside your addon's `Libs/` folder. 2. Load `LibStub` before `LibSettingsBuilder`. -3. Treat the library as embedded, not as a standalone dependency players must install separately. +3. Load `Libs\LibSettingsBuilder\embed.xml` so the library's internal source files keep their required order. +4. Treat the library as embedded, not as a standalone dependency players must install separately. ## Versioning notes @@ -48,13 +49,30 @@ The library integrates with Blizzard's Settings UI and installs a few global hoo - scrollable dropdowns, - clickable slider value editing. +When you use `input` rows with `debounce` / `resolveText`, the library also uses callback handles and `C_Timer.NewTimer` to keep previews in sync. + Those hooks are part of the library's behavior and should be considered when debugging conflicts with heavily customized Settings UI code. -## Canvas layout compatibility +## Built-in controls vs custom templates + +Most library features are available with no extra XML: + +- proxy controls like `checkbox`, `slider`, `dropdown`, `color`, and `input`, +- layout rows like `header`, `subheader`, `info`, `button`, `pageActions`, and `canvas`, +- composite builders like `border`, `fontOverride`, and `heightOverride`. + +`input` is a built-in row type implemented entirely in Lua on top of `SettingsListElementTemplate` plus a runtime-created `InputBoxTemplate` edit box. + +Only `type = "custom"` rows require you to supply your own template. In that case: + +1. define the template in XML, +2. load that XML from your TOC before calling `LSB.New({ ... })`, and +3. pass the template name through `spec.template`. + +## Canvas support -Canvas layout spacing defaults are modeled after Blizzard's retail Settings panel measurements and can be adjusted when needed: +The documented public API for embedded custom content is the `canvas` row type. -- per-library via `SB.SetCanvasLayoutDefaults(overrides)` -- per-layout via `SB.ConfigureCanvasLayout(layout, overrides)` +Build the frame yourself, then register it through `type = "canvas"` with an explicit `height` when needed. -See [API Reference](API_REFERENCE.md) for examples. +Older canvas-layout helpers live under internal implementation details and are not part of the documented library surface. diff --git a/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md b/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md index 3858f132..9b1dd612 100644 --- a/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md +++ b/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md @@ -23,53 +23,82 @@ | AceConfig stack | LibSettingsBuilder | |---|---| -| `RegisterOptionsTable` | `SB.RegisterFromTable` | -| `AddToBlizOptions` | `SB.RegisterCategories()` | +| `RegisterOptionsTable` | export declarative root/page/section specs | +| `AddToBlizOptions` | `LSB.New({ name = "My Addon", page = ..., sections = ... })` | | one `get`/`set` per field | one `path` per field in path mode | +| `type = "input"` | `type = "input"` | | custom refresh dance | reactive modifiers re-evaluate automatically | ## Path mode replaces repeated getters and setters ```lua -local SB = LSB:New({ - pathAdapter = LSB.PathAdapter({ - getStore = function() - return db.profile - end, - getDefaults = function() - return db.defaults.profile - end, - }), - varPrefix = "MYADDON", +local lsb = LSB.New({ + name = "My Addon", + store = db.profile, + defaults = db.defaults.profile, onChanged = function() MyAddon:Refresh() end, }) ``` -## Useful aliases +## Field name updates -`RegisterFromTable` accepts several AceConfig-style aliases: +When moving old option tables over: -- `toggle` → `checkbox` -- `range` → `slider` -- `select` → `dropdown` -- `execute` → `button` -- `description` → `subheader` -- `desc` → `tooltip` +- use `tooltip`, not AceConfig's `desc`, +- replace removed fields like `condition`, `parent`, and `parentCheck` with `disabled` / `hidden` predicates on rows or pages, +- prefer the current field names `formatter`, `scrollHeight`, and `debounce` even though the declarative loader still normalizes a few older aliases for compatibility. + +## Canonical row types + +Declarative pages use canonical row types only: + +- `checkbox` +- `slider` +- `dropdown` +- `input` +- `color` +- `custom` +- `button` +- `header` +- `subheader` +- `info` +- `canvas` +- `pageActions` +- `list` +- `sectionList` ## Features you gain - native Blizzard Settings integration, - composite builders for common UI groups, -- canvas layout helpers for complex pages, +- first-class dynamic list rows for complex editors, +- built-in text input rows with optional debounced previews, +- page-owned refresh hooks for async/transient state, - clickable slider value editing, - deterministic dropdown ordering. ## Features you still build yourself -- custom input widgets, - specialized row templates, -- bespoke canvas pages. +- genuinely bespoke embedded frames. + +If you only need text or numeric entry, use the built-in `input` type first. Reach for `type = "custom"` only when you need a genuinely different widget. + +If you need an ordered list, grouped editor, or add/remove workflow, prefer `type = "list"` or `type = "sectionList"` before reaching for `type = "custom"` or `type = "canvas"`. + +## Migrating AceConfig input fields + +Simple AceConfig `input` fields usually map directly: + +```lua +search = { + type = "input", + path = "searchText", + name = "Search", + order = 10, +} +``` -Use `SB.Custom(...)` or `CreateCanvasLayout(...)` when the standard controls stop fitting. +If your old AceConfig input also computed helper text or validity hints, move that into `resolveText(...)` and optionally add `debounce` to avoid recomputing on every keystroke. diff --git a/Libs/LibSettingsBuilder/docs/QUICK_START.md b/Libs/LibSettingsBuilder/docs/QUICK_START.md index d22e0fc1..8fb53537 100644 --- a/Libs/LibSettingsBuilder/docs/QUICK_START.md +++ b/Libs/LibSettingsBuilder/docs/QUICK_START.md @@ -10,113 +10,126 @@ ## Choose a setup style -- Use **table-driven registration** if you want the shortest path to a normal settings page. -- Use the **imperative API** if you want precise control over layout and call order. +- Use **declarative root registration** for standard settings pages. - Use **handler mode** if your settings are not stored in a dot-path table. +- Use `input` rows when you need text or numeric entry without building a custom template. +- Use `list` or `sectionList` rows when you need ordered lists, grouped editors, or add/remove workflows without dropping into a bespoke frame API. -## Table-driven setup +## Declarative setup ```lua local LSB = LibStub("LibSettingsBuilder-1.0") -local SB = LSB:New({ - pathAdapter = LSB.PathAdapter({ - getStore = function() - return MyAddonDB.profile - end, - getDefaults = function() - return MyAddonDefaults.profile - end, - }), - varPrefix = "MYADDON", - onChanged = function() +local lsb = LSB.New({ + name = "My Addon", + store = MyAddonDB.profile, + defaults = MyAddonDefaults.profile, + onChanged = function(ctx) MyAddon:Refresh() end, -}) - -SB.CreateRootCategory("My Addon") - -SB.RegisterFromTable({ - name = "General", - path = "general", - args = { - enabled = { - type = "toggle", - path = "enabled", - name = "Enable", - desc = "Enable or disable the addon.", - order = 1, - }, - opacity = { - type = "range", - path = "opacity", - name = "Opacity", - min = 0, - max = 100, - step = 1, - order = 2, + sections = { + { + key = "general", + name = "General", + path = "general", + pages = { + { + key = "main", + rows = { + { + type = "checkbox", + path = "enabled", + name = "Enable", + tooltip = "Enable or disable the addon.", + }, + { + type = "slider", + path = "opacity", + name = "Opacity", + min = 0, + max = 100, + step = 1, + }, + { + type = "input", + path = "spellIdText", + name = "Spell ID", + numeric = true, + maxLetters = 10, + debounce = 1, + resolveText = function(value) + local id = tonumber(value) + return id and C_Spell.GetSpellName(id) or nil + end, + }, + }, + }, + }, }, }, }) - -SB.RegisterCategories() ``` -## Imperative setup +`name` and `onChanged` are required when you register a root page or section tree. `store` enables path mode; use handler mode when your values do not live in a dot-path table. -```lua -SB.CreateRootCategory("My Addon") -SB.CreateSubcategory("General") - -SB.Checkbox({ - path = "general.enabled", - name = "Enable", - tooltip = "Enable or disable the addon.", -}) - -SB.Slider({ - path = "general.opacity", - name = "Opacity", - min = 0, - max = 100, - step = 1, -}) - -SB.RegisterCategories() -``` +Declarative pages can mix persisted controls and layout-only rows freely, so it is normal to combine `checkbox`, `slider`, `input`, `header`, `subheader`, `info`, `button`, `pageActions`, `list`, `sectionList`, and `canvas` entries on one page. ## Handler mode ```lua -local SB = LSB:New({ - varPrefix = "MYADDON", - onChanged = function() +local lsb = LSB.New({ + name = "My Addon", + onChanged = function(ctx) MyAddon:ApplySettings() end, + sections = { + { + key = "general", + name = "General", + pages = { + { + key = "main", + rows = { + { + type = "checkbox", + get = function() + return MyStore.enabled + end, + set = function(value) + MyStore.enabled = value + end, + key = "enabled", + default = true, + name = "Enable", + }, + { + type = "input", + get = function() + return MyStore.searchText or "" + end, + set = function(value) + MyStore.searchText = value + end, + key = "searchText", + default = "", + name = "Search", + }, + }, + }, + }, + }, + }, }) - -SB.CreateRootCategory("My Addon") -SB.CreateSubcategory("General") - -SB.Checkbox({ - get = function() - return MyStore.enabled - end, - set = function(value) - MyStore.enabled = value - end, - key = "enabled", - default = true, - name = "Enable", -}) - -SB.RegisterCategories() ``` +Handler rows require `get`, `set`, and a stable `key` (or `id`). + ## Good defaults for public addons -- Keep `varPrefix` short and unique. -- Point `getStore()` and `getDefaults()` at live tables. +- Pick a stable `name`; the library derives its internal variable prefix from that. +- Point `store` and `defaults` at live tables. - Keep `onChanged` fast; use it to refresh UI, not rebuild the world. -- Use composites for repeated patterns like borders, font overrides, and positioning. -- Prefer table-driven registration for large standard settings pages. +- Use composites for repeated patterns like borders, font overrides, and height overrides. +- Prefer declarative root registration for large standard settings pages. +- Look up registered page handles with `lsb:GetRootPage()` or `lsb:GetPage(...)`, then call `page:Refresh()` for async or transient redraws. +- Reach for `type = "custom"` or `type = "canvas"` only when built-ins like `input`, `list`, and `sectionList` stop fitting. diff --git a/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md b/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md index 5f980968..4a09e3df 100644 --- a/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md +++ b/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md @@ -10,27 +10,37 @@ ## Controls do not save values -Check the `PathAdapter` first. +Check the path binding config first. -- `getStore()` must return the live writable table. -- `getDefaults()` should return the matching defaults table. +- `store` must point at the live writable table. +- `defaults` should point at the matching defaults table. - In handler mode, verify both `get` and `set` are present. ## Path mode errors immediately Common causes: -- you created the builder without `pathAdapter`, +- you created `LSB.New({ ... })` with `page` or `sections` but no `name`, +- you created the builder without `store` / `defaults`, - a spec mixes `path` with `get` / `set`, -- handler mode is missing `key`. +- handler mode is missing `key` or `id`. + +## Registration fails with deprecated or removed field errors + +Common fixes: + +- rename `desc` to `tooltip`, +- replace removed fields like `condition`, `parent`, and `parentCheck` with `disabled` / `hidden`, +- use `pageActions` for page-header buttons instead of attaching actions to a `header` row. ## Settings page exists but nothing appears in-game Usually one of these: -- you forgot `SB.RegisterCategories()`, -- you created a subcategory but never added controls to it, -- a `hidden` predicate is always returning `true`. +- you created `LSB.New({ name = "My Addon", ... })` without a `page` or `sections` tree, +- your registered root page or section page ended up with no visible rows, +- a `hidden` predicate is always returning `true`, +- a `custom` template was never loaded from XML. ## A child control is always disabled or hidden @@ -38,10 +48,18 @@ Check modifier predicates: - `disabled = function() ... end` - `hidden = function() ... end` -- `parent` + `parentCheck` Remember these are reactive and will be re-evaluated after setting changes. +## Input preview does not refresh + +Check these pieces: + +- `resolveText(...)` must return a string or `nil`, +- `debounce` delays preview updates intentionally, + +If you just need raw text entry with no secondary preview, omit `resolveText` entirely. + ## Dropdown options look wrong `values` can be a table or a function returning a table. @@ -54,6 +72,19 @@ Recommendations: Dropdown entries are ordered deterministically by label, then by value, to avoid random menu ordering between sessions. +## Custom template control never initializes + +Built-in rows like `checkbox`, `slider`, `dropdown`, `color`, and `input` do not need extra XML. + +`custom` controls do. + +If a custom control appears blank or never receives its initializer data: + +- verify the XML file defining the template is loaded from your TOC, +- verify the template name passed in `spec.template` matches the XML definition, +- verify the template inherits the correct Blizzard settings row template for your widget, +- verify no addon is replacing the Settings initialization pipeline. + ## Slider value editing does not behave as expected The library adds inline numeric editing to slider value labels. @@ -64,24 +95,13 @@ If debugging slider behavior: - confirm no other addon is replacing the slider frame structure, - test with other UI customizers disabled. -## Canvas pages look slightly off after a WoW patch - -Canvas layout spacing is configurable. - -Use: - -```lua -SB.SetCanvasLayoutDefaults({ elementHeight = 28 }) -``` - -or per layout: +## Embedded canvas rows look off -```lua -local layout = SB.CreateCanvasLayout("My Page") -SB.ConfigureCanvasLayout(layout, { labelX = 40 }) -``` +`type = "canvas"` embeds the frame you provide. If spacing or clipping looks wrong: -If Blizzard adjusts Settings panel spacing in a major patch, this is the intended escape hatch. +- give the row an explicit `height`, or make sure the frame reports a stable height, +- prefer built-in rows, `list`, or `sectionList` when you want Blizzard-style settings layout instead of a bespoke frame, +- use `type = "custom"` for XML-backed row widgets rather than a full embedded canvas when you only need one custom control. ## Debugging spec mistakes diff --git a/Libs/LibSettingsBuilder/embed.xml b/Libs/LibSettingsBuilder/embed.xml new file mode 100644 index 00000000..665eda0d --- /dev/null +++ b/Libs/LibSettingsBuilder/embed.xml @@ -0,0 +1,13 @@ + +