Fixes issue with showing hidden settings in speed dial.

This commit is contained in:
Lam Wei Lun
2026-09-16 16:02:39 +08:00
parent b01d18bba3
commit 075a84093f
10 changed files with 198 additions and 40 deletions
+21 -13
View File
@@ -114,6 +114,13 @@ function sourceNorm(a) {
a._sn = NormText(a.source || "", false);
return a._sn;
}
// Search-only alias for the descriptive name when the title differs (e.g. "Reverse on even" vs
// "Overhang reversal"). Never rendered, so no highlight ranges.
function fullNorm(a) {
if (a._fn === undefined)
a._fn = NormText(a.full_label || "", false);
return a._fn;
}
// Match one pre-normalized field vs one pre-normalized needle. Returns {score, ranges, contiguous}
// when the needle is present, else null. wwRe is a compiled whole-word (\b-bounded) regex for the
@@ -152,19 +159,19 @@ function tokenWordRe(token) {
return new RegExp("\\b" + EscapeRegExp(token) + "\\b");
}
// One token's best match across an action's three fields, keeping the per-field ranges so the caller
// can highlight each matched word. Returns {score, title, group, source} (ranges or null per field), or
// null when no field contains the token. Score mirrors scoreFields' tiers: contiguous > fuzzy, then
// title > group > source.
// One token's best match across an action's searchable fields, keeping per-field ranges for
// highlighting. Returns {score, title, group, source}, or null if no field matches.
function tokenMatch(a, token, wwRe) {
var t = fieldMatchScore(titleNorm(a), token, wwRe);
var g = fieldMatchScore(groupNorm(a), token, wwRe);
var s = fieldMatchScore(sourceNorm(a), token, wwRe);
if (!t && !g && !s) return null;
var f = fieldMatchScore(fullNorm(a), token, wwRe);
if (!t && !g && !s && !f) return null;
var score = Math.max(
t ? (t.contiguous ? SCORE_CONTIGUOUS : 0) + SCORE_TITLE + t.score : -Infinity,
g ? (g.contiguous ? SCORE_CONTIGUOUS : 0) + SCORE_GROUP + g.score : -Infinity,
s ? (s.contiguous ? SCORE_CONTIGUOUS : 0) + s.score : -Infinity
s ? (s.contiguous ? SCORE_CONTIGUOUS : 0) + s.score : -Infinity,
f ? (f.contiguous ? SCORE_CONTIGUOUS : 0) + f.score : -Infinity
);
return { score: score, title: t ? t.ranges : null, group: g ? g.ranges : null, source: s ? s.ranges : null };
}
@@ -189,11 +196,9 @@ function mergeRanges(ranges) {
return out;
}
// Combine the per-field match scores into one comparable value, or null when no field matched.
// Ranking tiers, strongest first:
// tier (contiguous/perfect vs fuzzy) > field (title > group > source) > start/gaps.
// The additive weights keep every contiguous match above every fuzzy one regardless of field.
function scoreFields(t, g, s) {
// Combine per-field scores into one value, or null when nothing matched.
// Ranking: contiguous > fuzzy, then title > group > source/full alias, then start/gaps.
function scoreFields(t, g, s, f) {
var best = null;
function consider(m, weight) {
if (!m) return;
@@ -203,6 +208,7 @@ function scoreFields(t, g, s) {
consider(t, SCORE_TITLE);
consider(g, SCORE_GROUP);
consider(s, 0);
consider(f, 0);
return best;
}
@@ -216,6 +222,7 @@ function scoreFields(t, g, s) {
// - tokens: every whitespace-separated word must match SOME field, but different words may match
// different fields. This is what lets "speed acceleration inner" find "Inner wall" whose path is
// "Process : Speed : Acceleration" (title + source breadcrumb together).
// full_label is searchable too but never highlighted, since it is not rendered.
// A phrase match always outranks a distributed token match.
function searchActions(actions, query) {
var q = (query || "").trim();
@@ -239,7 +246,8 @@ function searchActions(actions, query) {
var t = fieldMatchScore(titleNorm(a), searchNeedle, wwRe);
var g = fieldMatchScore(groupNorm(a), searchNeedle, wwRe);
var s = fieldMatchScore(sourceNorm(a), searchNeedle, wwRe);
var phrase = scoreFields(t, g, s);
var f = fieldMatchScore(fullNorm(a), searchNeedle, wwRe);
var phrase = scoreFields(t, g, s, f);
var score, ranges;
if (phrase !== null) {
score = phrase + SCORE_PHRASE;
@@ -312,7 +320,7 @@ function completionFor(query, list) {
var top = (list || []).slice(0, 10);
for (var i = 0; i < top.length; i++) {
var a = top[i];
var fields = [a.title, a.group, a.source];
var fields = [a.title, a.group, a.source, a.full_label];
for (var f = 0; f < fields.length; f++) {
var words = completionWords(fields[f]);
for (var w = 0; w < words.length; w++) {
@@ -117,6 +117,18 @@ const negativePool = [
assert.equal(ctx.searchActions(negativePool, "ornt").length, 1,
"a low-score fuzzy match is not mistaken for no match");
// A setting whose displayed title is the page row label keeps the descriptive ConfigOptionDef name as
// a search-only alias, so the old wording still finds it without being shown.
const aliasPool = [
{ id: "rev", title: "Reverse on even", full_label: "Overhang reversal", source: "Process : Quality : Overhangs", group: "", input: "" }
];
assert.deepEqual(ctx.searchActions(aliasPool, "overhang reversal").map(function (a) { return a.id; }), ["rev"],
"the descriptive full_label is searchable even though the title shows the row label");
assert.equal(ctx.matchIndex.rev.title, null,
"an alias-only match does not highlight the displayed title");
assert.deepEqual(ctx.searchActions(aliasPool, "reversal").map(function (a) { return a.id; }), ["rev"],
"a token that exists only in the full_label still matches");
// Multi-token cross-field search: each whitespace-separated word must match SOME searchable field,
// but different words may match different fields. "inner" is the title while "speed" and
// "acceleration" live in the source breadcrumb, so the query as a whole is never contiguous in one
+27 -12
View File
@@ -561,10 +561,24 @@ void ActionRegistry::materialize_setting_actions()
std::unordered_set<std::string> seen;
for (const Search::Option& opt : options) {
// The row's live state drives both the hidden filter and the title (labels can change at
// runtime, e.g. brim_width -> "Brim ear radius"). Hidden rows are skipped, not marked seen.
Tab* tab = wxGetApp().get_tab(opt.type);
Tab::SettingRowState row;
if (tab)
row = tab->setting_row_state(opt.opt_key());
if (!row.visible)
continue;
const std::string id = SettingAction::id_for(opt.opt_key(), opt.type);
seen.insert(id);
const std::wstring label_w = opt.label_local.empty() ? opt.label : opt.label_local;
// The page draws Line::label; the descriptive ConfigOptionDef name stays a search-only alias
// ("overhang reversal" still finds "Reverse on even").
const std::string search_label = boost::nowide::narrow(opt.label_local.empty() ? opt.label : opt.label_local);
std::string title = into_u8(Search::resolve_setting_title(from_u8(opt.display_label), row.label, row.multi));
if (title.empty())
title = search_label;
// Eyebrow/source = the full settings path "Process : Quality : Layers" (localized). The JS
// renders group || source and searches source + " " + group, so putting the whole path in
@@ -575,22 +589,22 @@ void ActionRegistry::materialize_setting_actions()
if (!opt.group_local.empty())
path += L" : " + opt.group_local;
// title = the option leaf name (last label segment); group stays empty so the source path
// (above) is the single display/search breadcrumb rather than being duplicated.
auto action = std::make_unique<SettingAction>(opt.opt_key(), opt.type, boost::nowide::narrow(label_w), std::string(),
opt.category, boost::nowide::narrow(path), opt.mode);
// title = the label the settings row draws; group stays empty so the source path (above) is
// the single display/search breadcrumb rather than being duplicated.
auto action = std::make_unique<SettingAction>(opt.opt_key(), opt.type, title, std::string(), opt.category,
boost::nowide::narrow(path), opt.mode);
if (title != search_label)
action->full_label = search_label;
// Tile pictogram = the icon of the setting's own group header (e.g. Advanced -> param_advanced),
// the one shown next to it in the page. Fall back to the page/category icon for groups
// without one. Keys are the English titles the GUI registers.
action->icon = opt.group_icon;
if (action->icon.empty() && !opt.category.empty()) {
if (Tab* tab = wxGetApp().get_tab(opt.type); tab) {
const auto& icons = tab->get_category_icon_map();
auto it = icons.find(wxString(opt.category));
if (it != icons.end())
action->icon = it->second;
}
if (action->icon.empty() && !opt.category.empty() && tab) {
const auto& icons = tab->get_category_icon_map();
auto it = icons.find(wxString(opt.category));
if (it != icons.end())
action->icon = it->second;
}
// Footer description + wiki affordance; only settings whose row declared a wiki path have one.
@@ -745,6 +759,7 @@ nlohmann::json ActionRegistry::snapshot()
auto action_to_json = [](const AppAction* a) {
return nlohmann::json({{"id", a->id()},
{"title", a->title()},
{"full_label", a->full_label},
{"source", a->source_name()},
{"group", a->group},
{"kind", a->kind == AppActionKind::Plugin ? "plugin" : "command"},
+3
View File
@@ -85,6 +85,9 @@ struct AppAction
ConfigOptionMode required_mode = comSimple;
// Description shown in the Speed Dial's footer strip (SettingActions: the localized tooltip).
std::string tooltip;
// Search-only alias when the title differs from the descriptive ConfigOptionDef name (e.g. title
// "Reverse on even", full_label "Overhang reversal"). Empty when the two agree.
std::string full_label;
// Full wiki URL, when the action has one (SettingActions whose row declared a label_path).
std::string help_url;
+19 -5
View File
@@ -1,6 +1,7 @@
#include "OptionsGroup.hpp"
#include "ConfigExceptions.hpp"
#include "Plater.hpp"
#include "SettingsIndex.hpp"
#include "GUI_App.hpp"
#include "MainFrame.hpp"
#include "OG_CustomCtrl.hpp"
@@ -244,11 +245,24 @@ void OptionsGroup::append_line(const Line& line)
{
m_lines.emplace_back(line);
// Record each option's wiki path (Line::label_path) so the Speed Dial can offer an "open wiki"
// affordance for it. Settings tabs only; the searcher already exists by the time tabs are built.
if (m_use_custom_ctrl && !line.label_path.empty())
for (const auto& opt : line.get_options())
wxGetApp().sidebar().settings_index().set_path(opt.opt_id, static_cast<Preset::Type>(config_type()), line.label_path);
// Feed the searcher the row's wiki path (Line::label_path, for the Speed Dial's "open wiki"
// affordance) and the label the row actually draws, so a setting action is named like the page.
if (m_use_custom_ctrl) {
Search::SettingsIndex& index = wxGetApp().sidebar().settings_index();
const Preset::Type type = static_cast<Preset::Type>(config_type());
const bool multi = line.get_options().size() > 1;
for (const auto& opt : line.get_options()) {
if (!line.label_path.empty())
index.set_path(opt.opt_id, type, line.label_path);
// Mirror the sub-label OG_CustomCtrl draws for a multi-option row, so the palette
// names each field like the page does.
const std::string& leaf_src = opt.opt.label;
const wxString leaf = (leaf_src == L_CONTEXT("Top", "Layers") || leaf_src == L_CONTEXT("Bottom", "Layers")) ?
_L_CONTEXT(leaf_src, "Layers") :
_(leaf_src);
index.set_line_label(opt.opt_id, type, Search::compose_display_label(line.label, leaf, multi));
}
}
if (line.full_width && (line.widget != nullptr || !line.get_extra_widgets().empty()))
return;
+13 -2
View File
@@ -55,9 +55,13 @@ static Option make_option(const std::string &key, Preset::Type type, const wxStr
category = wxString::Format("%s %d", "Extruder", atoi(opt_idx.c_str()) + 1);
}
return Option{boost::nowide::widen(key), type, (label + suffix).ToStdWstring(), (_(label) + suffix_local).ToStdWstring(),
Option option{boost::nowide::widen(key), type, (label + suffix).ToStdWstring(), (_(label) + suffix_local).ToStdWstring(),
gc.group.ToStdWstring(), _(gc.group).ToStdWstring(), into_u8(gc.icon), gc.category.ToStdWstring(),
GUI::Tab::translate_category(category, type).ToStdWstring(), false, mode, tooltip, gc.path};
GUI::Tab::translate_category(category, type).ToStdWstring(), false, mode, tooltip, gc.path,
// The settings page draws Line::label; carrying it lets the Speed Dial name a setting
// the way the page does. `label`/`label_local` stay the search-oriented name.
into_u8(gc.line_label)};
return option;
}
void SettingsIndex::append_options(DynamicPrintConfig *config, Preset::Type type, ConfigOptionMode mode)
@@ -225,5 +229,12 @@ void SettingsIndex::set_path(const std::string &opt_key, Preset::Type type, cons
m_groups_and_categories[get_key(opt_key, type)].path = path;
}
void SettingsIndex::set_line_label(const std::string &opt_key, Preset::Type type, const wxString &label)
{
if (label.IsEmpty())
return;
m_groups_and_categories[get_key(opt_key, type)].line_label = label;
}
} // namespace Search
} // namespace Slic3r
+27 -2
View File
@@ -26,10 +26,31 @@ struct GroupAndCategory
{
wxString group;
wxString category;
wxString icon; // icon of the group's own header, or empty
std::string path; // wiki path (Line::label_path) of the option's line, or empty
wxString icon; // icon of the group's own header, or empty
wxString line_label; // label the settings row actually draws (Line::label), or empty
std::string path; // wiki path (Line::label_path) of the option's line, or empty
};
// Title for a setting: the row label, qualified with the field leaf when the row packs several
// options (e.g. "Cool Plate \u2013 First layer"). Pure; inputs are already localized.
inline wxString compose_display_label(const wxString& line_label, const wxString& leaf_label, bool multi)
{
if (line_label.empty())
return leaf_label;
if (!multi || leaf_label.empty() || leaf_label == line_label)
return line_label;
return line_label + L" \u2013 " + leaf_label; // en dash separator
}
// Title to show. A single-option row uses its live label, which can be renamed at runtime
// (brim_width -> "Brim ear radius"); otherwise fall back to the precomposed label.
inline wxString resolve_setting_title(const wxString& precomposed, const wxString& live_label, bool live_multi)
{
if (!live_multi && !live_label.empty())
return live_label;
return precomposed;
}
struct Option
{
// bool operator<(const Option& other) const { return other.label > this->label; }
@@ -50,6 +71,7 @@ struct Option
ConfigOptionMode mode{comSimple}; // option's visibility threshold; drives the Speed Dial's mode prompt
std::string tooltip; // localized ConfigOptionDef::tooltip, or empty
std::string wiki_path; // Line::label_path for the option's row, or empty
std::string display_label; // label the settings row draws (localized); empty falls back to label
std::string opt_key() const;
};
@@ -75,6 +97,9 @@ public:
void add_key(const std::string &opt_key, Preset::Type type, const wxString &group, const wxString &category,
const wxString &icon = wxEmptyString);
void set_path(const std::string &opt_key, Preset::Type type, const std::string &path);
// Record the label the option's row draws, so the Speed Dial names a setting like the page
// (ConfigOptionDef::label/full_label is a search name, not the row text).
void set_line_label(const std::string &opt_key, Preset::Type type, const wxString &label);
const std::vector<Option> &options() const { return m_options; }
const std::vector<Option> &all_options() const { return m_all_modes; }
+36 -6
View File
@@ -1736,16 +1736,46 @@ void Tab::toggle_option(const std::string& opt_key, bool toggle, int opt_index/*
void Tab::toggle_line(const std::string &opt_key, bool toggle, int opt_index)
{
if (!m_active_page) return;
Line *line = m_active_page->get_line(opt_key, opt_index);
if (line) line->toggle_visible = toggle;
// Apply to every page that owns the option, not just m_active_page. ConfigManipulation runs while
// each tab updates at preset load, so the Speed Dial sees the same visibility regardless of page.
for (const PageShp& page : m_pages) {
if (!page) continue;
if (Line *line = page->get_line(opt_key, opt_index))
line->toggle_visible = toggle;
}
};
void Tab::set_option_label(const std::string &opt_key, const wxString &label, int opt_index)
{
if (!m_active_page) return;
Line *line = m_active_page->get_line(opt_key, opt_index);
if (line) line->set_label(label);
// Same as toggle_line: a runtime rename (brim_width -> "Brim ear radius") must reach every page
// so the Speed Dial titles the setting before the page has been shown.
for (const PageShp& page : m_pages) {
if (!page) continue;
if (Line *line = page->get_line(opt_key, opt_index))
line->set_label(label);
}
}
Tab::SettingRowState Tab::setting_row_state(const std::string &opt_id) const
{
bool found = false;
for (const PageShp& page : m_pages) {
if (!page) continue;
for (const ConfigOptionsGroupShp& group : page->m_optgroups) {
if (!group) continue;
for (const Line& line : group->get_lines()) {
for (const Option& opt : line.get_options()) {
if (opt.opt_id != opt_id)
continue;
if (line.toggle_visible) // shown on any owning page is enough
return {true, line.label, line.get_options().size() > 1};
found = true;
}
}
}
}
// Never registered on a page -> visible, but with no row label to contribute.
return {!found, wxString(), false};
}
// To be called by custom widgets, load a value into a config,
+10
View File
@@ -401,6 +401,16 @@ public:
void toggle_option(const std::string &opt_key, bool toggle, int opt_index = -1);
void toggle_line(const std::string &opt_key, bool toggle, int opt_index = -1); // BBS: hide some line
void set_option_label(const std::string &opt_key, const wxString &label, int opt_index = -1);
// Live state of the settings row that owns an option, read from the built pages.
struct SettingRowState
{
bool visible{true}; // false when ConfigManipulation hides the row
wxString label; // Line::label the row draws (may change at runtime)
bool multi{false}; // row packs several options, so label is precomposed
};
SettingRowState setting_row_state(const std::string &opt_id) const;
wxSizer* description_line_widget(wxWindow* parent, ogStaticText** StaticText, wxString text = wxEmptyString);
bool current_preset_is_dirty() const;
bool saved_preset_is_dirty() const;
+30
View File
@@ -2,6 +2,7 @@
#include "slic3r/GUI/ActionRegistry.hpp"
#include "slic3r/GUI/NativeCommands.hpp"
#include "slic3r/GUI/SettingsIndex.hpp"
#include <boost/filesystem.hpp>
@@ -284,6 +285,35 @@ TEST_CASE("Native command catalog covers the Add menus", "[ActionSource][SpeedDi
}
}
// A setting action is named like its settings row, not the ConfigOptionDef label: the row's
// Line::label, plus the field leaf when the row packs several options.
TEST_CASE("Setting display labels mirror the settings row", "[ActionSource][SpeedDial]")
{
using Slic3r::Search::compose_display_label;
// Single-option row: the row label is the whole title.
CHECK(compose_display_label(L"Reverse on even", L"Reverse on even", false) == L"Reverse on even");
// No recorded row label falls back to the field leaf.
CHECK(compose_display_label(L"", L"Outer wall", false) == L"Outer wall");
// Multi-option row: qualify with the leaf so the plate-temperature fields are distinct.
CHECK(compose_display_label(L"Cool Plate", L"First layer", true) == wxString(L"Cool Plate \u2013 First layer"));
CHECK(compose_display_label(L"Cool Plate", L"Other layers", true) == wxString(L"Cool Plate \u2013 Other layers"));
// A leaf equal to the row label is not repeated.
CHECK(compose_display_label(L"Skirt loops", L"Skirt loops", true) == L"Skirt loops");
using Slic3r::Search::resolve_setting_title;
// A single-option row's live label wins, so a runtime rename is reflected.
CHECK(resolve_setting_title(L"Brim width", L"Brim ear radius", false) == L"Brim ear radius");
// Multi-option rows keep their precomposed "row field" label (the leaf disambiguates them).
CHECK(resolve_setting_title(L"Cool Plate \u2013 First layer", L"Cool Plate", true) ==
wxString(L"Cool Plate \u2013 First layer"));
// No live row label (option not on a built page) keeps the precomposed label.
CHECK(resolve_setting_title(L"Reverse on even", L"", false) == L"Reverse on even");
// Neither present: empty, so the caller falls back to the descriptive label.
CHECK(resolve_setting_title(L"", L"", false).IsEmpty());
}
// A setting whose mode is above the user's current mode must be prompted before it can be edited.
// Developer settings (comDevelop) are above every non-developer mode, so they always prompt then.
TEST_CASE("Settings above the current mode require a switch", "[ActionSource][SpeedDial]")