mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-12 02:11:39 +00:00
Support searching the Speed Dial by English names in any UI language (#16369)
This commit is contained in:
@@ -9,6 +9,8 @@
|
||||
// - action.input "percent"/"tab" -> NativeCommands catalog (phases handled in activateEntry)
|
||||
// - action.icon SVG base name -> AppAction::icon / resources/images/<name>.svg
|
||||
// - action.desc/wiki -> AppAction::tooltip / help_url (footer detail strip)
|
||||
// - action.*_en / tab.title_en -> ActionRegistry action_json / tab_options (English search aliases,
|
||||
// sent only when they differ from the displayed text)
|
||||
// - payload.tooltip_expanded -> ActionRegistry::tooltip_expanded (persisted footer state)
|
||||
// - action list is frecency-sorted -> ActionRegistry::snapshot()
|
||||
|
||||
@@ -97,45 +99,49 @@ var ghostEl = null, ghostTypedEl = null, ghostSuffixEl = null;
|
||||
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
|
||||
// char) so the ranges FuzzyRangesNorm returns slice the ORIGINAL title/group/source text correctly.
|
||||
// The action objects arrive from C++ and are stable for the dialog's lifetime, so we compute these once.
|
||||
function titleNorm(a) {
|
||||
if (a._tn === undefined)
|
||||
a._tn = NormText(a.title, false);
|
||||
return a._tn;
|
||||
}
|
||||
// The eyebrow (header) line shows group when present, else source. Settings keep an empty group so
|
||||
// their source path is the eyebrow; commands / plates / recents carry a non-empty group. Splitting the
|
||||
// two lets a match range stay aligned to whichever string the eyebrow actually renders.
|
||||
function groupNorm(a) {
|
||||
if (a._gn === undefined)
|
||||
a._gn = NormText(a.group || "", false);
|
||||
return a._gn;
|
||||
}
|
||||
function sourceNorm(a) {
|
||||
if (a._sn === undefined)
|
||||
a._sn = NormText(a.source || "", false);
|
||||
return a._sn;
|
||||
}
|
||||
function pluginCategoryNorm(a) {
|
||||
if (a._pcn === undefined)
|
||||
a._pcn = a.kind === "plugin" ? NormText(T("sd_plugins", "Plugins"), false) : "";
|
||||
return a._pcn;
|
||||
}
|
||||
// Plugin type is searchable metadata, so typing "plugin" can find runnable plugin actions even
|
||||
// when neither their capability nor plugin name contains that word. Keep both category forms.
|
||||
function pluginTypeNorm(a) {
|
||||
if (a._pn === undefined)
|
||||
a._pn = a.kind === "plugin" ? NormText("plugin plugins", false) : "";
|
||||
return a._pn;
|
||||
}
|
||||
// Search-only alias for the descriptive name when the title differs (e.g. "Reverse on even" vs
|
||||
// "Overhang reversal"). Never rendered, so no highlight ranges.
|
||||
function fullNorm(a) {
|
||||
if (a._fn === undefined)
|
||||
a._fn = NormText(a.full_label || "", false);
|
||||
return a._fn;
|
||||
// Every searchable field of an action: its text, its ranking weight (title > group/category >
|
||||
// source/aliases) and the matchIndex key its highlight ranges go to (null for a search-only field).
|
||||
// - group vs source: the eyebrow (header) line shows group when present, else source. Settings keep
|
||||
// an empty group so their source path is the eyebrow; commands / plates / recents carry a non-empty
|
||||
// group. Splitting the two lets a match range stay aligned to whichever string the eyebrow renders.
|
||||
// - full_label: search-only alias for the descriptive name when the title differs (e.g. "Reverse on
|
||||
// even" vs "Overhang reversal"). Never rendered, so no highlight ranges.
|
||||
// - plugin category / type: the visible "Plugins" category, plus the plugin kind as searchable
|
||||
// metadata, so typing "plugin" finds runnable plugin actions even when neither their capability
|
||||
// nor plugin name contains that word. Keep both category forms.
|
||||
// - *_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.
|
||||
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" },
|
||||
{ text: function (a) { return a.source; }, weight: 0, range: "source" },
|
||||
{ text: function (a) { return a.full_label; }, weight: 0, range: null },
|
||||
{ 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 }
|
||||
];
|
||||
var MATCH_RANGE_KEYS = SEARCH_FIELDS.map(function (f) { return f.range; }).filter(Boolean);
|
||||
|
||||
// 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
|
||||
// actually has (an English UI pays nothing for the aliases it never receives). The fold is
|
||||
// length-preserving (1:1 per char) so the ranges FuzzyRangesNorm returns slice the ORIGINAL text.
|
||||
function searchFields(a) {
|
||||
if (a._sf === undefined) {
|
||||
a._sf = [];
|
||||
for (var i = 0; i < SEARCH_FIELDS.length; i++) {
|
||||
var norm = NormText(SEARCH_FIELDS[i].text(a) || "", false);
|
||||
if (norm)
|
||||
a._sf.push({ norm: norm, weight: SEARCH_FIELDS[i].weight, range: SEARCH_FIELDS[i].range });
|
||||
}
|
||||
}
|
||||
return a._sf;
|
||||
}
|
||||
|
||||
// Match one pre-normalized field vs one pre-normalized needle. Returns {score, ranges, contiguous}
|
||||
@@ -175,26 +181,22 @@ function tokenWordRe(token) {
|
||||
return new RegExp("\\b" + EscapeRegExp(token) + "\\b");
|
||||
}
|
||||
|
||||
// One token's best match across an action's searchable fields, keeping per-field ranges for
|
||||
// highlighting. Returns {score, title, group, source}, or null if no field matches.
|
||||
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);
|
||||
var f = fieldMatchScore(fullNorm(a), token, wwRe);
|
||||
var p = fieldMatchScore(pluginCategoryNorm(a), token, wwRe);
|
||||
var typeMatch = fieldMatchScore(pluginTypeNorm(a), token, wwRe);
|
||||
if (!t && !g && !s && !f && !p && !typeMatch) 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,
|
||||
f ? (f.contiguous ? SCORE_CONTIGUOUS : 0) + f.score : -Infinity,
|
||||
p ? (p.contiguous ? SCORE_CONTIGUOUS : 0) + SCORE_GROUP + p.score : -Infinity,
|
||||
typeMatch ? (typeMatch.contiguous ? SCORE_CONTIGUOUS : 0) + SCORE_GROUP + typeMatch.score : -Infinity
|
||||
);
|
||||
return { score: score, title: t ? t.ranges : null, group: g ? g.ranges : null, source: s ? s.ranges : null,
|
||||
plugin: p ? p.ranges : null };
|
||||
// One needle (the whole query, or one token of it) against every SEARCH_FIELDS field of an action.
|
||||
// Returns {score, ranges}: the best weighted field score (contiguous > fuzzy, then field weight, then
|
||||
// start/gaps) and the ranges of each matching highlighted field keyed by its range name. Null when no
|
||||
// field matches.
|
||||
function matchFields(a, needle, wwRe) {
|
||||
var fields = searchFields(a);
|
||||
var best = null, ranges = null; // allocated on the first match: most actions match nothing
|
||||
for (var i = 0; i < fields.length; i++) {
|
||||
var field = fields[i];
|
||||
var m = fieldMatchScore(field.norm, needle, wwRe);
|
||||
if (!m) continue;
|
||||
var v = (m.contiguous ? SCORE_CONTIGUOUS : 0) + field.weight + m.score;
|
||||
if (best === null || v > best) best = v;
|
||||
if (field.range) (ranges || (ranges = {}))[field.range] = m.ranges;
|
||||
}
|
||||
return best === null ? null : { score: best, ranges: ranges || {} };
|
||||
}
|
||||
|
||||
// Merge per-field token ranges into sorted, coalesced ranges for highlighting. Overlapping or adjacent
|
||||
@@ -217,24 +219,6 @@ function mergeRanges(ranges) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Combine per-field scores into one value, or null when nothing matched.
|
||||
// Ranking: contiguous > fuzzy, then title > group/category > source/full alias, then start/gaps.
|
||||
function scoreFields(t, g, s, f, p, typeMatch) {
|
||||
var best = null;
|
||||
function consider(m, weight) {
|
||||
if (!m) return;
|
||||
var v = (m.contiguous ? SCORE_CONTIGUOUS : 0) + weight + m.score;
|
||||
best = best === null ? v : Math.max(best, v);
|
||||
}
|
||||
consider(t, SCORE_TITLE);
|
||||
consider(g, SCORE_GROUP);
|
||||
consider(s, 0);
|
||||
consider(f, 0);
|
||||
consider(p, SCORE_GROUP);
|
||||
consider(typeMatch, SCORE_GROUP);
|
||||
return best;
|
||||
}
|
||||
|
||||
// The unified main-phase search: every action (command/plugin/setting) matching the query, ranked
|
||||
// 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
|
||||
@@ -245,8 +229,8 @@ function scoreFields(t, g, s, f, p, typeMatch) {
|
||||
// - 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).
|
||||
// full_label and canonical plugin kind are searchable aliases. The visible category is also searchable
|
||||
// so its own match can be highlighted in plugin rows.
|
||||
// full_label, canonical plugin kind and the English counterparts (*_en) are searchable aliases. The
|
||||
// visible category is also searchable so its own match can be highlighted in plugin rows.
|
||||
// A phrase match always outranks a distributed token match.
|
||||
function searchActions(actions, query) {
|
||||
var q = (query || "").trim();
|
||||
@@ -262,50 +246,43 @@ function searchActions(actions, query) {
|
||||
// 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");
|
||||
// A one-word query's only token is the needle itself, so its token pass would repeat the failed
|
||||
// phrase match exactly; such an action is dropped without it.
|
||||
var tokenPass = searchTokens.length !== 1 || searchTokens[0] !== searchNeedle;
|
||||
|
||||
var scored = [];
|
||||
var seen = {};
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var a = list[i];
|
||||
var t = fieldMatchScore(titleNorm(a), searchNeedle, wwRe);
|
||||
var g = fieldMatchScore(groupNorm(a), searchNeedle, wwRe);
|
||||
var s = fieldMatchScore(sourceNorm(a), searchNeedle, wwRe);
|
||||
var f = fieldMatchScore(fullNorm(a), searchNeedle, wwRe);
|
||||
var p = fieldMatchScore(pluginCategoryNorm(a), searchNeedle, wwRe);
|
||||
var typeMatch = fieldMatchScore(pluginTypeNorm(a), searchNeedle, wwRe);
|
||||
var phrase = scoreFields(t, g, s, f, p, typeMatch);
|
||||
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,
|
||||
plugin: p ? p.ranges : null };
|
||||
var phrase = matchFields(a, searchNeedle, wwRe);
|
||||
var score, perField;
|
||||
if (phrase) {
|
||||
score = phrase.score + SCORE_PHRASE;
|
||||
} else {
|
||||
if (!tokenPass) continue;
|
||||
// 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, pluginR = null, all = true;
|
||||
var sum = 0, all = true;
|
||||
perField = {};
|
||||
for (var tokenIndex = 0; tokenIndex < searchTokens.length; tokenIndex++) {
|
||||
var m = tokenMatch(a, searchTokens[tokenIndex], searchTokenRes[tokenIndex]);
|
||||
var m = matchFields(a, searchTokens[tokenIndex], searchTokenRes[tokenIndex]);
|
||||
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 (m.plugin) (pluginR || (pluginR = [])).push(m.plugin);
|
||||
for (var key in m.ranges)
|
||||
(perField[key] || (perField[key] = [])).push(m.ranges[key]);
|
||||
}
|
||||
if (!all) continue;
|
||||
score = sum;
|
||||
ranges = { title: mergeRanges(titleR), group: mergeRanges(groupR), source: mergeRanges(sourceR),
|
||||
plugin: mergeRanges(pluginR) };
|
||||
}
|
||||
// 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: ranges.title,
|
||||
group: ranges.group,
|
||||
source: ranges.source,
|
||||
plugin: ranges.plugin,
|
||||
useEyebrowGroup: !!(a.group)
|
||||
};
|
||||
// Ranges are per-field against the ACTUAL text drawn: title for the row-name, group (or source
|
||||
// when group is empty) for the eyebrow, and title_en for the English hint - so highlight offsets
|
||||
// stay aligned to the label.
|
||||
var entry = { useEyebrowGroup: !!(a.group) };
|
||||
for (var k = 0; k < MATCH_RANGE_KEYS.length; k++) {
|
||||
var rangeKey = MATCH_RANGE_KEYS[k];
|
||||
entry[rangeKey] = phrase ? phrase.ranges[rangeKey] || null : mergeRanges(perField[rangeKey]);
|
||||
}
|
||||
matchIndex[a.id] = entry;
|
||||
scored.push({ a: a, s: score });
|
||||
seen[a.id] = true;
|
||||
}
|
||||
@@ -350,7 +327,8 @@ function completionFor(query, list) {
|
||||
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, a.full_label];
|
||||
// English aliases after the displayed text, so English typing completes in a localized UI.
|
||||
var fields = [a.title, a.group, a.source, a.full_label, a.title_en, a.group_en, a.source_en, a.full_label_en];
|
||||
for (var f = 0; f < fields.length; f++) {
|
||||
var words = completionWords(fields[f]);
|
||||
for (var w = 0; w < words.length; w++) {
|
||||
@@ -427,13 +405,15 @@ function tabTitle(t) {
|
||||
return title || prettySource((t && t.id) || "");
|
||||
}
|
||||
|
||||
// Filter the tab list by a fuzzy title/id match. Pure so the node-vm test can exercise it.
|
||||
// Filter the tab list by a fuzzy title/id/English-title match (title_en is sent only when the page
|
||||
// label is translated). Pure so the node-vm test can exercise it.
|
||||
function filterTabs(tabs, query) {
|
||||
var q = (query || "").trim();
|
||||
if (!q)
|
||||
return (tabs || []).slice(0);
|
||||
return (tabs || []).filter(function (t) {
|
||||
return FuzzyRanges(tabTitle(t), q, false) || FuzzyRanges(t.id, q, false);
|
||||
return FuzzyRanges(tabTitle(t), q, false) || FuzzyRanges(t.id, q, false) ||
|
||||
(t.title_en && FuzzyRanges(t.title_en, q, false));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -973,6 +953,10 @@ function renderActionRow(a, i) {
|
||||
var eyebrowMatch = mi ? (showPluginCategory ? mi.plugin : (mi.useEyebrowGroup ? mi.group : mi.source)) : null;
|
||||
shell.left.insertBefore(markedText("row-eyebrow", eyebrow, eyebrowMatch), 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));
|
||||
var badge = modeBadge(a, USER_MODE);
|
||||
if (badge) {
|
||||
var tag = document.createElement("span");
|
||||
|
||||
@@ -50,6 +50,15 @@ assert.equal(ctx.filterTabs(tabOptions, "Device").length, 1,
|
||||
"a typed query matches a tab title");
|
||||
assert.deepEqual(ctx.filterTabs(tabOptions, "zzz"), [],
|
||||
"a typed query with no match returns an empty list");
|
||||
// A translated tab whose id is not its English name carries title_en, so English still finds it.
|
||||
const localizedTabs = [
|
||||
{ id: "monitor", title: "设备", title_en: "Device" },
|
||||
{ id: "prepare", title: "准备" }
|
||||
];
|
||||
assert.deepEqual(ctx.filterTabs(localizedTabs, "device").map(function (t) { return t.id; }), ["monitor"],
|
||||
"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");
|
||||
|
||||
// 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",
|
||||
@@ -192,6 +201,48 @@ assert.equal(ctx.completionFor("", compPool), null, "an empty query has no compl
|
||||
assert.equal(ctx.completionFor("zzz", ctx.searchActions(compPool, "zzz")), null,
|
||||
"a query with no match has no completion");
|
||||
|
||||
// English aliases: in a non-English UI each translated field may carry its English counterpart
|
||||
// (*_en), searched with the weight of the field it mirrors. An English UI sends none.
|
||||
const zhPool = [
|
||||
{ id: "rev", title: "反转偶数层悬垂方向", full_label: "悬垂反转", source: "工艺 : 质量 : 悬垂",
|
||||
title_en: "Reverse on even", full_label_en: "Overhang reversal", source_en: "Process : Quality : Overhangs",
|
||||
group: "", input: "" },
|
||||
{ id: "slice", title: "切片并预览", source: "OrcaSlicer", group: "切片和导出",
|
||||
title_en: "Slice and Preview", group_en: "Slice & Export", input: "" },
|
||||
{ id: "brim", title: "Brim宽度", source: "工艺 : 其他 : Brim", source_en: "Process : Others : Brim",
|
||||
group: "", input: "" }
|
||||
];
|
||||
assert.deepEqual(ctx.searchActions(zhPool, "reverse on even").map(function (a) { return a.id; }), ["rev"],
|
||||
"an English query finds a translated setting through its English title");
|
||||
assert.equal(ctx.matchIndex.rev.title, null, "an English-only match does not highlight the translated title");
|
||||
assert.deepEqual(ctx.matchIndex.rev.title_en, [[0, 15]], "the English title keeps its ranges for the hint");
|
||||
assert.deepEqual(ctx.searchActions(zhPool, "偶数层").map(function (a) { return a.id; }), ["rev"],
|
||||
"the translated title is still searchable");
|
||||
assert.equal(ctx.matchIndex.rev.title_en, null, "a translated-title match leaves no English hint ranges");
|
||||
assert.deepEqual(ctx.searchActions(zhPool, "reverse 悬垂").map(function (a) { return a.id; }), ["rev"],
|
||||
"a mixed query may match one token in English and another in the translation");
|
||||
assert.deepEqual(ctx.searchActions(zhPool, "overhang reversal").map(function (a) { return a.id; }), ["rev"],
|
||||
"the English full_label alias is searchable");
|
||||
assert.deepEqual(ctx.searchActions(zhPool, "export").map(function (a) { return a.id; }), ["slice"],
|
||||
"the English group alias is searchable");
|
||||
assert.deepEqual(ctx.searchActions(zhPool, "brim").map(function (a) { return a.id; }), ["brim"],
|
||||
"a translation that keeps the English word matches the displayed title");
|
||||
assert.deepEqual(ctx.matchIndex.brim.title, [[0, 4]], "that match highlights the displayed title, so no hint is drawn");
|
||||
// Weights mirror the localized fields: an English title match outranks an English breadcrumb match.
|
||||
const zhRankPool = [
|
||||
{ id: "src", title: "内壁", source: "工艺 : 速度", source_en: "Process : Speed", group: "", input: "" },
|
||||
{ id: "ttl", title: "速度", source: "工艺 : 其他", title_en: "Speed", group: "", input: "" }
|
||||
];
|
||||
assert.deepEqual(ctx.searchActions(zhRankPool, "speed").map(function (a) { return a.id; }), ["ttl", "src"],
|
||||
"an English title match outranks an English breadcrumb match, as their localized fields do");
|
||||
// Absent aliases (an English UI) leave no English hint; every pool above has none and ranks as before.
|
||||
ctx.searchActions(pool, "layer");
|
||||
assert.equal(ctx.matchIndex.c1.title_en, null, "an action without English aliases has no English hint ranges");
|
||||
assert.equal(ctx.completionFor("reverse o", ctx.searchActions(zhPool, "reverse o")).suffix, "n",
|
||||
"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");
|
||||
|
||||
// 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",
|
||||
@@ -456,9 +507,9 @@ assert.equal(ctx.stateFromPayload({}).tooltipExpanded, true, "expansion defaults
|
||||
assert.equal(ctx.stateFromPayload({ tooltip_expanded: false }).tooltipExpanded, false, "a collapsed payload is honored");
|
||||
assert.equal(ctx.stateFromPayload({ tooltip_expanded: true }).tooltipExpanded, true, "an expanded payload is honored");
|
||||
|
||||
// resultCountText: a search counts the shown matches only ("Showing N actions"); the total is used
|
||||
// resultCountText: a search counts the shown matches only ("Matching actions: N"); the total is used
|
||||
// solely for the empty-query count.
|
||||
assert.equal(ctx.resultCountText(100, 3, "lay"), "Showing 3 actions", "a search reports the shown match count only");
|
||||
assert.equal(ctx.resultCountText(100, 100, ""), "100 actions", "an empty query reports the total");
|
||||
assert.equal(ctx.resultCountText(100, 3, "lay"), "Matching actions: 3", "a search reports the shown match count only");
|
||||
assert.equal(ctx.resultCountText(100, 100, ""), "Actions: 100", "an empty query reports the total");
|
||||
|
||||
console.log("ok");
|
||||
|
||||
@@ -414,8 +414,20 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* English title of a row found only through it (non-English UI); gives way to the title first. */
|
||||
.row-alias {
|
||||
flex: 0 100 auto;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-name mark,
|
||||
.row-eyebrow mark {
|
||||
.row-eyebrow mark,
|
||||
.row-alias mark {
|
||||
background: var(--plugin-status-warn-bg);
|
||||
color: var(--plugin-status-warn);
|
||||
border-radius: 2px;
|
||||
|
||||
Reference in New Issue
Block a user