From b01d18bba3d1b50b79230ba1dc11c6c082bdfaa6 Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Wed, 16 Sep 2026 13:06:16 +0800 Subject: [PATCH] Tab autocomplete. Better search integration of categories and action names --- resources/web/dialog/SpeedDial/index.html | 5 +- resources/web/dialog/SpeedDial/speeddial.js | 199 ++++++++++++++++-- .../web/dialog/SpeedDial/speeddial.test.js | 49 +++++ resources/web/dialog/SpeedDial/style.css | 35 +++ 4 files changed, 266 insertions(+), 22 deletions(-) diff --git a/resources/web/dialog/SpeedDial/index.html b/resources/web/dialog/SpeedDial/index.html index e258f34f86..a9070b972e 100644 --- a/resources/web/dialog/SpeedDial/index.html +++ b/resources/web/dialog/SpeedDial/index.html @@ -19,7 +19,10 @@ - + + + + diff --git a/resources/web/dialog/SpeedDial/speeddial.js b/resources/web/dialog/SpeedDial/speeddial.js index 96725c8a8c..41e180a3cd 100644 --- a/resources/web/dialog/SpeedDial/speeddial.js +++ b/resources/web/dialog/SpeedDial/speeddial.js @@ -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") { diff --git a/resources/web/dialog/SpeedDial/speeddial.test.js b/resources/web/dialog/SpeedDial/speeddial.test.js index 4c2f606843..ea9517cfa6 100644 --- a/resources/web/dialog/SpeedDial/speeddial.test.js +++ b/resources/web/dialog/SpeedDial/speeddial.test.js @@ -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", diff --git a/resources/web/dialog/SpeedDial/style.css b/resources/web/dialog/SpeedDial/style.css index 4373ff7067..0beae39931 100644 --- a/resources/web/dialog/SpeedDial/style.css +++ b/resources/web/dialog/SpeedDial/style.css @@ -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;