feat(editor): pin an Engine category, not just the section

Sections can name an inner view via pinTarget/ctx.sub; a pin's identity
includes it, so two Engine categories don't evict each other.
This commit is contained in:
digital-garbage
2026-08-22 22:36:13 +03:00
parent 7172edac69
commit bd92dbc555
5 changed files with 78 additions and 13 deletions
+2 -1
View File
@@ -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
+26 -2
View File
@@ -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: '<svg viewBox="0 0 16 16" width="13" height="13"><path d="M9.2 1.3 3 9h4.1l-1 5.7L12.9 7H8.5l.7-5.7z" fill="#fff"/></svg>',
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 = {
+11 -5
View File
@@ -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 ───────────────────────────────────────────────────────────────
+32 -1
View File
@@ -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), "");
});
+7 -4
View File
@@ -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) => {