feat: picker wheel -- customizable radial menu, replaces v4's pinned buttons

[[project_v5_ui_requirements]]: "any action in the UI can be assigned to a
slot... full wheel or half wheel... half wheel positioned left or right...
both draggable... default: middle mouse button, anywhere."

app/legacy/actions.js: ported the composer tree's tiny action registry
(app/shell/actions.js, an ES module, unreferenced from boot) as a classic
global window.FunPackActions -- offerAction({id,label,icon,run}), offered(),
run(id). Whatever announces itself is what the wheel (or anything later --
a command palette, a keymap) can show.

app/legacy/wheel.js: middle-mouse (anywhere) opens a radial menu of
whatever's currently on it. Full wheel spaces items evenly around 360°;
half wheel spaces them over a 180° arc on the chosen side, with drag
locked to the vertical axis only (full wheel drags both axes) per the
spec's "half wheel moves up/down, full wheel up/down and left/right" line.
Dragging repositions the CURRENTLY OPEN wheel only, not persisted -- read
"draggable" as "don't get stuck somewhere awkward this time" rather than a
remembered dock position, since the spec doesn't say which and this is the
simpler reading. Customize dialog (reuses pinned_buttons.js's existing
.pin-modal/.pin-slot CSS) toggles shape, side, and which offered actions
are on the wheel. pinned_buttons.js is NOT removed -- the spec says this
supersedes it, but retiring a working control felt like a separate
decision from adding a new one; both coexist for now, confirmed to not
conflict (separate storage keys, no shared DOM, never both open at once).

app/legacy/wheel_actions_seed.js: seeds 9 starter actions (Generate,
Generate selected, Render, Auto Montage, Undo, Redo, Models & Pipeline,
Engine settings, Sampler quick-access) so the wheel isn't empty on first
use.

Two rounds of extensive_testing, plus a bug I found myself in live
testing before either round: the half wheel's fixed radius, sized for a
full wheel's 360° spread, packed the same item count into half the
angular room and produced heavily overlapping items -- fixed by growing
the radius only as far as needed to keep adjacent items from touching,
confirmed visually before/after.

Round 1: no functional break found in outside-click-vs-item-click
ordering, listener leaks on repeated opens, or coexistence with
pinned_buttons.js.

Round 2: found the real defect -- enabledIds()'s first-call snapshot
("default to everything offered so far") could run in the gap between
wheel.js executing and wheel_actions_seed.js finishing (classic <script>
tags block sequentially, and this window widens on a slow/cold-cache
load), permanently persisting an empty or partial action list with no
error, no indication -- the user would just experience "the wheel doesn't
work" and have no way to know why. Fixed by deferring the snapshot to the
window `load` event (guaranteed after every script has run); before that,
enabledIds() shows whatever's offered so far live without writing it
down as the permanent default. Also cleaned up a dead if/else in the
half-wheel angle math flagged as a "looks like a bug that got half-fixed"
smell.

Live-verified after both fixes: real physical drag (manual mousedown/
mousemove/mouseup sequences, not just JS calls) confirms half-wheel drag
is genuinely locked to the y-axis (x stays exactly put) while full-wheel
drag moves both, and neither drag opens Customize -- only a press that
never crosses the drag threshold does. npm test: 691 pass, 1 pre-existing
unrelated flake (shell/tests/projects.test.js:285).

No test file exists yet for actions.js/wheel.js/wheel_actions_seed.js --
a real, named test gap, not fixed here. Real hardware middle-click and
cross-browser autoscroll-blocking are also unverified (browser automation
has no native middle-click) -- declared, not chased further without
physical hardware.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-09-16 06:25:12 +03:00
co-authored by Claude Sonnet 5
parent 4a1f60bda6
commit 4f18eefa69
5 changed files with 384 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
// Everything the app can be asked to do, by name.
//
// Announced, not listed: whoever builds a control offers it here, and
// whoever shows a list of them -- the wheel today, a command palette or a
// keymap later -- reads what is there. A part of the app that failed to
// load offers nothing and is simply not among them, the same rule regions
// and modules already live by elsewhere in this codebase.
//
// Ported from app/shell/actions.js (the composer-era original, unreferenced
// from boot) as a classic global instead of an ES module -- same registry,
// same contract, so anything written against the composer version's shape
// needs no translation if it is ever revived.
(function () {
const actions = new Map();
/**
* offerAction({ id, label, icon, run }) -> a function that takes it away
* again. The id is the address: offering the same one twice replaces it,
* because the alternative is two entries that look identical and do
* different things.
*/
function offerAction({ id, label, icon, run } = {}) {
if (!id || typeof run !== "function") {
throw new TypeError("An action needs an id and something to run.");
}
actions.set(id, { id, label: label || id, icon: icon || "•", run });
return () => actions.delete(id);
}
/** In the order they were offered, which is the order they were built in. */
function offered() { return [...actions.values()]; }
/** Run one by name. Unknown is not an error: whatever offered it may be gone. */
function run(id) {
const action = actions.get(id);
if (!action) return false;
try {
action.run();
} catch (err) {
// One broken action must not take down whatever listed it.
console.warn(`[FunPack] the "${id}" action failed: ${err.message}`);
return false;
}
return true;
}
window.FunPackActions = { offerAction, offered, run };
})();
+3
View File
@@ -120,6 +120,7 @@
<script src="api.js"></script>
<script src="dom.js"></script>
<script src="actions.js"></script>
<script src="pipeline_state.js"></script>
<script src="settings_field.js"></script>
<!-- Module scripts always defer, so this runs after every classic script
@@ -154,6 +155,8 @@
<script src="sampler_panel.js"></script>
<script src="engine_settings.js"></script>
<script src="sampler_quickbar.js"></script>
<script src="wheel.js"></script>
<script src="wheel_actions_seed.js"></script>
<script src="editor_settings.js"></script>
<script src="temp_browser.js"></script>
<script src="block_sweep.js"></script>
+25
View File
@@ -1080,6 +1080,31 @@ input[type=number] { font-family: var(--font-ui); }
.sq-body { padding: 10px 12px 12px; }
.sq-body .sw-rows { margin-top: 0; }
.sq-body .sw-rows-label:first-child { margin-top: 0; }
/* Picker wheel: a hub plus radial items, each absolutely positioned by its
own inline transform (the geometry lives in wheel.js, this just supplies
the look). Anchored at {left,top} = the point it was opened/dragged to,
with every child centered on that point via translate(-50%,-50%) before
its own radial offset. */
.fp-wheel { position: fixed; z-index: 10002; width: 0; height: 0; }
.fp-wheel-hub {
position: absolute; left: 0; top: 0; transform: translate(-50%, -50%);
width: 40px; height: 40px; border-radius: 50%; cursor: grab;
background: var(--popup); border: 1px solid var(--popup-line); color: var(--text);
font-size: 16px; box-shadow: 0 8px 24px color-mix(in srgb, var(--shadow-ink) 55%, transparent);
}
.fp-wheel-hub:active { cursor: grabbing; }
.fp-wheel-item {
position: absolute; left: 0; top: 0; width: 56px; height: 56px; border-radius: 50%;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
background: var(--popup); border: 1px solid var(--popup-line); color: var(--text);
box-shadow: 0 8px 24px color-mix(in srgb, var(--shadow-ink) 45%, transparent);
cursor: pointer;
}
.fp-wheel-item:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); background: var(--ink-3); }
.fp-wheel-item-icon { font-size: 16px; line-height: 1; }
.fp-wheel-item-label { font-size: 8px; line-height: 1.1; max-width: 48px; text-align: center;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
.me-rating-section { padding: 8px 10px 6px; border-bottom: 1px solid var(--overlay-1); }
.me-rating-section-label {
font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px;
+283
View File
@@ -0,0 +1,283 @@
// The picker wheel: any offered action, under the pointer.
//
// [[project_v5_ui_requirements]]: "any action in the UI can be assigned to
// a slot -- opening a menu, changing a value, pressing a button, anything.
// Two shapes, the user's choice: full wheel or half wheel. Half wheel is
// positioned left or right. Both draggable... default: middle mouse
// button, anywhere." Supersedes v4's pinned_buttons.js (three fixed
// toolbar slots) -- kept installed alongside this for now rather than
// removed in the same change, since replacing a working control is a
// bigger decision than adding a new one.
//
// Reads window.FunPackActions.offered() -- whatever announced itself is
// what the wheel can show. Which of those are actually ON the wheel, and
// the shape/side preference, is a per-browser preference (like
// timeline_peek.js's own reasoning: how you want this gadget to behave is
// a property of the screen you're at, not the project), stored in
// localStorage.
(function () {
const { el } = window.dom;
const A = window.FunPackActions;
if (!A) return;
const LS_KEY = "funpack_wheel_prefs";
const RADIUS = 92;
const ITEM_SIZE = 56;
const MIDDLE_BUTTON = 1;
const DRAG_THRESHOLD = 6; // px before a hub press counts as a drag, not a click
function loadPrefs() {
try {
const raw = localStorage.getItem(LS_KEY);
const p = raw ? JSON.parse(raw) : {};
return {
shape: p.shape === "half" ? "half" : "full",
side: p.side === "left" ? "left" : "right",
enabled: Array.isArray(p.enabled) ? p.enabled : null, // null = not chosen yet
};
} catch (_) {
return { shape: "full", side: "right", enabled: null };
}
}
function savePrefs(p) {
try { localStorage.setItem(LS_KEY, JSON.stringify(p)); } catch (_) { /* private mode */ }
}
let prefs = loadPrefs();
// Default set, the first time this ever opens: everything offered by
// the time the page has FINISHED loading. After that, an action offered
// later still (a module that loaded after this ran) does not silently
// appear on the wheel uninvited -- the user adds it from Customize, same
// as any other action they didn't ask for yet.
//
// Snapshotting on the FIRST CALL rather than on `load` would let an
// unlucky fast middle-click, fired in the gap between this script and a
// later one that still has actions to register, permanently persist an
// empty or partial default -- there would be nothing wrong to notice,
// just a wheel that looks broken forever until someone thinks to open
// Customize. `defaultsReady` closes that window: before `load`, this
// shows whatever is offered SO FAR without writing it down as the
// permanent choice.
let defaultsReady = false;
window.addEventListener("load", () => {
defaultsReady = true;
if (prefs.enabled === null) {
prefs.enabled = A.offered().map((a) => a.id);
savePrefs(prefs);
}
});
function enabledIds() {
if (prefs.enabled) return prefs.enabled;
if (!defaultsReady) return A.offered().map((a) => a.id);
prefs.enabled = A.offered().map((a) => a.id);
savePrefs(prefs);
return prefs.enabled;
}
function wheelItems() {
const byId = new Map(A.offered().map((a) => [a.id, a]));
return enabledIds().map((id) => byId.get(id)).filter(Boolean);
}
// ── geometry ──────────────────────────────────────────────────────────
// 0deg = right, 90deg = down (screen y-down), matching CSS/canvas convention.
function anglesFor(n, shape, side) {
if (n <= 0) return [];
if (shape === "full") {
return Array.from({ length: n }, (_, i) => -90 + i * (360 / n));
}
if (n === 1) return [side === "left" ? 180 : 0];
// Left and right arcs both sweep in the same rotational direction --
// it's the START angle (90 vs -90) that mirrors them, not the sweep.
const start = side === "left" ? 90 : -90;
const step = 180 / (n - 1);
return Array.from({ length: n }, (_, i) => start + i * step);
}
// ── the wheel itself ─────────────────────────────────────────────────
let wheelEl = null;
let cleanup = null;
function close() {
if (!wheelEl) return;
wheelEl.remove();
wheelEl = null;
if (cleanup) { cleanup(); cleanup = null; }
}
function openCustomize() {
close();
openCustomizeDialog();
}
function open(cx, cy) {
if (wheelEl) { close(); return; }
const items = wheelItems();
const angles = anglesFor(items.length, prefs.shape, prefs.side);
wheelEl = el("div", "fp-wheel");
wheelEl.style.left = cx + "px";
wheelEl.style.top = cy + "px";
const hub = el("button", "fp-wheel-hub", "⚙");
hub.type = "button";
hub.title = "Drag to move — click to customize";
wheelEl.append(hub);
// A half wheel packs the same item count into HALF the angular room a
// full wheel gets -- the same fixed radius that looks fine for a full
// wheel of 9 puts adjacent half-wheel items closer together than their
// own diameter. Grow the radius only as far as needed to keep adjacent
// items from overlapping, using the tightest gap in the layout (the
// last-to-first wraparound for a full wheel, the actual last step for
// a half wheel, which never wraps).
const stepDeg = items.length > 1
? (prefs.shape === "full" ? 360 / items.length : 180 / (items.length - 1))
: 360;
const minRadius = ITEM_SIZE / (2 * Math.sin((stepDeg / 2) * Math.PI / 180));
const r = Math.max(RADIUS, minRadius + 8);
items.forEach((action, i) => {
const a = (angles[i] * Math.PI) / 180;
const x = Math.cos(a) * r;
const y = Math.sin(a) * r;
const btn = el("button", "fp-wheel-item");
btn.type = "button";
btn.title = action.label;
btn.style.transform = `translate(${x}px, ${y}px) translate(-50%, -50%)`;
btn.append(el("span", "fp-wheel-item-icon", action.icon));
btn.append(el("span", "fp-wheel-item-label", action.label));
btn.onclick = (e) => { e.stopPropagation(); close(); A.run(action.id); };
wheelEl.append(btn);
});
document.body.append(wheelEl);
// Hub drag: reposition THIS open only (not persisted -- "draggable" per
// the spec is about the wheel not being stuck somewhere awkward once
// it's up, not a remembered dock position). A press that never moves
// past the threshold is a click, opening Customize instead.
let dragging = false;
let moved = false;
let startX = 0, startY = 0, originX = cx, originY = cy;
const axis = prefs.shape === "half" ? "y" : "xy";
const onMove = (e) => {
if (!dragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!moved && Math.hypot(dx, dy) > DRAG_THRESHOLD) moved = true;
const nx = axis === "xy" ? originX + dx : originX;
const ny = originY + dy;
wheelEl.style.left = nx + "px";
wheelEl.style.top = ny + "px";
};
const onUp = () => {
dragging = false;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
if (!moved) openCustomize();
};
hub.onmousedown = (e) => {
e.preventDefault(); e.stopPropagation();
dragging = true; moved = false;
startX = e.clientX; startY = e.clientY;
originX = parseFloat(wheelEl.style.left) || cx;
originY = parseFloat(wheelEl.style.top) || cy;
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
const onOutside = (e) => { if (!wheelEl.contains(e.target)) close(); };
const onKey = (e) => { if (e.key === "Escape") close(); };
setTimeout(() => document.addEventListener("mousedown", onOutside, true), 0);
document.addEventListener("keydown", onKey);
cleanup = () => {
document.removeEventListener("mousedown", onOutside, true);
document.removeEventListener("keydown", onKey);
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
}
function toggleAt(cx, cy) { if (wheelEl) close(); else open(cx, cy); }
// ── trigger: middle mouse, anywhere ──────────────────────────────────
// Middle click is otherwise autoscroll, which does nothing useful over
// this app -- safe to claim everywhere, not just inside one region.
document.addEventListener("mousedown", (e) => {
if (e.button !== MIDDLE_BUTTON) return;
e.preventDefault();
toggleAt(e.clientX, e.clientY);
});
// ── customize dialog: which offered actions are on the wheel, and its shape ──
let dialog = null;
function closeDialog() { dialog?.remove(); dialog = null; }
function openCustomizeDialog() {
closeDialog();
dialog = el("div", "modal-overlay pin-overlay");
const box = el("div", "modal pin-modal");
const head = el("div", "modal-head");
head.append(el("div", "modal-title", "Customize the wheel"));
const closeBtn = el("button", "btn ghost tiny", "✕");
closeBtn.type = "button";
closeBtn.onclick = closeDialog;
const hr = el("div", "modal-head-right");
hr.append(closeBtn);
head.append(hr);
box.append(head);
const content = el("div", "modal-content");
const shapeRow = el("div", "pin-clear-row");
["full", "half"].forEach((shape) => {
const b = el("button", "btn ghost tiny" + (prefs.shape === shape ? " occupied" : ""),
shape === "full" ? "Full wheel" : "Half wheel");
b.type = "button";
b.onclick = () => { prefs.shape = shape; savePrefs(prefs); openCustomizeDialog(); };
shapeRow.append(b);
});
if (prefs.shape === "half") {
["left", "right"].forEach((side) => {
const b = el("button", "btn ghost tiny" + (prefs.side === side ? " occupied" : ""),
side === "left" ? "Opens left" : "Opens right");
b.type = "button";
b.onclick = () => { prefs.side = side; savePrefs(prefs); openCustomizeDialog(); };
shapeRow.append(b);
});
}
content.append(shapeRow);
content.append(el("div", "pin-hint",
"Middle mouse button opens the wheel anywhere. Check the actions you want on it."));
const list = el("div", "pin-slots");
const enabled = new Set(enabledIds());
A.offered().forEach((action) => {
const row = el("button", "pin-slot" + (enabled.has(action.id) ? " occupied" : ""));
row.type = "button";
const left = el("div", "pin-slot-text");
left.append(el("div", "pin-slot-name", `${action.icon} ${action.label}`));
row.append(left);
row.append(el("span", "pin-slot-pos", enabled.has(action.id) ? "on the wheel" : "not shown"));
row.onclick = () => {
if (enabled.has(action.id)) enabled.delete(action.id); else enabled.add(action.id);
prefs.enabled = A.offered().map((a) => a.id).filter((id) => enabled.has(id));
savePrefs(prefs);
openCustomizeDialog();
};
list.append(row);
});
content.append(list);
box.append(content);
dialog.append(box);
dialog.addEventListener("click", (e) => { if (e.target === dialog) closeDialog(); });
document.body.append(dialog);
}
window.FunPackWheel = { open, close, toggleAt, openCustomizeDialog };
})();
+25
View File
@@ -0,0 +1,25 @@
// The wheel's starter set: a modest, obviously-useful list so the feature
// isn't empty the first time anyone opens it. Anything else offers itself
// from wherever it's built -- this file is not the only place an action can
// come from, just where the first ones do.
(function () {
const A = window.FunPackActions;
const S = window.Store;
if (!A || !S) return;
A.offerAction({ id: "generate-all", label: "Generate", icon: "▶", run: () => S.generate(null) });
A.offerAction({ id: "generate-selected", label: "Generate selected", icon: "▶", run: () => S.generateSelected() });
A.offerAction({ id: "render-final", label: "Render", icon: "⧉", run: () => S.renderFinal() });
A.offerAction({ id: "auto-montage", label: "Auto Montage", icon: "⚡", run: () => window.MontageDialog?.open() });
A.offerAction({
id: "undo", label: "Undo", icon: "↶",
run: () => { window.EditorHistory?.undo(S); },
});
A.offerAction({
id: "redo", label: "Redo", icon: "↷",
run: () => { window.EditorHistory?.redo(S); },
});
A.offerAction({ id: "open-models", label: "Models & Pipeline", icon: "▤", run: () => window.ModelsModal?.open() });
A.offerAction({ id: "open-engine", label: "Engine settings", icon: "⚙", run: () => window.SettingsWindow?.open("engine") });
A.offerAction({ id: "sampler-quick", label: "Sampler", icon: "⏱", run: () => window.SamplerQuickbar?.toggle() });
})();