Add category headers. Alphabetical sorting when no search query. Recent count adjustments

This commit is contained in:
Lam Wei Lun
2026-09-11 14:56:59 +08:00
parent 7c2991d00c
commit 81458dae86
9 changed files with 271 additions and 31 deletions
+3 -1
View File
@@ -124,7 +124,9 @@ var LangText = {
sd_showing: "Showing",
sd_of: "of",
sd_actions: "actions",
sd_recent: "recent",
sd_recent: "Recent",
sd_plugins: "Plugins",
sd_other: "Other",
sd_matches: "matches",
sd_tabs: "tabs",
sd_no_match: "No actions match",
+107 -11
View File
@@ -1,10 +1,11 @@
// 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) ----
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 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 sel = { zone: "list", i: 0 }; // zone: 'list' | 'fav'
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 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.
var searchNeedle = "";
@@ -274,12 +283,75 @@ function fillTile(tile, a, mono) {
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)
// 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) {
var all = actions || [];
if (shouldRenderActionList(query))
return searchActions(actions || [], query);
return (recents || []).slice(0);
return searchActions(all, query);
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
@@ -676,16 +748,31 @@ function renderActionRow(a, i) {
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) {
var spacer = spacerEl || ensureSpacer();
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);
row.setAttribute("data-idx", i);
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
// listEl.innerHTML="" destroys the old node.
function ensureSpacer() {
@@ -697,10 +784,13 @@ function ensureSpacer() {
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) {
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
@@ -720,6 +810,7 @@ function rebuildCommandsList(list) {
listEl.className = "dial-list";
ensureSpacer();
renderEnd = 0;
sectionRendered = 0;
appendActionRows(list, 0, Math.min(list.length, K_ROWS));
renderEnd = Math.min(list.length, K_ROWS);
setBottomSpacer(list.length);
@@ -754,6 +845,7 @@ function renderEmpty(text) {
listEl.innerHTML = "";
spacerEl = null;
listEl.className = "dial-list empty";
setSections(null);
if (countEl) countEl.hidden = true;
var empty = document.createElement("div");
empty.className = "dial-empty";
@@ -782,6 +874,8 @@ function renderCommandsList() {
var key = buildKey() + "|" + total;
if (key !== builtKey) {
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);
} else if (sel.i >= renderEnd) {
// Arrow-nav walked past the rendered window - reveal enough to keep the selection visible.
@@ -789,9 +883,11 @@ function renderCommandsList() {
}
listEl.className = "dial-list";
// The empty-query list is labelled by its section headers instead.
if (countEl) {
countEl.hidden = false;
countEl.textContent = showList ? resultCountText(ACTIONS.length, total, query) : total + " " + T("sd_recent", "recent");
countEl.hidden = !showList;
if (showList)
countEl.textContent = resultCountText(ACTIONS.length, total, query);
}
updateSelection();
updatePins(list);
@@ -22,16 +22,16 @@ assert.equal(
"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, "whitespace-only 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 shows the recents+pool list");
assert.equal(ctx.shouldRenderActionList("r"), true, "typing starts rendering matching actions");
// commandList: an empty query shows recents; a typed query filters all actions.
assert.deepEqual(ctx.commandList(duplicateActions, [], ""), [],
"empty query + no recents shows nothing");
// 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]],
"empty query shows the recent list");
[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");
@@ -117,22 +117,73 @@ const negativePool = [
assert.equal(ctx.searchActions(negativePool, "ornt").length, 1,
"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
// the mixed recents (no discrimination) for an empty query.
// recents + the category-grouped pool for an empty query.
const mixed = [
{ id: "cmd", title: "Slice", source: "OrcaSlicer", group: "Commands", input: "" },
{ id: "set", title: "Sparse Infill Density", source: "Quality", group: "Quality", input: "" }
{ 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"],
"an empty query shows the mixed recents list verbatim");
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");
// 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(
ctx.selectedActionId({ zone: "list", i: 0 }, ctx.commandList(duplicateActions, [], ""), []),
null,
"Enter with an empty query and no recents must not resolve to an action the list never showed"
"0123456789abcdef",
"Enter with an empty query resolves the first action in the recents+pool list"
);
assert.equal(
ctx.selectedActionId({ zone: "list", i: 0 }, ctx.commandList(duplicateActions, [], "rep"), []),
+15
View File
@@ -279,6 +279,21 @@ body {
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 {
+18
View File
@@ -286,6 +286,8 @@ void AppConfig::set_defaults()
// 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_SPEED_DIAL_RECENT_COUNT, std::to_string(get_speed_dial_recent_count()));
if (get(SETTING_OPENGL_SHOW_FPS_OVERLAY).empty())
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);
}
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> result;
+8
View File
@@ -46,6 +46,11 @@ using namespace nlohmann;
#define PLUGIN_PAGES_VISIBLE_COUNT_DEFAULT 5
#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)
#define BAMBU_NETWORK_AGENT_VERSION_LEGACY "01.10.01.09"
#else
@@ -394,6 +399,9 @@ public:
// dropdown on the last tab.
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;
void add_skipped_network_version(const std::string& version);
bool is_network_version_skipped(const std::string& version) const;
+6 -4
View File
@@ -720,6 +720,7 @@ nlohmann::json ActionRegistry::snapshot()
{"title", a->title()},
{"source", a->source_name()},
{"group", a->group},
{"kind", a->kind == AppActionKind::Plugin ? "plugin" : "command"},
{"input", a->input},
{"icon", a->icon},
{"mode", mode_key(a->required_mode)}});
@@ -744,8 +745,9 @@ nlohmann::json ActionRegistry::snapshot()
write_section("favourite_actions", nlohmann::json(live_favs));
nlohmann::json favourites(live_favs);
// Recent = the last-N launched actions by recency (only actions with a run history).
constexpr size_t kRecentLimit = 5;
// Recent = the last-N launched actions by recency (only actions with a run history). N is a
// 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;
for (const auto& entry : m_actions)
if (entry.second->last > 0)
@@ -755,8 +757,8 @@ nlohmann::json ActionRegistry::snapshot()
return a->last > b->last;
return a->id() < b->id();
});
if (recent.size() > kRecentLimit)
recent.resize(kRecentLimit);
if (recent.size() > recent_limit)
recent.resize(recent_limit);
nlohmann::json recent_json = nlohmann::json::array();
for (const AppAction* a : recent)
recent_json.push_back(action_to_json(a));
+10
View File
@@ -1728,6 +1728,16 @@ void PreferencesDialog::create_items()
"enable_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
g_sizer->Add(create_item_title(_L("Filament Grouping")), 1, wxEXPAND);
//temporarily disable it
+38
View File
@@ -43,3 +43,41 @@ TEST_CASE("AppConfig network version helpers", "[AppConfig]") {
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);
}
}