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..69849f71 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,25 +1,41 @@ name: Package Release +run-name: Package Release ${{ inputs.version }} on: workflow_dispatch: - push: - tags: - - "v*" + inputs: + version: + description: Release version from EnhancedCooldownManager.toc. + required: true + 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,223 @@ 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 - - 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 + 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 + + INPUT_VERSION=$(printf '%s' "$INPUT_VERSION" | tr -d '[:space:]') + if [ "$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 - - name: Run Busted tests with LuaCov (JUnit output) + 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: | - 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 + 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: Publish Test Results - uses: EnricoMi/publish-unit-test-result-action@v2.23.0 - 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 + - name: Create local release tag + env: + RELEASE_NOTES: ${{ inputs.release_notes }} + VERSION: ${{ steps.release.outputs.version }} run: | set -euo pipefail - VERSION="${{ github.ref_name }}" - TOC_VERSION=$(grep -oP '## Version: \K.*' "${ADDON_NAME}.toc" | tr -d '[:space:]') + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - echo "Tag version: $VERSION" - echo "TOC version: $TOC_VERSION" + 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 - 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." + 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: Prepare packager release notes + - name: Package release artifact + uses: BigWigsMods/packager@v2 + with: + 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 - TAG_NAME="${{ github.ref_name }}" - TAG_MESSAGE=$(git for-each-ref --format='%(contents)' "refs/tags/$TAG_NAME") - - if [ -f .pkgmeta ]; then - cp .pkgmeta .github/release.pkgmeta - else - : > .github/release.pkgmeta + packages=(.release/*.zip) + if [ "${#packages[@]}" -eq 0 ]; then + echo "::error::Packager did not create any release zip files." + exit 1 fi - 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." + 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 [ "${#matching[@]}" -eq 0 ]; then + echo "::error::No package zip matched expected version '$VERSION'." + exit 1 fi - echo "PACKAGER_ARGS=-m .github/release.pkgmeta -p 1427906 -w 27051" >> "$GITHUB_ENV" + - name: Push release tag + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + + 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 - 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: .release/*.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 }} + run: | + set -euo pipefail + + release_args=(release create "$VERSION" .release/*.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/.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/project.yml b/.serena/project.yml index 0ac22307..380b8efb 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,8 @@ 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: diff --git a/AGENTS.md b/AGENTS.md index 05c566cb..e09897c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,95 +1,167 @@ +IMPORTANT: Run initialize serena tool, if it's available. + +# 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 | +| [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 own quick-start, API, and tests. + +--- + # Validation ```sh -# Addon tests -busted Tests - -# Library tests -busted --run libsettingsbuilder +busted Tests # addon +busted --run libsettingsbuilder # per-library suites 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 any root-level `*.lua` must pass `busted Tests` and `luacheck . -q`. +- Changes under `Libs//` must additionally 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 +## Architecture and Boundaries -- 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`. +- Prefer the simplest production code that satisfies current supported runtime requirements. No fallback paths, compatibility branches, or defensive adapters without a concrete supported environment that needs them. +- Single source of truth for shared state and derived values: derive once, store once, read everywhere. +- Loose coupling via events, hooks, callbacks, or messages. +- No duplicated utilities or trivial passthrough wrappers — extend the canonical owner. +- Don't extract a helper, wrapper, or abstraction unless it has an independently testable contract or 2+ callers. +- No production-only indirection around fixed literals or stable signatures. Pass the value directly. +- Prefer constant lookup tables over pure mapping functions for small fixed domains. +- Remove dead code, stale fields, impossible branches, unused locale strings. +- Clear critical state flags via `pcall` so one error can't wedge later work. -## Config, Events, and State +## State and Style -- 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. +- Mutable state belongs on the owning instance (`self._field`), not file-level locals. Prefix private fields/methods with `_`. +- No forward declarations. Alias shared modules once at file scope. +- Prefer assertions for required parameters over guards and fallbacks. +- Target WoW Lua 5.1 — no `goto`, labels, or `//`. +- No compatibility shims for built-ins WoW already provides. Shims that exist only for `busted` must be documented as such. ## Performance -- 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()`. +- Never use `OnUpdate` or frame-rate tickers; use event-driven updates plus a single deferred timer when needed. +- Reuse hot-path tables with `wipe()`. Avoid snapshot-copying callback lists. - 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. +- Periodic setup must stop once all targets are handled. +- Defer once when leaving restricted contexts; don't stack `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 for repeated 2–3 call sequences. +- `O(1)` set lookups over linear scans for fixed load-time lists. +- Compact single-line bodies for trivial functions. +- Don't assign fields to `nil` to "clear" them — only assign fields that will be read later. +- Closures differing only in one value should share a parameterised path. + +## Tests + +- Be skeptical when changing tests to satisfy failures — the failure may be real. +- Test load order mirrors TOC load order. Test files mirror source paths; library tests live under `Libs//Tests/`. +- Test production code directly. Don't mirror or reimplement production logic in specs. +- Stub the canonical function, not a wrapper or alias. If a stub diverges from real behavior, fix the stub — don't add fallbacks to live code. +- 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`. -## Architecture and Boundaries +## Libraries and Migrations -- 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 - -- 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. +- 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 via Lua hooks on Blizzard functions like `Settings.CreateElementInitializer`. XML virtual templates with `mixin="GlobalMixinName"` are inherently multi-addon safe via LibStub. +- Migrations in `Migration.lua` are frozen snapshots and must not depend on live production code. +- A single style/metric must have a single owner. If a library renders a widget, the library owns its dimensions, padding, fonts, and colors — callers must not redeclare those values, even via "override" knobs that happen to match the default. If every caller would pass the same value, delete the knob and bake it into the library. Override hooks are only justified when callers genuinely need different values. --- # Review Heuristics -- Optimize for simple, explicit, maintainable code. -- Watch for unused variables, redundant guards or assignments, duplication, tight coupling, needless complexity, missing coverage, and avoidable allocations. +Optimize for simple, explicit, maintainable code. Watch for unused variables, redundant guards, duplication, tight coupling, needless complexity, missing coverage, and avoidable allocations. --- # Secret Values -- 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. +Treat `UnitPowerMax`, `UnitPower`, `UnitPowerPercent`, and `C_UnitAuras.GetUnitAuraBySpellID` as secret values. + +- Only nil-check them or pass them to built-ins/APIs that accept secrets. +- No arithmetic, comparisons, boolean tests, length, indexing, assignment, 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. +- Don't nil-check or wrap built-ins like `issecretvalue`, `issecrettable`, `canaccesstable`. + +--- + +# Deprecated Blizzard APIs (12.0.5) + +Do not use the functions, constants, or mixins listed below — they are backward-compat shims and may be removed. Use the modern replacement (typically a `C_*` namespace method or mixin method) shown in Blizzard source: https://github.com/Gethe/wow-ui-source/tree/12.0.5/Interface/AddOns (`Blizzard_Deprecated*` folders). + +## 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_GetLastToldTarget`, `ChatEdit_GetNextTellTarget`, `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` diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4d5fa523..710dac45 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 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..20b82739 100644 --- a/Constants.lua +++ b/Constants.lua @@ -9,12 +9,13 @@ local constants = { ADDON_ICON_TEXTURE = "Interface\\AddOns\\EnhancedCooldownManager\\Media\\icon", ADDON_METADATA_VERSION_KEY = "Version", DEBUG_COLOR = "F17934", - RELEASE_POPUP_VERSION = "v0.7.1", + 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 +28,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 +103,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 +126,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 @@ -176,12 +177,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 +189,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 +273,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 +284,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..86b7ec5d 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. @@ -85,7 +89,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 +103,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 +159,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 @@ -273,13 +302,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..f0dbf1da 100644 --- a/ECM.lua +++ b/ECM.lua @@ -43,9 +43,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) @@ -186,12 +184,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 @@ -287,6 +288,7 @@ function mod:ChatCommand(input) if cmd == "clearseen" then gc.releasePopupSeenVersion = nil ns.Print(L["SEEN_CLEARED"]) + ReloadUI() return end end @@ -368,7 +370,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 +379,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 diff --git a/EnhancedCooldownManager.toc b/EnhancedCooldownManager.toc index 423298c2..ff4f475b 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-beta1 ## 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..f81efc9c --- /dev/null +++ b/Libs/LibSettingsBuilder/Controls/Base.lua @@ -0,0 +1,231 @@ +-- 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 + if initializer.SetSetting then + initializer:SetSetting(setting) + end + initializer._lsbRefreshFrame = function(frame) + if frame and frame.RefreshDropdownText then + frame:RefreshDropdownText() + end + end + internal.registerCategoryRefreshable(self, category, initializer) + end + + if initializer.SetSetting and (not initializer.GetSetting or 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 + + if not initializer.GetSetting then + initializer.GetSetting = function() + return setting + end + 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 + if frame._lsbInputEditBox.ClearFocus then + frame._lsbInputEditBox:ClearFocus() + end + 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, + }) + + if initializer.SetSetting then + initializer:SetSetting(setting) + end + + 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..910f5020 --- /dev/null +++ b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua @@ -0,0 +1,1053 @@ +-- 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 + if frame.SetPropagateMouseClicks then + frame:SetPropagateMouseClicks(false) + end + if frame.GetChildren then + local children = { frame:GetChildren() } + for i = 1, #children do + preventMouseClickPropagation(children[i]) + end + 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 row._label.SetFontObject and labelFontObject then + row._label:SetFontObject(labelFontObject) + end + if row._label and row._label.SetTextColor 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 and row._label.SetAlpha then + row._label:SetAlpha(alpha) + end + if row._icon and row._icon.SetAlpha then + row._icon:SetAlpha(alpha) + end + if row._icon and row._icon.SetDesaturated then + row._icon:SetDesaturated(iconDesaturated == true) + end + if row._icon and row._icon.SetVertexColor 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 and highlight.Show then + highlight:Show() + end + elseif highlight and highlight.Hide 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 or not row.SetScript then + return + end + + local label = row._label + local tooltipOwner = row._tooltipOwner or label + if row.EnableMouse then + row:EnableMouse(item ~= nil) + end + + row:SetScript("OnEnter", nil) + row:SetScript("OnLeave", nil) + if label and label.SetScript then + label:SetScript("OnEnter", nil) + label:SetScript("OnLeave", nil) + end + if label and label.EnableMouse then + label:EnableMouse(false) + end + if tooltipOwner and tooltipOwner ~= label then + tooltipOwner:SetScript("OnEnter", nil) + tooltipOwner:SetScript("OnLeave", nil) + if tooltipOwner.EnableMouse then + tooltipOwner:EnableMouse(false) + end + if tooltipOwner.Hide then + tooltipOwner:Hide() + end + 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 tooltipOwner.SetScript or (not item.onEnter and not item.tooltip) then + return + end + + if tooltipOwner.EnableMouse then + tooltipOwner:EnableMouse(true) + end + if tooltipOwner.Show then + tooltipOwner:Show() + end + tooltipOwner:SetScript("OnEnter", function(self) + setCollectionRowHighlight(row, true) + if item.onEnter then + item.onEnter(self, item) + elseif GameTooltip then + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + if GameTooltip.ClearLines then + GameTooltip:ClearLines() + end + 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) + if row._swatch.SetEnabled then + local enabled = evaluateStaticOrFunction(item.enabled, item, row) ~= false + and evaluateStaticOrFunction(color.enabled, item, row) ~= false + row._swatch:SetEnabled(enabled) + end +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) + if row._removeButton.RegisterForClicks then + row._removeButton:RegisterForClicks("LeftButtonUp") + end + 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) + if row._removeButton.SetEnabled then + row._removeButton:SetEnabled(item.remove == nil or item.remove.enabled ~= false) + end + 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 + if widgets.slider.SetValue then + widgets.slider:SetValue(field.value or 0) + end + 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) + if row._swatch.SetEnabled then + row._swatch:SetEnabled(color.enabled ~= false) + end +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) + if icon.SetDesaturated then + icon:SetDesaturated(false) + end + if icon.SetVertexColor then + icon:SetVertexColor(1, 1, 1, 1) + end + 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)) + if icon.SetDesaturated then + icon:SetDesaturated(disabled) + end + if icon.SetVertexColor then + 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 + end + setTextureValue(icon, iconTexture) + icon:Show() + + if button.SetText then + button:SetText("") + end +end + +local function applyActionButtonState(button, enabled) + local interactive = enabled ~= false + if button.EnableMouse then + button:EnableMouse(interactive) + end + if button.UnlockHighlight then + button:UnlockHighlight() + end + + local highlight = button.GetHighlightTexture and button:GetHighlightTexture() or nil + if highlight and highlight.SetAlpha then + if interactive then + highlight:SetAlpha(button._lsbDisabledHighlightAlpha or 1) + button._lsbDisabledHighlightAlpha = nil + else + if button._lsbDisabledHighlightAlpha == nil and highlight.GetAlpha 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") + if button.RegisterForClicks then + button:RegisterForClicks("LeftButtonUp") + end + row._buttons[key] = button + + button = CreateFrame("Button", nil, row) + if button.RegisterForClicks then + button:RegisterForClicks("LeftButtonUp") + end + 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) + if button.SetEnabled then + button:SetEnabled(enabled) + end + 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) + if row._editBox.SetNumeric then + row._editBox:SetNumeric(true) + end + if row._editBox.SetMaxLetters then + row._editBox:SetMaxLetters(10) + end + if row._editBox.SetTextInsets then + row._editBox:SetTextInsets(6, 6, 0, 0) + end + + 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 + if self.ClearFocus then + self:ClearFocus() + end + 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) + if activeRow._modeButton.SetEnabled then + activeRow._modeButton:SetEnabled(not disabled and modeEnabled ~= false) + end + + if activeRow._editBox.GetText and activeRow._editBox:GetText() ~= text then + activeRow._lsbSyncingText = true + activeRow._editBox:SetText(text) + activeRow._lsbSyncingText = nil + end + if activeRow._editBox.SetEnabled then + activeRow._editBox:SetEnabled(not disabled and inputEnabled ~= false) + end + + 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) + if activeRow._submitButton.SetEnabled then + activeRow._submitButton:SetEnabled(canSubmit) + end + 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..72b4fd05 --- /dev/null +++ b/Libs/LibSettingsBuilder/Controls/Collections.lua @@ -0,0 +1,62 @@ +-- 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) + if activeFrame.SetAlpha then + activeFrame:SetAlpha(enabled and 1 or 0.5) + end + 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..cf244c07 --- /dev/null +++ b/Libs/LibSettingsBuilder/Core.lua @@ -0,0 +1,1180 @@ +-- 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 + + if button.SetText then + button:SetText(textures and textures.normal and "" or (action and action.text or "")) + end + + 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 + ) + + if button.SetAlpha then + button:SetAlpha(enabled == false and (textures.disabledAlpha or DEFAULT_ACTION_BUTTON_DISABLED_ALPHA) or 1) + end + + 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 + + if button.SetAlpha then + button:SetAlpha(1) + end +end + +local function setGameTooltipText(text, wrap) + GameTooltip:SetText(text, 1, 1, 1, 1, wrap == true) +end + +local function setSimpleTooltip(owner, text) + if not owner or not owner.SetScript 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") + if GameTooltip.ClearLines then + GameTooltip:ClearLines() + end + 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 + if swatch.EnableMouse then + swatch:EnableMouse(true) + end + if swatch.RegisterForClicks then + swatch:RegisterForClicks("LeftButtonUp", "RightButtonUp") + end + if swatch.SetPropagateMouseClicks then + swatch:SetPropagateMouseClicks(false) + end + 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) + if canvas.SetAlpha then + canvas:SetAlpha(enabled and 1 or 0.5) + end + 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..5c167320 --- /dev/null +++ b/Libs/LibSettingsBuilder/Primitives/BlizzardControls.lua @@ -0,0 +1,445 @@ +-- 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 and slider._lsbEditBox.ClearFocus then + slider._lsbEditBox:ClearFocus() + end + if slider._lsbEditBox then + slider._lsbEditBox:Hide() + end + if textLabel and textLabel.Show 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") + if valueButton.SetPropagateMouseClicks then + valueButton:SetPropagateMouseClicks(false) + end + 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 and slider.Slider.SetValueStep 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 and textLabel.SetText 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 + + 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 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..1590539d --- /dev/null +++ b/Libs/LibSettingsBuilder/Primitives/Rows.lua @@ -0,0 +1,606 @@ +-- 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 "") + if button.SetEnabled then + local enabled = evaluateStaticOrFunction(action.enabled, action, frame) + if enabled == nil then + enabled = true + end + button:SetEnabled(enabled) + end + 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 + + if value.SetWordWrap then + value:SetWordWrap(isMultiline) + end + if value.SetJustifyV then + value:SetJustifyV(isMultiline and "TOP" or "MIDDLE") + end + if value.SetJustifyH then + value:SetJustifyH("LEFT") + end + 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 + + if frame.SetAlpha then + frame:SetAlpha(enabled and 1 or 0.5) + end + + local editBox = frame._lsbInputEditBox + if not editBox then + return + end + + if editBox.SetEnabled then + editBox:SetEnabled(enabled) + end + if editBox.EnableMouse then + editBox:EnableMouse(enabled) + end +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) + if editBox.SetNumeric then + editBox:SetNumeric(data.numeric == true) + end + if editBox.SetMaxLetters and data.maxLetters then + editBox:SetMaxLetters(data.maxLetters) + end + if editBox.SetTextInsets then + editBox:SetTextInsets(6, 6, 0, 0) + end + 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) + if self.ClearFocus then + self:ClearFocus() + end + 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 + if self.ClearFocus then + self:ClearFocus() + end + 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 and currentInitializer.SetEnabled 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..8819974a --- /dev/null +++ b/Libs/LibSettingsBuilder/Tests/Core_spec.lua @@ -0,0 +1,196 @@ +-- 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("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 @@ + +