diff --git a/resources/web/dialog/PluginsDialog/index.html b/resources/web/dialog/PluginsDialog/index.html
index 7f863fc742..1e879339ed 100644
--- a/resources/web/dialog/PluginsDialog/index.html
+++ b/resources/web/dialog/PluginsDialog/index.html
@@ -17,6 +17,7 @@
+
diff --git a/resources/web/dialog/PluginsDialog/plugin-search.js b/resources/web/dialog/PluginsDialog/plugin-search.js
index 2df851507b..7f7f32da8f 100644
--- a/resources/web/dialog/PluginsDialog/plugin-search.js
+++ b/resources/web/dialog/PluginsDialog/plugin-search.js
@@ -4,60 +4,14 @@ function PluginSearchActive() {
return pluginSearch.query.length > 0;
}
-// --- matcher: fold per-character on the fly so matched offsets stay in ORIGINAL coordinates ---
-// why: highlighting marks slices of the original string; a separate folded string would desync offsets.
-function FoldChar(ch) {
- return ch.normalize("NFD").replace(/\p{Diacritic}/gu, ""); // accents always folded (both Cc states)
-}
-function Norm(ch, caseSensitive) {
- const folded = FoldChar(ch);
- return caseSensitive ? folded : folded.toLowerCase(); // Cc controls case only
-}
-function EscapeRegExp(value) {
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
-}
-
+// why: matcher (FoldChar/Norm/FuzzyRanges/WholeWordRanges) lives in shared ../js/fuzzy-search.js,
+// loaded before this script - it is shared with the Speed Dial popup. Cc = pluginSearch.caseSensitive.
function MatchText(text, query) {
if (!query)
return [];
- return pluginSearch.wholeWord ? WholeWordRanges(text, query) : FuzzyRanges(text, query);
-}
-
-// Fuzzy: ordered subsequence. Builds ranges in original coordinates, merging adjacent runs on the fly.
-function FuzzyRanges(text, query) {
- const caseSensitive = pluginSearch.caseSensitive;
- const needle = Array.from(query).map((ch) => Norm(ch, caseSensitive)).join("");
- const ranges = [];
- let qi = 0;
- for (let i = 0; i < text.length && qi < needle.length; i++) {
- if (Norm(text[i], caseSensitive) === needle[qi]) {
- const last = ranges[ranges.length - 1];
- if (last && last[1] === i)
- last[1] = i + 1;
- else
- ranges.push([i, i + 1]);
- qi++;
- }
- }
- return qi === needle.length ? ranges : null;
-}
-
-// Whole word: literal \b-bounded match that bypasses fuzzy; Cc still applies. The per-char fold keeps the
-// haystack length-aligned to the original text, so regex indices map straight back to original offsets.
-// note: one-to-many folds (ligatures, eszett) shift offsets by a char; rare in plugin names, cosmetic only.
-function WholeWordRanges(text, query) {
- const caseSensitive = pluginSearch.caseSensitive;
- const haystack = Array.from(text).map((ch) => Norm(ch, caseSensitive)).join("");
- const needle = Array.from(query).map((ch) => Norm(ch, caseSensitive)).join("");
- if (!needle)
- return null;
- const re = new RegExp(`\\b${EscapeRegExp(needle)}\\b`, "g");
- const ranges = [];
- let match;
- // why: needle is non-empty, so \b-bounded matches are never zero-length - no empty-match guard needed.
- while ((match = re.exec(haystack)) !== null)
- ranges.push([match.index, match.index + match[0].length]);
- return ranges.length > 0 ? ranges : null;
+ return pluginSearch.wholeWord
+ ? WholeWordRanges(text, query, pluginSearch.caseSensitive)
+ : FuzzyRanges(text, query, pluginSearch.caseSensitive);
}
// Per-plugin evaluator consumed by RenderPlugins. The name text mirrors LabelCell's pluginLabelText so
diff --git a/resources/web/dialog/SpeedDial/index.html b/resources/web/dialog/SpeedDial/index.html
new file mode 100644
index 0000000000..95a34b377a
--- /dev/null
+++ b/resources/web/dialog/SpeedDial/index.html
@@ -0,0 +1,32 @@
+
+
+
+
+
+ Speed Dial
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/web/dialog/SpeedDial/speeddial.js b/resources/web/dialog/SpeedDial/speeddial.js
new file mode 100644
index 0000000000..65f64b4399
--- /dev/null
+++ b/resources/web/dialog/SpeedDial/speeddial.js
@@ -0,0 +1,498 @@
+// Speed Dial launcher page. Static-safe module: no DOM access at load time so a
+// node vm can exercise the pure helpers (filterActions / actionLabel / nextSel).
+
+// ---- state (populated by the C++ bridge via window.HandleStudio) ----
+var ACTIONS = []; // [{id,title,source,shortcut}], already frecency-sorted by C++
+var FAVS = []; // [id...]
+var query = "";
+var sel = { zone: "list", i: 0 }; // zone: 'list' | 'fav'
+var lastResizeHeight = 0;
+var matchIndex = {};
+
+// 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;
+
+// ---- pure helpers (no DOM; unit-tested) -------------------------------------
+function filterActions(actions, query) {
+ var q = (query || "").trim();
+ var list = actions || [];
+ matchIndex = {};
+ if (!q)
+ return list.slice(0);
+
+ var out = [];
+ 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);
+ }
+ return out;
+}
+
+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.
+ var seen = {};
+ (actions || []).forEach(function (a) { seen[a.id] = true; });
+ return (favourites || []).filter(function (id, i, arr) {
+ return seen[id] && arr.indexOf(id) === i;
+ });
+}
+
+function resultCountText(total, shown, query) {
+ return (query || "").trim() ? "Showing " + shown + " of " + total + " actions" : total + " actions";
+}
+
+function shouldRenderActionList(query) {
+ return !!(query || "").trim();
+}
+
+// Resolve the selection cursor {zone,i} to the action id it points at: fav zone indexes the
+// visible favourites, list zone the filtered actions. Pure so runSelected() shares one lookup.
+function selectedActionId(sel, actions, favIds, query) {
+ if (sel.zone === "fav")
+ return favIds[sel.i];
+ // why: search-first keeps the list blank until the user types; an empty query still
+ // filters to ALL actions, so without this gate Enter fires an action never shown.
+ if (!shouldRenderActionList(query))
+ return null;
+ var a = actions[sel.i];
+ return a && a.id;
+}
+
+function foldLabel(s) { return String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, ""); }
+
+// Title-case a source for display: "GCODE OPTIMIZER"/"iRoNiNg pRo" -> "Gcode Optimizer"/"Ironing Pro".
+function prettySource(source) {
+ return String(source || "").toLowerCase().replace(/\b\w/g, function (c) { return c.toUpperCase(); });
+}
+
+// Accessible label "Title from Pretty Source", disambiguated with the opaque action id when another
+// action shares the same title+source (case/separator-insensitive) - so two rows never read out identically.
+function actionLabel(action, actions) {
+ var label = action.title + " from " + prettySource(action.source);
+ if (actions && actions.length) {
+ var mine = foldLabel(action.title) + "|" + foldLabel(action.source);
+ var clash = actions.some(function (o) {
+ return o.id !== action.id && foldLabel(o.title) + "|" + foldLabel(o.source) === mine;
+ });
+ if (clash)
+ label += " (" + action.id + ")";
+ }
+ return label;
+}
+
+// Monogram code for a tile: title initial, escalated on collision by PREPENDING the source
+// initial (pi+ti, e.g. "GC"), then a 1-based ordinal - so same-titled actions stay distinct.
+// why: ordinal is assigned by id, not by ACTIONS order - ACTIONS is frecency-sorted and
+// reshuffles as usage changes, which would otherwise flip who's "1" and who's "2" across runs.
+function tileCode(action, actions) {
+ var list = actions || [];
+ var ti = (action.title || " ").charAt(0).toUpperCase();
+ var sameTitle = list.filter(function (o) { return (o.title || " ").charAt(0).toUpperCase() === ti; });
+ if (sameTitle.length <= 1)
+ return ti;
+ var pi = (action.source || " ").charAt(0).toUpperCase();
+ var sameSource = sameTitle.filter(function (o) { return (o.source || " ").charAt(0).toUpperCase() === pi; });
+ if (sameSource.length <= 1)
+ return pi + ti;
+ sameSource.sort(function (a, b) { return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; });
+ for (var i = 0; i < sameSource.length; i++)
+ if (sameSource[i].id === action.id)
+ return pi + ti + (i + 1);
+ return pi + ti;
+}
+
+function syncClearButton() {
+ if (clearEl)
+ clearEl.hidden = !query;
+}
+
+function stateFromPayload(payload) {
+ return {
+ actions: payload.actions || [],
+ favourites: payload.favourites || [],
+ query: "",
+ sel: { zone: "list", i: 0 },
+ lastResizeHeight: 0
+ };
+}
+
+function resetScrollPositions(list, doc) {
+ if (list)
+ list.scrollTop = 0;
+ if (doc && doc.scrollingElement)
+ doc.scrollingElement.scrollTop = 0;
+ if (doc && doc.documentElement)
+ doc.documentElement.scrollTop = 0;
+ if (doc && doc.body)
+ 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}.
+function nextSel(sel, key, listLen, favLen) {
+ var zone = sel.zone, i = sel.i;
+ if (key === "ArrowDown") {
+ if (zone === "fav") return { zone: "list", i: 0 };
+ return { zone: "list", i: Math.min(i + 1, Math.max(0, listLen - 1)) };
+ }
+ if (key === "ArrowUp") {
+ if (zone === "list") {
+ if (i <= 0) return favLen ? { zone: "fav", i: 0 } : { zone: "list", i: 0 };
+ return { zone: "list", i: i - 1 };
+ }
+ return { zone: zone, i: i };
+ }
+ if (key === "ArrowLeft" && zone === "fav") return { zone: "fav", i: Math.max(0, i - 1) };
+ if (key === "ArrowRight" && zone === "fav") return { zone: "fav", i: Math.min(favLen - 1, i + 1) };
+ return { zone: zone, i: i };
+}
+
+// ---- bridge ------------------------------------------------------------------
+function SendMessage(msg) {
+ if (typeof SendWXMessage !== "function")
+ return;
+ if (typeof msg === "string") msg = { command: msg };
+ if (msg.sequence_id === undefined) msg.sequence_id = Date.now();
+ SendWXMessage(JSON.stringify(msg));
+}
+
+// C++ pushes payloads here. Only list_actions is handled; it (re)seeds all state.
+window.HandleStudio = function (payload) {
+ if (!payload) return;
+ if (typeof payload === "string") { try { payload = JSON.parse(payload); } catch (e) { return; } }
+ if (payload.command === "list_actions") {
+ var next = stateFromPayload(payload);
+ ACTIONS = next.actions;
+ FAVS = next.favourites;
+ query = next.query;
+ sel = next.sel;
+ lastResizeHeight = next.lastResizeHeight;
+ if (qEl) {
+ qEl.value = "";
+ qEl.placeholder = "Search " + ACTIONS.length + " actions";
+ syncClearButton();
+ }
+ render({ resize: true, resetScroll: true });
+ focusInput();
+ }
+};
+
+// ---- DOM helpers -------------------------------------------------------------
+function $(id) { return document.getElementById(id); }
+
+function byId(id) {
+ for (var i = 0; i < ACTIONS.length; i++) if (ACTIONS[i].id === id) return ACTIONS[i];
+ return null;
+}
+
+function currentVisibleFavs() { return visibleFavourites(FAVS, ACTIONS); }
+
+function hue(id) {
+ var h = 0;
+ for (var i = 0; i < id.length; i++)
+ h = (h * 31 + id.charCodeAt(i)) >>> 0;
+ return h % 360;
+}
+
+// Build a with the search-match ranges wrapped in
. Used for both the
+// title and the source eyebrow. Pure (only touches the document factory), so the node-vm test never
+// calls it and load-time stays DOM-free.
+function markedText(className, text, match) {
+ var node = document.createElement("div");
+ node.className = className; node.title = text;
+ if (!match || !match.length) {
+ node.textContent = text;
+ return node;
+ }
+ var last = 0;
+ for (var i = 0; i < match.length; i++) {
+ var range = match[i];
+ if (range[0] > last)
+ node.appendChild(document.createTextNode(text.slice(last, range[0])));
+ var m = document.createElement("mark");
+ m.textContent = text.slice(range[0], range[1]);
+ node.appendChild(m);
+ last = range[1];
+ }
+ if (last < text.length)
+ node.appendChild(document.createTextNode(text.slice(last)));
+ return node;
+}
+
+function starSvg(on) {
+ return '';
+}
+
+// ---- render ------------------------------------------------------------------
+function renderFav() {
+ favEl.innerHTML = "";
+ var favs = currentVisibleFavs();
+ favEl.hidden = favs.length === 0;
+ if (!favs.length && sel.zone === "fav")
+ sel = { zone: "list", i: 0 };
+ else if (sel.zone === "fav")
+ sel.i = Math.max(0, Math.min(sel.i, favs.length - 1));
+ updateFavEyebrow(favs);
+ favs.forEach(function (id, i) {
+ var a = byId(id);
+ 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);
+ tile.title = a.title;
+ tile.setAttribute("aria-label", actionLabel(a, ACTIONS));
+ tile.onclick = function () { sel = { zone: "fav", i: i }; run(a); };
+ tile.oncontextmenu = function (ev) {
+ ev.preventDefault();
+ // why: selecting shows the eyebrow, which grows the launcher - resize so the popup
+ // isn't clipped (mirrors arrow-nav). requestResize no-ops when height is unchanged.
+ sel = { zone: "fav", i: i }; render({ resize: true });
+ showFavMenu(ev.clientX, ev.clientY, id);
+ };
+ favEl.appendChild(tile);
+ });
+}
+
+// ---- favourite context menu (right-click a tile) -----------------------------
+var favMenuEl = null;
+
+function hideFavMenu() { if (favMenuEl) favMenuEl.hidden = true; }
+
+function addFavMenuItem(label, enabled, fn) {
+ var item = document.createElement("button");
+ item.className = "ctx-item";
+ item.textContent = label;
+ item.disabled = !enabled;
+ item.onclick = function () { hideFavMenu(); fn(); };
+ favMenuEl.appendChild(item);
+}
+
+// One reused menu node (Move left/right + Unpin), positioned at the cursor and clamped
+// to the viewport. Native browser context menus can't add items, so we roll our own tiny one.
+function showFavMenu(x, y, id) {
+ if (!favMenuEl) {
+ favMenuEl = document.createElement("div");
+ favMenuEl.className = "ctx-menu";
+ document.body.appendChild(favMenuEl);
+ }
+ favMenuEl.innerHTML = "";
+ var favs = currentVisibleFavs();
+ var vi = favs.indexOf(id);
+ addFavMenuItem("Move left", vi > 0, function () { moveFav(id, -1); });
+ addFavMenuItem("Move right", vi >= 0 && vi < favs.length - 1, function () { moveFav(id, 1); });
+ addFavMenuItem("Unpin", true, function () { toggleFav(id); });
+ favMenuEl.hidden = false;
+ favMenuEl.style.left = Math.max(0, Math.min(x, window.innerWidth - favMenuEl.offsetWidth - 4)) + "px";
+ favMenuEl.style.top = Math.max(0, Math.min(y, window.innerHeight - favMenuEl.offsetHeight - 4)) + "px";
+}
+
+// Swap a favourite with its visible neighbour (dir -1/+1) and persist the new order. Swapping
+// by id inside FAVS (not the visible slice) keeps any hidden pins (no live action) in place.
+function moveFav(id, dir) {
+ var favs = currentVisibleFavs();
+ var vi = favs.indexOf(id);
+ var ni = vi + dir;
+ if (vi === -1 || ni < 0 || ni >= favs.length) return;
+ var a = FAVS.indexOf(id), b = FAVS.indexOf(favs[ni]);
+ if (a === -1 || b === -1) return;
+ FAVS[a] = favs[ni]; FAVS[b] = id;
+ SendMessage({ command: "reorder_favourites", ids: FAVS.slice() });
+ sel = { zone: "fav", i: ni };
+ render({ resize: true });
+}
+
+// Name of the selected favourite, shown above the bar; hidden unless a fav is selected.
+function updateFavEyebrow(favs) {
+ if (!eyeEl) return;
+ var a = sel.zone === "fav" && favs.length ? byId(favs[sel.i]) : null;
+ eyeEl.textContent = a ? a.title : "";
+ eyeEl.hidden = !a;
+}
+
+function renderList() {
+ var q = (query || "").trim();
+ var arr = filterActions(ACTIONS, query);
+ if (sel.zone === "list")
+ sel.i = Math.max(0, Math.min(sel.i, arr.length - 1));
+ listEl.innerHTML = "";
+ // why: search-first - the list stays blank until the user types; only a
+ // non-empty query with zero hits earns the "No actions match" message.
+ if (!shouldRenderActionList(query)) {
+ listEl.className = "dial-list empty";
+ if (countEl) countEl.hidden = true;
+ return;
+ }
+ listEl.className = "dial-list" + (!arr.length ? " empty" : "");
+ if (!arr.length) {
+ if (countEl) countEl.hidden = true;
+ var empty = document.createElement("div");
+ empty.className = "dial-empty";
+ empty.textContent = "No actions match (Total: " + ACTIONS.length + ")";
+ listEl.appendChild(empty);
+ return;
+ }
+ if (countEl) {
+ countEl.hidden = false;
+ countEl.textContent = resultCountText(ACTIONS.length, arr.length, query);
+ }
+ arr.forEach(function (a, i) {
+ var on = FAVS.indexOf(a.id) !== -1;
+ var row = document.createElement("div");
+ row.className = "row" + (sel.zone === "list" && sel.i === i ? " sel" : "");
+ row.setAttribute("aria-label", actionLabel(a, ACTIONS));
+
+ var tile = document.createElement("div");
+ tile.className = "tile";
+ tile.style.setProperty("--h", hue(a.id));
+ tile.textContent = tileCode(a, ACTIONS);
+
+ var left = document.createElement("div");
+ left.className = "row-left";
+ var mi = matchIndex[a.id];
+ var sourceEl = markedText("row-eyebrow", a.source, mi ? mi.source : null);
+ var line = document.createElement("div");
+ line.className = "row-line";
+ var name = markedText("row-name", a.title, mi ? mi.title : null);
+ line.appendChild(name);
+ if (a.shortcut) {
+ var sc = document.createElement("div");
+ sc.className = "row-sc";
+ a.shortcut.split("+").forEach(function (k) {
+ var key = document.createElement("kbd");
+ key.textContent = k;
+ sc.appendChild(key);
+ });
+ line.appendChild(sc);
+ }
+ left.appendChild(sourceEl);
+ left.appendChild(line);
+ row.appendChild(tile);
+ row.appendChild(left);
+
+ var star = document.createElement("button");
+ star.className = "star" + (on ? " on" : "");
+ star.innerHTML = starSvg(on);
+ star.title = on ? "Unpin from favourites" : "Pin to favourites";
+ star.onclick = function (ev) { ev.stopPropagation(); toggleFav(a.id); };
+ // why: two quick fav/unfav clicks must not dblclick-run the row
+ star.ondblclick = function (ev) { ev.stopPropagation(); };
+ row.appendChild(star);
+
+ row.onclick = function () { sel = { zone: "list", i: i }; render(); };
+ row.ondblclick = function () { sel = { zone: "list", i: i }; run(a); };
+ listEl.appendChild(row);
+ });
+}
+
+function render(opts) {
+ renderFav();
+ renderList();
+ scrollSelectedIntoView();
+ if (opts && opts.resetScroll)
+ resetScrollPositions(listEl, document);
+ if (opts && opts.resize)
+ requestResize();
+}
+
+// Keep the selected item in view as arrows move it: the list scrolls vertically, the fav bar
+// horizontally (arrow nav "pushes" the scrollable fav row to follow the selection).
+function scrollSelectedIntoView() {
+ var el = null;
+ if (sel.zone === "list" && listEl)
+ el = listEl.querySelector(".row.sel");
+ else if (sel.zone === "fav" && favEl)
+ el = favEl.querySelector(".fav-tile.sel");
+ if (el && el.scrollIntoView)
+ el.scrollIntoView({ block: "nearest", inline: "nearest" });
+}
+
+function requestResize() {
+ if (!document.body)
+ return;
+ setTimeout(function () {
+ var launcher = document.querySelector(".launcher");
+ if (!launcher)
+ return;
+ var height = Math.ceil(launcher.getBoundingClientRect().height);
+ if (!height || height === lastResizeHeight)
+ return;
+ lastResizeHeight = height;
+ SendMessage({ command: "resize", height: height });
+ }, 0);
+}
+
+// ---- actions -----------------------------------------------------------------
+function toggleFav(id) {
+ var k = FAVS.indexOf(id);
+ var newState = k === -1;
+ if (newState) FAVS.push(id); else FAVS.splice(k, 1);
+ SendMessage({ command: "toggle_favourite", id: id, fav: newState });
+ render({ resize: true });
+}
+
+// Fire the action; C++ owns the run-confirm (native dialog) + suppression, then closes the popup + toasts.
+function run(a) {
+ if (!a) return;
+ SendMessage({ command: "run_action", id: a.id, title: a.title });
+}
+
+function runSelected() {
+ var id = selectedActionId(sel, filterActions(ACTIONS, query), currentVisibleFavs(), query);
+ if (id) run(byId(id));
+}
+
+function focusInput() { setTimeout(function () { qEl.focus(); }, 0); }
+
+// ---- init --------------------------------------------------------------------
+function OnInit() {
+ qEl = $("q"); listEl = $("list"); favEl = $("favBar"); clearEl = $("clear"); eyeEl = $("favEyebrow"); countEl = $("count");
+ syncClearButton();
+
+ $("clear").onclick = function () {
+ query = ""; qEl.value = ""; sel = { zone: "list", i: 0 }; render({ resize: true, resetScroll: true }); qEl.focus();
+ syncClearButton();
+ };
+ qEl.addEventListener("input", function () {
+ query = qEl.value; sel = { zone: "list", i: 0 }; syncClearButton(); render({ resize: true, resetScroll: true });
+ });
+
+ // why: dismiss the fav context menu on any click/scroll away from it (capture scroll to catch nested scrollers).
+ document.addEventListener("click", hideFavMenu);
+ document.addEventListener("scroll", hideFavMenu, true);
+
+ document.addEventListener("keydown", function (e) {
+ if (favMenuEl && !favMenuEl.hidden && e.key === "Escape") { e.preventDefault(); hideFavMenu(); return; }
+ var arr = filterActions(ACTIONS, query);
+ var favs = currentVisibleFavs();
+ // why: Up/Down always navigate; Left/Right only navigate the fav bar. In the list zone,
+ // let Left/Right fall through so they move the caret in the focused search field.
+ var lr = e.key === "ArrowLeft" || e.key === "ArrowRight";
+ if (e.key === "ArrowDown" || e.key === "ArrowUp" || (lr && sel.zone === "fav")) {
+ e.preventDefault();
+ sel = nextSel(sel, e.key, arr.length, favs.length);
+ // why: entering/leaving the fav zone toggles the eyebrow line, changing launcher height;
+ // resize so the popup grows/shrinks instead of clipping. requestResize no-ops when unchanged.
+ render({ resize: true });
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ runSelected();
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ if (query) { query = ""; qEl.value = ""; sel = { zone: "list", i: 0 }; syncClearButton(); render({ resize: true, resetScroll: true }); }
+ else SendMessage({ command: "close_page" });
+ }
+ });
+
+ SendMessage({ command: "request_actions" });
+}
diff --git a/resources/web/dialog/SpeedDial/speeddial.test.js b/resources/web/dialog/SpeedDial/speeddial.test.js
new file mode 100644
index 0000000000..05c1e69c23
--- /dev/null
+++ b/resources/web/dialog/SpeedDial/speeddial.test.js
@@ -0,0 +1,45 @@
+// Regression tests for the DOM-free Speed Dial helpers.
+// Run: node resources/web/dialog/SpeedDial/speeddial.test.js
+const assert = require("assert");
+const fs = require("fs");
+const vm = require("vm");
+
+const ctx = {};
+ctx.window = ctx;
+vm.createContext(ctx);
+vm.runInContext(fs.readFileSync(__dirname + "/../../js/fuzzy-search.js", "utf8"), ctx);
+vm.runInContext(fs.readFileSync(__dirname + "/speeddial.js", "utf8"), ctx);
+
+assert.equal(typeof ctx.parseId, "undefined", "opaque action ids must never be parsed");
+
+const duplicateActions = [
+ { id: "0123456789abcdef", title: "Repair", source: "Mesh Tools" },
+ { id: "fedcba9876543210", title: "Repair", source: "Mesh Tools" }
+];
+assert.equal(
+ ctx.actionLabel(duplicateActions[0], duplicateActions),
+ "Repair from Mesh Tools (0123456789abcdef)",
+ "duplicate labels should use the opaque id without interpreting its contents"
+);
+
+assert.equal(ctx.shouldRenderActionList(""), false, "an empty search keeps the action list hidden");
+assert.equal(ctx.shouldRenderActionList(" "), false, "whitespace-only search keeps the action list hidden");
+assert.equal(ctx.shouldRenderActionList("r"), true, "typing starts rendering matching actions");
+
+assert.equal(
+ ctx.selectedActionId({ zone: "list", i: 0 }, duplicateActions, [], ""),
+ null,
+ "Enter with an empty query must not resolve to an action the blank list never showed"
+);
+assert.equal(
+ ctx.selectedActionId({ zone: "list", i: 0 }, duplicateActions, [], "rep"),
+ "0123456789abcdef",
+ "a typed query resolves the list selection"
+);
+assert.equal(
+ ctx.selectedActionId({ zone: "fav", i: 0 }, duplicateActions, ["fedcba9876543210"], ""),
+ "fedcba9876543210",
+ "favourites stay runnable with an empty query - the fav bar is always visible"
+);
+
+console.log("ok");
diff --git a/resources/web/dialog/SpeedDial/style.css b/resources/web/dialog/SpeedDial/style.css
new file mode 100644
index 0000000000..72de412f66
--- /dev/null
+++ b/resources/web/dialog/SpeedDial/style.css
@@ -0,0 +1,225 @@
+* { box-sizing: border-box; }
+html, body { margin: 0; }
+body {
+ font-family: var(--orca-font, "Segoe UI", sans-serif);
+ font-size: 13px;
+ color: var(--text, var(--orca-fg, #1b1c1e));
+ background: var(--bg, var(--orca-bg, #fff));
+ overflow: hidden;
+ user-select: none;
+}
+.launcher {
+ display: flex;
+ flex-direction: column;
+ background: var(--panel, var(--orca-bg, #fff));
+ border: 1px solid var(--border, var(--orca-border, #ddd));
+ overflow: hidden;
+ /* why: no height cap here - the launcher reports its true natural height to C++, which
+ sizes the popup to match (HTML is source of truth). The list's own max-height is what
+ bounds growth; capping the launcher would make the measurement circular. */
+}
+.fav-bar {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 9px 10px;
+ border-bottom: 1px solid var(--border, var(--orca-border, #ddd));
+ overflow-x: auto; /* scroll horizontally once favourites overflow the row */
+ scrollbar-width: thin;
+ /* why: keep scrollIntoView (arrow-nav) from scrolling the first/last tile flush to the edge,
+ which would clip its selected outline. Matches the 10px horizontal padding. */
+ scroll-padding-inline: 10px;
+}
+.fav-bar[hidden] { display: none; }
+/* Name of the selected favourite, above the bar; left-aligned to the tiles' 10px inset. */
+.fav-eyebrow { flex: 0 0 auto; padding: 8px 10px 0; }
+.fav-tile {
+ flex: 0 0 auto; /* keep tiles full-size; don't shrink to fit - scroll instead */
+ width: 30px;
+ height: 30px;
+ border: 0;
+ border-radius: 8px;
+ cursor: pointer;
+ color: hsl(var(--h) var(--speed-tile-text-s, 72%) var(--speed-tile-text-l, 38%));
+ font-weight: 700;
+ /* why: mirror .tile centering - tileCode can be 2-3 chars (e.g. "EA1") on collision, and a
+ bare