mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-27 10:51:22 +00:00
Merge branch 'main' into feat/printer-agent-infra
This commit is contained in:
@@ -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>
|
||||
@@ -27,6 +30,7 @@
|
||||
<div class="dial-count" id="count" hidden></div>
|
||||
</div>
|
||||
<div class="dial-list" id="list"></div>
|
||||
<div class="dial-detail" id="detail" hidden></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,33 +13,427 @@ vm.runInContext(fs.readFileSync(__dirname + "/speeddial.js", "utf8"), ctx);
|
||||
assert.equal(typeof ctx.parseId, "undefined", "opaque action ids must never be parsed");
|
||||
|
||||
const duplicateActions = [
|
||||
{ id: "0123456789abcdef", title: "Repair", source: "Mesh Tools" },
|
||||
{ id: "fedcba9876543210", title: "Repair", source: "Mesh Tools" }
|
||||
{ id: "0123456789abcdef", title: "Repair", source: "Mesh Tools" },
|
||||
{ id: "fedcba9876543210", title: "Repair", source: "Mesh Tools" }
|
||||
];
|
||||
assert.equal(
|
||||
ctx.actionLabel(duplicateActions[0], duplicateActions),
|
||||
"Repair from Mesh Tools (0123456789abcdef)",
|
||||
"duplicate labels should use the opaque id without interpreting its contents"
|
||||
ctx.actionLabel(duplicateActions[0], duplicateActions),
|
||||
"Repair from Mesh Tools (0123456789abcdef)",
|
||||
"duplicate labels should use the opaque id without interpreting its contents"
|
||||
);
|
||||
|
||||
assert.equal(ctx.shouldRenderActionList(""), false, "an empty search keeps the action list hidden");
|
||||
assert.equal(ctx.shouldRenderActionList(" "), false, "whitespace-only search keeps the action list hidden");
|
||||
assert.equal(ctx.shouldRenderActionList(""), false, "an empty search shows the recents+pool list");
|
||||
assert.equal(ctx.shouldRenderActionList(" "), false, "whitespace-only search shows the recents+pool list");
|
||||
assert.equal(ctx.shouldRenderActionList("r"), true, "typing starts rendering matching actions");
|
||||
|
||||
// commandList: an empty query shows recents first, then every other action; a typed query filters all.
|
||||
assert.deepEqual(ctx.commandList(duplicateActions, [], ""), duplicateActions,
|
||||
"empty query + no recents shows the whole action pool");
|
||||
assert.deepEqual(ctx.commandList(duplicateActions, [duplicateActions[0]], ""),
|
||||
[duplicateActions[0], duplicateActions[1]],
|
||||
"empty query shows the recent first, then the remaining actions");
|
||||
assert.deepEqual(ctx.commandList(duplicateActions, [], "rep"), duplicateActions,
|
||||
"a typed query filters actions (both identical titles match) instead of showing recents");
|
||||
|
||||
// filterTabs (tab phase): an empty query keeps the whole list; a typed query filters by title/id.
|
||||
const tabOptions = [
|
||||
{ id: "home", title: "Home" },
|
||||
{ id: "prepare", title: "Prepare" },
|
||||
{ id: "monitor", title: "Device" },
|
||||
{ id: "project", title: "Project" }
|
||||
];
|
||||
assert.deepEqual(ctx.filterTabs(tabOptions, ""), tabOptions,
|
||||
"empty query keeps the whole tab list");
|
||||
assert.equal(ctx.filterTabs(tabOptions, "prep").length, 1,
|
||||
"a typed query filters tabs by title");
|
||||
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");
|
||||
|
||||
// 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",
|
||||
"an empty title falls back to the title-cased id");
|
||||
assert.equal(ctx.tabTitle({ id: "home" }), "Home",
|
||||
"a missing title falls back to the title-cased id");
|
||||
assert.equal(ctx.tabTitle({ id: "prepare", title: "Prepare" }), "Prepare",
|
||||
"a populated title is kept as-is");
|
||||
assert.equal(ctx.tabTitle({ id: "prepare", title: " Prepare" }), "Prepare",
|
||||
"a stray leading space in a tab title is trimmed so the label shows cleanly");
|
||||
assert.equal(ctx.filterTabs([{ id: "home", title: "" }], "home").length, 1,
|
||||
"an untitled tab still matches a typed query via the id/title fallback");
|
||||
assert.equal(ctx.filterTabs([{ id: "prepare", title: " Prepare" }], "prepare").length, 1,
|
||||
"a leading-space tab title still matches a typed query");
|
||||
|
||||
// The main phase is ONE pool: commands/plugins/settings are all actions, ranked by relevance
|
||||
// (no group headers, no actions-vs-settings discrimination).
|
||||
const pool = [
|
||||
{ id: "c1", title: "Layer Height", source: "Quality", group: "Quality : Layers", input: "" },
|
||||
{ id: "s1", title: "Go to layer (percent)", source: "OrcaSlicer", group: "Commands", input: "percent" },
|
||||
{ id: "c2", title: "Top Surface Layers", source: "Quality", group: "Quality : Layers", input: "" }
|
||||
];
|
||||
assert.deepEqual(ctx.searchActions(pool, ""), pool, "an empty query returns the pool unchanged");
|
||||
assert.equal(ctx.searchActions(pool, "zzz").length, 0, "a query with no match returns nothing");
|
||||
// "layer" matches multiple; the exact-titled action ranks above the loosely-matching command.
|
||||
assert.equal(ctx.searchActions(pool, "layer")[0].id, "c1",
|
||||
"a title-exact match ranks above a partial match");
|
||||
assert.equal(ctx.searchActions(pool, "layer").length >= 2, true,
|
||||
"both a setting and a command match the same query in the same list");
|
||||
assert.equal(ctx.searchActions(pool, "surface")[0].id, "c2",
|
||||
"a later-but-precise match still ranks by relevance, not by pool type");
|
||||
|
||||
// A perfect match (the needle as one contiguous run) outranks a fuzzy match of the same field - and a
|
||||
// contiguous GROUP/header hit ("Recent Projects") beats a scattered fuzzy TITLE hit ("Retraction Length"),
|
||||
// which is what the old flat title-bonus ranking got backwards.
|
||||
const perfectPool = [
|
||||
{ id: "set", title: "Retraction Length", source: "Process : Quality : Retraction", group: "", input: "" },
|
||||
{ id: "recent", title: "myproject.3mf", source: "/home/me/projects/myproject.3mf", group: "Recent Projects", input: "" }
|
||||
];
|
||||
assert.equal(ctx.searchActions(perfectPool, "recent")[0].id, "recent",
|
||||
"a contiguous header/group match ranks above a scattered fuzzy title match");
|
||||
// Within a perfect match, the row-name (title) outranks the header (group): the action whose TITLE
|
||||
// contains the needle perfectly beats the action whose GROUP does, both being contiguous matches.
|
||||
const titleFirstPool = [
|
||||
{ id: "grp", title: "Delete Selected", source: "OrcaSlicer", group: "Object", input: "" },
|
||||
{ id: "t", title: "Object Preview", source: "OrcaSlicer", group: "View", input: "" }
|
||||
];
|
||||
assert.equal(ctx.searchActions(titleFirstPool, "object")[0].id, "t",
|
||||
"a perfect title match ranks above an equally-perfect group match");
|
||||
|
||||
// Highlighting: the needle is matched as a whole word / most-contiguous run, so "orient" lights up the
|
||||
// whole word in "Auto-Orient" instead of the stray "o" of "Auto" plus "rient" (greedy-leftmost).
|
||||
const orientPool = [
|
||||
{ id: "ao", title: "Auto-Orient", source: "OrcaSlicer", group: "Object", input: "" }
|
||||
];
|
||||
ctx.searchActions(orientPool, "orient");
|
||||
assert.deepEqual(ctx.matchIndex.ao.title, [[5, 11]],
|
||||
"a whole-word match highlights the full word, not a scattered fuzzy pick");
|
||||
|
||||
// A fuzzy source-only match with a very late start still counts, even though its score is negative;
|
||||
// the old `score < 0` sentinel mistook it for "no field matched" and dropped the action.
|
||||
const negativePool = [
|
||||
{ id: "n", title: "Unrelated", source: "o" + "x".repeat(200) + "rnt", group: "", input: "" }
|
||||
];
|
||||
assert.equal(ctx.searchActions(negativePool, "ornt").length, 1,
|
||||
"a low-score fuzzy match is not mistaken for no match");
|
||||
|
||||
// A setting whose displayed title is the page row label keeps the descriptive ConfigOptionDef name as
|
||||
// a search-only alias, so the old wording still finds it without being shown.
|
||||
const aliasPool = [
|
||||
{ id: "rev", title: "Reverse on even", full_label: "Overhang reversal", source: "Process : Quality : Overhangs", group: "", input: "" }
|
||||
];
|
||||
assert.deepEqual(ctx.searchActions(aliasPool, "overhang reversal").map(function (a) { return a.id; }), ["rev"],
|
||||
"the descriptive full_label is searchable even though the title shows the row label");
|
||||
assert.equal(ctx.matchIndex.rev.title, null,
|
||||
"an alias-only match does not highlight the displayed title");
|
||||
assert.deepEqual(ctx.searchActions(aliasPool, "reversal").map(function (a) { return a.id; }), ["rev"],
|
||||
"a token that exists only in the full_label still matches");
|
||||
|
||||
// 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",
|
||||
"a command's group is its category");
|
||||
assert.equal(ctx.actionCategory({ id: "s", group: "", source: "Process : Quality : Layers", kind: "command" }), "Process",
|
||||
"a setting's category is the top-level source segment");
|
||||
assert.equal(ctx.actionCategory({ id: "s", group: "", source: "Filament : Cooling", kind: "command" }), "Filament",
|
||||
"a Filament setting groups under Filament");
|
||||
assert.equal(ctx.actionCategory({ id: "plugin_script_action:Foo:bar.py", group: "", source: "Gcode Optimizer", kind: "plugin" }), "Plugins",
|
||||
"every plugin shares one Plugins header");
|
||||
assert.equal(ctx.actionCategory({ id: "x", group: "", source: "", kind: "command" }), "Other",
|
||||
"a category-less action falls back to Other");
|
||||
|
||||
// groupActions: bucket by category, order the groups alphabetically, keep the incoming order within
|
||||
// each group (the pool arrives frecency-sorted).
|
||||
const groupPool = [
|
||||
{ id: "q1", title: "Q1", source: "Quality", group: "Quality", kind: "command" },
|
||||
{ id: "h1", title: "H1", source: "OrcaSlicer", group: "Help", kind: "command" },
|
||||
{ id: "p1", title: "P1", source: "Process : A", group: "", kind: "command" },
|
||||
{ id: "h2", title: "H2", source: "OrcaSlicer", group: "Help", kind: "command" }
|
||||
];
|
||||
assert.deepEqual(ctx.groupActions(groupPool).map(function (a) { return a.id; }), ["h1", "h2", "p1", "q1"],
|
||||
"groups are alphabetical and each group keeps its incoming order");
|
||||
|
||||
// commandList (the main-phase list) delegates to the ranked search for a typed query and returns
|
||||
// recents + the category-grouped pool for an empty query.
|
||||
const mixed = [
|
||||
{ id: "cmd", title: "Slice", source: "OrcaSlicer", group: "Commands", kind: "command", input: "" },
|
||||
{ id: "set", title: "Sparse Infill Density", source: "Quality", group: "Quality", kind: "command", input: "" }
|
||||
];
|
||||
assert.equal(ctx.commandList(mixed, [], "sli")[0].id, "cmd",
|
||||
"a typed query keeps the relevance-ranked action list (best match first)");
|
||||
assert.deepEqual(ctx.commandList(mixed, mixed.slice(0, 1), "").map(function (a) { return a.id; }), ["cmd", "set"],
|
||||
"an empty query shows the recents first and de-dupes them out of the tail");
|
||||
assert.deepEqual(ctx.commandList(mixed, [], "").map(function (a) { return a.id; }), ["cmd", "set"],
|
||||
"empty query + no recents shows the whole action pool");
|
||||
assert.deepEqual(ctx.commandList(mixed, [mixed[1]], "").map(function (a) { return a.id; }), ["set", "cmd"],
|
||||
"the recent is hoisted above the alphabetically-ordered groups");
|
||||
|
||||
// commandSections: "Recent" (when recents exist) plus one header per category in the grouped list;
|
||||
// a typed query or an empty list yields no headers. Uses a computed grouped list so the recents
|
||||
// hoist and the category ordering are exercised together.
|
||||
const sectionPool = [
|
||||
{ id: "cmd", title: "Slice", source: "OrcaSlicer", group: "Commands", kind: "command" },
|
||||
{ id: "help", title: "Shortcuts", source: "OrcaSlicer", group: "Help", kind: "command" },
|
||||
{ id: "set", title: "Infill", source: "Quality", group: "Quality", kind: "command" }
|
||||
];
|
||||
const sectionList = ctx.commandList(sectionPool, [sectionPool[0]], "");
|
||||
assert.deepEqual(sectionList.map(function (a) { return a.id; }), ["cmd", "help", "set"],
|
||||
"recents are hoisted, then the rest is grouped alphabetically (Commands, Help, Quality)");
|
||||
assert.deepEqual(ctx.commandSections(sectionList, 1, ""), { 0: "Recent", 1: "Help", 2: "Quality" },
|
||||
"recents + grouped actions get one header per category");
|
||||
assert.deepEqual(ctx.commandSections([sectionPool[0]], 1, ""), { 0: "Recent" },
|
||||
"a list that is all recents gets only the Recent header");
|
||||
assert.deepEqual(ctx.commandSections(ctx.commandList(sectionPool, [], ""), 0, ""),
|
||||
{ 0: "Commands", 1: "Help", 2: "Quality" },
|
||||
"with no recents the grouped list still gets category headers");
|
||||
assert.equal(ctx.commandSections(sectionList, 1, "sli"), null,
|
||||
"a typed query has no section headers");
|
||||
assert.equal(ctx.commandSections([], 0, ""), null,
|
||||
"an empty list has no section headers");
|
||||
|
||||
// selectedActionId: resolves the active list (recents+pool for an empty query, filtered list otherwise).
|
||||
assert.equal(
|
||||
ctx.selectedActionId({ zone: "list", i: 0 }, duplicateActions, [], ""),
|
||||
null,
|
||||
"Enter with an empty query must not resolve to an action the blank list never showed"
|
||||
ctx.selectedActionId({ zone: "list", i: 0 }, ctx.commandList(duplicateActions, [], ""), []),
|
||||
"0123456789abcdef",
|
||||
"Enter with an empty query resolves the first action in the recents+pool list"
|
||||
);
|
||||
assert.equal(
|
||||
ctx.selectedActionId({ zone: "list", i: 0 }, duplicateActions, [], "rep"),
|
||||
"0123456789abcdef",
|
||||
"a typed query resolves the list selection"
|
||||
ctx.selectedActionId({ zone: "list", i: 0 }, ctx.commandList(duplicateActions, [], "rep"), []),
|
||||
"0123456789abcdef",
|
||||
"a typed query resolves the list selection"
|
||||
);
|
||||
assert.equal(
|
||||
ctx.selectedActionId({ zone: "fav", i: 0 }, duplicateActions, ["fedcba9876543210"], ""),
|
||||
"fedcba9876543210",
|
||||
"favourites stay runnable with an empty query - the fav bar is always visible"
|
||||
ctx.selectedActionId({ zone: "list", i: 0 }, ctx.commandList(duplicateActions, [duplicateActions[0]], ""), []),
|
||||
"0123456789abcdef",
|
||||
"Enter with an empty query resolves the recent entry"
|
||||
);
|
||||
assert.equal(
|
||||
ctx.selectedActionId({ zone: "fav", i: 0 }, duplicateActions, ["fedcba9876543210"]),
|
||||
"fedcba9876543210",
|
||||
"favourites stay runnable with an empty query - the fav bar is always visible"
|
||||
);
|
||||
|
||||
// Fav quick-launch slots: digit 1..9 -> index 0..8, digit 0 -> index 9 (the 10th), else -1.
|
||||
assert.equal(ctx.favIndexForDigit("1"), 0, "digit 1 maps to the 1st favourite slot");
|
||||
assert.equal(ctx.favIndexForDigit("9"), 8, "digit 9 maps to the 9th favourite slot");
|
||||
assert.equal(ctx.favIndexForDigit("0"), 9, "digit 0 maps to the 10th (last) favourite slot");
|
||||
assert.equal(ctx.favIndexForDigit("x"), -1, "non-digit keys are not a slot");
|
||||
assert.equal(ctx.favIndexForDigit(""), -1, "an empty key is not a slot");
|
||||
|
||||
// Badge label per 0-based index: 0..8 -> "1".."9", index 9 -> "0", out of range -> null.
|
||||
assert.equal(ctx.favSlotForIndex(0), "1", "index 0 shows badge 1");
|
||||
assert.equal(ctx.favSlotForIndex(8), "9", "index 8 shows badge 9");
|
||||
assert.equal(ctx.favSlotForIndex(9), "0", "index 9 shows badge 0 (the 10th slot)");
|
||||
assert.equal(ctx.favSlotForIndex(10), null, "index 10 is beyond the cap");
|
||||
assert.equal(ctx.favSlotForIndex(-1), null, "negative index is not a slot");
|
||||
assert.equal(ctx.K_FAV_LIMIT, 10, "the slot count matches the quick-launch cap");
|
||||
|
||||
// favDigitFromEvent: prefer the physical code (so macOS Option+digit still maps even though e.key
|
||||
// is the composed symbol), and fall back to e.key for keyboards/synthetic events without a code.
|
||||
assert.equal(ctx.favDigitFromEvent({ code: "Digit1", key: "¡" }), "1", "Digit1 wins over a composed key");
|
||||
assert.equal(ctx.favDigitFromEvent({ code: "Digit0", key: "0" }), "0", "Digit0 is a physical digit");
|
||||
assert.equal(ctx.favDigitFromEvent({ code: "Numpad7", key: "7" }), "7", "numpad digits count");
|
||||
assert.equal(ctx.favDigitFromEvent({ code: "", key: "3" }), "3", "a missing code falls back to key");
|
||||
assert.equal(ctx.favDigitFromEvent({ key: "a" }), "a", "non-digit input is passed through (maps to -1)");
|
||||
assert.equal(ctx.favDigitFromEvent(null), "", "a null event yields no digit");
|
||||
|
||||
// nextSel: arrow-nav wrapping. Down wraps at the list bottom to the first row; Up wraps at the
|
||||
// list top to the last row ONLY when there's no fav bar above (else it goes to the fav bar).
|
||||
assert.deepEqual(ctx.nextSel({ zone: "list", i: 2 }, "ArrowDown", 3, 0), { zone: "list", i: 0 },
|
||||
"ArrowDown at the last row wraps to the first row");
|
||||
assert.deepEqual(ctx.nextSel({ zone: "list", i: 1 }, "ArrowDown", 3, 0), { zone: "list", i: 2 },
|
||||
"ArrowDown in the middle advances by one");
|
||||
assert.deepEqual(ctx.nextSel({ zone: "list", i: 0 }, "ArrowUp", 3, 0), { zone: "list", i: 2 },
|
||||
"ArrowUp at the first row with no fav bar wraps to the last row");
|
||||
assert.deepEqual(ctx.nextSel({ zone: "list", i: 0 }, "ArrowUp", 3, 2), { zone: "fav", i: 0 },
|
||||
"ArrowUp at the first row with a fav bar goes to the fav bar (unchanged)");
|
||||
assert.deepEqual(ctx.nextSel({ zone: "list", i: 2 }, "ArrowUp", 3, 0), { zone: "list", i: 1 },
|
||||
"ArrowUp in the middle moves up by one");
|
||||
assert.deepEqual(ctx.nextSel({ zone: "fav", i: 1 }, "ArrowDown", 3, 2), { zone: "list", i: 0 },
|
||||
"ArrowDown from the fav bar lands on the first list row");
|
||||
assert.deepEqual(ctx.nextSel({ zone: "list", i: 0 }, "ArrowDown", 1, 0), { zone: "list", i: 0 },
|
||||
"a single-row list never wraps off the end");
|
||||
assert.deepEqual(ctx.nextSel({ zone: "list", i: 0 }, "ArrowUp", 1, 0), { zone: "list", i: 0 },
|
||||
"ArrowUp on the only row stays put");
|
||||
|
||||
// Windowed list reveal: how many rows must be materialized to cover `fromIndex` plus `size` more,
|
||||
// clamped to the total. Drives the "render the next window on scroll / arrow-nav" append.
|
||||
assert.equal(ctx.revealTarget(100, 0, 100), 100, "covers the whole list when the window reaches the end");
|
||||
assert.equal(ctx.revealTarget(100, 60, 100), 100, "clamps to the total at the tail");
|
||||
assert.equal(ctx.revealTarget(30, 5, 100), 30, "a short list is fully covered");
|
||||
assert.equal(ctx.revealTarget(100, 5, 50), 55, "reveals exactly fromIndex + size");
|
||||
assert.equal(ctx.revealTarget(100, -5, 50), 50, "negative start is clamped to the first row");
|
||||
assert.equal(ctx.revealTarget(200, 50, 100), 150, "a scroll viewpoint reveals a window past the current rows");
|
||||
assert.equal(ctx.revealTarget(10, 0, 50), 10, "a list shorter than one window stays fully materialized");
|
||||
|
||||
// spacerHeight: un-rendered rows (44px) plus un-rendered section headers (30px), never negative.
|
||||
assert.equal(ctx.spacerHeight(100, 50, 0, 0), 50 * 44, "the tail rows reserve their full height");
|
||||
assert.equal(ctx.spacerHeight(100, 100, 0, 0), 0, "a fully-rendered list needs no spacer");
|
||||
assert.equal(ctx.spacerHeight(100, 50, 3, 1), 50 * 44 + 2 * 30, "pending section headers reserve their height too");
|
||||
assert.equal(ctx.spacerHeight(10, 0, 2, 0), 10 * 44 + 2 * 30, "a short list still reserves its headers");
|
||||
assert.equal(ctx.spacerHeight(0, 0, 0, 0), 0, "an empty list has no spacer");
|
||||
assert.equal(ctx.spacerHeight(10, 20, 0, 5), 0, "over-rendered counters clamp to zero");
|
||||
|
||||
// visibleFavourites: the quick-bar drops pins whose action no longer exists (plugin unloaded,
|
||||
// command removed) and collapses duplicate ids, keeping the persisted pin order.
|
||||
assert.deepEqual(ctx.visibleFavourites(["a", "b", "c"], [{ id: "a" }, { id: "b" }]),
|
||||
["a", "b"], "a pin with no live action is dropped from the quick-bar");
|
||||
assert.deepEqual(ctx.visibleFavourites(["b", "a", "b"], [{ id: "a" }, { id: "b" }]),
|
||||
["b", "a"], "duplicate pins collapse to the first occurrence");
|
||||
assert.deepEqual(ctx.visibleFavourites([], [{ id: "a" }]), [], "no pins renders an empty quick-bar");
|
||||
assert.deepEqual(ctx.visibleFavourites(["a"], []), [], "a stale config with no actions renders nothing");
|
||||
|
||||
// actionIcon: the SVG base name for a tile's pictogram, or "" when the action has none (blank tile).
|
||||
assert.equal(ctx.actionIcon({ id: "x", title: "Slice", icon: "media_play" }), "media_play",
|
||||
"an action's icon base name is returned verbatim");
|
||||
assert.equal(ctx.actionIcon({ id: "x", title: "Go to tab...", icon: "" }), "",
|
||||
"an empty icon renders a blank tile");
|
||||
assert.equal(ctx.actionIcon({ id: "x", title: "Plugin action" }), "",
|
||||
"a missing icon field renders a blank tile");
|
||||
assert.equal(ctx.actionIcon(null), "",
|
||||
"a null action (tab row) renders a blank tile");
|
||||
|
||||
// tileIcon: the base name a tile renders - the action's own icon when present, else the placeholder.
|
||||
assert.equal(ctx.tileIcon({ id: "x", title: "Slice", icon: "media_play" }), "media_play",
|
||||
"an action with an icon keeps it");
|
||||
assert.equal(ctx.tileIcon({ id: "x", title: "Go to tab...", icon: "" }), "action_default",
|
||||
"an empty icon falls back to the placeholder");
|
||||
assert.equal(ctx.tileIcon({ id: "x", title: "Plugin action" }), "action_default",
|
||||
"a missing icon falls back to the placeholder");
|
||||
assert.equal(ctx.DEFAULT_ICON, "action_default", "the placeholder is the dedicated default glyph");
|
||||
assert.ok(fs.existsSync(__dirname + "/../../../images/action_default.svg"),
|
||||
"the placeholder SVG ships alongside the page's other icons");
|
||||
|
||||
// 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");
|
||||
|
||||
// MODE_RANK must match the C++ ConfigOptionMode order (comSimple < comAdvanced < comExpert < comDevelop).
|
||||
assert.deepEqual(ctx.MODE_RANK, { simple: 0, advanced: 1, expert: 2, develop: 3 },
|
||||
"mode rank matches the C++ ConfigOptionMode order");
|
||||
|
||||
// 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");
|
||||
|
||||
// actionHasWiki: the footer's wiki link/F1 path is offered only when the action carries a wiki flag.
|
||||
assert.equal(ctx.actionHasWiki({ id: "x", wiki: true }), true, "a wiki-flagged setting offers the wiki action");
|
||||
assert.equal(ctx.actionHasWiki({ id: "x", wiki: false }), false, "a setting without a wiki path offers nothing");
|
||||
assert.equal(ctx.actionHasWiki({ id: "x" }), false, "a missing wiki field offers nothing");
|
||||
assert.equal(ctx.actionHasWiki(null), false, "no action selected offers nothing");
|
||||
|
||||
// actionHasDetail: the footer strip appears only when the highlighted action has a description or
|
||||
// wiki link; selecting a plain command hides it.
|
||||
assert.equal(ctx.actionHasDetail({ id: "a", desc: "Layer height" }), true, "a description shows the footer");
|
||||
assert.equal(ctx.actionHasDetail({ id: "a", wiki: true }), true, "a wiki link shows the footer");
|
||||
assert.equal(ctx.actionHasDetail({ id: "a", desc: "Layer height", wiki: true }), true, "both show the footer");
|
||||
assert.equal(ctx.actionHasDetail({ id: "a", desc: "" }), false, "an empty description hides the footer");
|
||||
assert.equal(ctx.actionHasDetail({ id: "a", desc: "", wiki: false }), false, "empty description and false wiki hide the footer");
|
||||
assert.equal(ctx.actionHasDetail({ id: "a" }), false, "an action with neither hides the footer");
|
||||
assert.equal(ctx.actionHasDetail(null), false, "no selected action hides the footer");
|
||||
|
||||
// detailToggleVisible: the expand/collapse arrow is offered only when there is a description to
|
||||
// toggle. Collapse is a global preference, so the control stays for short tooltips too.
|
||||
assert.equal(ctx.detailToggleVisible({ id: "a", desc: "Layer height" }), true, "a description offers the toggle");
|
||||
assert.equal(ctx.detailToggleVisible({ id: "a", desc: "" }), false, "an empty description offers no toggle");
|
||||
assert.equal(ctx.detailToggleVisible({ id: "a", wiki: true }), false, "a wiki-only action has nothing to collapse");
|
||||
assert.equal(ctx.detailToggleVisible({ id: "a" }), false, "an action with no description offers no toggle");
|
||||
assert.equal(ctx.detailToggleVisible(null), false, "no selected action offers no toggle");
|
||||
|
||||
// stateFromPayload: the footer expansion is a persisted global and defaults to expanded when the
|
||||
// C++ payload omits it (first run / older config).
|
||||
assert.equal(ctx.stateFromPayload({}).tooltipExpanded, true, "expansion defaults to true when absent");
|
||||
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");
|
||||
|
||||
console.log("ok");
|
||||
|
||||
@@ -1,225 +1,549 @@
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; }
|
||||
body {
|
||||
font-family: var(--orca-font, "Segoe UI", sans-serif);
|
||||
font-size: 13px;
|
||||
color: var(--text, var(--orca-fg, #1b1c1e));
|
||||
background: var(--bg, var(--orca-bg, #fff));
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--orca-font, "Segoe UI", sans-serif);
|
||||
font-size: 13px;
|
||||
color: var(--text, var(--orca-fg, #1b1c1e));
|
||||
background: var(--bg, var(--orca-bg, #fff));
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.launcher {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel, var(--orca-bg, #fff));
|
||||
border: 1px solid var(--border, var(--orca-border, #ddd));
|
||||
overflow: hidden;
|
||||
/* why: no height cap here - the launcher reports its true natural height to C++, which
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel, var(--orca-bg, #fff));
|
||||
border: 1px solid var(--border, var(--orca-border, #ddd));
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
/* why: no height cap here - the launcher reports its true natural height to C++, which
|
||||
sizes the popup to match (HTML is source of truth). The list's own max-height is what
|
||||
bounds growth; capping the launcher would make the measurement circular. */
|
||||
}
|
||||
|
||||
.fav-bar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid var(--border, var(--orca-border, #ddd));
|
||||
overflow-x: auto; /* scroll horizontally once favourites overflow the row */
|
||||
scrollbar-width: thin;
|
||||
/* why: keep scrollIntoView (arrow-nav) from scrolling the first/last tile flush to the edge,
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid var(--border, var(--orca-border, #ddd));
|
||||
overflow-x: auto;
|
||||
/* scroll horizontally once favourites overflow the row */
|
||||
scrollbar-width: thin;
|
||||
/* why: keep scrollIntoView (arrow-nav) from scrolling the first/last tile flush to the edge,
|
||||
which would clip its selected outline. Matches the 10px horizontal padding. */
|
||||
scroll-padding-inline: 10px;
|
||||
scroll-padding-inline: 10px;
|
||||
}
|
||||
.fav-bar[hidden] { display: none; }
|
||||
|
||||
.fav-bar[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Name of the selected favourite, above the bar; left-aligned to the tiles' 10px inset. */
|
||||
.fav-eyebrow { flex: 0 0 auto; padding: 8px 10px 0; }
|
||||
.fav-eyebrow {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 10px 0;
|
||||
}
|
||||
|
||||
.fav-tile {
|
||||
flex: 0 0 auto; /* keep tiles full-size; don't shrink to fit - scroll instead */
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--h) var(--speed-tile-text-s, 72%) var(--speed-tile-text-l, 38%));
|
||||
font-weight: 700;
|
||||
/* why: mirror .tile centering - tileCode can be 2-3 chars (e.g. "EA1") on collision, and a
|
||||
bare <button> inherits 13px + UA padding, clipping the code (e.g. "AE1"). inline-flex + 12px + pad:0 fits it. */
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: var(--speed-tile-bg, #f0f0f0);
|
||||
border: 1px solid var(--speed-tile-border, #d8d8d8);
|
||||
flex: 0 0 auto;
|
||||
/* keep tiles full-size; don't shrink to fit - scroll instead */
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
/* why: mirror .tile centering - icons/placeholder are 16px, and a bare <button> inherits
|
||||
13px + UA padding, which would clip them. inline-flex + pad:0 + overflow:hidden fits them. */
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: var(--speed-tile-bg, #f0f0f0);
|
||||
border: 1px solid var(--speed-tile-border, #d8d8d8);
|
||||
}
|
||||
.fav-tile.sel { outline: 2px solid var(--main-color, var(--orca-accent, #009688)); outline-offset: 2px; }
|
||||
|
||||
.fav-tile.sel {
|
||||
outline: 2px solid var(--main-color, var(--orca-accent, #009688));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Hover-revealed remove button in the tile's corner; sits inside the tile bounds so overflow:hidden clips it. */
|
||||
.fav-tile {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fav-unpin {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 1px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
background: rgba(127, 127, 127, .35);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fav-tile:hover .fav-unpin,
|
||||
.fav-tile.sel .fav-unpin {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.fav-unpin:hover {
|
||||
background: rgba(127, 127, 127, .6);
|
||||
}
|
||||
|
||||
/* Numbered quick-launch slot (Alt/Option+digit), top-left corner of the fav tile. */
|
||||
.fav-slot {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
min-width: 11px;
|
||||
height: 11px;
|
||||
padding: 0 2px;
|
||||
border-radius: 3px;
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
line-height: 11px;
|
||||
text-align: center;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
background: rgba(127, 127, 127, .22);
|
||||
}
|
||||
|
||||
/* Transient flash banner pinned to the top of the launcher (e.g. "favourites are full"). */
|
||||
.dial-flash {
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
color: var(--plugin-status-warn, #b45309);
|
||||
background: var(--plugin-status-warn-bg, rgba(255, 193, 7, .22));
|
||||
animation: dial-flash-fade 2.5s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes dial-flash-fade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
80% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ctx-menu {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
min-width: 120px;
|
||||
padding: 4px;
|
||||
background: var(--panel, var(--orca-bg, #fff));
|
||||
border: 1px solid var(--border, var(--orca-border, #ddd));
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,.18);
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
min-width: 120px;
|
||||
padding: 4px;
|
||||
background: var(--panel, var(--orca-bg, #fff));
|
||||
border: 1px solid var(--border, var(--orca-border, #ddd));
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, .18);
|
||||
}
|
||||
.ctx-menu[hidden] { display: none; }
|
||||
|
||||
.ctx-menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ctx-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--text, var(--orca-fg, #1b1c1e));
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--text, var(--orca-fg, #1b1c1e));
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ctx-item:hover { background: var(--row-hover, rgba(127,127,127,.16)); }
|
||||
.ctx-item:disabled { opacity: .4; cursor: default; }
|
||||
.ctx-item:disabled:hover { background: transparent; }
|
||||
.dial-head { flex: 0 0 auto; padding: 8px; }
|
||||
|
||||
.ctx-item:hover {
|
||||
background: var(--row-hover, rgba(127, 127, 127, .16));
|
||||
}
|
||||
|
||||
.ctx-item:disabled {
|
||||
opacity: .4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctx-item:disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dial-head {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.plugin-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
background: var(--panel, var(--orca-bg, #fff));
|
||||
border: 1px solid var(--border, var(--orca-border, #ddd));
|
||||
border-radius: 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
background: var(--panel, var(--orca-bg, #fff));
|
||||
border: 1px solid var(--border, var(--orca-border, #ddd));
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.plugin-search:focus-within {
|
||||
border-color: var(--main-color, var(--orca-accent, #009688));
|
||||
box-shadow: 0 0 0 2px rgba(0,150,136,.25);
|
||||
border-color: var(--main-color, var(--orca-accent, #009688));
|
||||
box-shadow: 0 0 0 2px rgba(0, 150, 136, .25);
|
||||
}
|
||||
.plugin-search-icon { display: inline-flex; color: var(--muted, var(--orca-muted, #6b7280)); }
|
||||
|
||||
.plugin-search-icon {
|
||||
display: inline-flex;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
}
|
||||
|
||||
.plugin-search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--text, var(--orca-fg, #1b1c1e));
|
||||
font: inherit;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--text, var(--orca-fg, #1b1c1e));
|
||||
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;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
cursor: pointer;
|
||||
background: rgba(127,127,127,.20);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
cursor: pointer;
|
||||
background: rgba(127, 127, 127, .20);
|
||||
}
|
||||
.plugin-search-clear[hidden] { display: none; }
|
||||
|
||||
.plugin-search-clear[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dial-count {
|
||||
padding: 4px 4px 0;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
padding: 4px 4px 0;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
}
|
||||
.dial-count[hidden] { display: none; }
|
||||
|
||||
.dial-count[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dial-list {
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
/* ADJUST HEIGHT HERE. The popup auto-resizes to the content (HTML is the source of truth), so
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
/* ADJUST HEIGHT HERE. The popup auto-resizes to the content (HTML is the source of truth), so
|
||||
this list cap drives the whole window height: --rows full rows + a ~30% peek of the next row
|
||||
as a "scroll for more" affordance. Bump --rows to show more rows; change 0.3 for a bigger/
|
||||
smaller peek. --row-h MUST match .row min-height (44px). */
|
||||
--row-h: 44px;
|
||||
--rows: 5;
|
||||
max-height: calc(var(--rows) * var(--row-h) + 0.3 * var(--row-h) + 4px);
|
||||
overflow-y: auto;
|
||||
padding: 4px 4px 6px;
|
||||
scrollbar-width: thin;
|
||||
--row-h: 44px;
|
||||
--rows: 5;
|
||||
max-height: calc(var(--rows) * var(--row-h) + 0.3 * var(--row-h) + 4px);
|
||||
overflow-y: auto;
|
||||
padding: 4px 4px 6px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.dial-list.empty { overflow-y: hidden; }
|
||||
|
||||
.dial-list.empty {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
/* Section header in the empty-query list ("Recent" then one per category). Its 30px height MUST match
|
||||
SECTION_H in speeddial.js, which reserves header space in the windowed-list bottom spacer. */
|
||||
.dial-section {
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
opacity: .85;
|
||||
}
|
||||
|
||||
/* Bottom spacer for the windowed command list: fills the un-rendered tail so the scrollbar reflects
|
||||
the full match count, while only a near-viewport window of rows exists in the DOM. */
|
||||
.dial-spacer-bottom {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row:hover { background: var(--row-hover, rgba(127,127,127,.16)); }
|
||||
.row.sel { background: var(--row-selected, rgba(0,150,136,.18)); }
|
||||
|
||||
.row:hover {
|
||||
background: var(--row-hover, rgba(127, 127, 127, .16));
|
||||
}
|
||||
|
||||
.row.sel {
|
||||
background: var(--row-selected, rgba(0, 150, 136, .18));
|
||||
}
|
||||
|
||||
.tile {
|
||||
flex: 0 0 auto;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--h) var(--speed-tile-text-s, 72%) var(--speed-tile-text-l, 38%));
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--speed-tile-bg, #f0f0f0);
|
||||
border: 1px solid var(--speed-tile-border, #d8d8d8);
|
||||
flex: 0 0 auto;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--speed-tile-bg, #f0f0f0);
|
||||
border: 1px solid var(--speed-tile-border, #d8d8d8);
|
||||
}
|
||||
.row-left { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
|
||||
|
||||
/* Native SVG pictogram in a tile. The Orca icon grid draws 1px strokes from 0.5..14.5 of the 16
|
||||
viewBox, so the visible glyph is 0..15 and, centered as-is, leaves one extra pixel on the right/
|
||||
bottom (the "Save Project leans left" look). Shift by half a pixel to center the visible artwork. */
|
||||
.tile-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
transform: translate(.5px, .5px);
|
||||
}
|
||||
|
||||
/* Tab-strip glyphs are drawn white for the dark tab bar; recolor to the shared #949494 gray so
|
||||
they read on both the light and dark tile backgrounds. */
|
||||
.tile-icon.tab-mono {
|
||||
filter: brightness(0) invert(.58);
|
||||
}
|
||||
|
||||
.row-left {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row-eyebrow {
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
opacity: .85;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
opacity: .85;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.row-line { display: flex; align-items: center; gap: 7px; min-width: 0; }
|
||||
.row-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.row-name mark, .row-eyebrow mark { background: var(--plugin-status-warn-bg); color: var(--plugin-status-warn); border-radius: 2px; }
|
||||
.row-sc { flex: 0 0 auto; display: inline-flex; gap: 3px; }
|
||||
kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
background: rgba(127,127,127,.12);
|
||||
border: 1px solid var(--border, var(--orca-border, #ddd));
|
||||
border-radius: 4px;
|
||||
|
||||
.row-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
.star {
|
||||
flex: 0 0 auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
|
||||
.row-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.star svg { display: block; }
|
||||
.row:hover .star:not(.on),
|
||||
.row.sel .star:not(.on) { opacity: .5; }
|
||||
.star.on { opacity: 1; color: var(--main-color, var(--orca-accent, #009688)); }
|
||||
.star:hover { background: rgba(127,127,127,.18); }
|
||||
|
||||
.row-name mark,
|
||||
.row-eyebrow mark {
|
||||
background: var(--plugin-status-warn-bg);
|
||||
color: var(--plugin-status-warn);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.pin {
|
||||
flex: 0 0 auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.pin svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.row:hover .pin:not(.on),
|
||||
.row.sel .pin:not(.on) {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
.pin.on {
|
||||
opacity: 1;
|
||||
color: var(--main-color, var(--orca-accent, #009688));
|
||||
}
|
||||
|
||||
.pin:hover {
|
||||
background: rgba(127, 127, 127, .18);
|
||||
}
|
||||
|
||||
.dial-empty {
|
||||
min-height: 64px;
|
||||
padding: 18px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
text-align: center;
|
||||
min-height: 64px;
|
||||
padding: 18px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Footer detail strip: the selected action's description, its wiki link and the expand/collapse
|
||||
arrow. Auto-sizes to the content; the description is clamped to 6 lines. */
|
||||
.dial-detail {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border, var(--orca-border, #ddd));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dial-detail[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detail-desc {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 6;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.detail-wiki {
|
||||
flex: 0 0 auto;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--main-color, var(--orca-accent, #009688));
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-wiki:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Expand/collapse arrow for the tooltip. Pinned right so it stays put whether or not the
|
||||
description/wiki are visible; the chevron points up (collapse) when expanded. */
|
||||
.detail-toggle {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.detail-toggle:hover {
|
||||
color: var(--text, var(--orca-fg, #1b1c1e));
|
||||
}
|
||||
|
||||
.detail-toggle svg {
|
||||
transition: transform .12s ease;
|
||||
}
|
||||
|
||||
.detail-toggle[aria-expanded="true"] svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
@@ -55,8 +55,6 @@
|
||||
/* Speed dial icon tile treatment */
|
||||
--speed-tile-bg: #efefef;
|
||||
--speed-tile-border: #d8d8d8;
|
||||
--speed-tile-text-s: 74%;
|
||||
--speed-tile-text-l: 34%;
|
||||
}
|
||||
|
||||
:root[data-orca-theme="dark"] {
|
||||
@@ -95,8 +93,6 @@
|
||||
/* Speed dial icon tile treatment */
|
||||
--speed-tile-bg: #34343b;
|
||||
--speed-tile-border: #50505a;
|
||||
--speed-tile-text-s: 82%;
|
||||
--speed-tile-text-l: 84%;
|
||||
}
|
||||
|
||||
/* Re-theme the shared common.css chrome through variables (replaces dark.css's
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
// Fold per-character so matched offsets stay in ORIGINAL string coordinates (highlighting slices the
|
||||
// original text; a separately-folded string would desync offsets).
|
||||
function FoldChar(ch) {
|
||||
// why: fast path - ASCII is already NFD-stable and diacritic-free, so the normalize/regex below are
|
||||
// no-ops. This skip is the hot cost in the Speed Dial search (thousands of settings per keystroke).
|
||||
if (ch.length === 1 && ch.charCodeAt(0) < 0x80)
|
||||
return ch;
|
||||
return ch.normalize("NFD").replace(/\p{Diacritic}/gu, ""); // accents always folded
|
||||
}
|
||||
|
||||
@@ -15,6 +19,74 @@ function Norm(ch, caseSensitive) {
|
||||
return caseSensitive ? folded : folded.toLowerCase(); // case-sensitivity is the only toggle
|
||||
}
|
||||
|
||||
// Pre-normalize a whole haystack with a length-preserving fold, so a caller can match it repeatedly
|
||||
// against one cached string. One input UTF-16 code unit always maps to one output code unit, so
|
||||
// indices stay aligned to the ORIGINAL text - the highlight ranges that FuzzyRangesNorm returns slice
|
||||
// the original correctly. Iterate by UTF-16 code unit (not Array.from code point) to mirror
|
||||
// FuzzyRanges' own indexing exactly.
|
||||
function NormText(text, caseSensitive) {
|
||||
const src = text || "";
|
||||
let out = "";
|
||||
for (let i = 0; i < src.length; i++)
|
||||
out += NormStable(src[i], caseSensitive);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Length-preserving variant of Norm: NFD can expand a code unit (Hangul syllables become Jamo) or
|
||||
// drop it (combining diacritics), and toLowerCase can expand one too (U+0130). Any of those would
|
||||
// desync highlight offsets, so fall back to the original unit whenever the fold is not 1:1.
|
||||
function NormStable(ch, caseSensitive) {
|
||||
const folded = FoldChar(ch);
|
||||
const stable = folded.length === 1 ? folded : ch;
|
||||
if (caseSensitive)
|
||||
return stable;
|
||||
const lower = stable.toLowerCase();
|
||||
return lower.length === 1 ? lower : stable;
|
||||
}
|
||||
|
||||
// Match a PRE-normalized haystack against a PRE-normalized needle (both produced by NormText with the
|
||||
// same caseSensitive flag). Skipping the per-character fold makes repeated matching (per keystroke over a
|
||||
// cached pool) cheap. Returns ranges in original coordinates, or null on no match.
|
||||
// Prefers the most-contiguous (smallest-span) occurrence over greedy-leftmost: a scattered match that
|
||||
// spans a stray earlier character is worse than a tight run later, so "orient" against a normalized
|
||||
// "auto-orient" returns [[5,11]] (the word) not [[3,4],[6,11]]. A fully contiguous run is the optimum
|
||||
// and short-circuits early.
|
||||
function FuzzyRangesNorm(haystackNorm, needleNorm) {
|
||||
const t = haystackNorm || "";
|
||||
const needle = needleNorm || "";
|
||||
if (!needle)
|
||||
return null;
|
||||
const n = t.length, nl = needle.length;
|
||||
let best = null; // {ranges, span, start}
|
||||
for (let start = 0; start < n; start++) {
|
||||
if (t[start] !== needle[0])
|
||||
continue;
|
||||
let qi = 0;
|
||||
const ranges = [];
|
||||
let lastEnd = start;
|
||||
for (let i = start; i < n && qi < nl; i++) {
|
||||
if (t[i] === needle[qi]) {
|
||||
const last = ranges[ranges.length - 1];
|
||||
if (last && last[1] === i)
|
||||
last[1] = i + 1;
|
||||
else
|
||||
ranges.push([i, i + 1]);
|
||||
lastEnd = i + 1;
|
||||
qi++;
|
||||
}
|
||||
}
|
||||
if (qi !== nl)
|
||||
continue;
|
||||
const span = lastEnd - start;
|
||||
if (!best || span < best.span || (span === best.span && start < best.start)) {
|
||||
best = { ranges, span, start };
|
||||
if (span === nl)
|
||||
return ranges; // can't beat a fully contiguous run
|
||||
}
|
||||
}
|
||||
return best ? best.ranges : null;
|
||||
}
|
||||
|
||||
function EscapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ const vm = require("vm"), assert = require("assert"), fs = require("fs");
|
||||
const ctx = {};
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(fs.readFileSync(__dirname + "/fuzzy-search.js", "utf8"), ctx);
|
||||
const { FoldChar, Norm, EscapeRegExp, FuzzyRanges, WholeWordRanges } = ctx;
|
||||
const { FoldChar, Norm, NormText, EscapeRegExp, FuzzyRanges, WholeWordRanges, FuzzyRangesNorm } = ctx;
|
||||
|
||||
// FoldChar / Norm: accents fold, case only folds when case-insensitive.
|
||||
assert.equal(FoldChar("é"), "e");
|
||||
@@ -34,4 +34,18 @@ assert.deepEqual(WholeWordRanges("a.b", "a", false), [[0, 1]]); // '.' is a
|
||||
assert.equal(EscapeRegExp("a.b*"), "a\\.b\\*");
|
||||
assert.deepEqual(WholeWordRanges("c++ tool", "c", false), [[0, 1]]); // '+' would be a regex error unescaped
|
||||
|
||||
// FuzzyRangesNorm: prefers the most-contiguous (smallest-span) occurrence over greedy-leftmost, so a
|
||||
// stray earlier character does not steal the highlight from a later tight word.
|
||||
assert.deepEqual(FuzzyRangesNorm(NormText("Auto-Orient", false), NormText("orient", false)), [[5, 11]]);
|
||||
// A contiguous run inside a larger word is also preferred over a scattered greedy pick.
|
||||
assert.deepEqual(FuzzyRangesNorm(NormText("AutoOriented", false), NormText("orient", false)), [[4, 10]]);
|
||||
assert.equal(FuzzyRangesNorm(NormText("Measure", false), NormText("xyz", false)), null); // no subsequence
|
||||
|
||||
// NormText stays 1:1 with the original even when NFD expands (Hangul syllables) or drops (combining
|
||||
// diacritics, U+0130) a code unit, so highlight ranges remain aligned to the original string.
|
||||
assert.equal(NormText("각x", false).length, "각x".length);
|
||||
assert.equal(NormText("e\u0301x", false).length, "e\u0301x".length);
|
||||
assert.equal(NormText("\u0130x", false).length, "\u0130x".length);
|
||||
assert.equal(FuzzyRangesNorm(NormText("각abcdeXfgh", false), NormText("X", false))[0][0], 6);
|
||||
|
||||
console.log("ok");
|
||||
|
||||
Reference in New Issue
Block a user