Skip to content

Action Palette: a hold-and-release action picker - #1187

Draft
nulltyto wants to merge 49 commits into
EllesmereGaming:mainfrom
nulltyto:feat/radial-wheel
Draft

Action Palette: a hold-and-release action picker#1187
nulltyto wants to merge 49 commits into
EllesmereGaming:mainfrom
nulltyto:feat/radial-wheel

Conversation

@nulltyto

@nulltyto nulltyto commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What this is

A new child addon, EllesmereUIActionPalette. You hold a binding, steer to an action, and release to use it. The release resolves inside a secure snippet, so the palette works in combat.

Four steering models:

  • Grid — a block of entries; the nearest entry to the cursor is selected.
  • Fan (cursor) — a fixed strip steered by pointing.
  • Fan (scroll) — a coverflow strip scrubbed with the mouse wheel; usable while the right mouse button steers the camera.
  • Arc — entries on a ring or arc, selected by angle.

An entry can open another palette (one level of nesting). Grid nests come in three styles: a lane along the palette's perimeter, a halo around the entry, or a popout block beside the palette.

Status: early draft, for eyes only

Nested palettes are still broken in-game. The current design gates a nest behind secure OnEnter/OnLeave frames, and the latest round of fixes has not yet been validated live. Everything else on the list below works in-game today:

  • Open, steer, release, and cancel in all four steering models.
  • Mount/toy/item/macro/spell entries, with a picker for adding them.
  • Saved variables migrate from the old Radial Wheel name.

Known open items besides nesting: unlock-mode placement is stand-in X/Y sliders, and the override bindings are not yet scoped.

Feedback on the interaction model and the options layout is what we are after at this stage.

@nulltyto
nulltyto force-pushed the feat/radial-wheel branch from 68d756a to de14176 Compare August 5, 2026 07:04
@nulltyto

nulltyto commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Pushed the next round of nested-palette work (rebased onto current main, plus four new commits).

Grid nests

  • A lane run may use the whole perimeter before it stacks a second row, so two sub-palettes in adjacent cells no longer collapse into clusters.
  • Each nest's coverage now has every other nest's entry cell carved out of it, so you can glide from one sub-palette's entry straight onto its neighbor's and the nests hand off in place.

Arc nests

  • Fixed children that showed but could not be selected: the disarm test had a dead ring between the parent's icon and the first ring of children, so every reach disarmed the nest the moment it left the icon. The ground is now a beam plus a wedge centered on the parent.
  • Children spread along a single ring up to Max Nest Span (default a quarter circle) before they spill into a second ring.

Options

  • Slot labels now ship off, and the center logo ships on. Profiles that never touched these pick up the new defaults; explicit choices are kept.
  • Palettes can be named, and given an icon shown by the nested entry and the picker. Unset keeps the current look.

All of it is verified offline by the agreement harness (real secure-snippet plumbing, walks to every child, release resolution). Still needs in-game confirmation:

  • Swap between the two adjacent bottom-row sub-palettes by direct glide.
  • Reach arc children, including near a widened arc's far ends.
  • Watch for adjacent arc nests flapping on small movements (the arc no longer darkens the other entries' gates while one nest is armed).
  • The rename/icon inputs in Palette Setup.

Known limit, fix in progress: with three or more sub-palettes on one grid the region budget can overflow and drop coverage, which can break a reach or a swap. A follow-up commit will make the budget lossless.

nulltyto added 23 commits August 5, 2026 13:42
A hold-to-open radial action wheel: hold a keybind, steer the mouse toward
a wedge, release to fire it. Releasing inside the dead zone cancels.

Activation is secure and taint-free. Each ring owns one hidden
SecureActionButtonTemplate registered for AnyDown/AnyUp, with the ring's
keybind routed to it via SetOverrideBindingClick. The DOWN edge opens the
wheel and clears "type"; the UP edge writes the hovered slot's action
attributes so the same hardware click performs the cast. The button pins
useOnKeyDown to false so the acting edge is UP regardless of the
ActionButtonUseKeyDown CVar. Override-binding updates are the only
protected calls and are deferred to PLAYER_REGEN_ENABLED in combat, guarded
by a signature check so the UPDATE_BINDINGS they emit cannot feed itself.

Slots hold spells, items, toys, mounts, macros, custom macro text and
battle pets. A ring's slots are a dense ordered array, so the wheel
auto-sizes to what the user has actually assigned rather than leaving dead
gaps. Mounts cast through their summon spell by name, since
C_MountJournal.SummonByID is protected and those spells are not in the
spellbook.

Configuration is an in-page ring preview rather than a full-screen editor.
Actions are added by clicking the trailing "+" wedge and choosing from a
searchable per-category picker, or by dropping an action from the cursor;
wedges reorder by drag and clear by right-click. Keybinds are set with an
inline click-to-bind capture that writes the real EUI_RADIAL<n> binding, so
it stays consistent with Blizzard's Keybindings page and accepts modified
mouse buttons.

Registered with the suite roster, profiles, spec overrides, first-install,
global search and the packager.
A coverflow alternative to steering by cursor angle. The new layout setting
takes RADIAL, FAN_H or FAN_V; the fan modes draw the ring as a horizontal or
vertical strip with the selected entry centred at full size and its neighbours
shrinking and fading by a fixed per-step ratio.

The motive is that the cursor is frozen whenever the right button steers the
camera, which is exactly when the wheel gets used. A fan is scrubbed with the
mouse wheel instead, and the centred entry is the selection, so there is no hit
test, dead zone or needle at all.

Scroll is captured by a full-screen frame with EnableMouseWheel and never
EnableMouse: a mouse-enabled overlay would sit between the player and the world
and would swallow the button presses the secure activation path depends on. An
override binding on the wheel is not an option, being protected and therefore
unclaimable at open time in combat.

Distance from the centre is the integral of the scale curve plus a constant gap
rather than a sum of discrete steps, so spacing derives from the sizes it
separates and stays defined for the fractional offsets the slide animation
produces. Past the knee where the curve meets its floor the spacing goes linear,
since scale stops shrinking there and geometric spacing would close the gaps
under icons that had stopped getting smaller. Depth is expressed with SetSize
rather than SetScale, because SetPoint offsets are read in the widget's own
scaled space and scaling would silently multiply the spacing.

The logical index moves the instant a scroll tick arrives and only the geometry
is interpolated, so a release mid-animation fires what was last scrolled to. The
strip opens with nothing selected and the first tick is what enters it, which
preserves the radial's contract that releasing without steering cancels.

The centre caption hangs on whichever side faces the middle of the screen and
justifies to match, so a strip opened near an edge writes inward rather than off
the edge.

The options preview shares the renderer as before, fitting the strip against the
axis it runs along rather than against the ring's radius budget, and raising its
scale and alpha floors so every entry stays legible and hittable while dragging.
Four ways to steer a ring, sharing one renderer.

Arcs generalise the wheel rather than sitting beside it: the radial layout is
now an arc of 360 degrees, and arcSpan below that fans the entries across a
sector centred on arcRotation. A full circle divides by the entry count and
wraps, while an arc divides by the count minus one, so its first and last
entries sit on its ends instead of leaving a step-wide gap belonging to no
entry. Off either end of an arc is a miss rather than a clamp, which is where
its cancel lives once the dead zone has been left. Resolving the hit angle folds
it into a full turn from the arc's start, NOT into a half turn either side: an
arc may span up to a full circle, so an offset past 180 degrees is a legitimate
position near its end rather than a negative one near its start.

The grid lays every entry out at a fixed cell and zooms whichever is nearest the
pointer. Pointer travel to the worst entry then grows with the square root of
the count rather than linearly, and a fixed arrangement is far easier to build
muscle memory against than a position along a line. Falloff is true 2D distance,
since a grid has no privileged axis to project onto. Rows are centred
individually so a short final row sits under the middle of the one above.

A pointer-steered fan is that same layout one entry deep -- FAN_H a single row,
FAN_V a single column -- so fanInput CURSOR resolves through the grid instead of
a parallel implementation. Only the scroll fan needs geometry of its own,
because it cycles a compressed window rather than showing fixed positions.

Flick-ahead holds the radial invisible for a moment and then fades it in, so a
gesture finished inside that window never summons a menu at all. It delays only
the drawing: the frame is shown and its OnUpdate runs throughout, so a fast
flick lands on exactly the selection a slow one would have.
The module draws arcs, strips and grids as well as wheels, so the old name
described one of four layouts. Folder, files, addon name, frame names, the
options page and the suite registrations all move to Action Palette.

Two names deliberately do NOT move. The EUI_RADIAL<n> bindings keep their
identifiers: WoW stores bindings by name and offers no rename, so changing them
would silently unbind every ring, and the strings are internal -- the
Keybindings page shows the header text, not these. The /euirw and /euiradial
slash commands stay registered as aliases alongside /euiap and /euipalette,
because a command that stops existing after a rename reads as the module having
been removed.

The saved variable does move, so the toc declares BOTH names for this release
and OnInitialize adopts the old table before AceDB sees the new one. An
undeclared SavedVariable is never read in at all, so declaring only the new name
would leave nothing to migrate from and every configured ring would vanish. The
whole table is adopted rather than just the profile, which keeps per-character
selection and profile structure intact. The second declaration can go a release
after this one ships.

Grid columns are now derived rather than fixed at four. Near-square is the right
default because a grid exists to shorten the WORST pointer travel, and that is
minimised when the axes are balanced: nine entries want 3x3, not 4 + 4 + 1. The
one refinement on ceil(sqrt) is that a final row holding a single entry reads as
a mistake rather than a layout, and widening by one column always absorbs it, so
three becomes one row of three and seven becomes 4 + 3. gridColumns still pins
the count when gridAutoColumns is turned off.
hubIcon swaps the hub's small additive star for the suite logo, with size and
opacity sliders. The path comes from EllesmereUI.MEDIA_PATH rather than a
literal, so the standalone packager's rename of the EllesmereUI token reaches it
like every other media reference.

Radial only, and only where the centre is empty: the fan and grid layouts put a
real entry there, so the hub draws no art in them at all. The texture keeps the
default blend mode rather than the star's ADD -- this is artwork with its own
alpha, and ADD would wash its dark areas out into whatever is behind the wheel.
It sits on ARTWORK so the hub's OVERLAY caption still reads on top, and the
opacity slider is what keeps that caption legible rather than being decoration.

Size is scaled by the factor the view scaled its geometry by, recovered from the
icon size Geom returns. The options preview fits the ring to its panel, so a hub
drawn at the profile's literal pixel size would swamp a ring shrunk to fit.
The column slider was dead. Auto-sizing arrived with a gridAutoColumns key
defaulting to true, but no control was ever added to turn it off, so the auto
path always won and the slider moved without changing anything. Adds the Auto
Columns toggle and gates the slider on it.

Auto columns were also counted from the SHOWN wedge count rather than the real
one. An interactive view draws one extra wedge for the trailing "+", so that
extra could tip the count and lay the editor's grid out differently from the way
the ring is actually played -- six actions previewed as 4 + 3 while the live
palette drew 3 + 3. Counting real entries makes the two agree for every ring
size; the "+" now appends into that arrangement instead of reshaping it.
An addon may not write a protected frame's attributes during combat, so the
insecure PreClick that chose and committed the wedge was blocked as soon as a
fight started. Move the choosing into a secure snippet wrapped around OnClick:
Lua pushes the ring's actions and the layout geometry onto the button out of
combat, and the snippet reads the cursor and picks the entry at release.

The mouse wheel moves into a snippet too, for the same reason, and the fan's
index is read back out of the catcher's attributes.

Also stop spell cooldowns reading secret values: C_Spell.GetSpellCooldown is
secret when cooldowns are restricted, so use GetSpellCooldownDuration and feed
the duration object straight to the widget.

Add "/euiap trace" to report what the snippet decided on the last release,
since the restricted environment has no way to print.
FAN_H and FAN_V differed only in which axis the strip ran along: every
setting, decay, floor and input model was shared. Replace them with a
single FAN layout plus a fanOrientation key, read through one
WheelView:FanHoriz predicate.

The options page loses a layout entry and gains a Fan Direction
dropdown, which is where orientation belongs -- it describes the strip,
not a different way to steer one.

Saved profiles holding FAN_H or FAN_V convert on load.
Two things the strip got wrong. It opened selecting nothing, with the
first wheel tick entering it -- so the entry it opened centred on was the
one entry that could not be chosen without scrolling off it and back.
The press now seeds the accumulator at 1, and every tick is a plain step
from there.

That leaves the strip with no cancel, since releasing without a tick was
the old one. Take the grid's gesture instead: carry the pointer clear of
the strip -- across its axis, more than 1.5 pitches from where the
pointer was when the palette opened -- and nothing is selected, so
nothing fires. The live view and the release snippet apply the same rule
in the same units, so a strip drawing no selection never casts.

Unattended closes now clear the accumulator as well as hiding the
catcher, or a key-up arriving after one would fire the seeded entry with
nothing on screen.
Cancelling only counted movement ACROSS the strip, on the reasoning that
travel along its axis reads as steering. That is not how leaving a thing
feels: the strip is a shape on the screen, and carrying the pointer off
its end is as clearly a departure as carrying it off its side.

The cancel is now a box -- a margin across, the drawn strip plus that
margin along -- and the strip's half-length comes from one method the
frame sizing shares, so the box can never disagree with what is drawn.
The strip now dims as the pointer is carried toward its cancel box, so
leaving is something the player watches happen instead of a boundary
they cross blind. It holds full brightness through the first third of
the travel and eases down to a floor over the rest -- never to nothing,
because bringing the pointer back re-selects what the strip is centred
on. The box itself is wider than it was: at 1.5 pitches an ordinary
scroll gesture could brush the edge of it.

Escape now cancels an open palette in every layout, in combat too. It
cannot be a key handler: the release is resolved inside a secure
snippet, and only secure code may leave that snippet a flag to read once
the player is fighting. So the press snippet binds Escape to a button
whose own snippet raises the flag, the release reads it before it steers
anything, and the binding goes back to the game menu on the way out --
including after an unattended close, which is the one path that never
sees a release.
The module stopped being a wheel some time ago, so the vocabulary goes
with it: a set of actions is a PALETTE, the angular layout is the ARC it
was generalised into, and an entry is an entry rather than a wedge. The
layouts now read Arc, Fan, Grid -- alphabetical, with the default first.
Only the names move; RADIAL survives as the binding action name, where
renaming it would unbind every palette a user has set.

Old profiles convert on first touch rather than once at load, because
switching profile repoints the profile table mid-session without a
reload and a per-spec profile is resolved after OnInitialize has run.
Migrating only the load-time profile would have left both reading a
default-seeded empty palette while the user's data sat under the old key.

Selecting an entry now grows it in place instead of scaling the widget.
SetPoint offsets are read in the widget's own scaled space, so scaling
also multiplied the radius the arc anchors at: the icon was thrown
outward, out from under the cursor that had selected it, and the two
states flickered against each other on the edge of every icon. Size
carries the same magnification and moves nothing.

The fan and grid magnify their selection too, which they never did --
the strip leaves half that width in every offset past the centre so the
zoom cannot close the gaps under its neighbours.

The options preview fits both axes of its block and may now grow as well
as shrink, capped at 2x. Fitting used to be shrink-only, which left a
short strip or a small grid as thumbnails in the middle of a 280px block.
The grid already draws no more columns than it has entries, so a column
count at the slot cap is a single row for every palette, and stays one as
entries are added. The slider stopped at 8 and could not say it.

Take it to MAX_SLOTS instead of inventing a 0 or -1 that would have to
mean "row" on a control labelled columns, and name both single-file
layouts -- column at 1, row at the top -- in the tooltip.
A slot of kind "palette" names another palette, and pointing through it
carries on into that palette's own entries -- so a "Utility" palette can
hold Mounts, Teleports and Toys rather than twelve loose actions.

The children subdivide their PARENT'S sector rather than taking a fixed
span of their own. Nothing runs between the press and the release, so the
release has only the final cursor position to go on, and parent regions
and child regions must therefore partition the plane between them. Two
neighbours each claiming a quarter turn would overlap, and the overlap
would be unresolvable. A sector too narrow for its children is answered
by pushing them outward, where the same angle buys more room; Nest Width
= Overflowing trades that away for a tighter ring, at the price of a long
flick through the borrowed angles firing a child rather than the entry it
points at.

They ride the SAME action table the palette's own entries use, appended
past the last of them, so the firing end of the snippet needs no idea
that nesting exists -- a child is a cell with a higher index. Only the
claim geometry that maps an angle onto one of those indices is new, and
it is computed once in ChildGeom and read by the drawing, the hit test
and the push alike.

Arc only so far. The pointer layouts will nest through the cell list,
which already searches a flat list of centres and does not care whether
they form a grid.

Also here:
- palettes past the sixth have no <Binding> entry and exist to be nested,
  so storage now runs to sixteen while binding stays at six
- ns.CanNest refuses a palette inside itself and any chain that would
  close a loop, which would otherwise send every push and every draw
  round until the client gave out
- the editor draws no nests at all: what a nested entry holds is that
  palette's own business, and drawing every one at once buried the
  palette actually being arranged
- Nest Distance is a GAP between the two rings' icons, not a radius --
  measured centre to centre it had to cover both icons' halves before it
  separated anything, and the default left them overlapping by 4px
The grid and a pointer-steered fan nest through the cell list rather than
through geometry of their own. That branch of the snippet already walks a
flat list of cell centres and picks the nearest, and it does not know a
grid from anything else -- so the nested entries are appended to that same
list and the snippet gains no branch at all, only a longer loop.

Outside the perimeter is the only space available. Every interior cell
already belongs to an entry, and a release is settled by which centre is
nearest, so a nest sharing space with an entry could not be resolved. It
is also the space these layouts already treat as their cancel, so hosting
a nest there costs nothing that was doing anything else.

The side is the one NEAREST the parent's cell, in cells, so nothing about
it depends on where the palette sits on the screen -- which matters
because the push runs long before the open that uses it, and the two have
to agree. Nest Side breaks the tie, and a strip is nothing but ties: one
entry deep, so both long sides are equally near. Claims are allocated
greedily, nearest side first, stepping to the next side or the next rank
out rather than ever overlapping -- a point inside two nests at once
would have no answer.

A nest on a grid's INTERIOR cell is therefore served by a row that does
not touch it. That is the honest cost of the rule; the answer is to keep
nested entries on a grid's edge rather than to bend the geometry.

Scroll-steered fans still do not nest: their entries are a cycling window
rather than fixed positions, and the cursor axis a nest would use is the
one that cancels. Every nest control is dead there rather than lying.

nestBand/nestScale lose their arc prefix, being shared now.
A nested cell was a cell like any other: the release picked whichever
cell centre was nearest, and nested ones sat in the same search as the
palette's own. That made a nest reachable from open space half a screen
away, gave every nest its own row stacked outward from the last, and left
a grid's middle entry served by a row that did not touch it.

Every nested cell now owns a BOX. Inside it, that child; outside every
box, the layout answers as though the nest were not there. Leaving a run
in any direction leaves the nest, which is what a nest should mean, and
boxes may sit over ground the block is using -- which is what the two new
styles need and a nearest-centre rule could never allow.

On a grid, three of them:

  Lane    one lane just outside the block, SHARED by every nest on that
          side. Runs are packed along the perimeter as a single circular
          coordinate, so two nests sit side by side instead of stacking
          outward, and a run longer than the edge it started on turns the
          corner rather than shooting off into space. The corners are
          rounded: around a square one the straight-line distance between
          two evenly spaced cells is short enough that the icons overlap.
  Halo    the eight positions around the parent's own cell, the block
          faded and shrunk behind them. Drawn tight enough that the
          neighbours keep their centres.
  Popout  the nested palette as a block of its own, set down outside on
          the side its parent leans toward, the parent block faded.

A strip ignores the setting: one entry deep, it has only ever the one
answer, which is to break out across itself.

The scroll-steered strip nests too, where before it was the one layout
that did not -- and the default, so choosing the Fan layout got the half
that could not. The wheel says WHICH nest, the cursor only which of its
children, and travel toward a nest no longer counts as leaving the strip:
its children sit past the cancel margin, so measuring them by it closed
the palette on the way to reaching them.

The hub caption hangs opposite the nest and swaps with Nest Side, rather
than sitting on top of it.

Boxes are tested in cell order and the first hit wins, so two that
overlap still have exactly one answer. Disjointness was never the
requirement -- the drawing and the snippet agreeing is, and they walk the
same list in the same order.
A nest was shifted sideways to make room for its neighbour, so a lane
with two of them put neither above its own entry. But a nest is reached
by going THROUGH its parent: anywhere else to put it is somewhere the
user did not aim.

So a nest is now always centred on its parent, and what gives instead
when two would collide is the number of cells per row. Each takes the
lane out to the halfway point with the nearest other nest, and wraps to a
second row further out when that is narrower than its run -- a crowded
nest becomes a compact block above its own entry rather than a long row
shoved off to one side. Rows past the first follow the outward normal, so
they stay square on a straight edge and fan around a corner.

Nests set down clear of the block -- Popout always, Lane whenever its
parent is not on the edge -- had ground in front of them belonging to the
block's own entries, and closing on the first step across it put them out
of reach of the very gesture that opens them. Each claim now carries a
corridor spanning its parent and its children, and stays drawn while the
pointer is anywhere inside it. Visibility only: the entries under a
corridor fire exactly as they did.

The halo ring goes out to two thirds of a pitch, the furthest it can go
while leaving the neighbouring entries their own centres, and its parent
draws back to six tenths so the ring has somewhere to be. It keeps its
full colour -- it is what the ring is about.

Nested entries in a halo or on a strip go unlabelled, as the strip's own
entries already do: at that spacing the captions of neighbouring icons
land on top of each other.
A nest's cells were live from the moment the palette opened, so two
nests could fight over ground they both claimed, and a popout was
easier to enter than to leave. Selection now requires the cursor to
have entered the parent entry first: secure OnEnter/OnLeave gate
frames keep one armed claim on the button, the release fires only that
claim's cells, and leaving the claim's true ground -- parent, children
and the corridor between them, not their bounding box -- disarms and
lets the block back out of its dim. Arming is exclusive: switching
nests means backing out of one before entering the other.

Arc children now hug the ring in concentric rows instead of riding a
narrow sector out; the lane splits by palette shape, a compact block
over the parent for a single row or column and the perimeter run for a
grid; nest captions move to the hub, which already says where the
cursor is; the layout list reads Grid, Fan, Arc. /euiap gates prints
the armed claim and the gate transcript for the last hold.
Arming no longer waits for an OnEnter edge: the press snippet and the
disarm path both test the cursor against the claim geometry directly,
so a gate shown under a parked cursor is live at once. Region rects
grow an outward grace band so a fast reach that overshoots a child
does not drop the nest. Unarmed hover now draws a dim preview instead
of a full open, and an armed parent keeps a pale border while the
selection is on a child. The lane style is rebuilt as a snug run that
hugs the block perimeter, centered on the point nearest its parent,
wrapping corners; each side's region is the bounding box of the run
plus the parent cell, so a straight reach cannot leave coverage.
Checks now cover press-time pre-arm, leave-path re-arm, grace probes,
lane placement against annotated expectations, and straight-line walks
to every lane child through the real gate plumbing.
…rc's children

Grid: a lane run may use the full perimeter before it stacks a second
row, so two nests in adjacent cells no longer collapse into clusters;
every other claim's parent cell is carved out of a claim's region
coverage, so a glide from one nest's entry straight onto its
neighbor's disarms the first and arms the second in place.

Arc: the disarm test's ground is now a beam plus a wedge centered on
the parent, which closes the dead ring between the parent's icon and
the first ring of children that disarmed every reach the moment it
left the icon; a claim's children spread along one ring up to Max
Nest Span before they spill into a second ring.

Options: slot labels now ship off and the center logo on; a palette
can be named, and given an icon that its nested entry and the picker
both show -- unset keeps today's look.
An ARC section drives the real snippets through press pre-arm, a
reach to every child, and a sideways exit; the block checks cover
nest-to-nest handoff across a carved parent cell and full-lane runs.
Two slot kinds join the model: raidtarget and worldmarker, stored by icon
position with 0 for the entry that clears. Both resolve to macrotext --
/tm, /wm through the engine-order map, /cwm -- so the secure snippet needs
no changes: it already clears the macro keys and writes one key per cell.
The picker offers them in a Markers category that keeps the star-to-skull
order every marker menu in the game shows, through a new per-category
keepOrder flag.

The Number of Palettes slider is replaced by an Add Palette button. A
slider says nothing about what the new palette will hold; the button opens
a chooser instead -- an empty palette, or one of the presets: target
markers, world markers, hearthstones, teleports, potions, druid forms.
Each preset builds from what the character knows or carries when the
chooser opens, and a preset with nothing to offer stays out of the list,
which is also how the class-specific ones gate themselves.

With the slider gone, a Delete Palette button takes over the other
direction, and really deletes rather than hiding the way the slider's
decrease did: the palettes above close ranks, nested entries repoint or
leave with the deleted palette, and each shifted palette's keybind moves
down with it.
@nulltyto
nulltyto force-pushed the feat/radial-wheel branch from de14176 to 68d07b7 Compare August 5, 2026 22:42
@nulltyto

nulltyto commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Update: markers, preset palettes, and add/delete palettes (68d07b70). The branch was also rebased onto current main.

Markers in the action picker. A new Markers category offers the eight target markers, the eight world markers, and a clear entry for each set, in the star-to-skull order the game's own marker menus use. They are two new slot kinds that resolve to /tm, /wm, and /cwm macrotext, so the secure snippet is untouched — it already writes one attribute per cell, and these slash commands do what the raid manager's buttons do. World marker slots store the icon position and map to the engine's marker numbers through Blizzard's WORLD_RAID_MARKER_ORDER.

Preset palettes. The Add Palette chooser offers presets beside the default empty palette: Target Markers, World Markers, Hearthstones, Teleports, Potions, and Druid Forms. Each preset builds from what the character knows or carries at the moment the chooser opens, and a preset with nothing to offer stays out of the list — which is also how the class-specific ones gate themselves.

Add and delete instead of the count slider. The Number of Palettes slider is gone. Add Palette opens the chooser above and points the editor at the new palette. Delete Palette (confirmed by a popup) really removes the palette rather than hiding it the way the slider's decrease did: the palettes above close ranks, nested entries repoint or leave with it, and each shifted palette's keybind moves down with it, both keys if two are bound. Deletion is blocked in combat because it touches bindings.

Verified offline (syntax, a fresh-context review pass, and the nest agreement harness still reports ALL AGREE) but not yet live-tested in-game. The first things to try live: marking while grouped (both sets, including the clears, in combat), the chooser anchoring under the Add Palette button, and deleting a middle palette with keys bound on either side of it.

The rename moved the module's home in the central store from
addons.EllesmereUIRadialWheel to addons.EllesmereUIActionPalette, but
nothing moved the data: the old migration renamed a child SV global that
NewDB wipes without reading, so configured rings vanished and the orphan
blob rode along in every profile. MigrateLegacySV now walks the central
profiles and moves the old key to the new one (current data wins, old key
always removed); P()/MigrateNames converts the field names on first touch.

Also document the profile contract above DB_DEFAULTS: everything under
profile is per-profile and rides export/import through ADDON_DB_MAP, so a
new setting needs only a default there. Correct the toc comment whose
wrong claim about the legacy global produced the stranding.
No slash command clears every target marker the way /cwm clears the
world ones, so the new clearmarkers slot kind takes the battlepet route:
no secure action type, fired from PostClick off the Lua-side selection.
SetRaidTarget is not protected, so the sweep is plain Lua over every
unit the client can name -- the group and its pets, visible nameplates,
target and focus. A marked mob nobody can reference keeps its mark, the
limit every clear-all macro shares.

The row sits directly after the single-target clear it complements, so
a palette can carry either or both.
The arc drew every entry at full size and full strength, so the only thing
saying where the cursor was is the selection border -- while the grid and both
strips have always drawn back from the entry under the pointer. AdvanceArc
gives the ring the same cue, measuring nearness as the shortest angle counted
in STEPS: every entry on a ring is the same distance out, so a radial measure
would say nothing, and steps are what make one falloff setting mean the same
thing in every layout. Positions never move -- an entry that slid as it grew
would drag itself out from under the cursor that had just reached it.

PointerPolar comes out of HitTest so the falloff and the hit test read one
copy of the cursor arithmetic: what the arc draws as nearest and what a release
fires cannot part company.

Both falloffs now sit behind a single Proximity Falloff toggle, on by default,
read through FalloffRatios. Switched off it answers 1, 1 -- a no-op wherever it
lands, since decay ^ k is 1 at every k and FanOffset's even-spacing branch
takes the strip out to full pitch -- so no layout can be left drawing a cue the
others have dropped. The two ratio sliders were gated to Fan and Grid; they
describe all three layouts now and are gated by the toggle instead.
nulltyto added 22 commits August 5, 2026 17:18
Two bugs, both older than the falloff work.

The strip's settled entry shifted a moment after the rest of it had stopped,
against the direction of travel. Every offset past the centre carries half the
width the selected entry gains, so magnifying it cannot close the gaps under
its neighbours -- but it was switched on the instant k left 0, and the strip
approaches its entry continuously. The centre entry therefore sat that half-
width out for the whole slide and dropped back only as k reached exactly 0,
which is the twitch. Ramped in over the first step instead. At every integer k
the ramp is already at full extra, so the settled strip is unchanged.

Clear All Target Markers raised ADDON_ACTION_FORBIDDEN on its first call and
cleared nothing. It swept the group, the nameplates, target and focus with
SetRaidTarget from plain Lua, on the belief that the function is unprotected.
It is not -- it is documented AllowedWhenUntainted and refuses any call an
addon makes from its own code. The sweep goes through the secure button now,
as SECURE_ACTIONS.raidtarget's clear-all branch, which calls RemoveRaidTargets
untainted. The action rides in the ordinary key/value slot the snippet already
pushes: clear-all is the one branch that never reads "marker", so the single
key per cell can be spent on "action" instead. That key is cleared with the
rest before each fire, or a sweep would leave the next raidtarget slot
clearing the whole group instead of marking one unit.

It also clears more than the loop could: RemoveRaidTargets reaches marks on
units the client cannot currently name, which no clear-all macro can. The /tm
branch, which does work, had the same wrong premise written above it; what
makes it legal is the secure button running the macro, and it now says so.
Nearness was measured to an entry's CENTRE, so the entry the cursor was on
breathed as the cursor crossed it: largest dead in the middle, drawing back
toward the edges. The one thing on screen that should hold still while you
settle on it was the one thing moving.

FalloffK flattens the step count over the entry's own ground -- half a step
each side, which is exactly what the hit tests hand an entry -- so the entry
drawn at full size and full alpha is precisely the entry a release would fire.
Past a whole step it is the identity, so nothing about the settled drawing
moves: a neighbour is still decay ^ 1, a grid diagonal still decay ^ sqrt 2.
Only the half-step band between an entry's edge and its neighbour's centre is
redrawn, at twice the rate.

The grid flattens each axis before combining them rather than flattening the
2D distance: a flat disc would leave the corners of a cell outside it, still
breathing, and would pull the diagonal neighbour in off sqrt 2. The strip needs
none of this -- its entries come to rest at whole steps, where the curve is the
identity -- and its spacing integral is left alone.
…ed for

Four costs, all of them borne by users who nest nothing, open nothing, or
have the module switched off. None of it changes what anyone sees.

A push writes on the order of a thousand attributes per bound palette, and
two of its loops walked all twelve possible claim slots whether or not the
palette nested anything -- which most do not. Both are bounded by a per-button
high-water mark now, held at zero for a palette that has never nested. The
mark is MONOTONIC, and that is the whole of its correctness: every snippet
that clears, hides or re-shows a gate walks 1..eapGateMax, so an index ever
pushed a box for has to stay inside the bound for the rest of the session,
where the loops go on nil'ing it exactly as they did at twelve. Lowering it to
today's count would strand a live gate at yesterday's rect with nothing left
to clear it.

The gates themselves were built twelve claims deep at login -- 120 frames and
240 wrapped scripts per bound palette -- for nests that mostly do not exist.
The pool grows on demand to the same mark and never shrinks.

_EAP_Apply reached that push on every slider tick, and the options panel
drags one per frame while the sandbox only ever reads the last. Refresh
requests a push now and a short window folds the drag into one, matching the
coalescer shape the suite already uses. The preview still tracks the slider:
only the pushed geometry waits. A press lands any pending push before it
opens, so a key can never fire geometry the palette has stopped drawing, nor
carry stale actions into a fight that starts inside the window. The
combat-end handler pushes only when a fight actually refused one, the same
bargain bindingsDirty already made beside it.

OnEnable built the live view and a full-screen secure frame whether or not
the feature was on. Nothing is built there now: the view is made by the first
push, which only runs for a palette that has a secure button, and bindings
build no buttons and no binding owner while the module is off. A session that
never enables it creates no frames at all.

Held open, the steering pass rewrote every entry's point, size and alpha every
frame, though all three passes are pure functions of the cursor, the armed
claim, and where the wheel left the strip. A frame bringing none of those in
new skips the pass. Not the alpha -- the flick-ahead and cancel fades are
time-based and still run every frame. Closing the strip clears fanVisual with
fanTarget: left behind, it reads as a settle that can never finish, and one
scroll-fan open would cost every later open its skip for the session.

The offline agreement harness needed a C_Timer to drive the deferred push
with; it fires inline, which leaves Refresh as synchronous as the sweeps need.
Its verdict is unchanged, sweep for sweep.
The palette could hold any one mount but not the button every player
actually presses. SummonByID(0) is protected, so the entry casts the
spell behind the Journal's own Summon Random Favorite Mount button
instead, by name, the same route the mount kind already takes.

Pinning is a property of the entry rather than of the category, so a
list that is otherwise alphabetical can still lead with the row the
user came for.
A palette of potions could not say which stack was nearly out, and a
palette of charge spells could not say how many were banked -- the one
number an action button has always carried.

The charge count is a secret whenever cooldowns are restricted, so
SlotCount answers whether there is a count separately from what it is:
the value only ever reaches SetText, and nothing tests it, not even for
nil. The count is shown and hidden rather than written and cleared,
because a font string carrying secret text refuses a tainted clear and
would leave the previous entry's number standing.
An entry the game would refuse looked exactly like one it would cast.
Spells, items and toys now carry the three cues an action button has
always given: red out of range, blue short of the resource, gray and
desaturated for anything else.

Every getter this reads was checked against the generated documentation
first -- IsSpellUsable, IsSpellInRange, IsUsableItem, ItemHasRange and
IsItemInRange carry no SecretWhenCooldownsRestricted flag, unlike the
cooldown and charge getters beside them, so these results may be
branched on. Mounts and macros stay untinted: a mount's summon spell is
outside the spellbook and reports every mount unusable, and a macro's
usability is whatever its body resolves to.

The tint multiplies the selection paint rather than replacing it, so an
out-of-range entry under the cursor still reads as the selected one.
Releasing on an entry that opens a palette has always fired nothing --
the snippet refuses on eapPal before it reads any action, and the close
runs as normal -- but on screen that entry was captioned in the
selection color like every other one, so the cancel read as the nested
palette being broken rather than as an answer.

The hub hint line says it instead. It takes over the line the keybind
sits on: nothing about the keybind matters while the cursor is standing
on a door, and it is handed straight back when the selection moves.
Three presets that were missing, and the kind one of them needed.

Specializations are a new slot kind. There is no secure action type for
a spec change and no slash command either, so it fires from PostClick
alongside the battle pet -- and that path had to be made honest first:
it read the selection the live view was drawing, which is not what the
release fires. Escaping out of an open palette leaves an entry selected
and fires nothing, and the pet was summoned anyway. It now reads the
cell the snippet actually resolved, off the button, and only when the
snippet says it chose one.

A spec slot banks the specID rather than the index, so a palette carried
to an alt points at nothing instead of at somebody else's spec.

Stances covers a warrior's three and a paladin's four auras: the same
one-of-a-set choice in two costumes. Druid Forms grows Prowl, the two
travel forms Travel Form has mostly absorbed, and Cancel Form -- getting
out is the half of shapeshifting no form spell covers.
A profile held one layout, so a grid of markers and an arc of cooldowns
could not both exist -- and the palettes most worth having are the ones
that want different shapes.

The settings that describe a palette's SHAPE and PLACE are now per
palette: layout and fan direction, where it opens and at what scale, and
every knob that only means anything inside one of the three layouts.
Everything describing the module's LOOK -- colors, hub art, labels,
nesting geometry, flick-ahead -- stays profile-wide, because a suite of
palettes that each looked different would read as several addons.

Every one is an override rather than a value, so there is nothing to
migrate: a palette that has never been given one reads the profile's,
which is exactly what the old flat keys already held. Overrides live
under palette.appearance rather than flat on the palette, which already
carries name, icon and slots.

The renderer still reads `p.layout`. What changed is what `p` is: a
read-only view with the palette's overrides in front of the profile,
handed out by PA and reached through PaletteView:P(). One metatable
instead of a fallback at sixty call sites, none of which could have been
left out safely. PushPalette measures palettes the view is not laid out
for, so it points that accessor at the palette it is pushing for the
length of the push.

Copy Appearance takes another palette's whole arrangement in one go --
entries untouched. It copies the override table rather than the values
it resolves to, so a setting the source inherits stays inherited here as
well and both still follow the profile if it is ever changed.

Every row that writes an override carries noCapture: these live inside
p.palettes[n], and a per-spec override banked against a flat key would
rewrite whichever palette happened to be on screen at capture time.

The nest harness grows two sweeps that set the profile to one layout and
the palette to another. A read that went to the profile on either side
would draw one layout and fire from the geometry of a different one,
which no existing sweep could produce.
PushPalette measures through the live view, which is laid out for
whatever was drawn last and never for the palette being pushed. Two
lines make that safe -- appIndex, and reading PA(index) rather than the
profile -- and neither had a test: every sweep drives palette 1, where
the view's own index already equals the pushed one and both are no-ops.

So this fixture pushes two palettes with different appearances at once
and holds palette 2's pushed geometry against palette 2's drawn
geometry, with the profile set to a third answer so a read that fell
back to it is wrong rather than accidentally right. Removing either line
now fails it, as does making the view's accessor ignore appIndex; the
first version of this fixture caught none of them, because the two
palettes did not differ in anything the view measures.

Three comments corrected alongside: ResolveAction returns nothing for
two kinds now, not one; the appearance views are not bounded by
MAX_PALETTES, since a deleted palette's table stays in them; and the
charge flag is one line further down than cited.
@nulltyto

nulltyto commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Status: a bug-hunt pass over the module found eight bugs. All are fixed in the nine commits ending at 12daf91.

Fixes

  • Stuck nests (the important one). A claim that arms geometrically — the press pre-arm, or the re-arm after a leave — never gets Blizzard's _wrapentered flag, because only a motion OnEnter raises it, and the sandbox cannot set underscore attributes. Its parent gate's OnLeave pre-body therefore never ran: step off the cell through ground no region rect covers and the nest stays open, the block stays dim, and no other nest can arm for the rest of the hold. The disarm now hangs off an OnEnter instead, which needs no flag: a screen-wide, motion-only floor gate per palette sits below the real gates, shows while a claim is armed, and runs a claim-agnostic leave test on entry.
  • Two-key holds. A second palette key pressed mid-hold re-laid the shared live view, so the first key's release mapped its chosen cell through the wrong palette — wrong pet, mount, or spec — and then closed the second palette mid-hold. The screen now belongs to the key that holds it: the second press opens nothing, and its release fires nothing insecure and closes nothing. Its own secure action still fires from its own pushed geometry; insecure code cannot stop that.
  • A release exactly on the arc's outer boundary rounded up to index n+1, which with nests pushed is the first nested cell — it fired without its claim armed. The release snippet now bounds the index the same way HitTest does.
  • A nested-palette slot with a missing or out-of-range palette number made EnsurePalette error on a nil compare, which broke every paint of the containing palette. It now degrades to the question-mark render.
  • The editor's fan strip and the options preview measured the strip at the full count, but the fold draws only half of it — the preview shrank its icons to half of what fits.
  • Switching to a profile with fewer palettes while the options page was open made the page silently create a phantom out-of-range palette in the saved variables. The editing index is now clamped in BuildPage and reset on module reset.
  • The dim-unusable tooltip promised toy tinting the module deliberately does not do.
  • A full 12-slot palette's preview reserved room for a "+" placeholder that is not drawn.

Verification so far

luac -p is clean on both files and the nest agreement harness reports ALL AGREE. The harness compiles the floor-gate snippets for real, but its focus model does not exercise the floor's behavior, so the first two fixes need a live pass:

  • Cursor-mode 3x3 grid, middle nest cell: press, move off the cell through uncovered ground — the nest must disarm.
  • Normal UI hover returns after every hold ends, including cancels and Escape (the floor must hide).
  • ARC palette: an armed nest now disarms over a plain entry. This follows the palette's own ground test, but it is a behavior change — confirm it feels right.
  • Hold key A, press key B mid-hold, release in both orders: A's palette stays, B fires nothing insecure and closes nothing.
  • Switch to a profile with fewer palettes while the options page is open: no phantom palette in the saved variables.
  • A 12-slot palette's preview fills its block.

Known limits: while a claim is armed, the floor takes hover (not clicks) from the rest of the screen for that hold. The drawn-versus-pushed divergence after an in-combat edit (a refused push) is not fixed; any cheap guard would also suppress correct in-combat fires.

A world marker entry maps the icon position it stores onto the number
the engine uses. That map was Blizzard's WORLD_RAID_MARKER_ORDER copied
whole, but the constant lists its eight numbers from SKULL to STAR --
the order its own dropdown draws them in. Read as though it ran star to
skull, it gives the marker mirrored about the middle: the star placed
the skull, the orange circle placed the red cross, the purple diamond
placed the blue square. Reverse the map. Target markers were never
affected, because they take the icon position directly.

Every palette now has a <Binding> entry of its own, so no palette is
reachable only by being nested in another one. MAX_BOUND_PALETTES and
the "Nested Only" state it produced are gone. A secure button is built
only for a palette that HAS a key, so a profile that binds two of its
sixteen pays for two, and an unbound palette costs no attribute writes.

Add two cycling marker entries, one per set. Each press places the next
marker, star to skull and back to the star -- the whole set in one slot,
for a palette with no room for nine. The position advances inside the
secure snippet, because an insecure SetAttribute is refused in combat,
which is the whole of when marking matters. The release hands the
snippet's answer back to the slot, so the icon always draws the marker
the next press places. Offered in the picker only, not in the presets.

Ask before a keybind takes a key from another action. SetBinding steals
one silently, and the displaced binding is often something the user does
not miss until they reach for it mid-fight.

Split the Markers picker into Target Markers and World Markers. Twenty
one rows in one list is more than the eye wants, and which set you are
after is decided before the menu opens. A category can now carry a
sub-list, and the Back row walks one level at a time.

This also carries the settings page overhaul: the page is cut to the
rows most users need, and the rest move into four cog popups.
@nulltyto

nulltyto commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

f542d44 — world marker order, per-palette keys, cycling markers

World markers placed the wrong marker. WORLD_MARKER_ENGINE was Blizzard's WORLD_RAID_MARKER_ORDER copied whole. That constant lists its eight engine numbers skull to star — the order its own dropdown draws them in — so read star to skull it lands on the marker mirrored about the middle. Reported live: star placed the skull, the orange circle placed the red cross, the purple diamond placed the blue square, the green triangle placed the moon. The map is now {5, 6, 3, 2, 7, 1, 4, 8}, which is the engine's own run of blue, green, purple, red, yellow, orange, silver, white. Target markers were never affected — they take the icon position directly.

Every palette can take a key. MAX_BOUND_PALETTES is gone and Bindings.xml carries all sixteen entries, so the "Nested Only" state no longer exists: a palette added as number 7 binds like number 1. To keep that free, a secure button is built only for a palette that has a key. A profile that binds two of its sixteen pays for two buttons and two pushes; the rest are skipped by PushPalette and still reach the sandbox through whatever nests them.

Cycling marker entries. Two new kinds, cycleraidtarget and cycleworldmarker. Each press places the next marker, star to skull and back — the whole set in one slot, for a palette with no room for nine. The run is pushed as one macro text per step and the secure snippet steps the counter itself, because an insecure SetAttribute is refused in combat, which is the whole of when marking matters. OnPostClick mirrors the counter back onto the slot, so it survives a reload and the icon always draws the marker the next press places. Offered in the picker only, never in the presets — a preset that lays out all eight has nothing to cycle.

Keybind overwrite now asks. SetBinding takes a key from whatever holds it without a word, and the displaced binding is often something the user does not miss until they reach for it mid-fight. The dialog names both sides and offers Rebind / Keep. It only asks for a real theft: an unbound chord takes nothing, and rebinding a palette to the key it already holds takes it from itself. Combat is re-checked on confirm, since the dialog can sit open while a fight starts.

Markers picker split. Markers now opens Target Markers (11 rows) and World Markers (10) instead of one list of 21. A picker category can carry a subs list, and the Back row walks one level at a time — general, so any category that grows too long can be split the same way.

Also carries the settings page overhaul from the previous batch: the page is cut to the rows most users need, with the rest in four cog popups.

Verification

.tools/palette-nest/nest_agree.lua gained a check that presses ten times round each eight-step cycle and asserts the fired macro text, the wrap, and that the drawn name matches what then fires. Reverting the marker table to the old values makes it fail with exactly the reported symptoms, which independently confirms the diagnosis:

cycle world marker: press 1: fired /wm 8, wanted /wm 5; press 2: fired /wm 4, wanted /wm 6;
                    press 3: fired /wm 1, wanted /wm 3; press 4: fired /wm 7, wanted /wm 2

With the fix in place the harness reports ALL AGREE.

Still to confirm in game

  • All eight world markers place the marker their icon shows.
  • A cycle entry steps through all eight and wraps; its icon between presses shows what that press will place; it resumes after a reload; it still advances in combat.
  • A palette at index 7+ binds, and its key opens it after a reload.
  • Deleting a palette in the middle shifts its successors' keys down.
  • The keybind dialog names both actions; Keep changes nothing, Rebind takes the key.
  • Picker navigation: Back reads "« Markers" from a marker list and "« Categories" from the Markers list; Custom Macro... still reads "« Categories" after visiting Markers; no row sits under the Back row on any level.

The picker navigation has no offline coverage — the harness does not load the options file.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant