mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-27 19:01:02 +00:00
Add category headers. Alphabetical sorting when no search query. Recent count adjustments
This commit is contained in:
@@ -124,7 +124,9 @@ var LangText = {
|
|||||||
sd_showing: "Showing",
|
sd_showing: "Showing",
|
||||||
sd_of: "of",
|
sd_of: "of",
|
||||||
sd_actions: "actions",
|
sd_actions: "actions",
|
||||||
sd_recent: "recent",
|
sd_recent: "Recent",
|
||||||
|
sd_plugins: "Plugins",
|
||||||
|
sd_other: "Other",
|
||||||
sd_matches: "matches",
|
sd_matches: "matches",
|
||||||
sd_tabs: "tabs",
|
sd_tabs: "tabs",
|
||||||
sd_no_match: "No actions match",
|
sd_no_match: "No actions match",
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// Speed Dial launcher page. Static-safe module: no DOM access at load time so a
|
// Speed Dial launcher page. Static-safe module: no DOM access at load time so a
|
||||||
// node vm can exercise the pure helpers (searchActions / filterTabs / actionLabel / nextSel / commandList).
|
// node vm can exercise the pure helpers (searchActions / filterTabs / actionLabel / nextSel /
|
||||||
|
// commandList / commandSections / actionCategory / groupActions).
|
||||||
|
|
||||||
// ---- state (populated by the C++ bridge via window.HandleStudio) ----
|
// ---- state (populated by the C++ bridge via window.HandleStudio) ----
|
||||||
var ACTIONS = []; // [{id,title,source,group,input,icon,mode}], already frecency-sorted by C++
|
var ACTIONS = []; // [{id,title,source,group,kind,input,icon,mode}], already frecency-sorted by C++
|
||||||
var FAVS = []; // [id...]
|
var FAVS = []; // [id...]
|
||||||
var RECENTS = []; // [{id,title,source,group,input,icon,mode}] - last-N launched
|
var RECENTS = []; // [{id,title,source,group,kind,input,icon,mode}] - last-N launched
|
||||||
var query = "";
|
var query = "";
|
||||||
var sel = { zone: "list", i: 0 }; // zone: 'list' | 'fav'
|
var sel = { zone: "list", i: 0 }; // zone: 'list' | 'fav'
|
||||||
var lastResizeHeight = 0;
|
var lastResizeHeight = 0;
|
||||||
@@ -47,6 +48,14 @@ var renderEnd = 0;
|
|||||||
var builtKey = ""; // phase|query|listLen - when it changes, rows are rebuilt from the first window
|
var builtKey = ""; // phase|query|listLen - when it changes, rows are rebuilt from the first window
|
||||||
var spacerEl = null; // the trailing height spacer, always the last child of listEl
|
var spacerEl = null; // the trailing height spacer, always the last child of listEl
|
||||||
|
|
||||||
|
// Section headers in the empty-query list ("Recent" then one per category). sectionStarts maps a flat
|
||||||
|
// action index to the header label that sits above it. sectionTotal/Rendered count headers so the
|
||||||
|
// bottom spacer reserves the same vertical space the not-yet-rendered headers will occupy.
|
||||||
|
var SECTION_H = 30; // MUST match .dial-section height (30px)
|
||||||
|
var sectionStarts = null;
|
||||||
|
var sectionTotal = 0;
|
||||||
|
var sectionRendered = 0;
|
||||||
|
|
||||||
// search-cache: the normalized (folded+lowercased) needle for the current query pass.
|
// search-cache: the normalized (folded+lowercased) needle for the current query pass.
|
||||||
var searchNeedle = "";
|
var searchNeedle = "";
|
||||||
|
|
||||||
@@ -274,12 +283,75 @@ function fillTile(tile, a, mono) {
|
|||||||
tile.appendChild(img);
|
tile.appendChild(img);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Category a row is grouped under in the empty-query list. Native commands and dynamic plate/recent
|
||||||
|
// actions carry a group; settings derive their top-level preset type from the source breadcrumb
|
||||||
|
// ("Process : Quality : Layers" -> "Process"); plugins all share one header.
|
||||||
|
function actionCategory(a) {
|
||||||
|
if (!a) return T("sd_other", "Other");
|
||||||
|
if (a.kind === "plugin") return T("sd_plugins", "Plugins");
|
||||||
|
if (a.group) return a.group;
|
||||||
|
var src = a.source || "";
|
||||||
|
var sep = src.indexOf(" : ");
|
||||||
|
var cat = sep === -1 ? src : src.slice(0, sep);
|
||||||
|
return cat || T("sd_other", "Other");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable-bucket actions by category, then order the groups alphabetically. Within a group the incoming
|
||||||
|
// (frecency) order is kept. Pure so the node-vm test can exercise grouping.
|
||||||
|
function groupActions(list) {
|
||||||
|
var buckets = Object.create(null);
|
||||||
|
var order = [];
|
||||||
|
(list || []).forEach(function (a) {
|
||||||
|
var c = actionCategory(a);
|
||||||
|
if (!buckets[c]) { buckets[c] = []; order.push(c); }
|
||||||
|
buckets[c].push(a);
|
||||||
|
});
|
||||||
|
order.sort(function (x, y) {
|
||||||
|
var a = x.toLowerCase(), b = y.toLowerCase();
|
||||||
|
return a < b ? -1 : a > b ? 1 : 0;
|
||||||
|
});
|
||||||
|
var out = [];
|
||||||
|
order.forEach(function (c) { out = out.concat(buckets[c]); });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// The active list for the main phase. A typed query ranks every action (commands/plugins/settings)
|
// The active list for the main phase. A typed query ranks every action (commands/plugins/settings)
|
||||||
// by relevance; an empty query shows the recent list (recents are a mixed bag - no discrimination).
|
// by relevance; an empty query shows the recents first, then the rest grouped under category headers.
|
||||||
|
// The empty-query result is cached on the action/recents array identities so scrolling doesn't regroup.
|
||||||
|
var commandListCache = null;
|
||||||
function commandList(actions, recents, query) {
|
function commandList(actions, recents, query) {
|
||||||
|
var all = actions || [];
|
||||||
if (shouldRenderActionList(query))
|
if (shouldRenderActionList(query))
|
||||||
return searchActions(actions || [], query);
|
return searchActions(all, query);
|
||||||
return (recents || []).slice(0);
|
var rec = recents || [];
|
||||||
|
if (commandListCache && commandListCache.actions === all && commandListCache.recents === rec)
|
||||||
|
return commandListCache.list;
|
||||||
|
var recentIds = {};
|
||||||
|
for (var i = 0; i < rec.length; i++)
|
||||||
|
recentIds[rec[i].id] = true;
|
||||||
|
var rest = all.filter(function (a) { return !recentIds[a.id]; });
|
||||||
|
var list = rec.concat(groupActions(rest));
|
||||||
|
commandListCache = { actions: all, recents: rec, list: list };
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section headers for the main list: "Recent" (when recents exist) then one per category. The list is
|
||||||
|
// already grouped, so a header is emitted whenever the category changes. A typed query has no headers.
|
||||||
|
// Returns {startIndex: label}, where startIndex is the flat list index the header sits above.
|
||||||
|
function commandSections(list, recentsLen, query) {
|
||||||
|
if (shouldRenderActionList(query) || !list || !list.length)
|
||||||
|
return null;
|
||||||
|
var sections = {};
|
||||||
|
if (recentsLen > 0)
|
||||||
|
sections[0] = T("sd_recent", "Recent");
|
||||||
|
var prev = null;
|
||||||
|
for (var i = recentsLen; i < list.length; i++) {
|
||||||
|
var c = actionCategory(list[i]);
|
||||||
|
if (i === recentsLen || c !== prev)
|
||||||
|
sections[i] = c;
|
||||||
|
prev = c;
|
||||||
|
}
|
||||||
|
return sections;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the selection cursor {zone,i} to the action id it points at: fav zone indexes the
|
// Resolve the selection cursor {zone,i} to the action id it points at: fav zone indexes the
|
||||||
@@ -676,16 +748,31 @@ function renderActionRow(a, i) {
|
|||||||
return shell.row;
|
return shell.row;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append rows [from, to) into listEl, always inserting before the bottom spacer so row order is preserved.
|
// Append rows [from, to) into listEl, always inserting before the bottom spacer so row order is
|
||||||
|
// preserved. A section header is inserted just before the first row of its section.
|
||||||
function appendActionRows(list, from, to) {
|
function appendActionRows(list, from, to) {
|
||||||
var spacer = spacerEl || ensureSpacer();
|
var spacer = spacerEl || ensureSpacer();
|
||||||
for (var i = from; i < to; i++) {
|
for (var i = from; i < to; i++) {
|
||||||
|
if (sectionStarts && sectionStarts[i] !== undefined) {
|
||||||
|
var header = document.createElement("div");
|
||||||
|
header.className = "dial-section";
|
||||||
|
header.textContent = sectionStarts[i];
|
||||||
|
listEl.insertBefore(header, spacer);
|
||||||
|
sectionRendered++;
|
||||||
|
}
|
||||||
var row = renderActionRow(list[i], i);
|
var row = renderActionRow(list[i], i);
|
||||||
row.setAttribute("data-idx", i);
|
row.setAttribute("data-idx", i);
|
||||||
listEl.insertBefore(row, spacer);
|
listEl.insertBefore(row, spacer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set the section map for the current list and reset the rendered-header counters (a fresh build).
|
||||||
|
function setSections(sections) {
|
||||||
|
sectionStarts = sections || null;
|
||||||
|
sectionTotal = sectionStarts ? Object.keys(sectionStarts).length : 0;
|
||||||
|
sectionRendered = 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure the bottom spacer exists as the last child of listEl. It is (re)created on rebuild because
|
// Ensure the bottom spacer exists as the last child of listEl. It is (re)created on rebuild because
|
||||||
// listEl.innerHTML="" destroys the old node.
|
// listEl.innerHTML="" destroys the old node.
|
||||||
function ensureSpacer() {
|
function ensureSpacer() {
|
||||||
@@ -697,10 +784,13 @@ function ensureSpacer() {
|
|||||||
return spacerEl;
|
return spacerEl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Size the spacer to the un-rendered tail so the scrollbar reflects the full match count.
|
// Size the spacer to the un-rendered tail so the scrollbar reflects the full match count. Pending
|
||||||
|
// section headers reserve their own height too, so the last rows stay reachable.
|
||||||
function setBottomSpacer(total) {
|
function setBottomSpacer(total) {
|
||||||
ensureSpacer();
|
ensureSpacer();
|
||||||
spacerEl.style.height = Math.max(0, total - renderEnd) * ROW_H + "px";
|
var remainingRows = Math.max(0, total - renderEnd);
|
||||||
|
var remainingHeaders = Math.max(0, sectionTotal - sectionRendered);
|
||||||
|
spacerEl.style.height = (remainingRows * ROW_H + remainingHeaders * SECTION_H) + "px";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reveal rows up to `upto` (an exclusive index), appending without rebuilding the whole list. Used by
|
// Reveal rows up to `upto` (an exclusive index), appending without rebuilding the whole list. Used by
|
||||||
@@ -720,6 +810,7 @@ function rebuildCommandsList(list) {
|
|||||||
listEl.className = "dial-list";
|
listEl.className = "dial-list";
|
||||||
ensureSpacer();
|
ensureSpacer();
|
||||||
renderEnd = 0;
|
renderEnd = 0;
|
||||||
|
sectionRendered = 0;
|
||||||
appendActionRows(list, 0, Math.min(list.length, K_ROWS));
|
appendActionRows(list, 0, Math.min(list.length, K_ROWS));
|
||||||
renderEnd = Math.min(list.length, K_ROWS);
|
renderEnd = Math.min(list.length, K_ROWS);
|
||||||
setBottomSpacer(list.length);
|
setBottomSpacer(list.length);
|
||||||
@@ -754,6 +845,7 @@ function renderEmpty(text) {
|
|||||||
listEl.innerHTML = "";
|
listEl.innerHTML = "";
|
||||||
spacerEl = null;
|
spacerEl = null;
|
||||||
listEl.className = "dial-list empty";
|
listEl.className = "dial-list empty";
|
||||||
|
setSections(null);
|
||||||
if (countEl) countEl.hidden = true;
|
if (countEl) countEl.hidden = true;
|
||||||
var empty = document.createElement("div");
|
var empty = document.createElement("div");
|
||||||
empty.className = "dial-empty";
|
empty.className = "dial-empty";
|
||||||
@@ -782,6 +874,8 @@ function renderCommandsList() {
|
|||||||
var key = buildKey() + "|" + total;
|
var key = buildKey() + "|" + total;
|
||||||
if (key !== builtKey) {
|
if (key !== builtKey) {
|
||||||
builtKey = key;
|
builtKey = key;
|
||||||
|
// Headers split the empty-query list into recents + category groups; typed queries have none.
|
||||||
|
setSections(showList ? null : commandSections(list, (RECENTS || []).length, query));
|
||||||
rebuildCommandsList(list);
|
rebuildCommandsList(list);
|
||||||
} else if (sel.i >= renderEnd) {
|
} else if (sel.i >= renderEnd) {
|
||||||
// Arrow-nav walked past the rendered window - reveal enough to keep the selection visible.
|
// Arrow-nav walked past the rendered window - reveal enough to keep the selection visible.
|
||||||
@@ -789,9 +883,11 @@ function renderCommandsList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
listEl.className = "dial-list";
|
listEl.className = "dial-list";
|
||||||
|
// The empty-query list is labelled by its section headers instead.
|
||||||
if (countEl) {
|
if (countEl) {
|
||||||
countEl.hidden = false;
|
countEl.hidden = !showList;
|
||||||
countEl.textContent = showList ? resultCountText(ACTIONS.length, total, query) : total + " " + T("sd_recent", "recent");
|
if (showList)
|
||||||
|
countEl.textContent = resultCountText(ACTIONS.length, total, query);
|
||||||
}
|
}
|
||||||
updateSelection();
|
updateSelection();
|
||||||
updatePins(list);
|
updatePins(list);
|
||||||
|
|||||||
@@ -22,16 +22,16 @@ assert.equal(
|
|||||||
"duplicate labels should use the opaque id without interpreting its contents"
|
"duplicate labels should use the opaque id without interpreting its contents"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(ctx.shouldRenderActionList(""), false, "an empty search keeps recent/empty list");
|
assert.equal(ctx.shouldRenderActionList(""), false, "an empty search shows the recents+pool list");
|
||||||
assert.equal(ctx.shouldRenderActionList(" "), false, "whitespace-only search keeps recent/empty 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");
|
assert.equal(ctx.shouldRenderActionList("r"), true, "typing starts rendering matching actions");
|
||||||
|
|
||||||
// commandList: an empty query shows recents; a typed query filters all actions.
|
// commandList: an empty query shows recents first, then every other action; a typed query filters all.
|
||||||
assert.deepEqual(ctx.commandList(duplicateActions, [], ""), [],
|
assert.deepEqual(ctx.commandList(duplicateActions, [], ""), duplicateActions,
|
||||||
"empty query + no recents shows nothing");
|
"empty query + no recents shows the whole action pool");
|
||||||
assert.deepEqual(ctx.commandList(duplicateActions, [duplicateActions[0]], ""),
|
assert.deepEqual(ctx.commandList(duplicateActions, [duplicateActions[0]], ""),
|
||||||
[duplicateActions[0]],
|
[duplicateActions[0], duplicateActions[1]],
|
||||||
"empty query shows the recent list");
|
"empty query shows the recent first, then the remaining actions");
|
||||||
assert.deepEqual(ctx.commandList(duplicateActions, [], "rep"), duplicateActions,
|
assert.deepEqual(ctx.commandList(duplicateActions, [], "rep"), duplicateActions,
|
||||||
"a typed query filters actions (both identical titles match) instead of showing recents");
|
"a typed query filters actions (both identical titles match) instead of showing recents");
|
||||||
|
|
||||||
@@ -117,22 +117,73 @@ const negativePool = [
|
|||||||
assert.equal(ctx.searchActions(negativePool, "ornt").length, 1,
|
assert.equal(ctx.searchActions(negativePool, "ornt").length, 1,
|
||||||
"a low-score fuzzy match is not mistaken for no match");
|
"a low-score fuzzy match is not mistaken for no match");
|
||||||
|
|
||||||
|
// 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
|
// commandList (the main-phase list) delegates to the ranked search for a typed query and returns
|
||||||
// the mixed recents (no discrimination) for an empty query.
|
// recents + the category-grouped pool for an empty query.
|
||||||
const mixed = [
|
const mixed = [
|
||||||
{ id: "cmd", title: "Slice", source: "OrcaSlicer", group: "Commands", input: "" },
|
{ id: "cmd", title: "Slice", source: "OrcaSlicer", group: "Commands", kind: "command", input: "" },
|
||||||
{ id: "set", title: "Sparse Infill Density", source: "Quality", group: "Quality", input: "" }
|
{ id: "set", title: "Sparse Infill Density", source: "Quality", group: "Quality", kind: "command", input: "" }
|
||||||
];
|
];
|
||||||
assert.equal(ctx.commandList(mixed, [], "sli")[0].id, "cmd",
|
assert.equal(ctx.commandList(mixed, [], "sli")[0].id, "cmd",
|
||||||
"a typed query keeps the relevance-ranked action list (best match first)");
|
"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"],
|
assert.deepEqual(ctx.commandList(mixed, mixed.slice(0, 1), "").map(function (a) { return a.id; }), ["cmd", "set"],
|
||||||
"an empty query shows the mixed recents list verbatim");
|
"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");
|
||||||
|
|
||||||
// selectedActionId: resolves the active list (recents for an empty query, filtered list otherwise).
|
// 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(
|
assert.equal(
|
||||||
ctx.selectedActionId({ zone: "list", i: 0 }, ctx.commandList(duplicateActions, [], ""), []),
|
ctx.selectedActionId({ zone: "list", i: 0 }, ctx.commandList(duplicateActions, [], ""), []),
|
||||||
null,
|
"0123456789abcdef",
|
||||||
"Enter with an empty query and no recents must not resolve to an action the list never showed"
|
"Enter with an empty query resolves the first action in the recents+pool list"
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
ctx.selectedActionId({ zone: "list", i: 0 }, ctx.commandList(duplicateActions, [], "rep"), []),
|
ctx.selectedActionId({ zone: "list", i: 0 }, ctx.commandList(duplicateActions, [], "rep"), []),
|
||||||
|
|||||||
@@ -279,6 +279,21 @@ body {
|
|||||||
overflow-y: hidden;
|
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
|
/* 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. */
|
the full match count, while only a near-viewport window of rows exists in the DOM. */
|
||||||
.dial-spacer-bottom {
|
.dial-spacer-bottom {
|
||||||
|
|||||||
@@ -286,6 +286,8 @@ void AppConfig::set_defaults()
|
|||||||
// The getter already defaults, parses and clamps; write back what it resolves to.
|
// The getter already defaults, parses and clamps; write back what it resolves to.
|
||||||
set(SETTING_PLUGIN_PAGES_VISIBLE_COUNT, std::to_string(get_plugin_pages_visible_count()));
|
set(SETTING_PLUGIN_PAGES_VISIBLE_COUNT, std::to_string(get_plugin_pages_visible_count()));
|
||||||
|
|
||||||
|
set(SETTING_SPEED_DIAL_RECENT_COUNT, std::to_string(get_speed_dial_recent_count()));
|
||||||
|
|
||||||
if (get(SETTING_OPENGL_SHOW_FPS_OVERLAY).empty())
|
if (get(SETTING_OPENGL_SHOW_FPS_OVERLAY).empty())
|
||||||
set_bool(SETTING_OPENGL_SHOW_FPS_OVERLAY, false);
|
set_bool(SETTING_OPENGL_SHOW_FPS_OVERLAY, false);
|
||||||
|
|
||||||
@@ -1664,6 +1666,22 @@ int AppConfig::get_plugin_pages_visible_count() const
|
|||||||
return std::clamp(visible_count, PLUGIN_PAGES_VISIBLE_COUNT_MIN, PLUGIN_PAGES_VISIBLE_COUNT_MAX);
|
return std::clamp(visible_count, PLUGIN_PAGES_VISIBLE_COUNT_MIN, PLUGIN_PAGES_VISIBLE_COUNT_MAX);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int AppConfig::get_speed_dial_recent_count() const
|
||||||
|
{
|
||||||
|
std::string value = get(SETTING_SPEED_DIAL_RECENT_COUNT);
|
||||||
|
if (value.empty())
|
||||||
|
return SPEED_DIAL_RECENT_COUNT_DEFAULT;
|
||||||
|
|
||||||
|
int recent_count = SPEED_DIAL_RECENT_COUNT_DEFAULT;
|
||||||
|
try {
|
||||||
|
recent_count = std::stoi(value);
|
||||||
|
}
|
||||||
|
catch (...) {
|
||||||
|
return SPEED_DIAL_RECENT_COUNT_DEFAULT;
|
||||||
|
}
|
||||||
|
return std::clamp(recent_count, SPEED_DIAL_RECENT_COUNT_MIN, SPEED_DIAL_RECENT_COUNT_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<std::string> AppConfig::get_skipped_network_versions() const
|
std::vector<std::string> AppConfig::get_skipped_network_versions() const
|
||||||
{
|
{
|
||||||
std::vector<std::string> result;
|
std::vector<std::string> result;
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ using namespace nlohmann;
|
|||||||
#define PLUGIN_PAGES_VISIBLE_COUNT_DEFAULT 5
|
#define PLUGIN_PAGES_VISIBLE_COUNT_DEFAULT 5
|
||||||
#define PLUGIN_PAGES_VISIBLE_COUNT_MAX 10
|
#define PLUGIN_PAGES_VISIBLE_COUNT_MAX 10
|
||||||
|
|
||||||
|
#define SETTING_SPEED_DIAL_RECENT_COUNT "speed_dial_recent_count"
|
||||||
|
#define SPEED_DIAL_RECENT_COUNT_MIN 0
|
||||||
|
#define SPEED_DIAL_RECENT_COUNT_DEFAULT 5
|
||||||
|
#define SPEED_DIAL_RECENT_COUNT_MAX 10
|
||||||
|
|
||||||
#if defined(_WIN32) || defined(_WIN64)
|
#if defined(_WIN32) || defined(_WIN64)
|
||||||
#define BAMBU_NETWORK_AGENT_VERSION_LEGACY "01.10.01.09"
|
#define BAMBU_NETWORK_AGENT_VERSION_LEGACY "01.10.01.09"
|
||||||
#else
|
#else
|
||||||
@@ -394,6 +399,9 @@ public:
|
|||||||
// dropdown on the last tab.
|
// dropdown on the last tab.
|
||||||
int get_plugin_pages_visible_count() const;
|
int get_plugin_pages_visible_count() const;
|
||||||
|
|
||||||
|
// Number of recently launched actions shown at the top of the Speed Dial; 0 hides them.
|
||||||
|
int get_speed_dial_recent_count() const;
|
||||||
|
|
||||||
std::vector<std::string> get_skipped_network_versions() const;
|
std::vector<std::string> get_skipped_network_versions() const;
|
||||||
void add_skipped_network_version(const std::string& version);
|
void add_skipped_network_version(const std::string& version);
|
||||||
bool is_network_version_skipped(const std::string& version) const;
|
bool is_network_version_skipped(const std::string& version) const;
|
||||||
|
|||||||
@@ -720,6 +720,7 @@ nlohmann::json ActionRegistry::snapshot()
|
|||||||
{"title", a->title()},
|
{"title", a->title()},
|
||||||
{"source", a->source_name()},
|
{"source", a->source_name()},
|
||||||
{"group", a->group},
|
{"group", a->group},
|
||||||
|
{"kind", a->kind == AppActionKind::Plugin ? "plugin" : "command"},
|
||||||
{"input", a->input},
|
{"input", a->input},
|
||||||
{"icon", a->icon},
|
{"icon", a->icon},
|
||||||
{"mode", mode_key(a->required_mode)}});
|
{"mode", mode_key(a->required_mode)}});
|
||||||
@@ -744,8 +745,9 @@ nlohmann::json ActionRegistry::snapshot()
|
|||||||
write_section("favourite_actions", nlohmann::json(live_favs));
|
write_section("favourite_actions", nlohmann::json(live_favs));
|
||||||
nlohmann::json favourites(live_favs);
|
nlohmann::json favourites(live_favs);
|
||||||
|
|
||||||
// Recent = the last-N launched actions by recency (only actions with a run history).
|
// Recent = the last-N launched actions by recency (only actions with a run history). N is a
|
||||||
constexpr size_t kRecentLimit = 5;
|
// user preference; 0 hides recents without affecting the frecency order below.
|
||||||
|
const size_t recent_limit = size_t(wxGetApp().app_config->get_speed_dial_recent_count());
|
||||||
std::vector<const AppAction*> recent;
|
std::vector<const AppAction*> recent;
|
||||||
for (const auto& entry : m_actions)
|
for (const auto& entry : m_actions)
|
||||||
if (entry.second->last > 0)
|
if (entry.second->last > 0)
|
||||||
@@ -755,8 +757,8 @@ nlohmann::json ActionRegistry::snapshot()
|
|||||||
return a->last > b->last;
|
return a->last > b->last;
|
||||||
return a->id() < b->id();
|
return a->id() < b->id();
|
||||||
});
|
});
|
||||||
if (recent.size() > kRecentLimit)
|
if (recent.size() > recent_limit)
|
||||||
recent.resize(kRecentLimit);
|
recent.resize(recent_limit);
|
||||||
nlohmann::json recent_json = nlohmann::json::array();
|
nlohmann::json recent_json = nlohmann::json::array();
|
||||||
for (const AppAction* a : recent)
|
for (const AppAction* a : recent)
|
||||||
recent_json.push_back(action_to_json(a));
|
recent_json.push_back(action_to_json(a));
|
||||||
|
|||||||
@@ -1728,6 +1728,16 @@ void PreferencesDialog::create_items()
|
|||||||
"enable_speed_dial");
|
"enable_speed_dial");
|
||||||
g_sizer->Add(item_speed_dial);
|
g_sizer->Add(item_speed_dial);
|
||||||
|
|
||||||
|
auto item_speed_dial_recents = create_item_spinctrl(
|
||||||
|
_L("Recent actions"),
|
||||||
|
"",
|
||||||
|
_L("actions"),
|
||||||
|
_L("How many recently launched actions to show at the top of the Speed Dial. Set to 0 to hide recent actions."),
|
||||||
|
SETTING_SPEED_DIAL_RECENT_COUNT,
|
||||||
|
SPEED_DIAL_RECENT_COUNT_MIN,
|
||||||
|
SPEED_DIAL_RECENT_COUNT_MAX);
|
||||||
|
g_sizer->Add(item_speed_dial_recents);
|
||||||
|
|
||||||
#if 0
|
#if 0
|
||||||
g_sizer->Add(create_item_title(_L("Filament Grouping")), 1, wxEXPAND);
|
g_sizer->Add(create_item_title(_L("Filament Grouping")), 1, wxEXPAND);
|
||||||
//temporarily disable it
|
//temporarily disable it
|
||||||
|
|||||||
@@ -43,3 +43,41 @@ TEST_CASE("AppConfig network version helpers", "[AppConfig]") {
|
|||||||
REQUIRE(config.is_network_version_skipped("02.01.01.52"));
|
REQUIRE(config.is_network_version_skipped("02.01.01.52"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("AppConfig Speed Dial recent count defaults, clamps and parses", "[AppConfig]") {
|
||||||
|
AppConfig config;
|
||||||
|
|
||||||
|
SECTION("unset falls back to the default") {
|
||||||
|
REQUIRE(config.get_speed_dial_recent_count() == SPEED_DIAL_RECENT_COUNT_DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("zero disables recents") {
|
||||||
|
config.set(SETTING_SPEED_DIAL_RECENT_COUNT, "0");
|
||||||
|
REQUIRE(config.get_speed_dial_recent_count() == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("a value in range is returned as-is") {
|
||||||
|
config.set(SETTING_SPEED_DIAL_RECENT_COUNT, "7");
|
||||||
|
REQUIRE(config.get_speed_dial_recent_count() == 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("the maximum is kept") {
|
||||||
|
config.set(SETTING_SPEED_DIAL_RECENT_COUNT, "10");
|
||||||
|
REQUIRE(config.get_speed_dial_recent_count() == SPEED_DIAL_RECENT_COUNT_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("values above the maximum clamp down") {
|
||||||
|
config.set(SETTING_SPEED_DIAL_RECENT_COUNT, "42");
|
||||||
|
REQUIRE(config.get_speed_dial_recent_count() == SPEED_DIAL_RECENT_COUNT_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("negative values clamp up to 0") {
|
||||||
|
config.set(SETTING_SPEED_DIAL_RECENT_COUNT, "-3");
|
||||||
|
REQUIRE(config.get_speed_dial_recent_count() == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("garbage falls back to the default") {
|
||||||
|
config.set(SETTING_SPEED_DIAL_RECENT_COUNT, "abc");
|
||||||
|
REQUIRE(config.get_speed_dial_recent_count() == SPEED_DIAL_RECENT_COUNT_DEFAULT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user