diff --git a/CHANGELOG.md b/CHANGELOG.md index bb816332..aade9000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ deep: fine to walk once, tedious while dialling something in. `📌 Pin to a button` in the Settings header puts whatever is open into one of three slots, chosen from a dialog that shows what each slot currently holds and asks before replacing one. Pinning while a node - is open pins the NODE, not the section it lives in. Slot 1 is always the leftmost button + is open pins the NODE, and pinning inside Engine pins the CATEGORY — "Sampler algorithm", + not "Engine" — since the section itself was never the slow part. Slot 1 is always the leftmost button and slot 3 the one nearest Assets; an empty slot closes up and the remaining buttons keep their own numbers, so a shortcut's position never moves. Hovering says where it leads. Pins ride with the project, like the other editor preferences. A node opened this way is a diff --git a/movie_editor/frontend/engine_settings.js b/movie_editor/frontend/engine_settings.js index b5f82124..7c09369d 100644 --- a/movie_editor/frontend/engine_settings.js +++ b/movie_editor/frontend/engine_settings.js @@ -1009,11 +1009,27 @@ if (pane) pane.scrollTop = scrollTop; } - function mount(body) { + /** Human name for a category, group included — "Sampler algorithm" alone appears under + * more than one group, and a pin's tooltip has to be unambiguous. */ + function viewLabel(id) { + // Guarded: this runs from mount(), and viewList reads project state that may not be + // there yet. A throw here would blank the whole section over a tooltip. + try { + const found = (viewList(S.get()) || []).find((v) => v.id === id); + if (!found) return null; + return found.group ? `${found.group} ▸ ${found.title}` : found.title; + } catch (_) { return null; } + } + + function mount(body, ctx) { const content = el("div", "models-mount eng-mount"); body.append(content); _mounted = { content }; - view = "overview"; + // A pinned button can name a category to open straight into. A category that is not in + // the list for THIS pipeline (Studio panes with no Studio wired) falls back to the + // overview — the sidebar visibly does not offer it, so the reason is on screen. + const wanted = ctx && ctx.sub; + view = (wanted && viewLabel(wanted)) ? wanted : "overview"; content.addEventListener("focusin", (e) => { const t = e.target; @@ -1045,6 +1061,14 @@ iconBg: "linear-gradient(180deg,#ffb64d,#e07f1f)", icon: '', mount, + // Pin the CATEGORY that is open, not the section — "Engine" is one click from anywhere + // already; "Sampler algorithm" is the one that costs a hunt through the sidebar. + pinTarget: () => { + if (!view || view === "overview") return null; + const label = viewLabel(view); + if (!label) return null; + return { kind: "section", id: "engine", sub: view, label: `Engine ▸ ${label}` }; + }, }); window.EngineSettingsModal = { diff --git a/movie_editor/frontend/pinned_buttons.js b/movie_editor/frontend/pinned_buttons.js index 5a0ed7b3..ac83b33a 100644 --- a/movie_editor/frontend/pinned_buttons.js +++ b/movie_editor/frontend/pinned_buttons.js @@ -30,19 +30,25 @@ .filter((e) => e.target); } + /** Identity of a destination. `sub` is part of it: two Engine categories are the same + * section but different places, and treating them as one would make the second pin + * silently evict the first. */ + function pinKey(t) { + return t ? `${t.kind}:${t.id}:${t.sub || ""}` : ""; + } + /** The same destination twice is a wasted slot. Placing it in `index` clears any other * slot pointing at the same thing. */ function placePin(list, index, target) { const next = normalizePins(list); - next.forEach((p, j) => { - if (p && j !== index && p.kind === target.kind && p.id === target.id) next[j] = null; - }); + const key = pinKey(target); + next.forEach((p, j) => { if (p && j !== index && pinKey(p) === key) next[j] = null; }); next[index] = target; return next; } if (typeof module !== "undefined" && module.exports) { - module.exports = { SLOTS, normalizePins, visibleSlots, placePin }; + module.exports = { SLOTS, normalizePins, visibleSlots, placePin, pinKey }; } if (typeof window === "undefined" || !window.dom || !window.Store) return; @@ -90,7 +96,7 @@ + "Pin something else to that slot to replace it."); return; } - window.SettingsWindow.open(t.id); + window.SettingsWindow.open(t.id, t.sub || null); } // ── the toolbar ─────────────────────────────────────────────────────────────── diff --git a/movie_editor/frontend/pinned_buttons.test.js b/movie_editor/frontend/pinned_buttons.test.js index f2ee736a..66eabb85 100644 --- a/movie_editor/frontend/pinned_buttons.test.js +++ b/movie_editor/frontend/pinned_buttons.test.js @@ -1,6 +1,6 @@ const test = require("node:test"); const assert = require("node:assert"); -const { SLOTS, normalizePins, visibleSlots, placePin } = require("./pinned_buttons.js"); +const { SLOTS, normalizePins, visibleSlots, placePin, pinKey } = require("./pinned_buttons.js"); const A = { kind: "section", id: "engine", label: "Engine" }; const B = { kind: "node", id: "fp_lora", label: "FunPack LoRA Loader" }; @@ -49,3 +49,34 @@ test("placing does not mutate the list it was given", () => { placePin(before, 1, B); assert.deepStrictEqual(before, [A, null, null]); }); + + +// ── sub-views: a place INSIDE a section is its own destination ───────────────── + +const ENG = { kind: "section", id: "engine", label: "Engine" }; +const ENG_SAMPLER = { kind: "section", id: "engine", sub: "studio_sampler", + label: "Engine ▸ FunPack Studio ▸ Sampler algorithm" }; +const ENG_GUIDANCE = { kind: "section", id: "engine", sub: "chain_guidance", + label: "Engine ▸ Chain Sampler ▸ Guidance" }; + +test("two categories of one section are different destinations", () => { + assert.notStrictEqual(pinKey(ENG_SAMPLER), pinKey(ENG_GUIDANCE)); + // Pinning the second must NOT evict the first — that was the failure the key prevents. + const after = placePin([ENG_SAMPLER, null, null], 1, ENG_GUIDANCE); + assert.deepStrictEqual(after, [ENG_SAMPLER, ENG_GUIDANCE, null]); +}); + +test("a section and one of its categories are different destinations", () => { + assert.notStrictEqual(pinKey(ENG), pinKey(ENG_SAMPLER)); + assert.deepStrictEqual(placePin([ENG, null, null], 2, ENG_SAMPLER), [ENG, null, ENG_SAMPLER]); +}); + +test("the SAME category still never occupies two slots", () => { + assert.deepStrictEqual(placePin([ENG_SAMPLER, null, null], 2, ENG_SAMPLER), + [null, null, ENG_SAMPLER]); +}); + +test("a missing sub is the same as an empty one", () => { + assert.strictEqual(pinKey(ENG), pinKey({ kind: "section", id: "engine", sub: "" })); + assert.strictEqual(pinKey(null), ""); +}); diff --git a/movie_editor/frontend/settings_window.js b/movie_editor/frontend/settings_window.js index 7854bee3..e66570dc 100644 --- a/movie_editor/frontend/settings_window.js +++ b/movie_editor/frontend/settings_window.js @@ -62,7 +62,9 @@ cleanup = null; } - function show(id) { + // `sub` names a place INSIDE a section (an Engine category, say). Sections that have + // inner views read it from ctx on mount; the rest ignore it harmlessly. + function show(id, subView) { const spec = registry.find((s) => s.id === id) || orderedSections()[0]; if (!spec || !overlay) return; teardownSection(); @@ -78,6 +80,7 @@ const ctx = { setActions: (nodes) => { clear(actionsEl); (nodes || []).forEach((n) => actionsEl.append(n)); }, openSection: (sid) => show(sid), + sub: subView || null, }; cleanup = spec.mount(bodyEl, ctx) || null; renderNav(); @@ -93,11 +96,11 @@ if (wasOpen) window.Store?.resumeSave?.(); } - function open(id) { + function open(id, subView) { // Recover if something removed our overlay from the DOM directly // (e.g. the tour's closeModalOverlay sweep) without calling close(). if (overlay && !overlay.isConnected) { close(); } - if (overlay) { show(id || activeId); return; } + if (overlay) { show(id || activeId, subView); return; } query = ""; // Held until close(). Paired with the overlay's lifetime, so only on this branch. window.Store?.suspendSave?.(); @@ -134,7 +137,7 @@ overlay.append(win); overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); document.body.append(overlay); - show(id); + show(id, subView); } window.addEventListener("keydown", (e) => {