diff --git a/resources/web/dialog/SpeedDial/speeddial.js b/resources/web/dialog/SpeedDial/speeddial.js
index b25df97723..65d2e24430 100644
--- a/resources/web/dialog/SpeedDial/speeddial.js
+++ b/resources/web/dialog/SpeedDial/speeddial.js
@@ -10,40 +10,118 @@ var sel = { zone: "list", i: 0 }; // zone: 'list' | 'fav'
var lastResizeHeight = 0;
var matchIndex = {};
-// Palette phase: 'commands' (search actions/commands, show recents), 'settings' ("Go to
-// setting..." second phase: search config options), 'percent' ("Go to layer" second phase:
-// enter a 0-100 percentage), 'tab' ("Go to tab..." second phase: pick a notebook tab).
+// ---- windowed list render ----------------------------------------------------
+// The command list is rendered in windows (append-on-scroll) so a huge settings pool doesn't build
+// the whole DOM per keystroke. Rows are exactly ROW_H tall (matches .row min-height 44px; see --row-h,
+// which is documented to stay in sync). `renderEnd` is the exclusive count of rows currently in the DOM;
+// a bottom spacer fills the rest of the list so the scrollbar reflects the full match count and
+// "scroll past the last rendered row" reveals the next window.
+var K_ROWS = 50;
+var ROW_H = 44;
+var renderEnd = 0;
+var builtKey = ""; // phase|query|listLen - when it changes, rows are rebuilt from the first window
+var spacerEl = null; // the trailing height spacer, always the last child of listEl
+
+// search-cache: the normalized (folded+lowercased) needle for the current query pass.
+var searchNeedle = "";
+
+// Palette phase: 'commands' (one unified search over actions/commands/settings, recents on empty
+// query), 'percent' ("Go to layer" second phase: enter a 0-100 percentage), 'tab' ("Go to tab..."
+// second phase: pick a notebook tab).
var phase = "commands";
-var settingsResults = []; // [{opt_key,type,label,category,group}]
var tabOptions = []; // [{id,title}] - notebook pages, fetched on entering the tab phase
// why: fuzzy matcher (FoldChar/Norm/FuzzyRanges) lives in shared ../../js/fuzzy-search.js, loaded before
// this script - it is shared with the Plugins dialog. Speed dial search is always case-insensitive.
// element handles, assigned in OnInit (kept null so load-time touches no DOM)
-var qEl = null, listEl = null, favEl = null, clearEl = null, eyeEl = null, countEl = null;
+var qEl = null, listEl = null, favEl = null, clearEl = null, eyeEl = null, countEl = null, headEl = null;
+
+// ---- inline setting editor state ---------------------------------------------
+// The "setting" phase (opened by activating a setting action) replaces the list with an editor card
+// for one option. phase transitions: commands -> setting -> (apply / open-in-sidebar) -> closed, or
+// Esc back to commands. settingDesc is the C++ descriptor; settingRows are the per-index control
+// descriptors (1 row for scalars, one per index for vectors); settingFieldEls hold the live controls.
+var settingId = ""; // the setting action id being edited
+var settingDesc = null; // {id,opt_key,type,title,breadcrumb,category,unit,tooltip,editable,control,cardinality,value|values,index_labels,enum_options,min,max,is_int}
+var settingRows = []; // [{index,kind,value,label,enum_options,min,max,unit,is_int}]
+var settingFieldEls = []; // [Element...] parallel to settingRows
+var settingPreviewIcon = null; // beside the editor title, updated live on dropdown pick
+var openDropDownEl = null; // the custom dropdown toggle button whose option list is expanded
// ---- pure helpers (no DOM; unit-tested) -------------------------------------
-function filterActions(actions, query) {
+// Pre-normalized haystacks, cached on the action object. The fold is length-preserving (1:1 per
+// char) so the ranges FuzzyRangesNorm returns slice the ORIGINAL title/source text correctly. The
+// action objects arrive from C++ and are stable for the dialog's lifetime, so we compute these once.
+function titleNorm(a) {
+ if (a._tn === undefined)
+ a._tn = NormText(a.title, false);
+ return a._tn;
+}
+function otherNorm(a) {
+ if (a._on === undefined)
+ a._on = NormText((a.source || "") + " " + (a.group || ""), false);
+ return a._on;
+}
+
+// Relevance score for a single field vs the current query needle, or -1 when there's no match.
+// Higher is better: an earlier start and a more contiguous (fewer gaps) match beat a scattered late one.
+function matchScoreNorm(haystackNorm) {
+ if (!searchNeedle) return -1;
+ var r = FuzzyRangesNorm(haystackNorm || "", searchNeedle);
+ if (!r) return -1;
+ var gaps = 0;
+ for (var i = 1; i < r.length; i++)
+ gaps += r[i][0] - r[i - 1][1];
+ return 1000 - r[0][0] * 10 - gaps * 10;
+}
+
+// Per-action score: title matches rank above a source/group-only match of equal quality.
+function actionSearchScore(a) {
+ var title = matchScoreNorm(titleNorm(a));
+ var other = matchScoreNorm(otherNorm(a));
+ if (title < 0 && other < 0) return -1;
+ return Math.max(title < 0 ? -1e9 : title + 10000, other < 0 ? -1e9 : other);
+}
+
+// The unified main-phase search: every action (command/plugin/setting) matching the query, ranked
+// by relevance (not by action type). Sets matchIndex so rows highlight their match ranges. The query
+// is normalized ONCE per pass - FuzzyRangesNorm then runs against each action's pre-normalized
+// haystack, so per-keystroke cost is a cheap scan (no per-char normalize/regex).
+function searchActions(actions, query) {
var q = (query || "").trim();
var list = actions || [];
matchIndex = {};
- if (!q)
- return list.slice(0);
+ if (!q) { searchNeedle = ""; return list.slice(0); }
+ searchNeedle = NormText(q, false);
- var out = [];
+ var scored = [];
for (var i = 0; i < list.length; i++) {
var a = list[i];
- var titleMatch = FuzzyRanges(a.title, q, false);
- var sourceMatch = FuzzyRanges(a.source || "", q, false);
- if (!titleMatch && !sourceMatch)
- continue;
- matchIndex[a.id] = { title: titleMatch, source: sourceMatch, useTitle: !!titleMatch };
- out.push(a);
+ var s = actionSearchScore(a);
+ if (s < 0) continue;
+ var titleMatch = FuzzyRangesNorm(titleNorm(a), searchNeedle);
+ matchIndex[a.id] = { title: titleMatch, source: FuzzyRangesNorm(otherNorm(a), searchNeedle), useTitle: !!titleMatch };
+ scored.push({ a: a, s: s });
}
- return out;
+ scored.sort(function (x, y) {
+ if (x.s !== y.s) return y.s - x.s;
+ if (x.a.title !== y.a.title) return x.a.title < y.a.title ? -1 : 1;
+ return x.a.id < y.a.id ? -1 : x.a.id > y.a.id ? 1 : 0;
+ });
+ return scored.map(function (e) { return e.a; });
}
+// Pure: how many rows must be materialized to cover the given starting index plus `size` more.
+// Clamped to the total; used to decide "render the next window" on scroll / arrow-nav.
+function revealTarget(total, fromIndex, size) {
+ return Math.min(total, Math.max(0, fromIndex) + size);
+}
+
+// buildKey: the command-list signature that decides whether rows must be rebuilt (new search / phase)
+// or just have their selection refreshed in place (arrow-nav / click). Cheap to compute.
+function buildKey() { return phase + "|" + (query || "").trim(); }
+
function visibleFavourites(favourites, actions) {
// why: a fav whose id has no live action (plugin unloaded/disabled) renders a dead
// monogram tile whose click run()s to a silent no-op; drop it from the quick-bar.
@@ -54,6 +132,24 @@ function visibleFavourites(favourites, actions) {
});
}
+// Numbered quick-launch slots (mirrors ActionRegistry::kFavLimit). Pure so the node-vm test
+// can exercise the digit<->slot mapping without a DOM.
+var K_FAV_LIMIT = 10;
+
+// Badge label for a 0-based fav-bar index: 0..8 -> "1".."9", index 9 (the 10th) -> "0".
+function favSlotForIndex(i) {
+ if (i < 0 || i >= K_FAV_LIMIT) return null;
+ return i < 9 ? String(i + 1) : "0";
+}
+
+// Digit key -> 0-based fav-bar index (1..9 -> 0..8, 0 -> 9); -1 for anything else.
+function favIndexForDigit(d) {
+ var c = String(d || "").charCodeAt(0);
+ if (c >= 49 && c <= 57) return c - 49;
+ if (c === 48) return 9;
+ return -1;
+}
+
function resultCountText(total, shown, query) {
return (query || "").trim() ? "Showing " + shown + " of " + total + " actions" : total + " actions";
}
@@ -80,11 +176,125 @@ function shouldRenderActionList(query) {
return !!((query || "").trim());
}
-// The active list for the commands phase. A typed query filters every action (plugins +
-// commands); an empty query shows the recent list instead (recents live below the search bar).
+// Label for a closed-enum entry, looked up from its enum_options by value; falls back to the value.
+// Pure so the node-vm test can exercise the dropdown label mapping. `value` is the current int value.
+function dropdownLabel(options, value) {
+ var want = String(value == null ? "" : value);
+ for (var i = 0; i < (options || []).length; i++)
+ if (String(options[i].value) === want)
+ return options[i].label != null && options[i].label !== "" ? String(options[i].label) : String(options[i].key != null ? options[i].key : options[i].value);
+ return want;
+}
+
+// Pure: the data:URI pictogram for a dropdown's selected value, or "" when none of the options has
+// one (most settings have no pattern icon). Mirrors dropdownLabel so tests can drive it without DOM.
+function dropdownIcon(options, value) {
+ var want = String(value == null ? "" : value);
+ for (var i = 0; i < (options || []).length; i++)
+ if (String(options[i].value) === want && options[i].icon)
+ return options[i].icon;
+ return "";
+}
+
+// Put an action's pattern pictogram into a tile (search row or favourites tile) when it has one,
+// otherwise fall back to the monogram. Toggles the has-icon class so CSS neutralises the hue.
+function fillTile(tile, a) {
+ tile.classList.remove("has-icon");
+ if (a && a.icon) {
+ tile.textContent = "";
+ var img = document.createElement("img");
+ img.className = "tile-icon";
+ img.src = a.icon;
+ img.alt = "";
+ tile.appendChild(img);
+ tile.classList.add("has-icon");
+ } else {
+ tile.textContent = a ? tileCode(a, ACTIONS) : "";
+ }
+}
+
+// Per-index control descriptors for the inline setting editor, derived from the C++ descriptor.
+// Pure so the node-vm test can exercise the scalar/vector + control mapping without a DOM.
+// Values are the current config value(s); vector options get one row per index, each labelled.
+function settingControlRows(desc) {
+ if (!desc || !desc.editable) return [];
+ var rows = [];
+ var values = desc.cardinality === "vector" ? (desc.values || []) : [desc.value];
+ var labels = desc.cardinality === "vector" ? (desc.index_labels || []) : [];
+ for (var i = 0; i < values.length; i++) {
+ rows.push({
+ index: i,
+ kind: desc.control,
+ value: values[i],
+ label: labels[i] != null ? String(labels[i]) : (desc.cardinality === "vector" ? String(i + 1) : null),
+ enum_options: desc.enum_options || [],
+ min: typeof desc.min === "number" ? desc.min : null,
+ max: typeof desc.max === "number" ? desc.max : null,
+ unit: desc.unit || "",
+ is_int: !!desc.is_int
+ });
+ }
+ return rows;
+}
+
+// Pure: read the value a control would submit back for a setting. `el` is a DOM element (never
+// passed in tests). Returns undefined for an unusable value (empty/invalid number, out of range),
+// boolean for toggles, number for numeric, string otherwise.
+function settingControlValue(row, el) {
+ if (!row || !el) return undefined;
+ switch (row.kind) {
+ case "toggle": return !!el.checked;
+ case "number": {
+ var raw = String(el.value || "").trim();
+ if (raw === "") return undefined;
+ var n = row.is_int ? parseInt(raw, 10) : parseFloat(raw);
+ if (!isFinite(n)) return undefined;
+ if (row.min != null && n < row.min) return undefined;
+ if (row.max != null && n > row.max) return undefined;
+ return n;
+ }
+ case "dropdown": {
+ // value is stored on the toggle button's dataset (set when an option is picked).
+ var v = parseInt(el.dataset ? el.dataset.value : "", 10);
+ return isFinite(v) ? v : undefined;
+ }
+ case "combo": {
+ // Open enum: free text field (never a select), so read it as the seeded integer value.
+ var v = parseInt(el.value, 10);
+ return isFinite(v) ? v : undefined;
+ }
+ case "color":
+ case "text":
+ case "percent": {
+ // percent submission is a string ("10%", "0.5"); C++ parses + clamps it. Empty is invalid.
+ var raw = String(el.value || "").trim();
+ return raw === "" ? undefined : raw;
+ }
+ default: return undefined;
+ }
+}
+
+// Pure: assemble the value payload for a setting from its edited control rows. Returns the scalar
+// for scalar settings, an array for vector settings, or undefined when any control is invalid.
+function settingCollectedValue(desc, rows, values) {
+ if (!desc || !desc.editable) return undefined;
+ if (desc.cardinality === "vector") {
+ var out = [];
+ for (var i = 0; i < rows.length; i++) {
+ var v = settingControlValue(rows[i], values[i]);
+ if (v === undefined) return undefined;
+ out.push(v);
+ }
+ return out;
+ }
+ return settingControlValue(rows[0], values[0]);
+}
+
+// The active list for the main phase. A typed query ranks every action (commands/plugins/settings)
+// by relevance; an empty query shows the recent list (recents are a mixed bag - no discrimination).
function commandList(actions, recents, query) {
if (shouldRenderActionList(query))
- return filterActions(actions || [], query);
+ return searchActions(actions || [], query);
return (recents || []).slice(0);
}
@@ -146,8 +356,8 @@ function monogramFor(item, list, titleOf, sourceOf, idOf) {
return pi + ti;
}
-// Action tile code - see monogramFor for the escalation ladder. Accessed via accessors so the
-// same helper serves settings rows (leaf label + category) without duplicating the logic.
+// Action tile code - see monogramFor for the escalation ladder. Settings are actions now, so
+// they share this ladder (title initial, then source, then a stable ordinal).
function tileCode(action, actions) {
return monogramFor(action, actions,
function (o) { return o.title; },
@@ -155,24 +365,6 @@ function tileCode(action, actions) {
function (o) { return o.id; });
}
-// Last " : "-separated segment of a "category : group : label" setting label = the option name
-// (e.g. "Quality : Layer Height" -> "Layer Height"); empty labels fall back to the opt_key.
-function leafLabel(s) {
- var label = String(s && (s.label || s.opt_key || "") || "");
- var parts = label.split(" : ");
- var leaf = parts[parts.length - 1];
- return (leaf || "").trim() || label.trim();
-}
-
-// Setting tile code - the option name initial, then the section (category) on collision, then a
-// stable ordinal, so settings no longer all collapse to a generic "S".
-function settingCode(s, list) {
- return monogramFor(s, list,
- function (o) { return leafLabel(o); },
- function (o) { return o.category; },
- function (o) { return o.opt_key; });
-}
-
function syncClearButton() {
if (clearEl)
clearEl.hidden = !query;
@@ -187,7 +379,6 @@ function stateFromPayload(payload) {
sel: { zone: "list", i: 0 },
lastResizeHeight: 0,
phase: "commands",
- settingsResults: [],
tabOptions: []
};
}
@@ -203,17 +394,19 @@ function resetScrollPositions(list, doc) {
doc.body.scrollTop = 0;
}
-// nextSel: pure arrow-nav transition. Down fav->list0; Down list->clamp; Up list@0->fav0;
-// Up list->i-1; Left/Right clamp within fav. Returns a fresh {zone,i}.
+// nextSel: pure arrow-nav transition. Down fav->list0; Down list wraps at the bottom (last -> first).
+// Up list wraps at the top (first -> last) only when there's no fav bar above; with a fav bar, Up at
+// the list top goes to fav0 (unchanged). Left/Right clamp within fav. Returns a fresh {zone,i}.
function nextSel(sel, key, listLen, favLen) {
var zone = sel.zone, i = sel.i;
+ var last = Math.max(0, listLen - 1);
if (key === "ArrowDown") {
if (zone === "fav") return { zone: "list", i: 0 };
- return { zone: "list", i: Math.min(i + 1, Math.max(0, listLen - 1)) };
+ return { zone: "list", i: i >= last ? 0 : i + 1 };
}
if (key === "ArrowUp") {
if (zone === "list") {
- if (i <= 0) return favLen ? { zone: "fav", i: 0 } : { zone: "list", i: 0 };
+ if (i <= 0) return favLen ? { zone: "fav", i: 0 } : { zone: "list", i: last };
return { zone: "list", i: i - 1 };
}
return { zone: zone, i: i };
@@ -245,8 +438,16 @@ window.HandleStudio = function (payload) {
sel = next.sel;
lastResizeHeight = next.lastResizeHeight;
phase = next.phase;
- settingsResults = next.settingsResults;
tabOptions = next.tabOptions;
+ // Reset any half-open setting editor (the dialog was closed/reopened), restoring the search.
+ settingId = ""; settingDesc = null; settingRows = []; settingFieldEls = [];
+ settingPreviewIcon = null;
+ openDropDownEl = null;
+ // why: builtKey caches phase|query so renderCommandsList can skip a rebuild on arrow-nav. It
+ // survives an apply-then-reopen (which never goes through exitPhase), so without a reset the
+ // leftover editor card would be mistaken for the empty-query commands list and never rebuilt.
+ builtKey = "";
+ if (headEl) headEl.hidden = false;
if (qEl) {
qEl.value = "";
qEl.placeholder = "Search " + ACTIONS.length + " actions";
@@ -254,16 +455,27 @@ window.HandleStudio = function (payload) {
}
render({ resize: true, resetScroll: true });
focusInput();
- } else if (payload.command === "settings_results") {
- settingsResults = payload.results || [];
- if (sel.zone === "list")
- sel.i = Math.max(0, Math.min(sel.i, settingsResults.length - 1));
+ } else if (payload.command === "setting_descriptor") {
+ // Inline editor loaded: render the card. Guard against a stale response for a different id.
+ if (payload.descriptor && payload.descriptor.id === settingId)
+ settingDesc = payload.descriptor;
render({ resize: true });
+ // why: keyboard focus must land on the field after the card is built, not stay on the hidden
+ // search input. fire on a timeout so the element is attached and its content selectable.
+ focusSettingEditor();
+ } else if (payload.command === "apply_failed") {
+ flashHint("Couldn't apply that value");
} else if (payload.command === "tab_results") {
tabOptions = payload.tabs || [];
if (sel.zone === "list")
sel.i = Math.max(0, Math.min(sel.i, tabOptions.length - 1));
render({ resize: true });
+ } else if (payload.command === "favourite_full") {
+ // Favourites are at the quick-launch cap - undo the optimistic pin and flash a hint.
+ var fid = payload.id;
+ if (fid && FAVS.indexOf(fid) !== -1) FAVS.splice(FAVS.indexOf(fid), 1);
+ render({ resize: true });
+ flashHint("Favourites are full (" + (payload.limit || K_FAV_LIMIT) + " max)");
}
};
@@ -284,7 +496,6 @@ function currentVisibleFavs() { return visibleFavourites(FAVS, ACTIONS); }
// Active list for the current phase (drives list rendering + arrow nav).
function currentList() {
- if (phase === "settings") return settingsResults;
if (phase === "tab") return filterTabs(tabOptions, query);
if (phase === "commands") return commandList(ACTIONS, RECENTS, query);
return []; // percent - the input itself is the only field
@@ -349,10 +560,19 @@ function renderFav() {
var tile = document.createElement("button");
tile.className = "fav-tile" + (sel.zone === "fav" && sel.i === i ? " sel" : "");
tile.style.setProperty("--h", hue(id));
- tile.textContent = tileCode(a, ACTIONS);
+ fillTile(tile, a);
tile.title = a.title;
tile.setAttribute("aria-label", actionLabel(a, ACTIONS));
tile.onclick = function () { sel = { zone: "fav", i: i }; activateEntry(a); };
+ // Numbered quick-launch badge (Alt/Option+digit), drawn on the corner.
+ var slot = favSlotForIndex(i);
+ if (slot) {
+ var badge = document.createElement("span");
+ badge.className = "fav-slot";
+ badge.textContent = slot;
+ badge.title = slot === "0" ? "Favourite 10 (Alt+0)" : "Favourite " + slot + " (Alt+" + slot + ")";
+ tile.appendChild(badge);
+ }
// Direct removal: a hover-revealed ✕ in the tile's corner. click() stops propagation so it
// unpins without activating the action.
var unpin = document.createElement("button");
@@ -430,35 +650,8 @@ function updateFavEyebrow(favs) {
}
// One settings row; has no star/tile because settings aren't pinnable.
-function renderSettingRow(s, i) {
- var row = document.createElement("div");
- row.className = "row" + (sel.zone === "list" && sel.i === i ? " sel" : "");
- row.setAttribute("aria-label", s.label);
-
- var tile = document.createElement("div");
- tile.className = "tile";
- tile.style.setProperty("--h", hue(s.opt_key));
- tile.textContent = settingCode(s, settingsResults);
-
- var left = document.createElement("div");
- left.className = "row-left";
- // why: the label already packs "Category : Group : Label", so no separate eyebrow.
- var line = document.createElement("div");
- line.className = "row-line";
- var name = document.createElement("div");
- name.className = "row-name";
- name.textContent = s.label;
- line.appendChild(name);
- left.appendChild(line);
-
- row.appendChild(tile);
- row.appendChild(left);
- row.onclick = function () { sel = { zone: "list", i: i }; render({ resize: true }); };
- row.ondblclick = function () { sel = { zone: "list", i: i }; jumpToSetting(s); };
- return row;
-}
-
-// A command/action row (used for both recents and filtered results).
+// A command/action row - used for search results, recents, and (because settings are actions now)
+// the setting options too. All rows are pinnable, so every row carries a star.
function renderActionRow(a, i) {
var on = FAVS.indexOf(a.id) !== -1;
var row = document.createElement("div");
@@ -468,7 +661,7 @@ function renderActionRow(a, i) {
var tile = document.createElement("div");
tile.className = "tile";
tile.style.setProperty("--h", hue(a.id));
- tile.textContent = tileCode(a, ACTIONS);
+ fillTile(tile, a);
var left = document.createElement("div");
left.className = "row-left";
@@ -507,78 +700,103 @@ function renderActionRow(a, i) {
return row;
}
-function renderCommandsList() {
- var q = (query || "").trim();
- var list = currentList();
- if (sel.zone === "list")
- sel.i = Math.max(0, Math.min(sel.i, list.length - 1));
- // Recents are not filtered, so clear any stale match marks from a previous typed query.
- if (!shouldRenderActionList(query))
- matchIndex = {};
- listEl.innerHTML = "";
-
- if (!shouldRenderActionList(query) && list.length) {
- var head = document.createElement("div");
- head.className = "dial-group";
- head.textContent = "Recent";
- listEl.appendChild(head);
+// Append rows [from, to) into listEl, always inserting before the bottom spacer so row order is preserved.
+function appendActionRows(list, from, to) {
+ var spacer = spacerEl || ensureSpacer();
+ for (var i = from; i < to; i++) {
+ var row = renderActionRow(list[i], i);
+ row.setAttribute("data-idx", i);
+ listEl.insertBefore(row, spacer);
}
-
- if (!list.length) {
- listEl.className = "dial-list empty";
- if (countEl) countEl.hidden = true;
- var empty = document.createElement("div");
- empty.className = "dial-empty";
- empty.textContent = shouldRenderActionList(query) ? ("No actions match (Total: " + ACTIONS.length + ")") : "No actions yet";
- listEl.appendChild(empty);
- return;
- }
-
- listEl.className = "dial-list";
- if (countEl) {
- countEl.hidden = false;
- countEl.textContent = shouldRenderActionList(query) ? resultCountText(ACTIONS.length, list.length, query) : list.length + " recent";
- }
- list.forEach(function (a, i) { listEl.appendChild(renderActionRow(a, i)); });
}
-function renderSettingsList() {
- var q = (query || "").trim();
- listEl.innerHTML = "";
- // Empty query + recents -> show the recent settings under a "Recent" group header.
- var showingRecents = !q && settingsResults.length > 0;
- if (!q && !showingRecents) {
- listEl.className = "dial-list empty";
- if (countEl) countEl.hidden = true;
- var hint = document.createElement("div");
- hint.className = "dial-empty";
- hint.textContent = "Type to search print, filament and printer settings";
- listEl.appendChild(hint);
- return;
+// Ensure the bottom spacer exists as the last child of listEl. It is (re)created on rebuild because
+// listEl.innerHTML="" destroys the old node.
+function ensureSpacer() {
+ if (!spacerEl || spacerEl.parentNode !== listEl) {
+ spacerEl = document.createElement("div");
+ spacerEl.className = "dial-spacer-bottom";
+ listEl.appendChild(spacerEl);
}
- if (q && !settingsResults.length) {
+ return spacerEl;
+}
+
+// Size the spacer to the un-rendered tail so the scrollbar reflects the full match count.
+function setBottomSpacer(total) {
+ ensureSpacer();
+ spacerEl.style.height = Math.max(0, total - renderEnd) * ROW_H + "px";
+}
+
+// Reveal rows up to `upto` (an exclusive index), appending without rebuilding the whole list. Used by
+// the scroll handler (viewport + overscan) and by arrow-nav that runs off the end of the current window.
+function revealTo(list, upto) {
+ var need = Math.min(list.length, upto);
+ if (need <= renderEnd)
+ return;
+ appendActionRows(list, renderEnd, need);
+ renderEnd = need;
+ setBottomSpacer(list.length);
+}
+
+// Rebuild the list from the first window (new search / phase change), clearing stale rows.
+function rebuildCommandsList(list) {
+ listEl.innerHTML = "";
+ listEl.className = "dial-list";
+ ensureSpacer();
+ renderEnd = 0;
+ appendActionRows(list, 0, Math.min(list.length, K_ROWS));
+ renderEnd = Math.min(list.length, K_ROWS);
+ setBottomSpacer(list.length);
+}
+
+// Toggle the .sel class in place - arrow-nav/click don't rebuild the DOM, just re-highlight the row.
+function updateSelection() {
+ var rows = listEl ? listEl.querySelectorAll(".row") : [];
+ for (var i = 0; i < rows.length; i++) {
+ var idx = parseInt(rows[i].getAttribute("data-idx"), 10);
+ rows[i].classList.toggle("sel", sel.zone === "list" && idx === sel.i);
+ }
+}
+
+function renderCommandsList() {
+ var list = currentList();
+ var total = list.length;
+ if (sel.zone === "list")
+ sel.i = Math.max(0, Math.min(sel.i, total - 1));
+ var showList = shouldRenderActionList(query);
+ // Recents are not filtered, so clear any stale match marks from a previous typed query.
+ if (!showList)
+ matchIndex = {};
+
+ if (!total) {
+ listEl.innerHTML = "";
+ spacerEl = null;
listEl.className = "dial-list empty";
if (countEl) countEl.hidden = true;
var empty = document.createElement("div");
empty.className = "dial-empty";
- empty.textContent = "No settings match";
+ empty.textContent = showList ? ("No actions match (Total: " + ACTIONS.length + ")") : "No actions yet";
listEl.appendChild(empty);
+ renderEnd = 0;
+ builtKey = buildKey() + "|0";
return;
}
- if (sel.zone === "list")
- sel.i = Math.max(0, Math.min(sel.i, settingsResults.length - 1));
+
+ var key = buildKey() + "|" + total;
+ if (key !== builtKey) {
+ builtKey = key;
+ rebuildCommandsList(list);
+ } else if (sel.i >= renderEnd) {
+ // Arrow-nav walked past the rendered window - reveal enough to keep the selection visible.
+ revealTo(list, revealTarget(total, sel.i, K_ROWS));
+ }
+
listEl.className = "dial-list";
if (countEl) {
countEl.hidden = false;
- countEl.textContent = showingRecents ? settingsResults.length + " recent" : settingsResults.length + " matches";
+ countEl.textContent = showList ? resultCountText(ACTIONS.length, total, query) : total + " recent";
}
- if (showingRecents) {
- var head = document.createElement("div");
- head.className = "dial-group";
- head.textContent = "Recent";
- listEl.appendChild(head);
- }
- settingsResults.forEach(function (s, i) { listEl.appendChild(renderSettingRow(s, i)); });
+ updateSelection();
}
// A tab row: no star/unpin (tabs aren't pinnable), tile monogram from the title. Uses tabTitle so
@@ -646,13 +864,349 @@ function renderPercentList() {
listEl.appendChild(ph);
}
+// ---- inline setting editor (DOM stage) ---------------------------------------
+
+// Collapse every open custom dropdown except `keep` (null collapses all). The menu list elements
+// are the .ed-dropdown-menu siblings of the toggle buttons we track via openDropDownEl.
+function closeOtherDropDowns(keep) {
+ if (openDropDownEl && openDropDownEl !== keep && openDropDownEl.parentNode) {
+ var m = openDropDownEl.parentNode.querySelector(".ed-dropdown-menu");
+ if (m) m.hidden = true;
+ openDropDownEl.classList.remove("open");
+ }
+ if (!keep)
+ openDropDownEl = null;
+}
+
+// Collapse the currently open dropdown, if any (kept for the editor's export/import-adjacent helpers).
+function closeEditorDropDown() { closeOtherDropDowns(null); }
+
+// Place an open dropdown menu as a fixed overlay just under its toggle, so the menu floats over the
+// card (never resizing it) and is clamped to the popup's bottom edge with an internal scrollbar for
+// long option lists. position:fixed escapes the card/launcher overflow clipping that an absolute
+// menu would otherwise hit, keeping every option reachable within the window.
+function positionDropDownMenu(btn, menu) {
+ var lrect = (document.querySelector(".launcher") || { getBoundingClientRect: function () { return { top: 0, bottom: window.innerHeight }; } }).getBoundingClientRect();
+ var rect = btn.getBoundingClientRect();
+ // Available room above and below the toggle, within the popup. Opening the menu must not push it
+ // past the window edge (that's the unreachable-overflow bug) - pick whichever side has more room
+ // and clamp the box to it. Overflow-y:auto scrolls any long list inside the menu itself.
+ var spaceBelow = lrect.bottom - (rect.bottom + 8);
+ var spaceAbove = (rect.top - 8) - lrect.top;
+ var openUp = spaceBelow < spaceAbove;
+ var maxH = Math.max(0, Math.min(openUp ? spaceAbove : spaceBelow, 200));
+ menu.style.position = "fixed";
+ menu.style.width = rect.width + "px";
+ menu.style.left = rect.left + "px";
+ menu.style.maxHeight = maxH + "px";
+ if (openUp) {
+ // bottom edge sits just above the toggle; the box grows upward to content height.
+ menu.style.top = "auto";
+ menu.style.bottom = (lrect.bottom - rect.top + 4) + "px";
+ } else {
+ menu.style.top = (rect.bottom + 4) + "px";
+ menu.style.bottom = "auto";
+ }
+}
+
+// Build the control element for one row (toggle/number/dropdown/combo/text/color) and seed it with
+// the current value. Returns {el, node, extra} - node is what is appended, extra carries a datalist.
+function settingInputFor(row) {
+ var el;
+ if (row.kind === "toggle") {
+ el = document.createElement("input");
+ el.type = "checkbox";
+ el.checked = !!row.value;
+ var sw = document.createElement("label");
+ sw.className = "ed-switch";
+ sw.appendChild(el);
+ var slider = document.createElement("span");
+ slider.className = "ed-slider";
+ sw.appendChild(slider);
+ return { el: el, node: sw };
+ }
+ if (row.kind === "number") {
+ el = document.createElement("input");
+ el.type = "number";
+ el.step = row.is_int ? 1 : "any";
+ if (row.min != null) el.min = row.min;
+ if (row.max != null) el.max = row.max;
+ if (row.value != null && row.value !== "") el.value = row.value;
+ return { el: el, node: el };
+ }
+ if (row.kind === "dropdown") {
+ // Native