Add actions speed dial to run plugin scripts (#14697)

# Description

Use Space to trigger a new **speed dial**, which allows users to run
**app actions**. The only app actions implemented currently are python
plugin scripts.

## Notes

- Only toggleable in the Prepare (3D) view. Press Space, type to filter,
Enter or double-click to run.
- Focuses on search bar automatically
- Frecency-sorted (run count + recency) with alphabetical fallback (C++
computes)
- Pin actions as favourites and they will show on the top favourites
bar. Done through star icon on each row.
- Script plugins have no icon art yet, so tiles show a collision-aware
monogram: the capability's initial, escalating only when names collide -
prepend the package initial, then add an ordinal - so same-named actions
from different plugins stay distinguishable.
- E.g., capability Bravo from plugin Alpha normally shows just B. If
another action's name also starts with B, they disambiguate by
prepending the package initial (Alpha -> AB). If two still collide on
both initials (both AB), they become AB1 and AB2.
- "Run X?" confirm with a per-plugin "don't ask again" scope, owned
C++-side; suppression persists.
- Persistence (`speed_dial` AppConfig section): `favourite_actions`
(ordered id list) + per-action `stats` + `ask_suppressed`.

- Example of shape in data_dir:
```json
{
    "speed_dial": {
        "ask_suppressed": "[\"9b12aa079924bbc4\"]",
        "favourite_actions": "[\"ccfdf8b9e492b624\",\"9b12aa079924bbc4\",\"b7abfa67626248e4\"]",
        "stats": "{\"31d9d129a616a8b7\":{\"count\":4,\"last\":1783924989},\"53ec17d430634f62\":{\"count\":3,\"last\":1783939329},\"9b12aa079924bbc4\":{\"count\":5,\"last\":1783924981},\"9f2cb0d3ca56a87c\":{\"count\":1,\"last\":1783668370},\"b7abfa67626248e4\":{\"count\":4,\"last\":1783939325},\"f93469da14248128\":{\"count\":3,\"last\":1783939337}}"
	},
}
```

# Screenshots/Recordings/Graphs

<img width="688" height="335" alt="image"
src="https://github.com/user-attachments/assets/683efa3b-9401-4977-a347-d70193188165"
/>

<img width="681" height="172" alt="image"
src="https://github.com/user-attachments/assets/fafb4965-054b-4fd5-ad6a-03145264fbe0"
/>

<img width="683" height="335" alt="image"
src="https://github.com/user-attachments/assets/00d5bd49-95c0-4f2f-966b-6c04c14c3cf3"
/>


## Tests

- **Web layer (green):** node-vm logic test `test-speeddial-logic.js`
covers `filterActions`, `visibleFavourites` (incl. the runnable guard),
`selectedActionId`, `resultCountText`, `actionLabel`, `tileCode`,
`nextSel`, and payload seeding - pure helpers, DOM-free.
- **Backend:** `ActionRegistry` FNV-1a id golden-vector Catch2 test
(`test_speed_dial_action_id`) pins the hash; the registry was verified
by fresh-context review including a threading fix (`run()` operates on a
stack copy so a queued refresh can't reallocate the action vector
mid-run).
- **Manual (all passing):**
- Space opens the dial in Prepare only; no regression to existing
Prepare-tab keys or the Plugins dialog.
- Search auto-focuses; typing filters live; a freshly-run action rises
in the frecency order.
- Up jumps to the favourites bar, Down into the list, Alt+1..9 hits
favourites; Enter and double-click run the highlighted action.
- Star pins/unpins an action; favourites persist across an app restart.
- "Run X?" confirm with per-plugin "don't ask again" is respected on
later runs.
  - The `?` shortcuts dialog shows the Space row.
  - Verified in both light and dark themes.

## Known Issues

When there are no actions, plugins, or scripts, the search bar will show
"Search 0 actions". This is bad UX. One alternative considered was to
show a call to action, for example "Please load plugin scripts so that
they appear here".

However, this is ultimately not implemented, as eventually it is not
expected that actions will be empty. The action registry will not be
expected to be empty because we will include in-app actions such as
opening dialogues or other app actions.

<img width="722" height="117" alt="image"
src="https://github.com/user-attachments/assets/a4b9c0db-b2bf-44bc-9550-398dd8b4c7aa"
/>

[How to Download Pull Requests Artifacts for
Testing](https://www.orcaslicer.com/wiki/how_to_download_pr_artifacts)
This commit is contained in:
SoftFever
2026-07-17 23:36:34 +08:00
committed by GitHub
25 changed files with 1813 additions and 97 deletions
@@ -17,6 +17,7 @@
<script type="text/javascript" src="../js/common.js"></script>
<script src="./index.js"></script>
<script src="./plugin-sort.js"></script>
<script src="../js/fuzzy-search.js"></script>
<script src="./plugin-search.js"></script>
</head>
<body onLoad="OnInit()">
@@ -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
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Speed Dial</title>
<link rel="stylesheet" href="./style.css" />
<link rel="stylesheet" type="text/css" href="../css/theme.css" />
<script type="text/javascript" src="../../include/globalapi.js"></script>
<script src="../../js/fuzzy-search.js"></script>
<script src="./speeddial.js"></script>
</head>
<body onload="OnInit()">
<div class="launcher">
<div class="row-eyebrow fav-eyebrow" id="favEyebrow" hidden></div>
<div class="fav-bar" id="favBar"></div>
<div class="dial-head">
<div class="plugin-search">
<span class="plugin-search-icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.5" y2="16.5"/></svg>
</span>
<input class="plugin-search-input" id="q" type="text" placeholder="Search actions" spellcheck="false" autocomplete="off" aria-label="Search actions" />
<button class="plugin-search-clear" id="clear" type="button" title="Clear" aria-label="Clear search" hidden>
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><line x1="5" y1="5" x2="11" y2="11"/><line x1="11" y1="5" x2="5" y2="11"/></svg>
</button>
</div>
<div class="dial-count" id="count" hidden></div>
</div>
<div class="dial-list" id="list"></div>
</div>
</body>
</html>
+498
View File
@@ -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 <div class=className> with the search-match ranges wrapped in <mark>. 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 '<svg width="15" height="15" viewBox="0 0 24 24" fill="' + (on ? "currentColor" : "none") +
'" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">' +
'<path d="M12 3.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 17.9 6.8 20.6l1-5.8L3.5 9.7l5.9-.9z"/></svg>';
}
// ---- 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" });
}
@@ -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");
+225
View File
@@ -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 <button> inherits 13px + UA padding, clipping the code (e.g. "AE1"). inline-flex + 12px + pad:0 fits it. */
font-size: 12px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: var(--speed-tile-bg, #f0f0f0);
border: 1px solid var(--speed-tile-border, #d8d8d8);
}
.fav-tile.sel { outline: 2px solid var(--main-color, var(--orca-accent, #009688)); outline-offset: 2px; }
.ctx-menu {
position: fixed;
z-index: 10;
min-width: 120px;
padding: 4px;
background: var(--panel, var(--orca-bg, #fff));
border: 1px solid var(--border, var(--orca-border, #ddd));
border-radius: 7px;
box-shadow: 0 4px 16px rgba(0,0,0,.18);
}
.ctx-menu[hidden] { display: none; }
.ctx-item {
display: block;
width: 100%;
padding: 6px 10px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--text, var(--orca-fg, #1b1c1e));
font: inherit;
text-align: left;
cursor: pointer;
}
.ctx-item:hover { background: var(--row-hover, rgba(127,127,127,.16)); }
.ctx-item:disabled { opacity: .4; cursor: default; }
.ctx-item:disabled:hover { background: transparent; }
.dial-head { flex: 0 0 auto; padding: 8px; }
.plugin-search {
display: flex;
align-items: center;
gap: 4px;
height: 30px;
padding: 0 8px;
background: var(--panel, var(--orca-bg, #fff));
border: 1px solid var(--border, var(--orca-border, #ddd));
border-radius: 7px;
}
.plugin-search:focus-within {
border-color: var(--main-color, var(--orca-accent, #009688));
box-shadow: 0 0 0 2px rgba(0,150,136,.25);
}
.plugin-search-icon { display: inline-flex; color: var(--muted, var(--orca-muted, #6b7280)); }
.plugin-search-input {
flex: 1;
min-width: 0;
background: transparent;
border: 0;
outline: 0;
color: var(--text, var(--orca-fg, #1b1c1e));
font: inherit;
}
.plugin-search-clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
padding: 0;
border: 0;
border-radius: 50%;
color: var(--muted, var(--orca-muted, #6b7280));
cursor: pointer;
background: rgba(127,127,127,.20);
}
.plugin-search-clear[hidden] { display: none; }
.dial-count {
padding: 4px 4px 0;
text-align: left;
font-size: 11px;
color: var(--muted, var(--orca-muted, #6b7280));
}
.dial-count[hidden] { display: none; }
.dial-list {
flex: 0 1 auto;
min-height: 0;
/* ADJUST HEIGHT HERE. The popup auto-resizes to the content (HTML is the source of truth), so
this list cap drives the whole window height: --rows full rows + a ~30% peek of the next row
as a "scroll for more" affordance. Bump --rows to show more rows; change 0.3 for a bigger/
smaller peek. --row-h MUST match .row min-height (44px). */
--row-h: 44px;
--rows: 5;
max-height: calc(var(--rows) * var(--row-h) + 0.3 * var(--row-h) + 4px);
overflow-y: auto;
padding: 4px 4px 6px;
scrollbar-width: thin;
}
.dial-list.empty { overflow-y: hidden; }
.row {
display: flex;
align-items: center;
gap: 10px;
min-height: 44px;
padding: 6px 8px;
border-radius: 7px;
cursor: pointer;
}
.row:hover { background: var(--row-hover, rgba(127,127,127,.16)); }
.row.sel { background: var(--row-selected, rgba(0,150,136,.18)); }
.tile {
flex: 0 0 auto;
width: 26px;
height: 26px;
border-radius: 6px;
color: hsl(var(--h) var(--speed-tile-text-s, 72%) var(--speed-tile-text-l, 38%));
font-weight: 700;
font-size: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--speed-tile-bg, #f0f0f0);
border: 1px solid var(--speed-tile-border, #d8d8d8);
}
.row-left { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
.row-eyebrow {
font-size: 10px;
line-height: 1.3;
color: var(--muted, var(--orca-muted, #6b7280));
opacity: .85;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-line { display: flex; align-items: center; gap: 7px; min-width: 0; }
.row-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row-name mark, .row-eyebrow mark { background: var(--plugin-status-warn-bg); color: var(--plugin-status-warn); border-radius: 2px; }
.row-sc { flex: 0 0 auto; display: inline-flex; gap: 3px; }
kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: 11px;
color: var(--muted, var(--orca-muted, #6b7280));
background: rgba(127,127,127,.12);
border: 1px solid var(--border, var(--orca-border, #ddd));
border-radius: 4px;
}
.star {
flex: 0 0 auto;
width: 24px;
height: 24px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--muted, var(--orca-muted, #6b7280));
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0;
}
.star svg { display: block; }
.row:hover .star:not(.on),
.row.sel .star:not(.on) { opacity: .5; }
.star.on { opacity: 1; color: var(--main-color, var(--orca-accent, #009688)); }
.star:hover { background: rgba(127,127,127,.18); }
.dial-empty {
min-height: 64px;
padding: 18px 10px;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted, var(--orca-muted, #6b7280));
text-align: center;
}
+12
View File
@@ -51,6 +51,12 @@
--ctx-bg: #ffffff;
--ctx-border: #cccccc;
--ctx-hover: #efefef;
/* Speed dial icon tile treatment */
--speed-tile-bg: #efefef;
--speed-tile-border: #d8d8d8;
--speed-tile-text-s: 74%;
--speed-tile-text-l: 34%;
}
:root[data-orca-theme="dark"] {
@@ -85,6 +91,12 @@
--ctx-bg: #2a313a;
--ctx-border: #4b5664;
--ctx-hover: #3a4451;
/* Speed dial icon tile treatment */
--speed-tile-bg: #34343b;
--speed-tile-border: #50505a;
--speed-tile-text-s: 82%;
--speed-tile-text-l: 84%;
}
/* Re-theme the shared common.css chrome through variables (replaces dark.css's
+58
View File
@@ -0,0 +1,58 @@
// Shared fuzzy-search core for the webview dialogs (Plugins dialog, Speed Dial popup).
// why: both pages carried their own copy of this matcher and had already drifted; one source of truth.
// note: keep this DOM-free and plain global-scope (no export/module) - it is loaded by <script src> in
// each page AND by a node vm.runInContext in the speed-dial logic test. It MUST be loaded before
// the page script that calls it.
// Fold per-character so matched offsets stay in ORIGINAL string coordinates (highlighting slices the
// original text; a separately-folded string would desync offsets).
function FoldChar(ch) {
return ch.normalize("NFD").replace(/\p{Diacritic}/gu, ""); // accents always folded
}
function Norm(ch, caseSensitive) {
const folded = FoldChar(ch);
return caseSensitive ? folded : folded.toLowerCase(); // case-sensitivity is the only toggle
}
function EscapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// Fuzzy: ordered subsequence. Builds ranges in original coordinates, merging adjacent runs on the fly.
function FuzzyRanges(text, query, caseSensitive) {
const t = text || "";
const needle = Array.from(query || "").map((ch) => Norm(ch, caseSensitive)).join("");
if (!needle)
return null;
const ranges = [];
let qi = 0;
for (let i = 0; i < t.length && qi < needle.length; i++) {
if (Norm(t[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. 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 names, cosmetic only.
function WholeWordRanges(text, query, 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;
}
+37
View File
@@ -0,0 +1,37 @@
// Unit test for the shared fuzzy matcher. Run: node resources/web/js/fuzzy-search.test.js
// why: fuzzy-search.js is plain global-scope (no exports, so a browser <script src> works) - load it into
// a vm context the same way the page and the speed-dial test do, then assert against the globals.
const vm = require("vm"), assert = require("assert"), fs = require("fs");
const ctx = {};
vm.createContext(ctx);
vm.runInContext(fs.readFileSync(__dirname + "/fuzzy-search.js", "utf8"), ctx);
const { FoldChar, Norm, EscapeRegExp, FuzzyRanges, WholeWordRanges } = ctx;
// FoldChar / Norm: accents fold, case only folds when case-insensitive.
assert.equal(FoldChar("é"), "e");
assert.equal(Norm("É", false), "e");
assert.equal(Norm("É", true), "E");
// FuzzyRanges: ordered subsequence, ranges in ORIGINAL coordinates, adjacent runs merged.
assert.deepEqual(FuzzyRanges("Auto Arrange", "aa", false), [[0, 1], [5, 6]]);
assert.deepEqual(FuzzyRanges("Measure", "eas", false), [[1, 4]]); // contiguous run merges to one range
assert.equal(FuzzyRanges("Measure", "xyz", false), null); // no subsequence -> null
assert.equal(FuzzyRanges("Measure", "", false), null); // empty query -> null
// Accent-insensitive matching, offsets stay in the original (accented) string.
assert.deepEqual(FuzzyRanges("Café", "cafe", false), [[0, 4]]);
// Case sensitivity is the only toggle.
assert.equal(FuzzyRanges("Measure", "MEAS", true), null); // case-sensitive: no match
assert.deepEqual(FuzzyRanges("Measure", "Meas", true), [[0, 4]]); // case-sensitive: matches exact case
// WholeWordRanges: \b-bounded literal, bypasses fuzzy. Substring inside a word does NOT match.
assert.deepEqual(WholeWordRanges("Auto Arrange", "arrange", false), [[5, 12]]);
assert.equal(WholeWordRanges("Rearrange", "arrange", false), null); // not on a word boundary
assert.deepEqual(WholeWordRanges("a.b", "a", false), [[0, 1]]); // '.' is a boundary
// EscapeRegExp: regex metachars in the query are treated literally by whole-word.
assert.equal(EscapeRegExp("a.b*"), "a\\.b\\*");
assert.deepEqual(WholeWordRanges("c++ tool", "c", false), [[0, 1]]); // '+' would be a regex error unescaped
console.log("ok");
+4
View File
@@ -118,6 +118,10 @@ set(SLIC3R_GUI_SOURCES
GUI/PluginPickerDialog.hpp
GUI/PluginsDialog.cpp
GUI/PluginsDialog.hpp
GUI/SpeedDialDialog.cpp
GUI/SpeedDialDialog.hpp
GUI/ActionRegistry.cpp
GUI/ActionRegistry.hpp
GUI/PluginsConfigDialog.cpp
GUI/PluginsConfigDialog.hpp
GUI/ProcessRunner.cpp
+390
View File
@@ -0,0 +1,390 @@
#include "ActionRegistry.hpp"
#include "GUI.hpp"
#include "GUI_App.hpp"
#include "I18N.hpp"
#include "slic3r/plugin/PluginManager.hpp"
#include <libslic3r/AppConfig.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <wx/thread.h>
#include <algorithm>
#include <cmath>
#include <ctime>
#include <unordered_map>
namespace Slic3r { namespace GUI {
namespace {
constexpr const char* kConfigSection = "speed_dial";
nlohmann::json parse_config_json(const std::string& value, nlohmann::json fallback)
{
nlohmann::json parsed = nlohmann::json::parse(value, nullptr, false);
return parsed.is_discarded() ? std::move(fallback) : parsed;
}
nlohmann::json read_section(const char* key, nlohmann::json fallback)
{
return parse_config_json(wxGetApp().app_config->get(kConfigSection, key), std::move(fallback));
}
void write_section(const char* key, const nlohmann::json& j)
{
wxGetApp().app_config->set(kConfigSection, key, j.dump());
}
std::vector<std::string> read_string_array(const char* key)
{
auto j = read_section(key, nlohmann::json::array());
std::vector<std::string> v;
for (auto& e : j)
if (e.is_string())
v.push_back(e.get<std::string>());
return v;
}
// frecency = frequency + recency; score halves every 30 idle days.
double frecency_score(int count, long long last, long long now)
{
if (count <= 0)
return 0.0;
constexpr double HALF_LIFE_DAYS = 30.0;
double age = std::max(0.0, double(now - last) / 86400.0);
return count * std::pow(2.0, -age / HALF_LIFE_DAYS);
}
// ---- script-plugin action source (the one and only source) ------------------
std::string find_loaded_source_name(PluginManager& manager, const std::string& plugin_key)
{
PluginDescriptor descriptor;
if (manager.try_get_plugin_descriptor(plugin_key, descriptor) && !descriptor.name.empty())
return descriptor.name;
return plugin_key;
}
// A runnable script capability exposed as a speed-dial action. source_key = plugin_key
// (identity), so a plugin display-name change does not re-key the action.
struct PluginScriptAction : AppAction
{
static constexpr const char* kIdPrefix = "plugin_script_action";
std::string plugin_key;
std::string capability;
// The id an action for (plugin_key, capability) would have - lets refresh_capability
// remove a gone capability without materialising the action.
static std::string id_for(const std::string& plugin_key, const std::string& capability)
{
return AppAction::compose_id(kIdPrefix, capability.empty() ? plugin_key : capability, plugin_key);
}
PluginScriptAction(std::string plugin_key_in, std::string capability_in, std::string source_name)
: AppAction(kIdPrefix,
capability_in.empty() ? plugin_key_in : capability_in, // title
plugin_key_in, // source_key
std::move(source_name)),
plugin_key(std::move(plugin_key_in)), capability(std::move(capability_in))
{}
AppActionRunResult run() const override
{
std::string error;
const ExecutionResult result = PluginManager::instance().run_script_capability(plugin_key, capability, error);
if (!error.empty())
return {AppActionRunResult::Level::Error, from_u8(error)};
const bool skipped = result.status == PluginResult::Skipped;
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
return {skipped ? AppActionRunResult::Level::Info : AppActionRunResult::Level::Success,
result.message.empty() ? fallback : from_u8(result.message)};
}
};
// Builds an action for a capability, or nullptr if it is not a currently-loaded,
// enabled script capability.
std::unique_ptr<AppAction> make_action(const std::string& plugin_key, const std::string& capability,
const std::string& source_name)
{
PluginManager& manager = PluginManager::instance();
if (!manager.is_plugin_loaded(plugin_key))
return nullptr;
// only_enabled defaults true, so a disabled capability resolves to nullptr here.
if (!manager.get_plugin_capability({PluginCapabilityType::Script, capability, plugin_key}))
return nullptr;
return std::make_unique<PluginScriptAction>(plugin_key, capability, source_name);
}
} // namespace
void ActionRegistry::init()
{
assert(wxThread::IsMain());
assert(!m_started);
m_started = true;
PluginManager& manager = PluginManager::instance();
auto on_source = [this](const std::string& plugin_key, ActionChange change) {
if (!wxTheApp || wxGetApp().is_closing())
return;
wxGetApp().CallAfter([this, plugin_key, change] {
if (!wxGetApp().is_closing())
this->refresh_source(plugin_key, change);
});
};
auto on_capability = [this](const PluginCapabilityId& capability, ActionChange change) {
if (capability.type != PluginCapabilityType::Script || !wxTheApp || wxGetApp().is_closing())
return;
const std::string plugin_key = capability.plugin_key;
const std::string name = capability.name;
wxGetApp().CallAfter([this, plugin_key, name, change] {
if (!wxGetApp().is_closing())
this->refresh_capability(plugin_key, name, change);
});
};
// Subscribe before enumerating so a concurrent load cannot land between the initial
// snapshot and callback registration. Duplicate notifications are safe: upsert is by
// id and the m_actions scan in refresh_source is idempotent.
manager.subscribe_on_load_callback(
[on_source](const std::string& key) { on_source(key, ActionChange::Added); });
manager.subscribe_on_unload_callback(
[on_source](const std::string& key) { on_source(key, ActionChange::Removed); });
manager.subscribe_on_capability_load_callback(
[on_capability](const PluginCapabilityId& capability) {
on_capability(capability, ActionChange::Added);
});
manager.subscribe_on_capability_unload_callback(
[on_capability](const PluginCapabilityId& capability) {
on_capability(capability, ActionChange::Removed);
});
// enumerate current script capabilities
std::unordered_map<std::string, std::string> source_names;
for (const PluginDescriptor& desc : manager.get_plugin_descriptors())
if (!desc.name.empty())
source_names.emplace(desc.plugin_key, desc.name);
for (const auto& capability : manager.get_plugin_capabilities("", PluginCapabilityType::Script)) {
if (!capability)
continue;
const std::string& key = capability->audit_plugin_key();
auto it = source_names.find(key);
const std::string& source_name = it == source_names.end() ? key : it->second;
if (auto action = make_action(key, capability->name(), source_name))
upsert(std::move(action));
}
}
void ActionRegistry::refresh_source(const std::string& plugin_key, ActionChange change)
{
assert(wxThread::IsMain());
// Remove this source's current actions. Collect first - erasing from m_actions while
// iterating invalidates the iterator. why: m_actions (not the loader) is the source of
// truth, so this is correct even after the plugin has already unloaded.
std::vector<std::string> stale;
for (const auto& [id, action] : m_actions)
if (action->source_key() == plugin_key)
stale.push_back(id);
for (const std::string& id : stale)
remove(id);
if (change == ActionChange::Removed)
return;
PluginManager& manager = PluginManager::instance();
const std::string source_name = find_loaded_source_name(manager, plugin_key);
for (const auto& capability : manager.get_plugin_capabilities(plugin_key, PluginCapabilityType::Script)) {
if (!capability)
continue;
if (auto action = make_action(plugin_key, capability->name(), source_name))
upsert(std::move(action));
}
}
void ActionRegistry::refresh_capability(const std::string& plugin_key, const std::string& capability,
ActionChange change)
{
assert(wxThread::IsMain());
const std::string id = PluginScriptAction::id_for(plugin_key, capability);
if (change == ActionChange::Removed) {
remove(id);
return;
}
PluginManager& manager = PluginManager::instance();
if (auto action = make_action(plugin_key, capability, find_loaded_source_name(manager, plugin_key)))
upsert(std::move(action));
else
remove(id);
}
void ActionRegistry::upsert(std::unique_ptr<AppAction> action)
{
assert(wxThread::IsMain());
if (!action)
return;
seed_state(*action);
std::string id = action->id();
std::shared_ptr<AppAction> stored = std::move(action);
m_actions.insert_or_assign(std::move(id), std::move(stored));
}
void ActionRegistry::remove(const std::string& id)
{
assert(wxThread::IsMain());
m_actions.erase(id);
}
void ActionRegistry::seed_state(AppAction& a) const
{
auto favs = read_string_array("favourite_actions");
a.favourite = std::find(favs.begin(), favs.end(), a.id()) != favs.end();
nlohmann::json stats = read_section("stats", nlohmann::json::object());
auto it = stats.find(a.id());
if (it != stats.end() && it->is_object()) {
a.count = it->value("count", 0);
a.last = it->value("last", 0LL);
} else {
a.count = 0;
a.last = 0;
}
}
// ---- read surface -----------------------------------------------------------
const AppAction* ActionRegistry::by_id(const std::string& id) const
{
assert(wxThread::IsMain());
auto it = m_actions.find(id);
return it == m_actions.end() ? nullptr : it->second.get();
}
AppAction* ActionRegistry::find(const std::string& id)
{
return const_cast<AppAction*>(by_id(id));
}
// ---- dispatch + write-through ----------------------------------------------
AppActionRunResult ActionRegistry::run(const std::string& id)
{
assert(wxThread::IsMain());
auto it = m_actions.find(id);
if (it == m_actions.end())
return {}; // default Info, empty message
// why: hold a shared_ptr keep-alive, never a bare map entry. A runner may pump a
// nested event loop; a queued source refresh can erase the entry while the
// keep-alive preserves the action until run returns.
std::shared_ptr<AppAction> keep = it->second;
AppActionRunResult o = keep->run();
if (o.level == AppActionRunResult::Level::Busy)
return o;
// Bump stats (write-through). Re-read to avoid clobbering a concurrent field.
nlohmann::json stats = read_section("stats", nlohmann::json::object());
if (!stats.is_object()) // corrupt (valid-JSON, non-object) value degrades to empty
stats = nlohmann::json::object();
nlohmann::json& e = stats[id];
if (!e.is_object())
e = nlohmann::json::object();
e["count"] = e.value("count", 0) + 1;
e["last"] = (long long) std::time(nullptr);
write_section("stats", stats);
if (AppAction* live = find(id)) { live->count = e["count"]; live->last = e["last"]; }
return o;
}
void ActionRegistry::set_favourite(const std::string& id, bool on)
{
assert(wxThread::IsMain());
auto favs = read_string_array("favourite_actions");
auto it = std::find(favs.begin(), favs.end(), id);
if (on && it == favs.end())
favs.push_back(id);
if (!on && it != favs.end())
favs.erase(it);
write_section("favourite_actions", nlohmann::json(favs));
if (AppAction* live = find(id))
live->favourite = on;
}
void ActionRegistry::reorder_favourites(const std::vector<std::string>& ids)
{
assert(wxThread::IsMain());
auto cur = read_string_array("favourite_actions");
std::vector<std::string> next;
// keep the requested order, but only ids that are actually favourites (guard a bad payload)
for (const auto& id : ids)
if (std::find(cur.begin(), cur.end(), id) != cur.end() &&
std::find(next.begin(), next.end(), id) == next.end())
next.push_back(id);
// why: don't drop favourites the page omitted (e.g. pins with no live action hidden from the bar)
for (const auto& id : cur)
if (std::find(next.begin(), next.end(), id) == next.end())
next.push_back(id);
write_section("favourite_actions", nlohmann::json(next));
}
bool ActionRegistry::should_ask(const std::string& id) const
{
assert(wxThread::IsMain());
auto arr = read_string_array("ask_suppressed");
return std::find(arr.begin(), arr.end(), id) == arr.end();
}
void ActionRegistry::suppress_ask(const std::string& id)
{
assert(wxThread::IsMain());
auto arr = read_string_array("ask_suppressed");
if (std::find(arr.begin(), arr.end(), id) == arr.end())
arr.push_back(id);
write_section("ask_suppressed", nlohmann::json(arr));
}
// ---- snapshot ---------------------------------------------------------------
nlohmann::json ActionRegistry::snapshot() const
{
assert(wxThread::IsMain());
std::vector<const AppAction*> sorted;
sorted.reserve(m_actions.size());
for (const auto& entry : m_actions)
sorted.push_back(entry.second.get());
const long long now = (long long) std::time(nullptr);
std::sort(sorted.begin(), sorted.end(), [&](const AppAction* a, const AppAction* b) {
double sa = frecency_score(a->count, a->last, now);
double sb = frecency_score(b->count, b->last, now);
if (sa != sb)
return sa > sb;
if (a->title() != b->title())
return a->title() < b->title();
if (a->source_name() != b->source_name())
return a->source_name() < b->source_name();
return a->id() < b->id();
});
nlohmann::json actions = nlohmann::json::array();
for (const AppAction* a : sorted)
actions.push_back({{"id", a->id()},
{"title", a->title()},
{"source", a->source_name()},
{"shortcut", ""}});
// why: favourites is the ORDERED pin list - it must come from favourite_actions
// as stored, not be re-derived from the frecency-sorted actions (that would
// reorder the favourites bar). The page (js) filters out ids with no live action itself.
nlohmann::json favourites(read_string_array("favourite_actions"));
return {{"actions", std::move(actions)}, {"favourites", std::move(favourites)}};
}
}} // namespace Slic3r::GUI
+135
View File
@@ -0,0 +1,135 @@
#pragma once
#include <nlohmann/json.hpp>
#include <wx/string.h>
#include <wx/thread.h>
#include <cassert>
#include <memory>
#include <string>
#include <string_view>
#include <unordered_map>
#include <utility>
#include <vector>
namespace Slic3r { namespace GUI {
// How a source's action set changed. Drives the registry's refresh handlers.
enum class ActionChange { Added, Removed };
// Result of running an AppAction, in the action layer's own vocabulary. Concrete
// actions translate their runner-specific result into this generic shape.
struct AppActionRunResult
{
enum class Level { Success, Info, Error, Busy };
Level level = Level::Info;
wxString message; // empty = "nothing worth showing"
};
// A speed-dial action: identity + user-state seeded from config + how to run itself.
// Abstract base - the only virtual is run(); concrete subclasses know how to run
// and what their source is.
// note: named AppAction, not Action - Slic3r::GUI::Action is already taken by
// UnsavedChangesDialog's exit-action enum, and this header reaches most GUI TUs.
struct AppAction
{
const std::string& id() const { return m_id; }
const std::string& title() const { return m_title; }
const std::string& source_key() const { return m_source_key; } // stable source identity
const std::string& source_name() const { return m_source_name; } // source display name
// Builds the stable id "<prefix>:<title>:<source_key>". why: one place owns the
// format - both the base ctor and the one raw-key lookup (removing a capability
// without an action object) go through this; ids are never parsed back apart.
static std::string compose_id(std::string_view prefix, std::string_view title, std::string_view source_key)
{
std::string out;
out.reserve(prefix.size() + title.size() + source_key.size() + 2);
out.append(prefix).append(1, ':').append(title).append(1, ':').append(source_key);
return out;
}
// seeded from AppConfig for the snapshot / sort:
bool favourite = false;
int count = 0;
long long last = 0; // epoch seconds
virtual ~AppAction() = default;
virtual AppActionRunResult run() const = 0; // re-resolves + runs (UI thread)
protected:
// The definition is constructor-set and immutable. Refreshes replace an action
// instead of mutating identity after the registry has indexed it by id.
// why: source_key (not the display name) carries identity, so renaming the source's
// display name leaves the id - and its persisted stats/favourite - intact.
AppAction(std::string_view prefix, std::string title, std::string source_key, std::string source_name)
: m_id(compose_id(prefix, title, source_key)),
m_title(std::move(title)),
m_source_key(std::move(source_key)),
m_source_name(std::move(source_name)) {}
private:
std::string m_id; // <prefix>:<title>:<source_key> - stable identity + AppConfig key
std::string m_title; // display name
std::string m_source_key; // stable identity of the action's source (e.g. plugin_key)
std::string m_source_name; // display name of the action's source
};
// Self-contained sink and single owner of runnable actions for the app session.
//
// Workflow:
// 1. init() (once, UI thread) subscribes to the plugin loader and enumerates the
// current script capabilities into actions.
// 2. Loader load/unload callbacks route through refresh_source()/refresh_capability(),
// which upsert()/remove() actions. The registry keeps the only action list and
// restores persisted user state as actions arrive.
// 3. Consumers use by_id(), snapshot(), and run() without knowing the source.
//
// note: there is exactly one source (script plugins), so it lives inline here rather
// than behind a polymorphic source interface.
class ActionRegistry
{
public:
// Subscribes to the plugin loader and enumerates its current actions. Call once
// on the UI thread after the plugin system is up; wires the initial list and live
// updates together.
void init();
// Takes ownership, seeds persisted state, then inserts the action or replaces
// the action with the same id. A null action is ignored.
void upsert(std::unique_ptr<AppAction> action);
// Removes the action with this id. Missing ids are a harmless no-op.
void remove(const std::string& id);
// Always-clean read surface. UI thread only.
const AppAction* by_id(const std::string& id) const;
// Dispatch + write-through (registry is the only thing that touches AppConfig).
AppActionRunResult run(const std::string& id); // runs + bumps stats
void set_favourite(const std::string& id, bool on);
void reorder_favourites(const std::vector<std::string>& ids); // persist a new bar order
// Run-confirm gate, keyed by action id (per-action "don't ask again").
bool should_ask(const std::string& id) const;
void suppress_ask(const std::string& id);
// Flat, frecency-sorted snapshot for the webview: {actions:[...], favourites:[...]}.
nlohmann::json snapshot() const;
private:
void seed_state(AppAction& a) const; // favourite/stats from config
AppAction* find(const std::string& id);
// Loader callbacks (marshalled to the UI thread) land here. refresh_source rebuilds
// one plugin's whole action set; refresh_capability touches a single capability.
void refresh_source(const std::string& plugin_key, ActionChange change);
void refresh_capability(const std::string& plugin_key, const std::string& capability, ActionChange change);
bool m_started = false; // init() runs exactly once; guards double-subscription
std::unordered_map<std::string, std::shared_ptr<AppAction>> m_actions; // UI-thread confined; no lock
};
}} // namespace Slic3r::GUI
+6
View File
@@ -1034,6 +1034,7 @@ wxDEFINE_EVENT(EVT_GLCANVAS_ORIENT_PARTPLATE, SimpleEvent);
wxDEFINE_EVENT(EVT_GLCANVAS_SELECT_CURR_PLATE_ALL, SimpleEvent);
wxDEFINE_EVENT(EVT_GLCANVAS_SELECT_ALL, SimpleEvent);
wxDEFINE_EVENT(EVT_GLCANVAS_QUESTION_MARK, SimpleEvent);
wxDEFINE_EVENT(EVT_GLCANVAS_OPEN_SPEED_DIAL, SimpleEvent);
wxDEFINE_EVENT(EVT_GLCANVAS_INCREASE_INSTANCES, Event<int>);
wxDEFINE_EVENT(EVT_GLCANVAS_INSTANCE_MOVED, SimpleEvent);
wxDEFINE_EVENT(EVT_GLCANVAS_INSTANCE_ROTATED, SimpleEvent);
@@ -3510,6 +3511,11 @@ void GLCanvas3D::on_char(wxKeyEvent& evt)
break;
}
case '?': { post_event(SimpleEvent(EVT_GLCANVAS_QUESTION_MARK)); break; }
case ' ': {
if (m_canvas_type == ECanvasType::CanvasView3D)
post_event(SimpleEvent(EVT_GLCANVAS_OPEN_SPEED_DIAL));
break;
}
case 'A':
case 'a':
{
+1
View File
@@ -166,6 +166,7 @@ wxDECLARE_EVENT(EVT_GLCANVAS_ORIENT_PARTPLATE, SimpleEvent);
wxDECLARE_EVENT(EVT_GLCANVAS_SELECT_CURR_PLATE_ALL, SimpleEvent);
wxDECLARE_EVENT(EVT_GLCANVAS_SELECT_ALL, SimpleEvent);
wxDECLARE_EVENT(EVT_GLCANVAS_QUESTION_MARK, SimpleEvent);
wxDECLARE_EVENT(EVT_GLCANVAS_OPEN_SPEED_DIAL, SimpleEvent);
wxDECLARE_EVENT(EVT_GLCANVAS_INCREASE_INSTANCES, Event<int>); // data: +1 => increase, -1 => decrease
wxDECLARE_EVENT(EVT_GLCANVAS_INSTANCE_MOVED, SimpleEvent);
wxDECLARE_EVENT(EVT_GLCANVAS_FORCE_UPDATE, SimpleEvent);
+19
View File
@@ -141,6 +141,7 @@
#include "slic3r/Utils/bambu_networking.hpp"
#include "PluginsDialog.hpp"
#include "SpeedDialDialog.hpp"
#include "TerminalDialog.hpp"
//#ifdef WIN32
@@ -3170,6 +3171,9 @@ bool GUI_App::on_init_inner()
init_plugin_gui_wiring();
// Subscribe to the plugin loader and enumerate current actions (UI thread, once).
m_action_registry.init();
for (const std::string& plugin_key : plugin_mgr.get_enabled_plugin_keys()) {
if (!plugin_mgr.is_plugin_loaded(plugin_key)) {
plugin_mgr.load_plugin(plugin_key, false);
@@ -8283,6 +8287,21 @@ void GUI_App::open_terminal_dialog()
});
}
void GUI_App::open_speed_dial()
{
if (!mainframe)
return;
if (!m_speed_dial_dialog) {
m_speed_dial_dialog = new SpeedDialWebDialog(mainframe);
m_speed_dial_dialog->Bind(wxEVT_DESTROY, [this](wxWindowDestroyEvent& event) {
if (event.GetEventObject() == m_speed_dial_dialog)
m_speed_dial_dialog = nullptr;
event.Skip();
});
}
m_speed_dial_dialog->request_show();
}
void GUI_App::open_exportpresetbundledialog(size_t open_on_tab, const std::string& highlight_option)
{
bool app_layout_changed = false;
+6
View File
@@ -3,6 +3,7 @@
#include <memory>
#include <string>
#include "ActionRegistry.hpp"
#include "ImGuiWrapper.hpp"
#include "ConfigWizard.hpp"
#include "OpenGLManager.hpp"
@@ -86,6 +87,7 @@ class ModelMallDialog;
class PingCodeBindDialog;
class NetworkErrorDialog;
class PluginsDialog;
class SpeedDialWebDialog;
class TerminalDialog;
@@ -553,7 +555,9 @@ public:
PresetBundleDialog* m_preset_bundle_dlg{nullptr};
PluginsDialog* m_plugins_dlg{nullptr};
SpeedDialWebDialog* m_speed_dial_dialog{nullptr};
TerminalDialog* m_terminal_dlg{nullptr};
ActionRegistry m_action_registry;
void start_http_server(const std::string& provider = ORCA_CLOUD_PROVIDER);
@@ -624,6 +628,8 @@ public:
void open_presetbundledialog(size_t open_on_tab = 0, const std::string& highlight_option = std::string());
void open_plugins_dialog(size_t open_on_tab = 0, const std::string& highlight_option = std::string());
void open_terminal_dialog();
void open_speed_dial();
ActionRegistry& action_registry() { return m_action_registry; }
void open_exportpresetbundledialog(size_t open_on_tab = 0, const std::string& highlight_option = std::string());
virtual bool OnExceptionInMainLoop() override;
// Calls wxLaunchDefaultBrowser if user confirms in dialog.
+1
View File
@@ -267,6 +267,7 @@ void KBShortcutsDialog::fill_shortcuts()
{ "O", L("Zoom out") },
{ "V", L("Toggle printable for object/part") },
{ L("Tab"), L("Switch between Prepare/Preview") },
{ L("Space"), L("Open actions speed dial") },
};
m_full_shortcuts.push_back({ { _L("Plater"), "" }, plater_shortcuts });
+4
View File
@@ -6029,6 +6029,10 @@ Plater::priv::priv(Plater *q, MainFrame *main_frame)
view3D_canvas->Bind(EVT_GLCANVAS_SELECT_ALL, [this](SimpleEvent&) { this->q->select_all(); });
view3D_canvas->Bind(EVT_GLCANVAS_QUESTION_MARK, [](SimpleEvent&) { wxGetApp().keyboard_shortcuts(); });
view3D_canvas->Bind(EVT_GLCANVAS_OPEN_SPEED_DIAL, [this](SimpleEvent&) {
if (this->q->is_view3D_shown())
wxGetApp().open_speed_dial();
});
view3D_canvas->Bind(EVT_GLCANVAS_INCREASE_INSTANCES, [this](Event<int>& evt)
{ if (evt.data == 1) this->q->increase_instances(); else if (this->can_decrease_instances()) this->q->decrease_instances(); });
view3D_canvas->Bind(EVT_GLCANVAS_INSTANCE_MOVED, [this](SimpleEvent&) { update(); });
+2 -4
View File
@@ -7,8 +7,6 @@
#include "slic3r/plugin/PluginConfig.hpp"
#include "slic3r/plugin/PluginFsUtils.hpp"
#include "slic3r/plugin/PluginManager.hpp"
#include "slic3r/plugin/PythonInterpreter.hpp"
#include "slic3r/plugin/pluginTypes/script/ScriptPluginCapability.hpp"
#include <libslic3r/Utils.hpp>
@@ -17,7 +15,7 @@
#include <slic3r/GUI/format.hpp>
#include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PluginLoader.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <boost/filesystem.hpp>
#include <boost/log/trivial.hpp>
@@ -977,7 +975,7 @@ void PluginsDialog::run_script_plugin_capability(const std::string& plugin_key,
manager.clear_plugin_error(plugin_key);
send_plugins();
const bool skipped = result.status == PluginResult::Skipped;
const bool skipped = result.status == PluginResult::Skipped;
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
const wxString message = result.message.empty() ? fallback : from_u8(result.message);
show_status(message, skipped ? "info" : "success");
-5
View File
@@ -250,11 +250,6 @@ private:
PluginSortKey m_plugin_sort_key = PluginSortKey::None;
PluginSortOrder m_plugin_sort_order = PluginSortOrder::Asc;
// Serializes run_script_plugin_capability. With main-thread execution a plugin's orca.host.ui modal
// (message/show_dialog) or the result message box pumps a nested event loop, which could
// re-dispatch the web "run_script_plugin_capability" command and start a second, overlapping run.
bool m_script_running = false;
// Plugin whose asynchronous activation is in flight, awaited by resolve_pending_activation().
// Empty when no activation is pending.
std::string m_activating_plugin_key;
+178
View File
@@ -0,0 +1,178 @@
#include "SpeedDialDialog.hpp"
#include "ActionRegistry.hpp"
#include "GUI.hpp"
#include "GUI_App.hpp"
#include "MainFrame.hpp"
#include "MsgDialog.hpp"
#include "NotificationManager.hpp"
#include "Plater.hpp"
#include "Widgets/WebViewHostDialog.hpp"
#include <algorithm>
#include <wx/display.h>
#include <wx/sizer.h>
#include <wx/stattext.h>
namespace Slic3r { namespace GUI {
namespace {
// ADJUST WIDTH HERE (DIP px). Fixed dialog width; was 360, now 1.5x. Height is not set here -
// the dialog auto-resizes to the page content (see resize_to_content + the list max-height in style.css).
constexpr int kPopupWidth = 540;
constexpr int kPopupMinHeight = 60; // just above the bare search-bar height, so the dialog hugs content
constexpr int kPopupMaxHeight = 282;
int json_int_or(const nlohmann::json& j, const char* key, int fallback)
{
auto it = j.find(key);
return it != j.end() && it->is_number() ? it->get<int>() : fallback;
}
wxColour bg_color() { return wxGetApp().get_window_default_clr(); }
}
SpeedDialWebDialog::SpeedDialWebDialog(wxWindow* parent)
: WebViewHostDialog(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize,
wxBORDER_NONE | wxFRAME_NO_TASKBAR)
{
SetBackgroundColour(bg_color());
Bind(wxEVT_ACTIVATE, [this](wxActivateEvent& event) {
if (!event.GetActive() && IsShown())
Hide();
event.Skip();
});
if (!create_webview("web/dialog/SpeedDial/index.html", wxEmptyString,
wxSize(kPopupWidth, kPopupMaxHeight), wxSize(kPopupWidth, kPopupMinHeight))) {
auto* sizer = new wxBoxSizer(wxVERTICAL);
sizer->Add(new wxStaticText(this, wxID_ANY, wxS("wxWebView unavailable")),
wxSizerFlags().Border(wxALL, 20));
SetSizer(sizer);
SetClientSize(FromDIP(wxSize(kPopupWidth, kPopupMinHeight)));
}
}
SpeedDialWebDialog::~SpeedDialWebDialog() { m_alive->store(false, std::memory_order_release); }
void SpeedDialWebDialog::request_show()
{
if (IsShown()) {
Raise();
if (browser())
browser()->SetFocus();
return;
}
Show();
Raise();
if (m_page_ready)
send_actions();
if (browser())
browser()->SetFocus();
}
void SpeedDialWebDialog::on_script_message(const nlohmann::json& payload)
{
if (handle_common_script_command(payload))
return;
// Defer command handling out of the webview script-message callback: GTK and macOS deliver
// it synchronously inside the native webview callback, and window work on that stack is the
// crash class fixed in b779a7bfed/f2ccbfc8b5 (see PluginsDialog::on_script_message).
// run_action puts a modal confirm on that stack, which is the same bug.
wxGetApp().CallAfter([this, alive = m_alive, payload]() {
if (alive->load(std::memory_order_acquire))
handle_web_command(payload);
});
}
void SpeedDialWebDialog::handle_web_command(const nlohmann::json& payload)
{
const std::string command = payload.value("command", "");
if (command == "request_actions") {
m_page_ready = true;
send_actions();
}
else if (command == "toggle_favourite")
wxGetApp().action_registry().set_favourite(payload.value("id", ""), payload.value("fav", false));
else if (command == "reorder_favourites") {
std::vector<std::string> ids;
if (payload.contains("ids") && payload["ids"].is_array())
for (const auto& id : payload["ids"])
if (id.is_string())
ids.push_back(id.get<std::string>());
wxGetApp().action_registry().reorder_favourites(ids);
}
else if (command == "run_action")
run_action(payload.value("id", ""), payload.value("title", ""));
else if (command == "resize")
resize_to_content(json_int_or(payload, "height", 0));
}
void SpeedDialWebDialog::resize_to_content(int height)
{
if (height <= 0)
return;
int display_index = wxDisplay::GetFromWindow(this);
if (display_index == wxNOT_FOUND)
display_index = 0;
const int screen_dip = ToDIP(wxDisplay(display_index).GetClientArea().GetHeight());
const int max_dip = std::max(kPopupMinHeight, screen_dip * 85 / 100);
const int height_dip = std::max(kPopupMinHeight, std::min(height, max_dip));
SetClientSize(FromDIP(wxSize(kPopupWidth, height_dip)));
Layout();
}
void SpeedDialWebDialog::run_action(const std::string& id, const std::string& title)
{
ActionRegistry& reg = wxGetApp().action_registry();
const AppAction* a = reg.by_id(id);
if (!a)
return;
const bool ask = reg.should_ask(id);
const std::string atitle = a->title();
if (IsModal())
EndModal(wxID_CANCEL);
else
Hide();
if (ask) {
const wxString label = title.empty() ? from_u8(atitle) : from_u8(title);
RichMessageDialog dlg(wxGetApp().mainframe, wxString::Format(_L("Run \"%s\"?"), label),
_L("Run plugin"), wxOK | wxCANCEL);
dlg.ShowCheckBox(_L("Don't ask again for this action"));
if (dlg.ShowModal() != wxID_OK)
return;
if (dlg.IsCheckBoxChecked())
wxGetApp().action_registry().suppress_ask(id);
}
wxGetApp().CallAfter([id] {
if (wxGetApp().is_closing())
return;
AppActionRunResult result = wxGetApp().action_registry().run(id);
if (result.level == AppActionRunResult::Level::Busy)
return;
if (!result.message.IsEmpty() && wxGetApp().plater())
wxGetApp().plater()->get_notification_manager()->push_notification(
NotificationType::CustomNotification,
result.level == AppActionRunResult::Level::Error ? NotificationManager::NotificationLevel::ErrorNotificationLevel :
NotificationManager::NotificationLevel::RegularNotificationLevel,
into_u8(result.message));
});
}
void SpeedDialWebDialog::send_actions()
{
nlohmann::json snap = wxGetApp().action_registry().snapshot();
call_web_handler({{"command", "list_actions"},
{"actions", std::move(snap["actions"])},
{"favourites", std::move(snap["favourites"])}});
}
}}
+34
View File
@@ -0,0 +1,34 @@
#ifndef slic3r_GUI_SpeedDialDialog_hpp_
#define slic3r_GUI_SpeedDialDialog_hpp_
#include <slic3r/GUI/Widgets/WebViewHostDialog.hpp>
#include <nlohmann/json_fwd.hpp>
#include <atomic>
#include <memory>
#include <string>
namespace Slic3r { namespace GUI {
class SpeedDialWebDialog : public WebViewHostDialog
{
public:
explicit SpeedDialWebDialog(wxWindow* parent);
~SpeedDialWebDialog() override;
void request_show();
private:
void on_script_message(const nlohmann::json& payload) override;
void handle_web_command(const nlohmann::json& payload);
void resize_to_content(int height);
void run_action(const std::string& id, const std::string& title);
void send_actions();
bool m_page_ready{false};
// Guards the CallAfter in on_script_message across dialog destruction, same as
// PluginsDialog::m_alive (PluginsDialog.hpp:249).
std::shared_ptr<std::atomic<bool>> m_alive = std::make_shared<std::atomic<bool>>(true);
};
}}
#endif
+64 -37
View File
@@ -1112,61 +1112,88 @@ std::vector<PluginManager::CapabilityLifecycleFn> PluginManager::copy_capability
return it == m_capability_callbacks.end() ? std::vector<CapabilityLifecycleFn>{} : it->second;
}
// The run_on_*_callbacks below are called from detached load/unload workers. They copy the
// subscriber list under m_mutex and invoke it OUTSIDE the lock: subscribers re-enter the manager,
// and no callback may run while the registry lock is held.
// The run_on_*_callbacks below are called from detached load/unload workers. They snapshot the
// subscriber list under m_mutex (the copy protects the in-flight loop from a callback that
// subscribes mid-dispatch, and serialises against a concurrent subscribe), then hand the snapshot
// to the UI thread: subscribers touch wx and must not run on a worker, and none may run while the
// registry lock is held. The dispatch runs inline when already on the UI thread, else via
// CallAfter, since calling wx off the main thread is UB.
void PluginManager::run_on_load_callbacks(const std::string& plugin_key)
{
for (auto& fn : copy_callbacks(CallbackType::Load)) {
try {
fn(plugin_key);
} catch (const std::exception& ex) {
BOOST_LOG_TRIVIAL(error) << "Plugin load completion callback failed for " << plugin_key << ": " << ex.what();
} catch (...) {
BOOST_LOG_TRIVIAL(error) << "Plugin load completion callback failed for " << plugin_key;
auto work = [callbacks = copy_callbacks(CallbackType::Load), plugin_key] {
for (auto& fn : callbacks) {
try {
fn(plugin_key);
} catch (const std::exception& ex) {
BOOST_LOG_TRIVIAL(error) << "Plugin load completion callback failed for " << plugin_key << ": " << ex.what();
} catch (...) {
BOOST_LOG_TRIVIAL(error) << "Plugin load completion callback failed for " << plugin_key;
}
}
}
};
if (wxTheApp == nullptr || wxIsMainThread())
work();
else
GUI::wxGetApp().CallAfter(std::move(work));
}
void PluginManager::run_on_unload_callbacks(const std::string& plugin_key)
{
for (auto& fn : copy_callbacks(CallbackType::Unload)) {
try {
fn(plugin_key);
} catch (const std::exception& ex) {
BOOST_LOG_TRIVIAL(error) << "Plugin unload completion callback failed for " << plugin_key << ": " << ex.what();
} catch (...) {
BOOST_LOG_TRIVIAL(error) << "Plugin unload completion callback failed for " << plugin_key << ": unknown error";
auto work = [callbacks = copy_callbacks(CallbackType::Unload), plugin_key] {
for (auto& fn : callbacks) {
try {
fn(plugin_key);
} catch (const std::exception& ex) {
BOOST_LOG_TRIVIAL(error) << "Plugin unload completion callback failed for " << plugin_key << ": " << ex.what();
} catch (...) {
BOOST_LOG_TRIVIAL(error) << "Plugin unload completion callback failed for " << plugin_key << ": unknown error";
}
}
}
};
if (wxTheApp == nullptr || wxIsMainThread())
work();
else
GUI::wxGetApp().CallAfter(std::move(work));
}
void PluginManager::run_on_capability_load_callbacks(const PluginCapabilityId& id)
{
for (auto& fn : copy_capability_callbacks(CallbackType::Load)) {
try {
fn(id);
} catch (const std::exception& ex) {
BOOST_LOG_TRIVIAL(error) << "Plugin capability load callback failed for " << id.plugin_key << "/" << id.name << ": "
<< ex.what();
} catch (...) {
BOOST_LOG_TRIVIAL(error) << "Plugin capability load callback failed for " << id.plugin_key << "/" << id.name;
auto work = [callbacks = copy_capability_callbacks(CallbackType::Load), id] {
for (auto& fn : callbacks) {
try {
fn(id);
} catch (const std::exception& ex) {
BOOST_LOG_TRIVIAL(error) << "Plugin capability load callback failed for " << id.plugin_key << "/" << id.name << ": "
<< ex.what();
} catch (...) {
BOOST_LOG_TRIVIAL(error) << "Plugin capability load callback failed for " << id.plugin_key << "/" << id.name;
}
}
}
};
if (wxTheApp == nullptr || wxIsMainThread())
work();
else
GUI::wxGetApp().CallAfter(std::move(work));
}
void PluginManager::run_on_capability_unload_callbacks(const PluginCapabilityId& id)
{
for (auto& fn : copy_capability_callbacks(CallbackType::Unload)) {
try {
fn(id);
} catch (const std::exception& ex) {
BOOST_LOG_TRIVIAL(error) << "Plugin capability unload callback failed for " << id.plugin_key << "/" << id.name << ": "
<< ex.what();
} catch (...) {
BOOST_LOG_TRIVIAL(error) << "Plugin capability unload callback failed for " << id.plugin_key << "/" << id.name;
auto work = [callbacks = copy_capability_callbacks(CallbackType::Unload), id] {
for (auto& fn : callbacks) {
try {
fn(id);
} catch (const std::exception& ex) {
BOOST_LOG_TRIVIAL(error) << "Plugin capability unload callback failed for " << id.plugin_key << "/" << id.name << ": "
<< ex.what();
} catch (...) {
BOOST_LOG_TRIVIAL(error) << "Plugin capability unload callback failed for " << id.plugin_key << "/" << id.name;
}
}
}
};
if (wxTheApp == nullptr || wxIsMainThread())
work();
else
GUI::wxGetApp().CallAfter(std::move(work));
}
// ── Cloud user ──────────────────────────────────────────────────────────────────────────────
+1
View File
@@ -1,6 +1,7 @@
get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME)
add_executable(${_TEST_NAME}_tests
${_TEST_NAME}_tests_main.cpp
test_action_source.cpp
test_plugin_host_api.cpp
test_plugin_capability_config.cpp
test_plugin_config.cpp
+55
View File
@@ -0,0 +1,55 @@
#include <catch2/catch_test_macros.hpp>
#include "slic3r/GUI/ActionRegistry.hpp"
#include <memory>
#include <string>
#include <type_traits>
using Slic3r::GUI::AppAction;
using Slic3r::GUI::AppActionRunResult;
using Slic3r::GUI::ActionRegistry;
namespace {
// AppAction is abstract; this minimal concrete action lets the tests exercise its
// constructor-composed identity without involving a plugin runner.
class TestAppAction final : public AppAction
{
public:
TestAppAction() : AppAction("test", "Action title", "src-key", "Action source") {}
AppActionRunResult run() const override { return {}; }
};
} // namespace
TEST_CASE("AppAction composes a stable id from prefix:title:source_key", "[speeddial][actions]")
{
CHECK(AppAction::compose_id("test", "Action title", "src-key") == "test:Action title:src-key");
// source_key (not the display name) carries identity, so it is the third field.
CHECK(AppAction::compose_id("script", "Do Thing", "pack.py") == "script:Do Thing:pack.py");
}
TEST_CASE("AppAction definitions are immutable after construction", "[speeddial][actions]")
{
using StringAccessor = const std::string& (AppAction::*)() const;
STATIC_CHECK(std::is_same_v<decltype(&AppAction::id), StringAccessor>);
STATIC_CHECK(std::is_same_v<decltype(&AppAction::title), StringAccessor>);
STATIC_CHECK(std::is_same_v<decltype(&AppAction::source_key), StringAccessor>);
STATIC_CHECK(std::is_same_v<decltype(&AppAction::source_name), StringAccessor>);
const TestAppAction action;
CHECK(action.id() == "test:Action title:src-key");
CHECK(action.title() == "Action title");
CHECK(action.source_key() == "src-key");
CHECK(action.source_name() == "Action source");
}
TEST_CASE("ActionRegistry takes exclusive ownership of published actions", "[speeddial][actions]")
{
using ExpectedUpsert = void (ActionRegistry::*)(std::unique_ptr<AppAction>);
STATIC_CHECK(std::is_same_v<decltype(&ActionRegistry::upsert), ExpectedUpsert>);
}