diff --git a/resources/web/dialog/SpeedDial/speeddial.js b/resources/web/dialog/SpeedDial/speeddial.js index d1d68bb2ef..6c2758db2f 100644 --- a/resources/web/dialog/SpeedDial/speeddial.js +++ b/resources/web/dialog/SpeedDial/speeddial.js @@ -112,7 +112,7 @@ var activeCompletion = null; // - *_en: English counterparts of the translated fields, sent by C++ only when they differ from the // displayed text (a non-English UI), so the page can still be searched in English. Each carries the // weight of the localized field it mirrors, so a match ranks the same in either language. Search-only, -// except title_en, whose ranges drive the row's English hint. +// except their ranges, which drive the row's English hint (see ENGLISH_HINT_FIELDS). var SEARCH_FIELDS = [ { text: function (a) { return a.title; }, weight: SCORE_TITLE, range: "title" }, { text: function (a) { return a.group; }, weight: SCORE_GROUP, range: "group" }, @@ -121,12 +121,48 @@ var SEARCH_FIELDS = [ { text: function (a) { return a.kind === "plugin" ? T("sd_plugins", "Plugins") : ""; }, weight: SCORE_GROUP, range: "plugin" }, { text: function (a) { return a.kind === "plugin" ? "plugin plugins" : ""; }, weight: SCORE_GROUP, range: null }, { text: function (a) { return a.title_en; }, weight: SCORE_TITLE, range: "title_en" }, - { text: function (a) { return a.group_en; }, weight: SCORE_GROUP, range: null }, - { text: function (a) { return a.source_en; }, weight: 0, range: null }, - { text: function (a) { return a.full_label_en; }, weight: 0, range: null } + { text: function (a) { return a.group_en; }, weight: SCORE_GROUP, range: "group_en" }, + { text: function (a) { return a.source_en; }, weight: 0, range: "source_en" }, + { text: function (a) { return a.full_label_en; }, weight: 0, range: "full_label_en" } ]; var MATCH_RANGE_KEYS = SEARCH_FIELDS.map(function (f) { return f.range; }).filter(Boolean); +// English aliases a row can match through. `range` names the matchIndex key matchFields fills for that +// field; the hint shows the action's English name (title_en), falling back to whichever alias matched. +var ENGLISH_HINT_FIELDS = [ + { range: "title_en", text: function (a) { return a.title_en; } }, + { range: "group_en", text: function (a) { return a.group_en; } }, + { range: "source_en", text: function (a) { return a.source_en; } }, + { range: "full_label_en", text: function (a) { return a.full_label_en; } } +]; + +// The English alias to show as a muted hint on an action row, or null. Only a row whose match came +// through an English alias gets one (a visible translated highlight, or a local-only alias, needs +// none). The hint is the action's English NAME - highlighted when the name itself matched, otherwise +// shown plain - so the row identifies the action instead of repeating its translated eyebrow: a +// "Process : Speed" breadcrumb match still names the setting ("Inner wall"), not the breadcrumb again. +function englishHint(a, mi, visibleMatch) { + if (!mi || visibleMatch) return null; + var matched = null; + for (var i = 0; i < ENGLISH_HINT_FIELDS.length; i++) { + if (mi[ENGLISH_HINT_FIELDS[i].range]) { matched = ENGLISH_HINT_FIELDS[i]; break; } + } + if (!matched) return null; + if (a.title_en) return { text: a.title_en, ranges: mi.title_en || null }; + var text = matched.text(a); + return text ? { text: text, ranges: mi[matched.range] } : null; +} + +// The English breadcrumb a row matched through (a command's group, a setting's source path), with its +// highlight ranges, or null when neither matched. Shown beside the translated eyebrow so an English +// hit is visible where it landed, alongside the English name hint on the row-name line. +function englishBreadcrumb(a, mi) { + if (!mi) return null; + if (mi.group_en && a.group_en) return { text: a.group_en, ranges: mi.group_en }; + if (mi.source_en && a.source_en) return { text: a.source_en, ranges: mi.source_en }; + return null; +} + // The action's non-empty SEARCH_FIELDS as pre-normalized haystacks ({norm, weight, range}), cached on // the action object. The action objects arrive from C++ and are stable for the dialog's lifetime, so // this runs once per action; skipping empty fields keeps the per-keystroke scan to the text an action @@ -417,6 +453,18 @@ function filterTabs(tabs, query) { }); } +// The English title of a tab matched only through it, with its highlight ranges, or null when the tab +// matched its translated title or id (so it already shows why) or has no English title. +function tabAliasHint(t, query) { + var q = (query || "").trim(); + if (!q || !t || !t.title_en) + return null; + if (FuzzyRanges(tabTitle(t), q, false) || FuzzyRanges(t.id, q, false)) + return null; + var ranges = FuzzyRanges(t.title_en, q, false); + return ranges ? { text: t.title_en, ranges: ranges } : null; +} + function shouldRenderActionList(query) { return !!((query || "").trim()); } @@ -951,12 +999,21 @@ function renderActionRow(a, i) { var showPluginCategory = a.kind === "plugin" && (typedQuery || isRecent); var eyebrow = actionEyebrow(a, query, isRecent); var eyebrowMatch = mi ? (showPluginCategory ? mi.plugin : (mi.useEyebrowGroup ? mi.group : mi.source)) : null; - shell.left.insertBefore(markedText("row-eyebrow", eyebrow, eyebrowMatch), shell.line); + // The eyebrow line: the translated breadcrumb, and beside it the English breadcrumb when the query + // matched that (non-English UI), so an English hit on a breadcrumb is visible where it landed. + var eyebrowLine = document.createElement("div"); + eyebrowLine.className = "row-eyebrow-line"; + eyebrowLine.appendChild(markedText("row-eyebrow", eyebrow, eyebrowMatch)); + var enBreadcrumb = englishBreadcrumb(a, mi); + if (enBreadcrumb) + eyebrowLine.appendChild(markedText("row-eyebrow-en", enBreadcrumb.text, enBreadcrumb.ranges)); + shell.left.insertBefore(eyebrowLine, shell.line); shell.line.appendChild(markedText("row-name", a.title, mi ? mi.title : null)); - // A row found only through its English title shows that title, highlighted, so the match is - // explained even when nothing in the translated name resembles the query. - if (mi && !mi.title && mi.title_en) - shell.line.appendChild(markedText("row-alias", a.title_en, mi.title_en)); + // A row found only through an English alias shows the action's English name, highlighted when the + // name itself matched, so the match makes sense even when the translated row resembles nothing. + var hint = englishHint(a, mi, mi ? (mi.title || eyebrowMatch) : null); + if (hint) + shell.line.appendChild(markedText("row-alias", hint.text, hint.ranges)); var badge = modeBadge(a, USER_MODE); if (badge) { var tag = document.createElement("span"); @@ -1134,6 +1191,11 @@ function renderTabRow(t, i) { name.className = "row-name"; name.textContent = label; shell.line.appendChild(name); + // A tab found only through its English title shows that title, highlighted, so the match is + // explained even when the translated label looks nothing like the query. + var hint = tabAliasHint(t, query); + if (hint) + shell.line.appendChild(markedText("row-alias", hint.text, hint.ranges)); shell.row.onclick = function () { sel = { zone: "list", i: i }; render({ resize: true }); }; shell.row.ondblclick = function () { sel = { zone: "list", i: i }; jumpToTab(t); }; return shell.row; diff --git a/resources/web/dialog/SpeedDial/speeddial.test.js b/resources/web/dialog/SpeedDial/speeddial.test.js index f614554ae7..357288d88b 100644 --- a/resources/web/dialog/SpeedDial/speeddial.test.js +++ b/resources/web/dialog/SpeedDial/speeddial.test.js @@ -59,6 +59,16 @@ assert.deepEqual(ctx.filterTabs(localizedTabs, "device").map(function (t) { retu "a localized tab matches its English title"); assert.deepEqual(ctx.filterTabs(localizedTabs, "设备").map(function (t) { return t.id; }), ["monitor"], "a localized tab still matches its translated title"); +// A tab found only through its English title shows it as a hint; one that matched its translated +// title or id already shows why, and one without an English title has nothing to show. +assert.deepEqual(ctx.tabAliasHint(localizedTabs[0], "device"), { text: "Device", ranges: [[0, 6]] }, + "an English-only tab match yields the English title and its highlight ranges"); +assert.equal(ctx.tabAliasHint(localizedTabs[0], "设备"), null, + "a translated-title tab match draws no English hint"); +assert.equal(ctx.tabAliasHint(localizedTabs[0], "monitor"), null, + "a tab matched by its id draws no English hint"); +assert.equal(ctx.tabAliasHint(localizedTabs[1], "Device"), null, + "a tab with no English title has no hint"); // tabTitle: pages added with an empty title (e.g. MainFrame's Home tab) fall back to the id. assert.equal(ctx.tabTitle({ id: "home", title: "" }), "Home", @@ -242,6 +252,47 @@ assert.equal(ctx.completionFor("reverse o", ctx.searchActions(zhPool, "reverse o "completion extends an English word from title_en"); assert.equal(ctx.completionFor("quality overh", ctx.searchActions(zhPool, "quality overh")).suffix, "angs", "completion also reaches the English breadcrumb"); +// A row matched only through an English group/source/full_label alias still names the action in +// English: the hint is title_en (the English name), not the alias that matched, so a breadcrumb hit +// does not just repeat the translated eyebrow. +ctx.searchActions(zhPool, "export"); +assert.deepEqual(ctx.matchIndex.slice.group_en, [[8, 14]], "the English group keeps its ranges"); +assert.deepEqual(ctx.englishHint(zhPool[1], ctx.matchIndex.slice, null), { text: "Slice and Preview", ranges: null }, + "an English group match names the action, unhighlighted"); +assert.deepEqual(ctx.englishBreadcrumb(zhPool[1], ctx.matchIndex.slice), { text: "Slice & Export", ranges: [[8, 14]] }, + "an English group match shows the English breadcrumb beside the eyebrow"); +ctx.searchActions(zhPool, "overhang reversal"); +assert.deepEqual(ctx.matchIndex.rev.full_label_en, [[0, 17]], "the English full_label keeps its ranges"); +assert.deepEqual(ctx.englishHint(zhPool[0], ctx.matchIndex.rev, null), { text: "Reverse on even", ranges: null }, + "an English full_label match names the action, unhighlighted"); +ctx.searchActions(zhPool, "quality"); +assert.deepEqual(ctx.matchIndex.rev.source_en, [[10, 17]], "the English breadcrumb keeps its ranges"); +assert.deepEqual(ctx.englishHint(zhPool[0], ctx.matchIndex.rev, null), { text: "Reverse on even", ranges: null }, + "an English breadcrumb match names the action, not the breadcrumb"); +assert.deepEqual(ctx.englishBreadcrumb(zhPool[0], ctx.matchIndex.rev), { text: "Process : Quality : Overhangs", ranges: [[10, 17]] }, + "an English breadcrumb match shows the English breadcrumb beside the eyebrow"); +// The name itself matching is the one case that highlights the hint. +ctx.searchActions(zhPool, "reverse on even"); +assert.deepEqual(ctx.englishHint(zhPool[0], ctx.matchIndex.rev, null), { text: "Reverse on even", ranges: [[0, 15]] }, + "an English title match names and highlights the action"); +assert.equal(ctx.englishBreadcrumb(zhPool[0], ctx.matchIndex.rev), null, + "an English title match shows no breadcrumb hint"); +// A breadcrumb match with no English name falls back to the matched alias. +assert.deepEqual(ctx.englishHint({ title_en: "", source_en: "Process : Speed" }, { source_en: [[10, 15]] }, null), + { text: "Process : Speed", ranges: [[10, 15]] }, "without an English name the matched alias is shown"); +// A visible translated highlight suppresses the hint even when an English alias also matched. +ctx.searchActions(zhPool, "brim"); +assert.equal(ctx.englishHint(zhPool[2], ctx.matchIndex.brim, ctx.matchIndex.brim.title), null, + "a row that highlights its translated title draws no English hint"); +// The reported case: "process speed" matches only the English breadcrumb of "Inner wall", so the hint +// names the setting rather than echoing the breadcrumb. +const zhInner = [{ id: "inner", title: "内壁", title_en: "Inner wall", source: "工艺 : 速度", + source_en: "Process : Speed", group: "", input: "" }]; +ctx.searchActions(zhInner, "process speed"); +assert.deepEqual(ctx.englishHint(zhInner[0], ctx.matchIndex.inner, null), { text: "Inner wall", ranges: null }, + "a breadcrumb-only match names the setting in English"); +assert.deepEqual(ctx.englishBreadcrumb(zhInner[0], ctx.matchIndex.inner), { text: "Process : Speed", ranges: [[0, 8], [10, 15]] }, + "a breadcrumb-only match shows the English breadcrumb beside the eyebrow"); // 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". diff --git a/resources/web/dialog/SpeedDial/style.css b/resources/web/dialog/SpeedDial/style.css index 06c7d96632..a0e2015373 100644 --- a/resources/web/dialog/SpeedDial/style.css +++ b/resources/web/dialog/SpeedDial/style.css @@ -390,7 +390,31 @@ body { flex-direction: column; } +/* Eyebrow line: the translated breadcrumb, plus the English breadcrumb beside it when it matched. */ +.row-eyebrow-line { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; + overflow: hidden; +} + .row-eyebrow { + font-size: 10px; + line-height: 1.3; + color: var(--muted, var(--orca-muted, #6b7280)); + opacity: .85; + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* English breadcrumb of a row found only through it (non-English UI); gives way to the translation. */ +.row-eyebrow-en { + flex: 0 100 auto; + min-width: 0; font-size: 10px; line-height: 1.3; color: var(--muted, var(--orca-muted, #6b7280)); @@ -427,6 +451,7 @@ body { .row-name mark, .row-eyebrow mark, +.row-eyebrow-en mark, .row-alias mark { background: var(--plugin-status-warn-bg); color: var(--plugin-status-warn);