diff --git a/resources/web/dialog/SpeedDial/speeddial.js b/resources/web/dialog/SpeedDial/speeddial.js index 41e180a3cd..f63e831a9e 100644 --- a/resources/web/dialog/SpeedDial/speeddial.js +++ b/resources/web/dialog/SpeedDial/speeddial.js @@ -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++) { diff --git a/resources/web/dialog/SpeedDial/speeddial.test.js b/resources/web/dialog/SpeedDial/speeddial.test.js index ea9517cfa6..33d7d0e17f 100644 --- a/resources/web/dialog/SpeedDial/speeddial.test.js +++ b/resources/web/dialog/SpeedDial/speeddial.test.js @@ -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 diff --git a/src/slic3r/GUI/ActionRegistry.cpp b/src/slic3r/GUI/ActionRegistry.cpp index 1687e28258..880c8e1fba 100644 --- a/src/slic3r/GUI/ActionRegistry.cpp +++ b/src/slic3r/GUI/ActionRegistry.cpp @@ -561,10 +561,24 @@ void ActionRegistry::materialize_setting_actions() std::unordered_set 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(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(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"}, diff --git a/src/slic3r/GUI/ActionRegistry.hpp b/src/slic3r/GUI/ActionRegistry.hpp index 045de70915..b192a8ccb2 100644 --- a/src/slic3r/GUI/ActionRegistry.hpp +++ b/src/slic3r/GUI/ActionRegistry.hpp @@ -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; diff --git a/src/slic3r/GUI/OptionsGroup.cpp b/src/slic3r/GUI/OptionsGroup.cpp index d4f3f1d62f..84e6fc43c3 100644 --- a/src/slic3r/GUI/OptionsGroup.cpp +++ b/src/slic3r/GUI/OptionsGroup.cpp @@ -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(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(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; diff --git a/src/slic3r/GUI/SettingsIndex.cpp b/src/slic3r/GUI/SettingsIndex.cpp index 6b0a01893d..994ef496d6 100644 --- a/src/slic3r/GUI/SettingsIndex.cpp +++ b/src/slic3r/GUI/SettingsIndex.cpp @@ -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 diff --git a/src/slic3r/GUI/SettingsIndex.hpp b/src/slic3r/GUI/SettingsIndex.hpp index 5a2f91fa51..a31d2109e0 100644 --- a/src/slic3r/GUI/SettingsIndex.hpp +++ b/src/slic3r/GUI/SettingsIndex.hpp @@ -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