Compare commits

...
1 Commits
7 changed files with 258 additions and 75 deletions
+1
View File
@@ -13,6 +13,7 @@
- **Every toggle can have a Value and a Negative, and `$name.bool`.** Give a toggle a Value and its variable prints it while on and nothing while off: a Rain toggle with "heavy rain" makes `$rain` print "heavy rain" or nothing, and its Negative ("sunny, dry") goes to the Assembler's negative output with it. The switch is still there as `$rain.bool` (true or false) for rules and conditions; `bool` can't name a tag group any more. Packs that read `$name.bool` need this version (schema 12).
- **An Off value and Off negative for Any on toggles.** A toggle can also say what its variable writes while off, with its own negative: a Buttons toggle Anime / Real writes "anime style" or "photorealistic". Empty, off prints nothing as before.
- **A Grid layout for WP Toggles.** The edit bar has a **Layout** switch, Rows or Grid, for every mode and style: Grid puts each toggle in a tile and wraps the tiles to the node's width (the node never resizes itself). Each label has a grid button for its own group, so one group can be a grid while another stays rows; a folded group keeps its toggles' dots on its title. Under a grid's tiles, thin lines carry the dots: mute/bypass dots stack on the left with their toggle's name and BOOLEAN outputs on the right with theirs, each side packed on its own, so one line can hold two toggles. The arrow on the first line merges a group's lines into one, like the Context Injector's merged wires, which says how many go in and out ("2 in · 3 out") with the names on hover. The edit view keeps rows.
- **Smoother drag and drop in WP Toggles.** Rows now drag like the Context Injector's: the row you hold dims, the row it lands beside opens a gap with a glowing line, and after the drop the rows slide into place and the moved one pulses, also on a zoomed canvas. An indented line in a group's colour puts a toggle in that group; a full-width line just above a label or below the last row puts it below the group, in no group (before, a toggle could only leave a group to the top). Groups can be reordered: a label moves with its toggles and lands only between groups.
- **More looks for WP Toggles.** Pick one has its own **Style**: Radio (as before), Chip, Switch or Check, the same for every row; a toggle's own Style and Needs are hidden there since they don't apply. A pick's **Value** is now a text box like the Assembler's that you can make taller: `$` lists every variable upstream (Context Injector ones too) and the toggles' own, tag, LoRA and embedding autocomplete work in it, and a `$name` nothing upstream sets is flagged under the box and reported in WP Debug after a run. Inline `{a|b}` picks need a seed, so in the Value and in Context Injector templates they print as written; both editors now say so. A Chip toggle now spans the whole row, in Any on and Pick one, so it's an easy target and lines up with the rows around it. In the edit view you can drag a toggle into or out of a label's group (the Group field still works), and a grouped toggle shows its label's colour on its left edge.
- **WP Debug knows the newer nodes.** The Variables tab now groups variables into one card per node that set them, with each row's kind as a small square and a Context module's odds, pin, sweep or branch beside its name, and WP Context Loop, WP Model Info, WP Toggles and the Context Injector each get their own icon, colour (orange, lime, indigo, blue) and row details: the Loop says which frame ran, with its seed, sweep combination and value line; Model Info says where each value came from; Toggles shows each toggle's true / false, or its value text with its `.bool` state and negative (in Pick one too, beside the picked value); the Injector shows each value with its type. Group headers show the node's title beside a Context's codename. A long value folds to three lines (a long list to its first three items, a long negative to one line) until you press **Show all**, and `.axis`, `.bool` and tag values are chips. A trace step now says much more about why it came out that way: a wildcard lists every option with its weight, chance and why it couldn't come up (filtered out, toggled off, excluded by a constraint, weight 0) and the pool after each step, plus its seed, repeats and separator; a combine shows its template and what each `$variable` read; fixed values say which rows were used; a constraint lists the steps it re-weighted; and the Loop, Model Info, Toggles and Injector rows say what they read (model class, matched rule, upstream values, a parent toggle that is off, a template's inputs). The Trace tab stays the run in order: those nodes' rows are numbered steps like any module, with their icon and colour, and module rows carry their kind's icon too. **Show node** now makes the module or field the row came from blink on the canvas, for every node, and a constraint warning links to its step in the trace again. Each row says where its value came from (a Loop frame and its seed and sweep combination, a Model Info detection, a pin or a file-name rule, a Toggles switch set upstream), and their warnings have readable names and sit on the node's row. The Loop's `$iteration` variables no longer show as coming from upstream, a sweep frame's wildcard says **swept**, Match variable says which variable it matched, a pool filter shows the value its `$variable` read, and with a Loop that skips frames the frame picker shows the Loop's own frame number.
+1 -1
View File
@@ -1752,7 +1752,7 @@
},
{
"title": "Editing a toggle",
"text": "Name\nShown on the row and on its output. A Chip shows Name · $variable (the variable while Show is on). A name colour also colours its variable.\nVariable\nGets true or false, or the toggle's Value while on and nothing while off. $name.bool is always true or false. It follows the name until you change it. Internal until you turn off the row's globe. Show puts it under the name, or after it on a Chip.\nLooks\nAn icon before the name, and colours for the icon, the name and the control (switch, check, radio, chip or buttons), so Day can be yellow and Night blue. None keeps the theme's colour.\nStyle\nSwitch, Check, Buttons (two words of your own, like Day / Night) or Chip (a small chip with the name). Any on only: in Pick one the Style in the top bar sets every row's look.\nValue\nWhat the toggle's variable gets while it is on, instead of true; off, it gets its Off value (nothing when that is empty), so $rain can print \"heavy rain\" or nothing. $rain.bool still reads true or false, for rules. In Pick one the picked variable gets it too, instead of the toggle's name. A text box like the Assembler's: $ lists every variable upstream (Context Injector ones included) and the toggles' own, and tag, LoRA and embedding autocomplete work when they are on. A $name nothing upstream sets is flagged under the box and in WP Debug. Inline {a|b} picks need a seed, so they print as written; roll them in a wildcard instead. Drag the corner to make it taller.\nNegative\nWords to keep out of the image while this toggle is on. They go with its variable (and, in Pick one, with the picked variable), so an Assembler that prints it sends them to its negative output, like a wildcard option's negative. It can read $variables; off, or without one, the variable carries none.\nOff value (Any on)\nWhat the variable gets while the toggle is off, with its own Off negative. Empty prints nothing. With both, one toggle writes either side: a Buttons toggle Anime / Real can write \"anime style\" or \"photorealistic\", each keeping the other's look out of the image. Either one makes the variable hold text, not true or false; $name.bool still reads the switch.\nNeeds\nAny on only. Another toggle this one needs. While that one is off, this one is off too; it keeps your choice for when it comes back on.\nWhen off\nNothing, Mute or Bypass: what happens to the nodes wired into the row's dot (and to the canvas groups you pick) while the toggle is off. On, they run. When several toggles drive one node, off wins: it runs only while all of them let it (Mute beats Bypass).\nInvert\nMute or bypass while the toggle is ON instead, for a node used only as a switchboard.\nCanvas groups\nGroups on the canvas whose nodes When off also covers, by title. No wires needed.\nUpstream can set it\nWhen the upstream Context holds this toggle's variable as a yes/no word (true, yes, on, 1, false, no, off, 0), that value sets the switch for the run.\nGroup\nWhich label it sits under. You can also drag the toggle's grip under another label, or above the first one.\nBOOLEAN output\nShow an output for switch nodes. Off for a toggle only rules read.\nDrag the grip to reorder. A toggle dropped under a label joins that group (a folded one opens), and one dropped above the first label leaves its group. A label moves with its toggles. Moving a row never re-points a wire: each output stays with its toggle."
"text": "Name\nShown on the row and on its output. A Chip shows Name · $variable (the variable while Show is on). A name colour also colours its variable.\nVariable\nGets true or false, or the toggle's Value while on and nothing while off. $name.bool is always true or false. It follows the name until you change it. Internal until you turn off the row's globe. Show puts it under the name, or after it on a Chip.\nLooks\nAn icon before the name, and colours for the icon, the name and the control (switch, check, radio, chip or buttons), so Day can be yellow and Night blue. None keeps the theme's colour.\nStyle\nSwitch, Check, Buttons (two words of your own, like Day / Night) or Chip (a small chip with the name). Any on only: in Pick one the Style in the top bar sets every row's look.\nValue\nWhat the toggle's variable gets while it is on, instead of true; off, it gets its Off value (nothing when that is empty), so $rain can print \"heavy rain\" or nothing. $rain.bool still reads true or false, for rules. In Pick one the picked variable gets it too, instead of the toggle's name. A text box like the Assembler's: $ lists every variable upstream (Context Injector ones included) and the toggles' own, and tag, LoRA and embedding autocomplete work when they are on. A $name nothing upstream sets is flagged under the box and in WP Debug. Inline {a|b} picks need a seed, so they print as written; roll them in a wildcard instead. Drag the corner to make it taller.\nNegative\nWords to keep out of the image while this toggle is on. They go with its variable (and, in Pick one, with the picked variable), so an Assembler that prints it sends them to its negative output, like a wildcard option's negative. It can read $variables; off, or without one, the variable carries none.\nOff value (Any on)\nWhat the variable gets while the toggle is off, with its own Off negative. Empty prints nothing. With both, one toggle writes either side: a Buttons toggle Anime / Real can write \"anime style\" or \"photorealistic\", each keeping the other's look out of the image. Either one makes the variable hold text, not true or false; $name.bool still reads the switch.\nNeeds\nAny on only. Another toggle this one needs. While that one is off, this one is off too; it keeps your choice for when it comes back on.\nWhen off\nNothing, Mute or Bypass: what happens to the nodes wired into the row's dot (and to the canvas groups you pick) while the toggle is off. On, they run. When several toggles drive one node, off wins: it runs only while all of them let it (Mute beats Bypass).\nInvert\nMute or bypass while the toggle is ON instead, for a node used only as a switchboard.\nCanvas groups\nGroups on the canvas whose nodes When off also covers, by title. No wires needed.\nUpstream can set it\nWhen the upstream Context holds this toggle's variable as a yes/no word (true, yes, on, 1, false, no, off, 0), that value sets the switch for the run.\nGroup\nWhich label it sits under. You can also drag the toggle's grip under another label, or above the first one.\nBOOLEAN output\nShow an output for switch nodes. Off for a toggle only rules read.\nDrag the grip to reorder: the row you drop beside opens a gap with a line. An indented line in a group's colour puts the toggle in that group (a folded one opens); a full-width line, just above a label or below the last row, puts it below the group, in no group. A label moves with its toggles and lands only between groups. Moving a row never re-points a wire: each output stays with its toggle."
},
{
"title": "Icons and colours",
@@ -133,3 +133,45 @@ describe("TogglesWidget folded group", () => {
w.unmount();
});
});
describe("TogglesWidget edit-view drag", () => {
const grouped = (): TogglesConfig => ({
...defaultTogglesConfig(),
rows: [
row("00000001", "Top", false),
{ id: "0000000a", kind: "label", name: "Light", color: 2 },
row("00000002", "Rim", false),
row("00000003", "Fog", false),
{ id: "0000000b", kind: "label", name: "Sky", color: 3 },
row("00000004", "Rain", false),
],
});
it("drops a toggle below a group, into no group, with a full-width line", async () => {
const w = mount(TogglesWidget, { props: { modelValue: grouped(), editRequest: 0 } });
await w.setProps({ editRequest: 1 });
const list = w.findComponent({ name: "SortableList" });
const dropLine = list.props("dropLine") as (d: { from: number; over: number; edge: string }, noOp: boolean) => unknown;
// Shown: Top, Light, Rim, Fog, Sky, Rain. Top before Sky: below Light.
expect(dropLine({ from: 0, over: 4, edge: "before" }, false)).toBe(true);
// Top after Rim: inside Light, an inset line in its colour.
expect(dropLine({ from: 0, over: 2, edge: "after" }, false)).toMatchObject({ inset: 22, color: "var(--wp-var-2)" });
// A label can't land inside another group.
expect(dropLine({ from: 4, over: 2, edge: "after" }, false)).toBe(false);
list.vm.$emit("drop", { from: 0, over: 4, edge: "before", to: 3 });
const out = lastConfig(w);
expect(out?.rows.map((r) => r.id)).toEqual(["0000000a", "00000002", "00000003", "00000001", "0000000b", "00000004"]);
expect(out?.rows.find((r) => r.id === "00000001")).toMatchObject({ ungrouped: true });
w.unmount();
});
it("draws a toggle below a group outside it in the clean view", () => {
const c = grouped();
c.rows = [c.rows[1], c.rows[2], { ...(c.rows[0] as ToggleRow), ungrouped: true }, c.rows[4], c.rows[5]];
const w = mount(TogglesWidget, { props: { modelValue: c } });
const top = w.find('[data-test="tg-row-00000001"]');
expect(top.exists()).toBe(true);
expect(top.element.closest(".wp-tg__member.is-grouped")).toBeNull();
w.unmount();
});
});
+39 -10
View File
@@ -16,7 +16,7 @@
*
* Toggles belong to the label above them. One sortable list holds labels
* and toggles, so a toggle drags into or out of a group and a label drags
* with its toggles (`dragRow`); the edit panel's Group field moves one too.
* with its toggles (`dropToggleRow`); the edit panel's Group field moves one too.
*
* Value contract: `modelValue` is a `TogglesConfig`; every edit emits the
* whole next config (`src/widgets/toggles.ts` fits the outputs to it).
@@ -53,7 +53,8 @@ import {
type TogglesMode,
type TogglesItem,
type PickStyle,
dragRow,
dropToggleRow,
type ToggleDropEdge,
visibleRows,
} from "../../extension/toggles";
import { ctlInputName, type ControlState } from "../../extension/toggle-control";
@@ -275,7 +276,7 @@ function removeToggle(t: ToggleRow): void {
}
/** Remove a label; its toggles join the group above. */
function removeLabel(l: LabelRow): void {
setRows(cfg.value.rows.filter((r) => r.id !== l.id));
setRows(rowsOf(groupsOf({ ...cfg.value, rows: cfg.value.rows.filter((r) => r.id !== l.id) })));
}
function toggleEdit(t: ToggleRow): void {
@@ -353,7 +354,10 @@ function needsOptions(t: ToggleRow): { value: string; label: string }[] {
}
function groupOptions(): { value: string; label: string }[] {
return groups.value.map((g) => ({ value: g.label?.id ?? "", label: g.label ? (g.label.name || "Group") : "No label (top)" }));
// One "no group" choice, however many runs of loose toggles there are.
return groups.value
.filter((g, i) => g.label || i === 0)
.map((g) => ({ value: g.label?.id ?? "", label: g.label ? (g.label.name || "Group") : "No label (top)" }));
}
function groupIdOf(t: ToggleRow): string {
return groups.value.find((g) => g.toggles.some((x) => x.id === t.id))?.label?.id ?? "";
@@ -364,6 +368,29 @@ type LabelledGroup = ToggleGroup & { label: LabelRow };
const labelled = computed(() => groups.value.filter((g): g is LabelledGroup => g.label !== null));
/** The edit view's rows: labels and the toggles of unfolded groups. */
const shownRows = computed(() => visibleRows(cfg.value));
/** A group's key: its label's id; `top` for the toggles above the first
* label, and `loose-<first toggle>` for a run dropped below a group. */
function groupKey(g: ToggleGroup, i: number): string {
return g.label?.id ?? (i === 0 ? "top" : `loose-${g.toggles[0]?.id ?? i}`);
}
/** Where an edit-view drag would land, as the kit list asks it: no line
* when it can't land there; inside a group the line is indented and wears
* the group's colour, below a group it spans the row. */
function dropPlanFor(d: { from: number; over: number; edge: ToggleDropEdge }) {
const from = shownRows.value[d.from];
const over = shownRows.value[d.over];
return from && over ? dropToggleRow(cfg.value, from.id, over.id, d.edge) : null;
}
function dropLineFor(d: { from: number; over: number; edge: ToggleDropEdge }): { inset?: number; color?: string } | boolean {
const plan = dropPlanFor(d);
if (!plan) return false;
const moved = shownRows.value[d.from];
return plan.group && moved?.kind === "toggle" ? { inset: 22, color: labelColor(plan.group) } : true;
}
function onDrop(d: { from: number; over: number; edge: ToggleDropEdge }): void {
const plan = dropPlanFor(d);
if (plan) setRows(plan.rows);
}
function groupOfLabel(l: LabelRow): ToggleGroup {
return groups.value.find((g) => g.label?.id === l.id) ?? { label: l, toggles: [] };
}
@@ -488,7 +515,7 @@ function foldedOuts(g: ToggleGroup): string | undefined {
</span>
</div>
<template v-for="g in groups" :key="g.label?.id ?? '__top'">
<template v-for="(g, gi) in groups" :key="groupKey(g, gi)">
<!-- Clean view: plain rows, no grips. -->
<div v-if="!editMode" class="wp-tg__group-box">
<div
@@ -527,7 +554,7 @@ function foldedOuts(g: ToggleGroup): string | undefined {
class="wp-tg__sockline is-on"
:data-wp-out="mergedOuts(socketLines(g))"
:data-wp-socket="mergedSockets(socketLines(g))"
:data-test="`tg-sockmerged-${g.label?.id ?? 'top'}`"
:data-test="`tg-sockmerged-${groupKey(g, gi)}`"
>
<span class="wp-tg__sockpad"><WpIconButton variant="bare" icon="pi-caret-right" label="Show each toggle's wires" data-test="tg-sock-expand" @click="toggleMerged(g)" /></span>
<span v-tooltip="sockNames(g, 'in')" class="wp-tg__sockname">{{ sockCount(g, "in") }}</span>
@@ -539,7 +566,7 @@ function foldedOuts(g: ToggleGroup): string | undefined {
class="wp-tg__sockline"
:data-wp-out="line.right ? `toggle_${line.right.out}` : undefined"
:data-wp-socket="line.left ? ctlInputName(line.left.id) : undefined"
:data-test="`tg-sockline-${g.label?.id ?? 'top'}-${i}`"
:data-test="`tg-sockline-${groupKey(g, gi)}-${i}`"
>
<!-- One caret column for every line, so the names line up. -->
<span v-if="sockPairs(g).length > 1" class="wp-tg__sockpad">
@@ -573,8 +600,8 @@ function foldedOuts(g: ToggleGroup): string | undefined {
</template>
<template v-if="editMode">
<!-- One list for toggles and labels, so a toggle drags into or out of
a group and a label drags with its toggles (`dragRow`). -->
<!-- One list for toggles and labels, so a toggle drags into, out of or
below a group and a label drags with its toggles (`dropToggleRow`). -->
<SortableList
v-if="shownRows.length"
:model-value="shownRows"
@@ -583,7 +610,9 @@ function foldedOuts(g: ToggleGroup): string | undefined {
label="Toggles and labels"
size="sm"
class="wp-tg__list"
@move="(m: { from: number; to: number }) => setRows(dragRow(cfg, m.from, m.to))"
end-zone
:drop-line="dropLineFor"
@drop="onDrop"
>
<template #item="{ item }">
<div
+47 -19
View File
@@ -10,7 +10,7 @@ import {
import {
MAX_OUTPUTS,
defaultTogglesConfig,
dragRow,
dropToggleRow,
visibleRows,
withUpstream,
groupsOf,
@@ -251,33 +251,61 @@ describe("pick_style", () => {
});
});
describe("dragRow (edit view drag across groups)", () => {
describe("dropToggleRow (edit view drag across groups)", () => {
const L = (id: string, collapsed = false): TogglesConfig["rows"][number] => ({ id, kind: "label", name: id, color: 1, ...(collapsed ? { collapsed: true } : {}) });
const ids = (rows: TogglesConfig["rows"]) => rows.map((r) => r.id);
const ids = (rows: TogglesConfig["rows"] | undefined) => rows?.map((r) => r.id);
const groupIds = (c: TogglesConfig) => groupsOf(c).map((g) => `${g.label?.id ?? "-"}:${g.toggles.map((x) => x.id).join("")}`);
const c = cfg([t("a", "a", true), L("L1"), t("b", "b", true), t("c", "c", true), L("L2"), t("d", "d", true)]);
it("moves a toggle into another group", () => {
// a L1 b c L2 d → drag a below L2
expect(ids(dragRow(c, 0, 4))).toEqual(["L1", "b", "c", "L2", "a", "d"]);
it("moves a toggle into another group, beside a toggle or first after its label", () => {
expect(ids(dropToggleRow(c, "a", "d", "before")?.rows)).toEqual(["L1", "b", "c", "L2", "a", "d"]);
const first = dropToggleRow(c, "d", "L1", "after");
expect(ids(first?.rows)).toEqual(["a", "L1", "d", "b", "c", "L2"]);
expect(first?.group?.id).toBe("L1");
});
it("moves a toggle out of a group to the top", () => {
expect(ids(dragRow(c, 2, 0))).toEqual(["b", "a", "L1", "c", "L2", "d"]);
it("moves a toggle out of a group, above the first label", () => {
expect(ids(dropToggleRow(c, "b", "a", "after")?.rows)).toEqual(["a", "b", "L1", "c", "L2", "d"]);
expect(dropToggleRow(c, "b", "L1", "before")?.group).toBeNull();
});
it("a label carries its toggles and never splits a group", () => {
// drag L2 between b and c: it moves past c, taking d along.
expect(ids(dragRow(c, 4, 3))).toEqual(["a", "L1", "b", "c", "L2", "d"]);
// drag L2 above L1.
expect(ids(dragRow(c, 4, 1))).toEqual(["a", "L2", "d", "L1", "b", "c"]);
// drag L1 to the end.
expect(ids(dragRow(c, 1, 5))).toEqual(["a", "L2", "d", "L1", "b", "c"]);
it("drops a toggle below a group, in no group, and reads it back the same", () => {
const below = dropToggleRow(c, "a", "L2", "before");
expect(below?.group).toBeNull();
const back = parseTogglesConfig({ rows: below?.rows });
expect(groupIds(back)).toEqual(["-:", "L1:bc", "-:a", "L2:d"]);
expect(back.rows.find((r) => r.id === "a")).toMatchObject({ ungrouped: true });
const end = dropToggleRow(c, "b", "d", "end");
expect(groupIds({ ...c, rows: end?.rows ?? [] })).toEqual(["-:a", "L1:c", "L2:d", "-:b"]);
// Back into a group, the mark goes.
const again = dropToggleRow({ ...c, rows: end?.rows ?? [] }, "b", "d", "after");
expect(again?.rows.find((r) => r.id === "b")).not.toHaveProperty("ungrouped");
});
it("lands nowhere new when nothing would change", () => {
expect(dropToggleRow(c, "b", "b", "after")).toBeNull();
expect(dropToggleRow(c, "c", "b", "after")).toBeNull();
expect(dropToggleRow(c, "a", "L1", "before")).toBeNull();
});
it("moves a label with its toggles, only between groups", () => {
expect(ids(dropToggleRow(c, "L2", "L1", "before")?.rows)).toEqual(["a", "L2", "d", "L1", "b", "c"]);
expect(ids(dropToggleRow(c, "L1", "d", "end")?.rows)).toEqual(["a", "L2", "d", "L1", "b", "c"]);
expect(ids(dropToggleRow(c, "L2", "c", "after")?.rows)).toBeUndefined(); // already there
expect(dropToggleRow(c, "L2", "b", "after")).toBeNull(); // would split L1
expect(dropToggleRow(c, "L1", "L2", "after")).toBeNull(); // inside L2
expect(dropToggleRow(c, "L1", "b", "before")).toBeNull(); // its own toggles
});
it("puts a label among toggles in no group, splitting their run", () => {
const loose = cfg([t("a", "a", true), t("b", "b", true), L("L1"), t("c", "c", true)]);
const out = dropToggleRow(loose, "L1", "a", "after");
expect(groupIds({ ...loose, rows: out?.rows ?? [] })).toEqual(["-:a", "L1:c", "-:b"]);
});
it("keeps a folded group's hidden toggles and unfolds on a drop into it", () => {
const f = cfg([t("a", "a", true), L("L1", true), t("b", "b", true), L("L2"), t("d", "d", true)]);
expect(ids(visibleRows(f))).toEqual(["a", "L1", "L2", "d"]);
const out = dragRow(f, 3, 2); // d just below folded L1
expect(ids(out)).toEqual(["a", "L1", "b", "d", "L2"]);
expect(out[1]).toMatchObject({ id: "L1", collapsed: false });
expect(ids(dragRow(f, 1, 3))).toEqual(["a", "L2", "d", "L1", "b"]);
const out = dropToggleRow(f, "d", "L1", "after");
expect(ids(out?.rows)).toEqual(["a", "L1", "d", "b", "L2"]);
expect(out?.rows[1]).toMatchObject({ id: "L1", collapsed: false });
expect(ids(dropToggleRow(f, "L1", "d", "end")?.rows)).toEqual(["a", "L2", "d", "L1", "b"]);
// After a folded label is between groups for a label.
expect(ids(dropToggleRow(f, "L2", "L1", "after")?.rows)).toBeUndefined();
});
});
+123 -41
View File
@@ -49,6 +49,9 @@ export interface ToggleRow {
invert?: true;
/** Clean view: no `$variable` line under the name. */
hide_var?: true;
/** In no group though a label is above it: dropped below a group. Only on
* toggles after the first label (`rowsOf` sets it). */
ungrouped?: true;
/** What the variable gets while on, instead of "true" ("" while off); in
* Pick one also what the picked variable gets instead of the name. May
* read `$vars`. `$name.bool` still reads the switch. */
@@ -285,6 +288,7 @@ export function parseTogglesConfig(raw: unknown): TogglesConfig {
if (TOGGLE_CONTROLS.includes(o.control as ToggleControl)) t.control = o.control as ToggleControl;
if (o.invert === true) t.invert = true;
if (o.hide_var === true) t.hide_var = true;
if (o.ungrouped === true) t.ungrouped = true;
if (o.visible === true) t.visible = true;
if (iconName(o.icon)) t.icon = iconName(o.icon);
if (hex(o.icon_color)) t.icon_color = hex(o.icon_color);
@@ -521,18 +525,42 @@ export interface ToggleGroup {
toggles: ToggleRow[];
}
/** Rows as label groups (toggles belong to the label above them). */
/** Rows as label groups: toggles belong to the label above them, except an
* `ungrouped` one, which starts a run of toggles in no group below it. */
export function groupsOf(cfg: TogglesConfig): ToggleGroup[] {
const groups: ToggleGroup[] = [{ label: null, toggles: [] }];
for (const r of cfg.rows) {
if (r.kind === "label") groups.push({ label: r, toggles: [] });
else groups[groups.length - 1].toggles.push(r);
else {
if (r.ungrouped && groups[groups.length - 1].label) groups.push({ label: null, toggles: [] });
groups[groups.length - 1].toggles.push(r);
}
}
return groups;
}
/** Rows from groups, marking the toggles in no group below a label
* `ungrouped` (and no others), so `groupsOf` reads them back the same. */
export function rowsOf(groups: ToggleGroup[]): TogglesItem[] {
return groups.flatMap((g) => (g.label ? [g.label, ...g.toggles] : g.toggles));
let labelSeen = false;
const out: TogglesItem[] = [];
for (const g of groups) {
if (g.label) {
labelSeen = true;
out.push(g.label);
}
for (const t of g.toggles) {
const loose = !g.label && labelSeen;
if (loose === !!t.ungrouped) out.push(t);
else if (loose) out.push({ ...t, ungrouped: true });
else {
const rest = { ...t };
delete rest.ungrouped;
out.push(rest);
}
}
}
return out;
}
/** Depth of a toggle in its needs chain (0 = needs nothing), for the indent. */
@@ -593,48 +621,102 @@ export function visibleRows(cfg: TogglesConfig): TogglesItem[] {
return groupsOf(cfg).flatMap((g) => (g.label ? [g.label, ...(g.label.collapsed ? [] : g.toggles)] : g.toggles));
}
/** Where a dragged edit-view row lands: beside row `over`, or `end` below
* the last row. */
export type ToggleDropEdge = "before" | "after" | "end";
/**
* Rows after dragging the edit view's row `from` to `to` (indices into
* {@link visibleRows}). A toggle joins the group of the label above where it
* lands (a folded one unfolds). A label takes all its toggles along and lands
* at a group edge: dropped among another group's toggles, it moves down past
* them, so it never splits a group.
* The rows after dropping the edit view's row `fromId` beside `overId`, and
* the group it lands in (null: none), or null when it can't land there or
* nothing would change.
*
* A toggle beside another toggle joins that toggle's group; after a label it
* goes first in that group (a folded one unfolds); before a label or at the
* end it lands below the group above, in no group. A label moves with all
* its toggles and only lands between groups, never inside one.
*/
export function dragRow(cfg: TogglesConfig, from: number, to: number): TogglesItem[] {
const shown = visibleRows(cfg);
const moved = shown[from];
if (!moved || from === to || to < 0 || to >= shown.length) return cfg.rows;
const next = [...shown];
next.splice(from, 1);
next.splice(to, 0, moved);
export function dropToggleRow(
cfg: TogglesConfig,
fromId: string,
overId: string,
edge: ToggleDropEdge,
): { rows: TogglesItem[]; group: LabelRow | null } | null {
const groups = groupsOf(cfg);
const ownToggles = (labelId: string): ToggleRow[] => groups.find((g) => g.label?.id === labelId)?.toggles ?? [];
let list = next;
let block: ToggleRow[] = [];
if (moved.kind === "label") {
block = ownToggles(moved.id);
const inBlock = new Set(block.map((t) => t.id));
list = next.filter((r) => !inBlock.has(r.id) && r.id !== moved.id);
let at = next.filter((r) => !inBlock.has(r.id)).indexOf(moved);
while (at < list.length && list[at]?.kind === "toggle") at++;
list.splice(at, 0, moved);
}
const out: TogglesItem[] = [];
let lastLabel: LabelRow | null = null;
for (const r of list) {
if (r.kind === "label") {
lastLabel = r;
out.push(r);
if (r.id === moved.id) out.push(...block);
else if (r.collapsed) out.push(...ownToggles(r.id));
continue;
const groupOf = (id: string): ToggleGroup | undefined =>
groups.find((g) => g.label?.id === id || g.toggles.some((t) => t.id === id));
const moved = cfg.rows.find((r) => r.id === fromId);
const over = cfg.rows.find((r) => r.id === overId);
if (!moved || !over) return null;
let next: ToggleGroup[];
let group: LabelRow | null;
if (moved.kind === "toggle") {
if (edge !== "end" && over.id === moved.id) return null;
next = groups.map((g) => ({ label: g.label, toggles: g.toggles.filter((t) => t.id !== moved.id) }));
const find = (id: string): ToggleGroup | undefined =>
next.find((g) => g.label?.id === id || g.toggles.some((t) => t.id === id));
if (edge === "end" || (over.kind === "label" && edge === "before")) {
// Below the group above: in no group.
const at = edge === "end" ? next.length : next.findIndex((g) => g.label?.id === over.id);
const above = next[at - 1];
if (above && !above.label) above.toggles.push(moved);
else next.splice(at, 0, { label: null, toggles: [moved] });
group = null;
} else if (over.kind === "label") {
const g = find(over.id);
if (!g?.label) return null;
g.toggles.unshift(moved);
if (g.label.collapsed) g.label = { ...g.label, collapsed: false };
group = g.label;
} else {
const g = find(over.id);
if (!g) return null;
const i = g.toggles.findIndex((t) => t.id === over.id);
g.toggles.splice(edge === "before" ? i : i + 1, 0, moved);
group = g.label;
}
if (r.id === moved.id && lastLabel?.collapsed) {
const i = out.indexOf(lastLabel);
out[i] = { ...lastLabel, collapsed: false };
lastLabel = out[i] as LabelRow;
} else {
// A label lands only between groups: before a label, among toggles in
// no group, after a group's last toggle (or a folded / empty label), or
// at the end.
const own = groupOf(moved.id);
if (!own) return null;
const og = groupOf(over.id);
if (!og) return null;
const lastOf = (g: ToggleGroup): string | undefined => (g.label?.collapsed ? g.label.id : g.toggles[g.toggles.length - 1]?.id ?? g.label?.id);
let at: { before: string } | { after: string } | "end";
if (edge === "end") at = "end";
else if (over.kind === "label" && edge === "before") at = { before: over.id };
else if (!og.label) at = edge === "before" ? { before: over.id } : { after: over.id };
else if (edge === "after" && lastOf(og) === over.id) at = { after: over.id };
else return null;
if (og === own && at !== "end") return null;
const rest = groups.filter((g) => g !== own);
next = [];
let placed = false;
const put = (): void => { next.push(own); placed = true; };
for (const g of rest) {
if (at !== "end" && "before" in at && g.label?.id === at.before) put();
if (at !== "end" && !g.label && g.toggles.some((t) => t.id === ("before" in at ? at.before : at.after))) {
const key = "before" in at ? at.before : at.after;
const i = g.toggles.findIndex((t) => t.id === key) + ("before" in at ? 0 : 1);
const head = g.toggles.slice(0, i);
const tail = g.toggles.slice(i);
if (head.length || !next.length) next.push({ label: null, toggles: head });
put();
if (tail.length) next.push({ label: null, toggles: tail });
continue;
}
next.push(g);
if (at !== "end" && "after" in at && g.label && lastOf(g) === at.after) put();
}
out.push(r);
if (!placed) put();
group = own.label;
}
return out;
const rows = rowsOf(next.filter((g, i) => i === 0 || g.label || g.toggles.length));
const same = rows.length === cfg.rows.length && rows.every((r, i) => {
const o = cfg.rows[i];
return o?.id === r.id && (r.kind !== "toggle" || o.kind !== "toggle" || !!r.ungrouped === !!o.ungrouped)
&& (r.kind !== "label" || o.kind !== "label" || !!r.collapsed === !!o.collapsed);
});
return same ? null : { rows, group };
}
+5 -4
View File
@@ -53,10 +53,11 @@ import VarToken from "../../../components/docs/VarToken.vue";
<DocSection title="Editing a toggle">
<DocFields node="WP_Toggles" group="edit" />
<Banner tone="info" note>
Drag the grip to reorder. A toggle dropped under a label joins that group (a folded one
opens), and one dropped above the first label leaves its group. A label moves with its
toggles. Moving a row never re-points a wire:
each output stays with its toggle.
Drag the grip to reorder: the row you drop beside opens a gap with a line. An indented
line in a group's colour puts the toggle in that group (a folded one opens); a
full-width line, just above a label or below the last row, puts it below the group, in no
group. A label moves with its toggles and lands only between groups. Moving a row never
re-points a wire: each output stays with its toggle.
</Banner>
</DocSection>