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_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",
+107 -11
View File
@@ -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"), []),
+15
View File
@@ -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 {
+18
View File
@@ -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;
+8
View File
@@ -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;
+6 -4
View File
@@ -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));
+10
View File
@@ -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
+38
View File
@@ -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);
}
}