Add Help actions. Add Toggle Developer action. Add warning popup when switching between process mode. Add primitives/handy models actions

This commit is contained in:
Lam Wei Lun
2026-09-10 18:34:05 +08:00
parent f520af9901
commit 8bbce371b2
12 changed files with 585 additions and 120 deletions
+70 -2
View File
@@ -10,6 +10,11 @@ var sel = { zone: "list", i: 0 }; // zone: 'list' | 'fav'
var lastResizeHeight = 0;
var matchIndex = {};
// The user's current settings mode (from the C++ payload) plus the rank order of the modes. Each
// action carries the mode it requires, so "would this need a switch?" is a rank comparison.
var USER_MODE = "simple";
var MODE_RANK = { simple: 0, advanced: 1, expert: 2, develop: 3 };
// ---- windowed list render ----------------------------------------------------
// The command list is rendered in windows (append-on-scroll) so a huge settings pool doesn't build
// the whole DOM per keystroke. Rows are exactly ROW_H tall (matches .row min-height 44px; see --row-h,
@@ -105,11 +110,15 @@ function searchActions(actions, query) {
matchIndex = {};
if (!q) { searchNeedle = ""; return list.slice(0); }
searchNeedle = NormText(q, false);
// Mode keywords ("advanced"/"expert"/"developer") are a union, not a filter: the normal text
// search still runs on the full query, and every setting requiring a named mode is appended.
var modes = modeFilterFromQuery(q);
// Compiled once per pass, reused over every field: non-global so no exec()/lastIndex state leaks
// between fields, and EscapeRegExp keeps regex metachars in the query literal.
var wwRe = new RegExp("\\b" + EscapeRegExp(searchNeedle) + "\\b");
var scored = [];
var seen = {};
for (var i = 0; i < list.length; i++) {
var a = list[i];
var t = fieldMatchScore(titleNorm(a), wwRe);
@@ -126,13 +135,28 @@ function searchActions(actions, query) {
useEyebrowGroup: !!(a.group)
};
scored.push({ a: a, s: score });
seen[a.id] = true;
}
scored.sort(function (x, y) {
if (x.s !== y.s) return y.s - x.s;
if (x.a.title !== y.a.title) return x.a.title < y.a.title ? -1 : 1;
return x.a.id < y.a.id ? -1 : x.a.id > y.a.id ? 1 : 0;
});
return scored.map(function (e) { return e.a; });
var result = scored.map(function (e) { return e.a; });
if (modes.length) {
// Settings requiring a named mode, after the ranked text matches and without duplicates.
var extras = [];
for (var j = 0; j < list.length; j++) {
if (!seen[list[j].id] && modes.indexOf(list[j].mode) !== -1)
extras.push(list[j]);
}
extras.sort(function (x, y) {
if (x.title !== y.title) return x.title < y.title ? -1 : 1;
return x.id < y.id ? -1 : x.id > y.id ? 1 : 0;
});
result = result.concat(extras);
}
return result;
}
// Pure: how many rows must be materialized to cover the given starting index plus `size` more.
@@ -264,6 +288,40 @@ function prettySource(source) {
return String(source || "").toLowerCase().replace(/\b\w/g, function (c) { return c.toUpperCase(); });
}
// True when the action's required mode is above the user's current mode, i.e. selecting it will
// prompt a mode switch. Unknown/empty modes are treated as "simple" so commands never flag.
function needsModeSwitch(item, userMode) {
var need = MODE_RANK[(item && item.mode) || "simple"] || 0;
var have = MODE_RANK[userMode || "simple"] || 0;
return need > have;
}
// Short mode tag for an action that needs a switch, or "" when it is already available.
function modeBadge(item, userMode) {
if (!needsModeSwitch(item, userMode)) return "";
if (item.mode === "develop") return "Developer";
if (item.mode === "expert") return "Expert";
if (item.mode === "advanced") return "Advanced";
return "";
}
// Search keywords that name a settings mode. "developer" (and the internal "develop") both select
// Developer; Simple is deliberately absent so it never floods the list with every command.
var MODE_WORDS = { advanced: "advanced", expert: "expert", developer: "develop", develop: "develop" };
// The mode values named as whole words in `query`, deduped. Case/diacritic-insensitive via Norm. A
// query with no mode keyword returns [] so the normal text search is completely unaffected.
function modeFilterFromQuery(query) {
var norm = NormText(String(query || "").trim(), false);
var found = [];
norm.split(/[^a-z0-9]+/).forEach(function (word) {
var mode = MODE_WORDS[word];
if (mode && found.indexOf(mode) === -1)
found.push(mode);
});
return found;
}
// 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) {
@@ -289,6 +347,7 @@ function stateFromPayload(payload) {
actions: payload.actions || [],
favourites: payload.favourites || [],
recent: payload.recent || [],
userMode: payload.user_mode || "simple",
query: "",
sel: { zone: "list", i: 0 },
lastResizeHeight: 0,
@@ -348,6 +407,7 @@ window.HandleStudio = function (payload) {
ACTIONS = next.actions;
FAVS = next.favourites;
RECENTS = next.recent;
USER_MODE = next.userMode;
query = next.query;
sel = next.sel;
lastResizeHeight = next.lastResizeHeight;
@@ -470,7 +530,8 @@ function renderFav() {
tile.className = "fav-tile" + (sel.zone === "fav" && sel.i === i ? " sel" : "");
tile.style.setProperty("--h", hue(id));
fillTile(tile, a);
tile.title = a.title;
var tileBadge = modeBadge(a, USER_MODE);
tile.title = a.title + (tileBadge ? " (" + tileBadge + ")" : "");
tile.setAttribute("aria-label", actionLabel(a, ACTIONS));
tile.onclick = function () { sel = { zone: "fav", i: i }; activateEntry(a); };
// Numbered quick-launch badge (Alt/Option+digit), drawn on the corner.
@@ -584,6 +645,13 @@ function renderActionRow(a, i) {
line.className = "row-line";
var name = markedText("row-name", a.title, mi ? mi.title : null);
line.appendChild(name);
var badge = modeBadge(a, USER_MODE);
if (badge) {
var tag = document.createElement("span");
tag.className = "row-mode";
tag.textContent = badge;
line.appendChild(tag);
}
if (a.shortcut) {
var sc = document.createElement("div");
sc.className = "row-sc";
@@ -211,4 +211,53 @@ assert.equal(ctx.tileCode(monoPool[2], monoPool), "FR",
assert.equal(ctx.tileCode({ id: "x", title: "Slice", source: "OrcaSlicer" }, [{ id: "x", title: "Slice", source: "OrcaSlicer" }]),
"S", "a unique title resolves to the bare title initial");
// needsModeSwitch: a setting is gated only when its required mode outranks the user's current mode.
assert.equal(ctx.needsModeSwitch({ mode: "advanced" }, "simple"), true, "Advanced is gated in Simple mode");
assert.equal(ctx.needsModeSwitch({ mode: "expert" }, "simple"), true, "Expert is gated in Simple mode");
assert.equal(ctx.needsModeSwitch({ mode: "expert" }, "advanced"), true, "Expert is gated in Advanced mode");
assert.equal(ctx.needsModeSwitch({ mode: "develop" }, "expert"), true, "Developer is gated in Expert mode");
assert.equal(ctx.needsModeSwitch({ mode: "simple" }, "simple"), false, "a Simple setting is not gated");
assert.equal(ctx.needsModeSwitch({ mode: "advanced" }, "advanced"), false, "an Advanced setting is not gated in Advanced mode");
assert.equal(ctx.needsModeSwitch({ mode: "develop" }, "develop"), false, "a Developer setting is not gated in Developer mode");
assert.equal(ctx.needsModeSwitch({}, "simple"), false, "a command with no mode is never gated");
// modeBadge: the tag text for gated settings, empty once the setting is available.
assert.equal(ctx.modeBadge({ mode: "advanced" }, "simple"), "Advanced", "Advanced badge text");
assert.equal(ctx.modeBadge({ mode: "expert" }, "simple"), "Expert", "Expert badge text");
assert.equal(ctx.modeBadge({ mode: "develop" }, "simple"), "Developer", "Developer badge text");
assert.equal(ctx.modeBadge({ mode: "advanced" }, "advanced"), "", "no badge when the mode already matches");
// modeFilterFromQuery: whole-word, case-insensitive mode keywords -> internal mode values. "developer"
// maps to the internal "develop"; "simple" is deliberately not a keyword; a prefix is not a match.
assert.deepEqual(ctx.modeFilterFromQuery("advanced"), ["advanced"], "Advanced is a mode keyword");
assert.deepEqual(ctx.modeFilterFromQuery("Expert"), ["expert"], "the keyword is case-insensitive");
assert.deepEqual(ctx.modeFilterFromQuery("developer"), ["develop"], "Developer maps to the develop value");
assert.deepEqual(ctx.modeFilterFromQuery("develop"), ["develop"], "the internal develop spelling also works");
assert.deepEqual(ctx.modeFilterFromQuery("expert retraction"), ["expert"], "a keyword is found among other text");
assert.deepEqual(ctx.modeFilterFromQuery("advanced expert"), ["advanced", "expert"], "multiple keywords are deduped in order");
assert.deepEqual(ctx.modeFilterFromQuery("advanced advanced"), ["advanced"], "a repeated keyword is deduped");
assert.deepEqual(ctx.modeFilterFromQuery("simple"), [], "Simple is not a mode keyword");
assert.deepEqual(ctx.modeFilterFromQuery("advance"), [], "a mode-word prefix is not a whole-word match");
assert.deepEqual(ctx.modeFilterFromQuery(""), [], "an empty query names no mode");
// searchActions mode union: a mode keyword keeps the normal text matches AND appends every setting
// requiring that mode. Not a filter - a Simple setting literally named "Advanced..." still shows, and
// commands (mode "simple") are never pulled in by a keyword.
const modePool = [
{ id: "a1", title: "Top Surface Layers", source: "Quality", group: "Quality : Layers", mode: "advanced" },
{ id: "a2", title: "Advanced Detection", source: "Quality", group: "Quality", mode: "simple" },
{ id: "a3", title: "Retraction Length", source: "Process", group: "Process : Quality", mode: "expert" },
{ id: "a4", title: "Slice", source: "OrcaSlicer", group: "Commands", mode: "simple" }
];
var adv = ctx.searchActions(modePool, "advanced");
assert.deepEqual(adv.map(function (a) { return a.id; }), ["a2", "a1"],
"a text match (a2) ranks above the mode-only setting (a1), and no expert/command leaks in");
var expert = ctx.searchActions(modePool, "expert");
assert.deepEqual(expert.map(function (a) { return a.id; }), ["a3"], "the expert keyword pulls in the expert setting");
assert.deepEqual(ctx.searchActions(modePool, "developer"), [], "no developer settings means no mode extras");
assert.equal(ctx.searchActions(modePool, "retraction")[0].id, "a3",
"a query with no mode keyword is unaffected by the mode union");
assert.equal(ctx.searchActions([{ id: "both", title: "Advanced", source: "Quality", group: "", mode: "advanced" }], "advanced").length,
1, "a setting that both matches text and requires the mode appears exactly once");
console.log("ok");
+12
View File
@@ -364,6 +364,18 @@ body {
gap: 3px;
}
/* Mode tag on settings above the user's current mode (Advanced/Expert/Developer). */
.row-mode {
flex: 0 0 auto;
padding: 1px 6px;
font-size: 10px;
line-height: 1.4;
border-radius: 8px;
color: var(--plugin-status-warn);
background: var(--plugin-status-warn-bg);
white-space: nowrap;
}
kbd {
display: inline-flex;
align-items: center;