From 81458dae8678c71d56fae8102690a8f9d3607f23 Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Fri, 11 Sep 2026 14:56:59 +0800 Subject: [PATCH] Add category headers. Alphabetical sorting when no search query. Recent count adjustments --- resources/web/data/text.js | 4 +- resources/web/dialog/SpeedDial/speeddial.js | 118 ++++++++++++++++-- .../web/dialog/SpeedDial/speeddial.test.js | 81 +++++++++--- resources/web/dialog/SpeedDial/style.css | 15 +++ src/libslic3r/AppConfig.cpp | 18 +++ src/libslic3r/AppConfig.hpp | 8 ++ src/slic3r/GUI/ActionRegistry.cpp | 10 +- src/slic3r/GUI/Preferences.cpp | 10 ++ tests/libslic3r/test_appconfig.cpp | 38 ++++++ 9 files changed, 271 insertions(+), 31 deletions(-) diff --git a/resources/web/data/text.js b/resources/web/data/text.js index 1a17c5ccdb..2f6682c051 100644 --- a/resources/web/data/text.js +++ b/resources/web/data/text.js @@ -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", diff --git a/resources/web/dialog/SpeedDial/speeddial.js b/resources/web/dialog/SpeedDial/speeddial.js index 2e61165559..2045d42224 100644 --- a/resources/web/dialog/SpeedDial/speeddial.js +++ b/resources/web/dialog/SpeedDial/speeddial.js @@ -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); diff --git a/resources/web/dialog/SpeedDial/speeddial.test.js b/resources/web/dialog/SpeedDial/speeddial.test.js index 7f08880510..521705d68f 100644 --- a/resources/web/dialog/SpeedDial/speeddial.test.js +++ b/resources/web/dialog/SpeedDial/speeddial.test.js @@ -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"), []), diff --git a/resources/web/dialog/SpeedDial/style.css b/resources/web/dialog/SpeedDial/style.css index 3aea0cf15a..6aaca682d9 100644 --- a/resources/web/dialog/SpeedDial/style.css +++ b/resources/web/dialog/SpeedDial/style.css @@ -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 { diff --git a/src/libslic3r/AppConfig.cpp b/src/libslic3r/AppConfig.cpp index 4c6eefa470..d1f488e523 100644 --- a/src/libslic3r/AppConfig.cpp +++ b/src/libslic3r/AppConfig.cpp @@ -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 AppConfig::get_skipped_network_versions() const { std::vector result; diff --git a/src/libslic3r/AppConfig.hpp b/src/libslic3r/AppConfig.hpp index c799502993..8e3305105a 100644 --- a/src/libslic3r/AppConfig.hpp +++ b/src/libslic3r/AppConfig.hpp @@ -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 get_skipped_network_versions() const; void add_skipped_network_version(const std::string& version); bool is_network_version_skipped(const std::string& version) const; diff --git a/src/slic3r/GUI/ActionRegistry.cpp b/src/slic3r/GUI/ActionRegistry.cpp index a64fe9f705..b44d73ddc1 100644 --- a/src/slic3r/GUI/ActionRegistry.cpp +++ b/src/slic3r/GUI/ActionRegistry.cpp @@ -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 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)); diff --git a/src/slic3r/GUI/Preferences.cpp b/src/slic3r/GUI/Preferences.cpp index c5f9bf1f89..54f117f245 100644 --- a/src/slic3r/GUI/Preferences.cpp +++ b/src/slic3r/GUI/Preferences.cpp @@ -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 diff --git a/tests/libslic3r/test_appconfig.cpp b/tests/libslic3r/test_appconfig.cpp index 59f9d11808..d3811c9827 100644 --- a/tests/libslic3r/test_appconfig.cpp +++ b/tests/libslic3r/test_appconfig.cpp @@ -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); + } +}