Tab autocomplete. Better search integration of categories and action names

This commit is contained in:
Lam Wei Lun
2026-09-16 13:06:16 +08:00
parent f76e4b1e02
commit b01d18bba3
4 changed files with 266 additions and 22 deletions
+4 -1
View File
@@ -19,7 +19,10 @@
<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" />
<span class="plugin-search-field">
<span class="search-ghost" id="ghost" hidden aria-hidden="true"><span class="search-ghost-typed" id="ghostTyped"></span><span class="search-ghost-suffix" id="ghostSuffix"></span></span>
<input class="plugin-search-input" id="q" type="text" placeholder="Search actions" spellcheck="false" autocomplete="off" aria-label="Search actions" />
</span>
<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>
+178 -21
View File
@@ -30,6 +30,9 @@ var MODE_RANK = { simple: 0, advanced: 1, expert: 2, develop: 3 };
var SCORE_CONTIGUOUS = 100000;
var SCORE_TITLE = 2000;
var SCORE_GROUP = 1000;
// A whole-query match in a single field must outrank any multi-token match distributed across fields.
// Larger than the largest plausible sum of per-token scores (SCORE_CONTIGUOUS * token count).
var SCORE_PHRASE = 10000000;
// Localized lookup for strings this page builds at runtime. The host injects the translated table
// as a document-start user script (SpeedDialWebDialog::add_user_scripts); the English literal is a
@@ -68,8 +71,11 @@ var sectionStarts = null;
var sectionTotal = 0;
var sectionRendered = 0;
// search-cache: the normalized (folded+lowercased) needle for the current query pass.
// search-cache: the normalized (folded+lowercased) needle for the current query pass, plus the
// whitespace-separated tokens and their compiled whole-word regexes for the multi-token path.
var searchNeedle = "";
var searchTokens = [];
var searchTokenRes = [];
// Palette phase: 'commands' (one unified search over actions/commands/settings, recents on empty
// query), 'percent' ("Go to layer" second phase: enter a 0-100 percentage), 'tab' ("Go to tab..."
@@ -82,6 +88,9 @@ var tabOptions = []; // [{id,title}] - notebook pages, fetched on entering
// 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, detailEl = null;
var ghostEl = null, ghostTypedEl = null, ghostSuffixEl = null;
// The completion currently offered as ghost text ({suffix, word, id}), or null. Tab accepts it.
var activeCompletion = null;
// ---- pure helpers (no DOM; unit-tested) -------------------------------------
// Pre-normalized haystacks, cached on the action object. The fold is length-preserving (1:1 per
@@ -106,20 +115,20 @@ function sourceNorm(a) {
return a._sn;
}
// Match one pre-normalized field vs the current needle. Returns {score, ranges, contiguous} when the
// needle is present, else null. wwRe is a compiled whole-word (\b-bounded) regex for the current needle.
// A whole-word hit is preferred - it highlights the full word (e.g. "orient" in "Auto-Orient", not the
// stray "o" of "Auto") and marks a perfect match. Otherwise FuzzyRangesNorm (which now prefers the
// Match one pre-normalized field vs one pre-normalized needle. Returns {score, ranges, contiguous}
// when the needle is present, else null. wwRe is a compiled whole-word (\b-bounded) regex for the
// needle. A whole-word hit is preferred - it highlights the full word (e.g. "orient" in "Auto-Orient",
// not the stray "o" of "Auto") and marks a perfect match. Otherwise FuzzyRangesNorm (which prefers the
// most-contiguous run) is used. score is higher for an earlier start and fewer gaps; contiguous marks
// a perfect match - the whole needle landed as one unbroken run.
function fieldMatchScore(norm, wwRe) {
if (!searchNeedle) return null;
function fieldMatchScore(norm, needle, wwRe) {
if (!needle) return null;
if (wwRe) {
var m = wwRe.exec(norm || "");
if (m)
return { score: 1000 - m.index * 10, ranges: [[m.index, m.index + m[0].length]], contiguous: true };
}
var r = FuzzyRangesNorm(norm || "", searchNeedle);
var r = FuzzyRangesNorm(norm || "", needle);
if (!r) return null;
var gaps = 0, len = 0;
for (var i = 0; i < r.length; i++) {
@@ -127,7 +136,57 @@ function fieldMatchScore(norm, wwRe) {
gaps += r[i][0] - r[i - 1][1];
len += r[i][1] - r[i][0];
}
return { score: 1000 - r[0][0] * 10 - gaps * 10, ranges: r, contiguous: r.length === 1 && len === searchNeedle.length };
return { score: 1000 - r[0][0] * 10 - gaps * 10, ranges: r, contiguous: r.length === 1 && len === needle.length };
}
// Split a query into normalized (folded+lowercased) whitespace-separated tokens. Empty for a blank
// query. These drive the multi-token path: every token must match some field, but different tokens
// may match different fields (the title, the group, or the source breadcrumb).
function queryTokens(query) {
var norm = NormText(String(query || "").trim(), false);
return norm ? norm.split(/\s+/).filter(Boolean) : [];
}
// Whole-word regex for one normalized token, same shape fieldMatchScore expects.
function tokenWordRe(token) {
return new RegExp("\\b" + EscapeRegExp(token) + "\\b");
}
// One token's best match across an action's three fields, keeping the per-field ranges so the caller
// can highlight each matched word. Returns {score, title, group, source} (ranges or null per field), or
// null when no field contains the token. Score mirrors scoreFields' tiers: contiguous > fuzzy, then
// title > group > source.
function tokenMatch(a, token, wwRe) {
var t = fieldMatchScore(titleNorm(a), token, wwRe);
var g = fieldMatchScore(groupNorm(a), token, wwRe);
var s = fieldMatchScore(sourceNorm(a), token, wwRe);
if (!t && !g && !s) return null;
var score = Math.max(
t ? (t.contiguous ? SCORE_CONTIGUOUS : 0) + SCORE_TITLE + t.score : -Infinity,
g ? (g.contiguous ? SCORE_CONTIGUOUS : 0) + SCORE_GROUP + g.score : -Infinity,
s ? (s.contiguous ? SCORE_CONTIGUOUS : 0) + s.score : -Infinity
);
return { score: score, title: t ? t.ranges : null, group: g ? g.ranges : null, source: s ? s.ranges : null };
}
// Merge per-field token ranges into sorted, coalesced ranges for highlighting. Overlapping or adjacent
// runs (the same word matched by two tokens) collapse to one span. Null when nothing matched.
function mergeRanges(ranges) {
var flat = [];
(ranges || []).forEach(function (rs) {
if (rs) rs.forEach(function (r) { flat.push([r[0], r[1]]); });
});
if (!flat.length) return null;
flat.sort(function (a, b) { return a[0] - b[0] || a[1] - b[1]; });
var out = [flat[0].slice()];
for (var i = 1; i < flat.length; i++) {
var last = out[out.length - 1];
if (flat[i][0] <= last[1])
last[1] = Math.max(last[1], flat[i][1]);
else
out.push(flat[i].slice());
}
return out;
}
// Combine the per-field match scores into one comparable value, or null when no field matched.
@@ -151,12 +210,21 @@ function scoreFields(t, g, s) {
// by relevance (not by action type). Sets matchIndex so rows highlight their match ranges. The query
// is normalized ONCE per pass - FuzzyRangesNorm then runs against each action's pre-normalized
// haystack, so per-keystroke cost is a cheap scan (no per-char normalize/regex).
//
// Two match modes, phrase preferred:
// - phrase: the whole trimmed query as one ordered subsequence in a SINGLE field (as before).
// - tokens: every whitespace-separated word must match SOME field, but different words may match
// different fields. This is what lets "speed acceleration inner" find "Inner wall" whose path is
// "Process : Speed : Acceleration" (title + source breadcrumb together).
// A phrase match always outranks a distributed token match.
function searchActions(actions, query) {
var q = (query || "").trim();
var list = actions || [];
matchIndex = {};
if (!q) { searchNeedle = ""; return list.slice(0); }
if (!q) { searchNeedle = ""; searchTokens = []; searchTokenRes = []; return list.slice(0); }
searchNeedle = NormText(q, false);
searchTokens = queryTokens(q);
searchTokenRes = searchTokens.map(tokenWordRe);
// 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);
@@ -168,17 +236,36 @@ function searchActions(actions, query) {
var seen = {};
for (var i = 0; i < list.length; i++) {
var a = list[i];
var t = fieldMatchScore(titleNorm(a), wwRe);
var g = fieldMatchScore(groupNorm(a), wwRe);
var s = fieldMatchScore(sourceNorm(a), wwRe);
var score = scoreFields(t, g, s);
if (score === null) continue;
var t = fieldMatchScore(titleNorm(a), searchNeedle, wwRe);
var g = fieldMatchScore(groupNorm(a), searchNeedle, wwRe);
var s = fieldMatchScore(sourceNorm(a), searchNeedle, wwRe);
var phrase = scoreFields(t, g, s);
var score, ranges;
if (phrase !== null) {
score = phrase + SCORE_PHRASE;
ranges = { title: t ? t.ranges : null, group: g ? g.ranges : null, source: s ? s.ranges : null };
} else {
// Require every token; a token that matches nothing drops the action immediately. Ranges
// from all matching tokens are merged per field so each matched word highlights.
var sum = 0, titleR = null, groupR = null, sourceR = null, all = true;
for (var k = 0; k < searchTokens.length; k++) {
var m = tokenMatch(a, searchTokens[k], searchTokenRes[k]);
if (!m) { all = false; break; }
sum += m.score;
if (m.title) (titleR || (titleR = [])).push(m.title);
if (m.group) (groupR || (groupR = [])).push(m.group);
if (m.source) (sourceR || (sourceR = [])).push(m.source);
}
if (!all) continue;
score = sum;
ranges = { title: mergeRanges(titleR), group: mergeRanges(groupR), source: mergeRanges(sourceR) };
}
// Ranges are per-field against the ACTUAL text drawn: title for the row-name, and group (or
// source when group is empty) for the eyebrow - so highlight offsets stay aligned to the label.
matchIndex[a.id] = {
title: t ? t.ranges : null,
group: g ? g.ranges : null,
source: s ? s.ranges : null,
title: ranges.title,
group: ranges.group,
source: ranges.source,
useEyebrowGroup: !!(a.group)
};
scored.push({ a: a, s: score });
@@ -206,6 +293,38 @@ function searchActions(actions, query) {
return result;
}
// Candidate words for inline completion, in reading order. Whitespace and the breadcrumb separator
// split them, so "Process : Speed : Acceleration" yields Process/Speed/Acceleration. Pure.
function completionWords(text) {
return String(text || "").split(/[\s:]+/).filter(Boolean);
}
// The inline completion for the query's LAST token: scan the top-ranked results' name first, then
// their path breadcrumb, and return the first word that extends the typed token as a prefix. Headlines
// the most likely word without committing to a result. Returns {suffix, word, id} or null. Pure so the
// node-vm test can exercise it; the caller appends `suffix` to the input.
function completionFor(query, list) {
var raw = String(query || "");
var parts = raw.match(/(\S+)\s*$/);
if (!parts) return null;
var prefix = NormText(parts[1], false);
if (!prefix) return null;
var top = (list || []).slice(0, 10);
for (var i = 0; i < top.length; i++) {
var a = top[i];
var fields = [a.title, a.group, a.source];
for (var f = 0; f < fields.length; f++) {
var words = completionWords(fields[f]);
for (var w = 0; w < words.length; w++) {
var norm = NormText(words[w], false);
if (norm.length > prefix.length && norm.indexOf(prefix) === 0)
return { suffix: words[w].slice(prefix.length), word: words[w], id: a.id };
}
}
}
return null;
}
// Pure: how many rows must be materialized to cover the given starting index plus `size` more.
// Clamped to the total; used to decide "render the next window" on scroll / arrow-nav.
function revealTarget(total, fromIndex, size) {
@@ -361,12 +480,15 @@ function commandList(actions, recents, query) {
var all = actions || [];
if (shouldRenderActionList(query)) {
if (searchCache && searchCache.actions === all && searchCache.query === query) {
matchIndex = searchCache.matchIndex;
searchNeedle = searchCache.needle;
matchIndex = searchCache.matchIndex;
searchNeedle = searchCache.needle;
searchTokens = searchCache.tokens;
searchTokenRes = searchCache.tokenRes;
return searchCache.list;
}
var found = searchActions(all, query);
searchCache = { actions: all, query: query, list: found, matchIndex: matchIndex, needle: searchNeedle };
searchCache = { actions: all, query: query, list: found, matchIndex: matchIndex, needle: searchNeedle,
tokens: searchTokens, tokenRes: searchTokenRes };
return found;
}
var rec = recents || [];
@@ -1049,10 +1171,28 @@ function renderDetail() {
}
}
// Refresh the muted inline completion shown at the end of the search field. Only offered in the
// commands phase, with the caret at the end of a non-empty, non-trailing-space input that isn't
// scrolled (so the overlay lines up with the real caret). The ghost is the typed text (hidden, to
// reserve its width) followed by the suggested suffix, so it sits exactly after the caret.
function updateGhost() {
activeCompletion = null;
if (!ghostEl || !qEl) return;
var eligible = phase === "commands" && qEl.value && qEl.selectionStart === qEl.value.length &&
!/\s$/.test(qEl.value) && qEl.scrollWidth <= qEl.clientWidth;
var comp = eligible ? completionFor(query, currentList()) : null;
if (!comp) { ghostEl.hidden = true; return; }
ghostTypedEl.textContent = qEl.value;
ghostSuffixEl.textContent = comp.suffix;
ghostEl.hidden = false;
activeCompletion = comp;
}
function render(opts) {
renderFav();
renderList();
renderDetail();
updateGhost();
// Pin toggles don't move the selection, so they pass keepScroll to avoid snapping the list
// back to a row that is currently off-screen.
if (!(opts && opts.keepScroll))
@@ -1203,6 +1343,7 @@ function focusInput() { setTimeout(function () { if (qEl) qEl.focus(); }, 0); }
// ---- init --------------------------------------------------------------------
function OnInit() {
qEl = $("q"); listEl = $("list"); favEl = $("favBar"); clearEl = $("clear"); eyeEl = $("favEyebrow"); countEl = $("count"); detailEl = $("detail");
ghostEl = $("ghost"); ghostTypedEl = $("ghostTyped"); ghostSuffixEl = $("ghostSuffix");
// text.js's TranslatePage() targets jQuery `.trans` nodes; this page has none and defines its own
// `$`, so don't call it. Runtime strings go through T() instead.
qEl.placeholder = T("sd_search", "Search actions");
@@ -1222,6 +1363,9 @@ function OnInit() {
query = qEl.value; sel = { zone: "list", i: 0 }; syncClearButton();
render({ resize: true, resetScroll: true });
});
// Caret moves without a value change (click / arrow keys) can enable or invalidate the ghost.
qEl.addEventListener("keyup", updateGhost);
qEl.addEventListener("click", updateGhost);
// Windowed reveal: as the list scrolls, materialize the next window (append-only, no rebuild) so the
// DOM stays bounded to what's near the viewport. Guarded to the commands phase (tabs/percent are tiny).
listEl.addEventListener("scroll", function () {
@@ -1250,6 +1394,19 @@ function OnInit() {
else flashHint(T("sd_no_wiki", "No wiki page for this action"));
return;
}
// Accept the inline completion: append the suggested word's suffix. Text only - the list
// selection is left where it is; Enter still runs the highlighted result. Shift+Tab is left
// alone so keyboard focus traversal still works.
if (e.key === "Tab" && phase === "commands" && activeCompletion &&
!e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
e.preventDefault();
qEl.value += activeCompletion.suffix;
query = qEl.value;
syncClearButton();
render({ resize: true, resetScroll: true });
qEl.focus();
return;
}
// Pin/unpin the highlighted action: Ctrl/Cmd+B. Commands phase only (tabs/percent aren't pinnable).
if (phase === "commands" && (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey &&
e.key.toLowerCase() === "b") {
@@ -117,6 +117,55 @@ const negativePool = [
assert.equal(ctx.searchActions(negativePool, "ornt").length, 1,
"a low-score fuzzy match is not mistaken for no match");
// Multi-token cross-field search: each whitespace-separated word must match SOME searchable field,
// but different words may match different fields. "inner" is the title while "speed" and
// "acceleration" live in the source breadcrumb, so the query as a whole is never contiguous in one
// field - the old single-needle match found nothing for this.
const crossPool = [
{ id: "acc", title: "Inner wall", source: "Process : Speed : Acceleration", group: "", input: "" },
{ id: "spd", title: "Inner wall", source: "Process : Speed : Other layers speed", group: "", input: "" },
{ id: "other", title: "Outer wall", source: "Process : Quality : Walls", group: "", input: "" }
];
assert.deepEqual(
ctx.searchActions(crossPool, "speed acceleration inner").map(function (a) { return a.id; }),
["acc"],
"words may match different fields and every word is required"
);
assert.deepEqual(
ctx.searchActions(crossPool, "speed inner").map(function (a) { return a.id; }).sort(),
["acc", "spd"],
"a two-word title+source query matches every setting under Speed"
);
assert.deepEqual(
ctx.searchActions(crossPool, "quality inner").map(function (a) { return a.id; }),
[],
"an action is dropped when any one word matches no field"
);
// Highlighting merges the per-field ranges the tokens produced.
ctx.searchActions(crossPool, "speed acceleration inner");
assert.deepEqual(ctx.matchIndex.acc.title, [[0, 5]], "the title token highlights in the title");
assert.deepEqual(ctx.matchIndex.acc.source, [[10, 15], [18, 30]], "each path token highlights in the breadcrumb");
assert.deepEqual(ctx.queryTokens(" Speed Acceleration "), ["speed", "acceleration"],
"a query splits into normalized whitespace-separated tokens");
assert.deepEqual(ctx.queryTokens(""), [], "an empty query has no tokens");
// Inline completion: the LAST token is completed to a word in the top-ranked result, name first then
// breadcrumb. Pure - the caller appends `suffix`.
const compPool = [
{ id: "acc", title: "Inner wall", source: "Process : Speed : Acceleration", group: "", input: "" },
{ id: "smooth", title: "Smooth", source: "Process : Speed : Other layers speed", group: "", input: "" }
];
assert.equal(ctx.completionFor("speed acc", ctx.searchActions(compPool, "speed acc")).suffix, "eleration",
"the last token completes to the next word in the breadcrumb");
assert.equal(ctx.completionFor("inner w", ctx.searchActions(compPool, "inner w")).suffix, "all",
"the title is preferred over the breadcrumb for completion");
assert.equal(ctx.completionFor("inner wall", ctx.searchActions(compPool, "inner wall")), null,
"an already-complete word has nothing to add");
assert.equal(ctx.completionFor("", compPool), null, "an empty query has no completion");
assert.equal(ctx.completionFor("zzz", ctx.searchActions(compPool, "zzz")), null,
"a query with no match has no completion");
// actionCategory: a command/dynamic action's group is its category; a setting uses the top-level
// source segment; every plugin shares one header; a category-less action falls back to "Other".
assert.equal(ctx.actionCategory({ id: "c", group: "Help", source: "OrcaSlicer", kind: "command" }), "Help",
+35
View File
@@ -225,6 +225,7 @@ body {
.plugin-search-input {
flex: 1;
min-width: 0;
padding: 0;
background: transparent;
border: 0;
outline: 0;
@@ -232,6 +233,40 @@ body {
font: inherit;
}
/* Wraps the input so the ghost completion can sit exactly at the caret. The ghost mirrors the typed
text in an invisible run (reserving its width) followed by the muted suggested suffix. */
.plugin-search-field {
position: relative;
flex: 1;
min-width: 0;
display: flex;
align-items: center;
}
.search-ghost {
position: absolute;
inset: 0;
display: flex;
align-items: center;
overflow: hidden;
white-space: pre;
font: inherit;
pointer-events: none;
}
.search-ghost[hidden] {
display: none;
}
.search-ghost-typed {
visibility: hidden;
}
.search-ghost-suffix {
color: var(--muted, var(--orca-muted, #6b7280));
opacity: .7;
}
.plugin-search-clear {
display: inline-flex;
align-items: center;