Back to spacebar for speed dial shortcut. Integrated inline process settings editing. Optimized saerch results

This commit is contained in:
Lam Wei Lun
2026-09-08 16:46:56 +08:00
parent b32f28e712
commit 2dce0ad24f
12 changed files with 1833 additions and 338 deletions
+597 -90
View File
@@ -16,18 +16,27 @@
#include <libslic3r/AppConfig.hpp>
#include <libslic3r/Config.hpp>
#include <libslic3r/PresetBundle.hpp>
#include <libslic3r/Utils.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <wx/thread.h>
#include <boost/algorithm/string/predicate.hpp>
#include <boost/any.hpp>
#include <boost/algorithm/string/trim.hpp>
#include <boost/filesystem.hpp>
#include <boost/nowide/convert.hpp>
#include <algorithm>
#include <cfloat>
#include <cmath>
#include <cstdlib>
#include <ctime>
#include <fstream>
#include <iterator>
#include <string>
#include <unordered_map>
#include <unordered_set>
namespace Slic3r { namespace GUI {
@@ -131,6 +140,60 @@ std::unique_ptr<AppAction> make_action(const std::string& plugin_key, const std:
constexpr const char* kCommandPrefix = "orca_command";
constexpr const char* kOrcaSourceKey = "orca";
constexpr const char* kOrcaSourceName = "OrcaSlicer";
constexpr const char* kSettingPrefix = "orca_setting";
// Display context for a setting action's eyebrow, e.g. the "Process" in "Process : Quality : Layers".
// Keyed by the option's preset type so the palette reads like the settings sidebar tabs.
std::string setting_type_context(Preset::Type type)
{
switch (type) {
case Preset::TYPE_FILAMENT:
case Preset::TYPE_SLA_MATERIAL: return _u8L("Filament");
case Preset::TYPE_PRINTER: return _u8L("Printer");
case Preset::TYPE_PRINT:
case Preset::TYPE_SLA_PRINT:
default: return _u8L("Process");
}
}
// A config setting exposed as a first-class action: selecting it jumps the sidebar to the option.
// The id is keyed by opt_key+type (NOT the display label), so renaming/localizing never re-keys
// the action; title/group/source are purely for display + search. run() performs the jump, and
// the generic registry run() bumps stats so a jump shows up in "recents" like any other action.
struct SettingAction : AppAction
{
std::string opt_key;
Preset::Type type;
std::wstring category; // localized category, forwarded to jump_to_option
static std::string id_for(const std::string& opt_key, Preset::Type type)
{ return std::string(kSettingPrefix) + ":" + opt_key + ":" + std::to_string(int(type)); }
SettingAction(std::string opt_key_in, Preset::Type type_in, std::string title, std::string group,
std::wstring category_in, std::string source_name)
: AppAction(AppActionId{id_for(opt_key_in, type_in)}, std::move(title), kOrcaSourceKey, std::move(source_name))
, opt_key(std::move(opt_key_in))
, type(type_in)
, category(std::move(category_in))
{
// A setting is a two-phase command: activating it opens the inline editor (the "setting"
// phase) instead of running. Native run() is a no-op fallback; the editor applies through
// apply_setting().
this->kind = AppActionKind::Command;
this->group = std::move(group);
this->input = "setting";
}
AppActionRunResult run(const std::string& /*param*/) const override
{
// Two-phase: the palette collects the edit; native run() is a no-op fallback.
return {AppActionRunResult::Level::Success};
}
// The current value's pattern pictogram (e.g. the selected infill pattern), for the search-result
// tile. Defined below after the icon helper it delegates to.
std::string icon() const override;
};
// Jump the preview to a layer selected by a 0-100 percent of the layer range. Best-effort:
// switches to the preview tab and requests a slice (select_view_3D("Preview", false)); if the
@@ -219,9 +282,9 @@ AppActionRunResult run_native_command(const std::string& command_key, const std:
}
return {AppActionRunResult::Level::Success};
}
// "go_to_setting"/"go_to_tab" are two-phase: the palette collects the option after
// activating it, so dispatch here is a no-op (the actual jump goes through the web command).
if (command_key == "go_to_setting" || command_key == "go_to_tab")
// "go_to_tab" is two-phase: the palette collects the tab after activating it, so native
// dispatch here is a no-op (the jump goes through the go_to_tab web command).
if (command_key == "go_to_tab")
return {AppActionRunResult::Level::Success};
return {AppActionRunResult::Level::Info, _L("Unknown command.")};
}
@@ -253,10 +316,9 @@ std::vector<std::unique_ptr<AppAction>> native_commands()
// why: _u8L (std::string) for titles/groups - make_command takes std::string; _L would
// return a wxString and silently fail to convert here.
out.push_back(make_command("slice_and_preview", _u8L("Slice and Preview"), _u8L("Commands")));
// Two-phase commands: activating them collects input in the palette, then runs.
// Two-phase commands: activating them collects input in the palette, then runs. Settings are
// not a command here - they're materialised as first-class SettingActions (see materialize_).
out.push_back(make_command("go_to_layer", _u8L("Go to layer (percent)"), _u8L("Commands"), "percent"));
// The "…" is avoided in the msgid: use ASCII "..." to keep the .pot extraction simple.
out.push_back(make_command("go_to_setting", _u8L("Go to setting..."), _u8L("Commands"), "settings"));
out.push_back(make_command("go_to_tab", _u8L("Go to tab..."), _u8L("Commands"), "tab"));
out.push_back(make_command("load_project", _u8L("Load Project"), _u8L("Commands")));
out.push_back(make_command("save_project", _u8L("Save Project"), _u8L("Commands")));
@@ -268,19 +330,305 @@ std::vector<std::unique_ptr<AppAction>> native_commands()
return out;
}
// Replicates Sidebar's get_search_inputs(): the configs of every tab supporting the current
// printer technology, in the current UI mode.
std::vector<Search::InputInfo> settings_inputs()
// ---- inline setting editor helpers ------------------------------------------
// The inline editor "control" kind for an option, or "" when it can't be edited inline
// (plugin-backed values, points, serialized strings, etc.). readonly/legend options are
// filtered out of the search entirely in materialize_setting_actions(), so they never
// reach here.
std::string setting_control(const ConfigOptionDef& def)
{
std::vector<Search::InputInfo> ret;
GUI_App& app = wxGetApp();
if (!app.preset_bundle)
return ret;
auto print_tech = app.preset_bundle->printers.get_selected_preset().printer_technology();
for (Tab* tab : app.tabs_list)
if (tab && tab->supports_printer_technology(print_tech))
ret.emplace_back(Search::InputInfo{tab->get_config(), tab->type(), app.get_mode()});
return ret;
if (def.readonly || def.gui_type == ConfigOptionDef::GUIType::legend ||
def.gui_type == ConfigOptionDef::GUIType::one_string || def.is_plugin_backed())
return "";
// Serialized vectors are entered as ONE semicolon-separated field (e.g. post_process), which the
// per-index editor doesn't model - keep them in the open-in-sidebar bucket.
if (def.gui_flags.find("serialized") != std::string::npos)
return "";
switch (def.gui_type) {
case ConfigOptionDef::GUIType::color: return "color";
case ConfigOptionDef::GUIType::i_enum_open:
case ConfigOptionDef::GUIType::f_enum_open: return "combo";
default: break;
}
switch (def.type) {
case coBool:
case coBools: return "toggle";
case coEnum:
case coEnums: return def.enum_values.empty() ? "combo" : "dropdown";
case coInt:
case coInts:
case coFloat:
case coFloats:
case coPercent:
case coPercents: return "number";
case coFloatOrPercent:
case coFloatsOrPercents: return "percent";
case coString:
case coStrings: return "text";
default: return "";
}
}
// Self-contained base64 encoder (for the tiny pictogram SVGs), avoiding a dependency on the exact
// wxBase64Encode overload/return type across wx versions.
std::string base64_encode(const std::string& data)
{
static const char* tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
auto enc = [&](unsigned n, int pad) {
// pad = number of extraneous bytes in the final group (0, 1 or 2):
// 0 leftover -> 4 chars from all 24 bits
// 2 leftover (pad=1) -> 3 chars then '='
// 1 leftover (pad=2) -> 2 chars then "=="
// The '=' padding always comes LAST; a misplaced '=' decodes as garbage in the webview.
std::string out;
out.push_back(tbl[(n >> 18) & 63]);
out.push_back(tbl[(n >> 12) & 63]);
out.push_back(pad >= 2 ? '=' : tbl[(n >> 6) & 63]);
out.push_back(pad >= 1 ? '=' : tbl[n & 63]);
return out;
};
std::string out;
out.reserve(((data.size() + 2) / 3) * 4);
size_t i = 0;
for (; i + 3 <= data.size(); i += 3)
out += enc(((unsigned char) data[i]) << 16 | ((unsigned char) data[i + 1]) << 8 | ((unsigned char) data[i + 2]), 0);
if (i + 1 == data.size())
out += enc(((unsigned char) data[i]) << 16, 2);
else if (i + 2 == data.size())
out += enc(((unsigned char) data[i]) << 16 | ((unsigned char) data[i + 1]) << 8, 1);
return out;
}
// data:URI for the pattern pictogram icons/param_<key>.svg, or "" when there is no such icon.
// This mirrors the sidebar Choice field (Field.cpp add_item_bitmaps), which loads param_<value>.svg
// per enum value - most settings have no icon, only pattern-style enums (infill/support patterns).
// Base64 data URIs are used so the embedded webview renders them identically on every backend
// (no file:// subresource / CORS restrictions).
std::string setting_icon_for_key(const std::string& key)
{
if (key.empty())
return {};
const std::string path = (boost::filesystem::path(resources_dir()) / "images" / ("param_" + key + ".svg")).string();
// Non-throwing stat: a throwing filesystem_error here would propagate out of snapshot() and
// abort the app (the palette opener). exists(fs ::error_code) never throws.
boost::system::error_code ec;
if (!boost::filesystem::exists(path, ec))
return {};
std::ifstream in(path, std::ios::binary);
if (!in)
return {};
std::string data((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
if (data.empty())
return {};
return "data:image/svg+xml;base64," + base64_encode(data);
}
// [{value,key,label,icon}...] for an enum/combo. Ordered by enum_values when present, else by the
// keys_map iteration. label falls back to the key when enum_labels doesn't provide one. `icon` is
// the value's pattern pictogram when one exists, empty otherwise.
nlohmann::json setting_enum_options(const ConfigOptionDef& def)
{
nlohmann::json out = nlohmann::json::array();
auto label_at = [&](size_t i, const std::string& key) -> std::string {
return (i < def.enum_labels.size() && !def.enum_labels[i].empty()) ? def.enum_labels[i] : key;
};
if (def.enum_keys_map != nullptr) {
std::vector<std::string> ordered;
if (!def.enum_values.empty())
ordered = def.enum_values;
else
for (const auto& kv : *def.enum_keys_map)
ordered.push_back(kv.first);
for (size_t i = 0; i < ordered.size(); ++i) {
auto it = def.enum_keys_map->find(ordered[i]);
if (it == def.enum_keys_map->end())
continue;
out.push_back({{"value", it->second},
{"key", ordered[i]},
{"label", label_at(i, ordered[i])},
{"icon", setting_icon_for_key(ordered[i])}});
}
} else {
for (size_t i = 0; i < def.enum_values.size(); ++i)
out.push_back({{"value", (long long) i},
{"key", def.enum_values[i]},
{"label", label_at(i, def.enum_values[i])},
{"icon", setting_icon_for_key(def.enum_values[i])}});
}
return out;
}
// The pattern pictogram for a setting's CURRENT value (its enum int), empty when it isn't a
// pattern-style enum or the value has no icon. Used for the search-result tile.
std::string setting_action_icon(const SettingAction& a)
{
Tab* tab = wxGetApp().get_tab(a.type);
if (!tab || !tab->get_config())
return {};
DynamicPrintConfig* config = tab->get_config();
const ConfigOptionDef* def = config->def()->get(a.opt_key);
if (!def || def->type != coEnum || (int(def->type) & int(coVectorType)) != 0)
return {};
// Read the value WITHOUT config->opt_int(): the non-const overload routes through a type-checked
// option<ConfigOptionInt>() that returns null for enum values (type() is coEnum, not coInt) and
// would deref null. Pull the ConfigOption* and dynamic_cast instead (succeeds: enums derive from
// ConfigOptionInt), falling back to the def default when the option is absent.
const ConfigOption* opt = (config->has(a.opt_key) ? config->option(a.opt_key) : def->default_value.get());
const ConfigOptionInt* int_opt = dynamic_cast<const ConfigOptionInt*>(opt);
if (!int_opt)
return {};
const int value = int_opt->getInt();
if (def->enum_keys_map)
for (const auto& kv : *def->enum_keys_map)
if (kv.second == value)
return setting_icon_for_key(kv.first);
return {};
}
std::string SettingAction::icon() const { return setting_action_icon(*this); }
// Current value of the option at vector index `idx` as JSON (bool/number/string), or null for a
// type the inline editor doesn't render. `config` is the tab's live config; when an option is
// absent the def's default is shown.
nlohmann::json setting_value_json(const DynamicPrintConfig& config, const ConfigOptionDef& def, size_t idx)
{
const ConfigOption* opt = config.option(def.opt_key);
const ConfigOption* root = opt ? opt : def.default_value.get();
if (!root)
return nullptr;
switch (def.type) {
case coBool: return root->getBool();
case coInt: return root->getInt();
case coFloat: return root->getFloat();
case coPercent: return root->getFloat();
case coString: return static_cast<const ConfigOptionString*>(root)->value;
case coEnum: return root->getInt();
case coBools: {
if (auto v = dynamic_cast<const ConfigOptionBools*>(root))
return bool(v->get_at(idx));
if (auto v = dynamic_cast<const ConfigOptionBoolsNullable*>(root))
return bool(v->get_at(idx) != 0);
return nullptr;
}
case coInts: {
if (auto v = dynamic_cast<const ConfigOptionInts*>(root))
return v->get_at(idx);
if (auto v = dynamic_cast<const ConfigOptionIntsNullable*>(root)) {
const int nil = ConfigOptionIntsNullable::nil_value();
int val = v->get_at(idx);
return val == nil ? nlohmann::json(nullptr) : nlohmann::json(val);
}
return nullptr;
}
case coFloats: {
if (auto v = dynamic_cast<const ConfigOptionFloats*>(root))
return v->get_at(idx);
if (auto v = dynamic_cast<const ConfigOptionFloatsNullable*>(root)) {
double val = v->get_at(idx);
return std::isnan(val) ? nlohmann::json(nullptr) : nlohmann::json(val);
}
return nullptr;
}
case coPercents: {
if (auto v = dynamic_cast<const ConfigOptionPercents*>(root))
return v->get_at(idx);
if (auto v = dynamic_cast<const ConfigOptionPercentsNullable*>(root)) {
double val = v->get_at(idx);
return std::isnan(val) ? nlohmann::json(nullptr) : nlohmann::json(val);
}
return nullptr;
}
case coStrings: return static_cast<const ConfigOptionStrings*>(root)->get_at(idx);
case coEnums: {
if (auto v = dynamic_cast<const ConfigOptionEnumsGeneric*>(root))
return v->get_at(idx);
if (auto v = dynamic_cast<const ConfigOptionEnumsGenericNullable*>(root)) {
const int nil = ConfigOptionEnumsGenericNullable::nil_value();
int val = v->get_at(idx);
return val == nil ? nlohmann::json(nullptr) : nlohmann::json(val);
}
return nullptr;
}
case coFloatOrPercent: return root->serialize();
case coFloatsOrPercents: {
if (auto v = dynamic_cast<const ConfigOptionFloatsOrPercents*>(root)) {
auto ss = v->vserialize();
return idx < ss.size() ? ss[idx] : nullptr;
}
if (auto v = dynamic_cast<const ConfigOptionFloatsOrPercentsNullable*>(root)) {
auto ss = v->vserialize();
return idx < ss.size() ? ss[idx] : nullptr;
}
return nullptr;
}
default: return nullptr;
}
}
// How many scalar values the option currently has (1 for scalars, the array length for vectors).
size_t setting_value_count(const DynamicPrintConfig& config, const ConfigOptionDef& def)
{
if ((int(def.type) & int(coVectorType)) == 0)
return 1;
// size() lives on ConfigOptionVectorBase, not ConfigOption - dynamic_cast to it covers every
// vector type (and their nullable variants) polymorphically.
const ConfigOption* opt = config.option(def.opt_key);
if (opt)
if (auto v = dynamic_cast<const ConfigOptionVectorBase*>(opt))
return v->size();
if (def.default_value)
if (auto v = dynamic_cast<const ConfigOptionVectorBase*>(def.default_value.get()))
return v->size();
return 1;
}
// boost::any for a single element, matching what Slic3r::GUI::change_opt_value expects.
boost::any setting_any_from_json(const ConfigOptionDef& def, const nlohmann::json& v)
{
if (!v.is_null()) {
switch (def.type) {
case coBool: return boost::any(v.is_boolean() ? v.get<bool>() : v.get<int>() != 0);
case coInt: return boost::any(v.get<int>());
case coFloat:
case coPercent: return boost::any(v.get<double>());
case coString: return boost::any(v.get<std::string>());
case coEnum: return boost::any(v.get<int>());
case coBools: return boost::any(static_cast<unsigned char>(v.get<bool>() ? 1 : 0));
case coInts: return boost::any(v.get<int>());
case coFloats:
case coPercents: return boost::any(v.get<double>());
case coStrings: return boost::any(v.get<std::string>());
case coFloatOrPercent:
case coFloatsOrPercents: {
// change_opt_value detects "percent" via a trailing '%', so trim whitespace first or a
// stray space (e.g. "10% ") would be misread as mm.
std::string s = v.is_string() ? v.get<std::string>() : std::to_string(v.get<double>());
boost::trim(s);
// An empty string would make change_opt_value's str.back() UB - bail out to a rejected apply.
return s.empty() ? boost::any() : boost::any(s);
}
case coEnums: return boost::any(v.get<int>());
default: break;
}
}
// Coerce numeric types that may arrive as a different JSON numeric type.
if (v.is_number()) {
switch (def.type) {
case coInt:
case coEnums: return boost::any(v.get<int>());
case coFloat:
case coPercent:
case coInts:
case coFloats:
case coPercents: return boost::any(v.get<double>());
default: break;
}
}
return boost::any();
}
} // namespace
@@ -411,7 +759,9 @@ void ActionRegistry::remove(const std::string& id)
void ActionRegistry::seed_state(AppAction& a) const
{
auto favs = read_string_array("favourite_actions");
// Favourites carry the quick-launch order, so the persisted list is the source of truth
// (not re-derived from the frecency sort). Cap it so stale configs can't exceed kFavLimit.
auto favs = favourite_ids();
a.favourite = std::find(favs.begin(), favs.end(), a.id()) != favs.end();
nlohmann::json stats = read_section("stats", nlohmann::json::object());
@@ -469,18 +819,40 @@ AppActionRunResult ActionRegistry::run(const std::string& id, const std::string&
return o;
}
void ActionRegistry::set_favourite(const std::string& id, bool on)
bool ActionRegistry::set_favourite(const std::string& id, bool on)
{
assert(wxThread::IsMain());
auto favs = read_string_array("favourite_actions");
// Start from the capped, deduped list so a persisted config can never be written back larger.
auto favs = favourite_ids();
auto it = std::find(favs.begin(), favs.end(), id);
if (on && it == favs.end())
if (on && it == favs.end()) {
if (favs.size() >= kFavLimit)
return false; // bar is full - the caller surfaces a hint
favs.push_back(id);
}
if (!on && it != favs.end())
favs.erase(it);
write_section("favourite_actions", nlohmann::json(favs));
if (AppAction* live = find(id))
live->favourite = on;
return true;
}
std::vector<std::string> ActionRegistry::favourite_ids() const
{
assert(wxThread::IsMain());
// Enforce the cap + dedupe on read so the persisted order can never grow past kFavLimit,
// even from an older config. The pinned order is intentionally preserved (slice, not sort).
std::vector<std::string> favs = read_string_array("favourite_actions");
std::vector<std::string> out;
out.reserve(std::min(favs.size(), kFavLimit));
for (const auto& id : favs) {
if (out.size() >= kFavLimit)
break;
if (std::find(out.begin(), out.end(), id) == out.end())
out.push_back(id);
}
return out;
}
void ActionRegistry::reorder_favourites(const std::vector<std::string>& ids)
@@ -496,9 +868,78 @@ void ActionRegistry::reorder_favourites(const std::vector<std::string>& ids)
for (const auto& id : cur)
if (std::find(next.begin(), next.end(), id) == next.end())
next.push_back(id);
// never write the bar back larger than the quick-launch slots
if (next.size() > kFavLimit)
next.resize(kFavLimit);
write_section("favourite_actions", nlohmann::json(next));
}
void ActionRegistry::materialize_setting_actions()
{
assert(wxThread::IsMain());
// Reuse the Sidebar's live searcher: it's the only OptionsSearcher whose groups_and_categories
// map is populated (Tab::add_key feeds it at build time), and it already mirrors the current
// configs/mode/printer-technology - i.e. exactly what the sidebar's own search would show. A
// fresh OptionsSearcher has an empty groups_and_categories, so append_options() would drop every
// option and nothing would materialise. Turn each visible option into a SettingAction.
const std::vector<Search::Option>& options = wxGetApp().sidebar().get_searcher().all_options();
// Load the persisted per-action state ONCE (not per-option) so a re-materialised setting keeps
// its recency/favourite; mirroring seed_state but amortised over the whole option set.
nlohmann::json stats = read_section("stats", nlohmann::json::object());
if (!stats.is_object())
stats = nlohmann::json::object();
const std::vector<std::string> favs = favourite_ids();
std::unordered_set<std::string> seen;
for (const Search::Option& opt : options) {
// Omit rows the inline editor can't represent and that aren't useful as a jump target:
// readonly (e.g. the detected thread count) and legend (static text) GUI rows. They remain
// in the sidebar's own search; only the Speed Dial pool drops them.
Tab* tab = wxGetApp().get_tab(opt.type);
if (tab && tab->get_config()) {
const ConfigOptionDef* def = tab->get_config()->def()->get(opt.opt_key());
if (!def || def->readonly || def->gui_type == ConfigOptionDef::GUIType::legend)
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;
// 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
// source both displays it and makes it matchable by any segment (e.g. a "quality" query).
std::wstring path = boost::nowide::widen(setting_type_context(opt.type));
if (!opt.category_local.empty())
path += L" : " + opt.category_local;
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_local, boost::nowide::narrow(path));
action->favourite = std::find(favs.begin(), favs.end(), id) != favs.end();
if (auto it = stats.find(id); it != stats.end() && it->is_object()) {
action->count = it->value("count", 0);
action->last = it->value("last", 0LL);
}
m_actions.insert_or_assign(action->id(), std::shared_ptr<AppAction>(std::move(action)));
}
// Drop SettingActions whose option no longer exists in the current configs (e.g. the printer
// technology / UI mode changed). Non-setting actions are untouched.
for (auto it = m_actions.begin(); it != m_actions.end();) {
if (it->first.rfind(kSettingPrefix, 0) == 0 && !seen.count(it->first))
it = m_actions.erase(it);
else
++it;
}
}
bool ActionRegistry::should_ask(const std::string& id) const
{
assert(wxThread::IsMain());
@@ -517,9 +958,13 @@ void ActionRegistry::suppress_ask(const std::string& id)
// ---- snapshot ---------------------------------------------------------------
nlohmann::json ActionRegistry::snapshot() const
nlohmann::json ActionRegistry::snapshot()
{
assert(wxThread::IsMain());
// Settings are first-class actions; make sure the current visible option set is materialised
// before we serialise the pool (tabs_list is built by the time the palette opens).
materialize_setting_actions();
std::vector<const AppAction*> sorted;
sorted.reserve(m_actions.size());
for (const auto& entry : m_actions)
@@ -544,7 +989,8 @@ nlohmann::json ActionRegistry::snapshot() const
{"source", a->source_name()},
{"group", a->group},
{"input", a->input},
{"shortcut", ""}});
{"shortcut", ""},
{"icon", a->icon()}});
};
nlohmann::json actions = nlohmann::json::array();
@@ -554,7 +1000,8 @@ nlohmann::json ActionRegistry::snapshot() const
// why: favourites is the ORDERED pin list - it must come from favourite_actions
// as stored, not be re-derived from the frecency-sorted actions (that would
// reorder the favourites bar). The page (js) filters out ids with no live action itself.
nlohmann::json favourites(read_string_array("favourite_actions"));
// Cap on read so the bar cannot exceed the quick-launch slots (kFavLimit).
nlohmann::json favourites(favourite_ids());
// Recent = the last-N launched actions by recency (only actions with a run history).
constexpr size_t kRecentLimit = 5;
@@ -576,49 +1023,6 @@ nlohmann::json ActionRegistry::snapshot() const
return {{"actions", std::move(actions)}, {"favourites", std::move(favourites)}, {"recent", std::move(recent_json)}};
}
nlohmann::json ActionRegistry::settings_search(const std::string& query)
{
assert(wxThread::IsMain());
std::string q = boost::trim_copy(query);
// Empty query: show the recently-jumped-to settings instead of a blank list.
if (q.empty())
return settings_recent();
// Use the sidebar's live searcher. It is the instance Tab registration (add_key) populates
// with each option's group/category, and it carries the current printer technology. A fresh
// OptionsSearcher has an empty groups_and_categories, so init()/append_options() drops every
// option and the search returns nothing.
Search::OptionsSearcher& searcher = wxGetApp().sidebar().get_searcher();
searcher.init(settings_inputs());
searcher.search(q, true);
auto& found = searcher.found_options();
constexpr size_t kLimit = 20;
const size_t n = std::min<size_t>(kLimit, found.size());
nlohmann::json out = nlohmann::json::array();
for (size_t i = 0; i < n; ++i) {
const auto& opt = searcher.get_option(i);
// Clean plain label "category : group : label" - OptionsSearcher's own label string
// carries ImGui icon control chars + <b>/</b> markup (SUPPORTS_MARKUP), which render
// as garbage in the webview. Build it from the Option's localized strings instead.
std::wstring plain;
const std::wstring* prev = nullptr;
for (const std::wstring* const s : {&opt.category_local, &opt.group_local, &opt.label_local})
if (s != nullptr && !s->empty() && (prev == nullptr || *prev != *s)) {
if (!plain.empty())
plain += L" : ";
plain += *s;
prev = s;
}
out.push_back({{"opt_key", opt.opt_key()},
{"type", int(opt.type)},
{"label", boost::nowide::narrow(plain)},
{"category", boost::nowide::narrow(opt.category)},
{"group", boost::nowide::narrow(opt.group)}});
}
return out;
}
// ---- tab options (enumerate the MainFrame notebook's current pages) ----------
nlohmann::json ActionRegistry::tab_options() const
@@ -640,37 +1044,140 @@ nlohmann::json ActionRegistry::tab_options() const
return out;
}
// ---- settings recents (persisted, most-recent-first, capped at 8) -----------
// ---- inline setting editor (read the current value) --------------------------
nlohmann::json ActionRegistry::settings_recent() const
nlohmann::json ActionRegistry::setting_descriptor(const std::string& id) const
{
assert(wxThread::IsMain());
return read_section("recent_settings", nlohmann::json::array());
const AppAction* a = by_id(id);
const SettingAction* sa = dynamic_cast<const SettingAction*>(a);
if (!sa)
return nlohmann::json::object();
Tab* tab = wxGetApp().get_tab(sa->type);
if (!tab)
return nlohmann::json::object();
DynamicPrintConfig* config = tab->get_config();
if (!config)
return nlohmann::json::object();
const ConfigOptionDef* def = config->def()->get(sa->opt_key);
if (!def)
return nlohmann::json::object();
const std::string control = setting_control(*def);
const bool vector = (int(def->type) & int(coVectorType)) != 0;
nlohmann::json d = {{"id", sa->id()},
{"opt_key", sa->opt_key},
{"type", int(sa->type)},
{"title", a->title()},
{"breadcrumb", a->source_name()},
{"category", boost::nowide::narrow(sa->category)},
{"unit", def->sidetext},
{"tooltip", def->tooltip},
{"editable", !control.empty()},
{"control", control},
{"cardinality", vector ? "vector" : "scalar"}};
if (!control.empty()) {
// Hide unbounded min/max so the page doesn't clamp a sane value to ±FLT_MAX.
if (def->min > -FLT_MAX)
d["min"] = def->min;
if (def->max < FLT_MAX)
d["max"] = def->max;
if (control == "number")
d["is_int"] = (def->type == coInt || def->type == coInts);
if (control == "dropdown" || control == "combo")
d["enum_options"] = setting_enum_options(*def);
if (vector) {
nlohmann::json values = nlohmann::json::array();
nlohmann::json labels = nlohmann::json::array();
const size_t n = setting_value_count(*config, *def);
for (size_t i = 0; i < n; ++i) {
values.push_back(setting_value_json(*config, *def, i));
labels.push_back(std::to_string(i + 1));
}
d["values"] = std::move(values);
d["index_labels"] = std::move(labels);
} else {
d["value"] = setting_value_json(*config, *def, 0);
}
}
return d;
}
void ActionRegistry::record_setting_recent(
const std::string& opt_key, int type, const std::string& label, const std::string& category, const std::string& group)
// ---- inline setting editor (write the edited value back) ---------------------
bool ActionRegistry::apply_setting(const std::string& id, const nlohmann::json& value)
{
assert(wxThread::IsMain());
if (opt_key.empty())
return;
const AppAction* a = by_id(id);
const SettingAction* sa = dynamic_cast<const SettingAction*>(a);
if (!sa)
return false;
Tab* tab = wxGetApp().get_tab(sa->type);
if (!tab)
return false;
DynamicPrintConfig* config = tab->get_config();
if (!config)
return false;
const ConfigOptionDef* def = config->def()->get(sa->opt_key);
if (!def)
return false;
const std::string control = setting_control(*def);
if (control.empty())
return false;
constexpr size_t kLimit = 8;
auto arr = read_section("recent_settings", nlohmann::json::array());
if (!arr.is_array())
arr = nlohmann::json::array();
auto same = [&](const nlohmann::json& e) {
return e.is_object() && e.value("opt_key", std::string()) == opt_key && e.value("type", int(-1)) == type;
};
const bool vector = (int(def->type) & int(coVectorType)) != 0;
const size_t n = vector ? (value.is_array() ? value.size() : 0) : 1;
if (vector && n == 0)
return false;
nlohmann::json next = nlohmann::json::array();
next.push_back({{"opt_key", opt_key}, {"type", type}, {"label", label}, {"category", category}, {"group", group}});
for (const auto& e : arr)
if (!same(e))
next.push_back(e);
if (next.size() > kLimit)
next.erase(next.begin() + long(kLimit), next.end());
write_section("recent_settings", next);
for (size_t i = 0; i < n; ++i) {
const nlohmann::json& elem = vector ? value[i] : value;
boost::any any = setting_any_from_json(*def, elem);
if (any.empty())
return false;
if (control == "number" && elem.is_number()) {
const double d = elem.get<double>();
if (d < def->min || d > def->max)
return false;
}
if (control == "percent" && elem.is_string()) {
// "mm or %" value: strip a trailing %/whitespace, clamp the numeric part to [min,max].
// Reject anything that isn't a well-formed number (which change_opt_value would throw on).
std::string s = elem.get<std::string>();
boost::trim(s);
if (!s.empty() && s.back() == '%')
s.pop_back();
boost::trim(s);
if (s.empty())
return false;
char* end = nullptr;
const double d = std::strtod(s.c_str(), &end);
if (end == s.c_str() || *end != '\0')
return false;
if (d < def->min || d > def->max)
return false;
}
Slic3r::GUI::change_opt_value(*config, sa->opt_key, any, int(i));
}
// Mark the preset modified like a sidebar edit. Scalar options also get the standard
// post-change hook so dependent settings refresh; vector options have no unambiguous scalar
// value to pass, so on_value_change is skipped (the config write + dirty flag is still correct).
tab->update_dirty();
if (!vector) {
boost::any any = setting_any_from_json(*def, value);
if (!any.empty())
tab->on_value_change(sa->opt_key, any);
}
// The config write is separate from the on-screen Field, so repaint the field(s) that display
// this option (on whatever page they live, not just the active page) - otherwise the sidebar
// shows the "modified" arrow but keeps the stale value pushed to the last edit/reload.
if (Page* page = nullptr; tab->get_field(sa->opt_key, &page) && page)
page->reload_config();
return true;
}
}} // namespace Slic3r::GUI
+48 -18
View File
@@ -6,6 +6,7 @@
#include <wx/thread.h>
#include <cassert>
#include <cstddef>
#include <memory>
#include <string>
#include <string_view>
@@ -31,6 +32,14 @@ struct AppActionRunResult
wxString message; // empty = "nothing worth showing"
};
// Tag carrying a precomputed action id, used by the explicit-id ctor below. It exists so the
// id ctor and the compose-from-prefix ctor are NOT both reachable from a `const char*` first
// argument (which would make calls like AppAction("orca_command", ...) ambiguous).
struct AppActionId
{
std::string id;
};
// A speed-dial action: identity + user-state seeded from config + how to run itself.
// Abstract base - the only virtual is run(); concrete subclasses know how to run
// and what their source is.
@@ -72,6 +81,11 @@ struct AppAction
// commands (e.g. a layer percentage); plugins ignore it.
virtual AppActionRunResult run(const std::string& param = {}) const = 0;
// Optional data:URI for a small pictogram to show in the palette row/tile/the editor
// (e.g. the current infill/pattern). Empty string = fall back to the monogram. Only
// SettingAction overrides this; the base returns an empty string.
virtual std::string icon() const { return {}; }
protected:
// The definition is constructor-set and immutable. Refreshes replace an action
// instead of mutating identity after the registry has indexed it by id.
@@ -83,6 +97,14 @@ protected:
m_source_key(std::move(source_key)),
m_source_name(std::move(source_name)) {}
// Explicit-id ctor: for actions whose id must NOT be derived from the display title
// (e.g. a setting action keyed by opt_key+type, so a rename/localization never re-keys it).
AppAction(AppActionId id, std::string title, std::string source_key, std::string source_name)
: m_id(std::move(id.id)),
m_title(std::move(title)),
m_source_key(std::move(source_key)),
m_source_name(std::move(source_name)) {}
private:
std::string m_id; // <prefix>:<title>:<source_key> - stable identity + AppConfig key
std::string m_title; // display name
@@ -122,34 +144,27 @@ public:
// Always-clean read surface. UI thread only.
const AppAction* by_id(const std::string& id) const;
// Hard cap on the favourites bar: the numbered quick-launch slots (Alt/Option+1..9, 0).
static constexpr size_t kFavLimit = 10;
// Dispatch + write-through (registry is the only thing that touches AppConfig).
AppActionRunResult run(const std::string& id, const std::string& param = {}); // runs + bumps stats
void set_favourite(const std::string& id, bool on);
// Pin/unpin. Returns false when `on` would exceed kFavLimit (the bar is full) so the
// caller can surface a "favourites are full" hint instead of silently dropping the pin.
bool set_favourite(const std::string& id, bool on);
void reorder_favourites(const std::vector<std::string>& ids); // persist a new bar order
// Ordered pinned list (the source of truth), capped at kFavLimit and deduped, matching the
// visible bar the palette renders.
std::vector<std::string> favourite_ids() const;
// Run-confirm gate, keyed by action id (per-action "don't ask again").
bool should_ask(const std::string& id) const;
void suppress_ask(const std::string& id);
// Flat, frecency-sorted snapshot for the webview:
// {actions:[...], favourites:[...], recent:[...]} (recent = last-N launched by recency).
nlohmann::json snapshot() const;
// "Go to setting..." Speed Dial helper: query the current print/filament/printer
// config options via the sidebar's live OptionsSearcher (the instance Tab registration
// populates with group/category, and which carries the current printer technology) and
// return the top matches as JSON. The searcher is re-seeded from the current configs +
// user mode on every call so the result always reflects what the sidebar's own search
// would show. An empty/whitespace query returns the recent settings list (below), and the
// page shows a "type to search" hint when there are no recents.
nlohmann::json settings_search(const std::string& query);
// Recently-jumped-to settings, persisted (most-recent-first, capped at 8). Returns the
// stored JSON array [{opt_key,type,label,category,group},...]; record_setting_recent()
// prepends an entry (deduped by opt_key+type) and re-persists.
nlohmann::json settings_recent() const;
void record_setting_recent(const std::string& opt_key, int type, const std::string& label,
const std::string& category, const std::string& group);
nlohmann::json snapshot();
// "Go to tab..." Speed Dial helper: enumerate the MainFrame notebook's current pages
// as [{id,title},...]. Live by construction - built-in tabs (Home/Prepare/Preview/Device/
@@ -158,10 +173,25 @@ public:
// plugins) aren't separate pages and are not listed. Call on the UI thread; null-safe.
nlohmann::json tab_options() const;
// Inline setting editor descriptor for a SettingAction id. Returns the JSON the palette
// renders: {id, opt_key, type, title, breadcrumb, category, group, unit, tooltip, editable,
// control ("toggle|number|dropdown|combo|text|color"), cardinality ("scalar"|"vector"),
// value|values, index_labels[], enum_options[], min|max}. Empty object for a non-setting id.
nlohmann::json setting_descriptor(const std::string& id) const;
// Apply an edit submitted by the palette. `value` is the control's JSON payload (scalar, or an
// array for vector settings). Writes the value(s) into the global preset config and marks the
// preset dirty, exactly like a sidebar edit. Returns false on a bad id/type/value.
bool apply_setting(const std::string& id, const nlohmann::json& value);
private:
void seed_state(AppAction& a) const; // favourite/stats from config
AppAction* find(const std::string& id);
// (Re)materialise the current visible config settings as SettingActions from the live
// searcher (respecting printer-tech + user-mode + visibility filtering), removing stale ones.
// Called at the top of snapshot() so the palette always reflects the current configs.
void materialize_setting_actions();
// Loader callbacks (marshalled to the UI thread) land here. refresh_source rebuilds
// one plugin's whole action set; refresh_capability touches a single capability.
void refresh_source(const std::string& plugin_key, ActionChange change);
+2 -1
View File
@@ -198,7 +198,8 @@ void KBShortcutsDialog::fill_shortcuts()
// Switch table page
{ ctrl + L("Tab"), L("Switch table page")},
// Open speed dial
{ ctrl + "K", L("Open speed dial") },
{ "Space", L("Open speed dial") },
{ alt + "1..9,0", L("Run a Speed Dial favourite") },
//DEL
#ifdef __APPLE__
{"fn+⌫", L("Delete Selected")},
+16 -5
View File
@@ -1,6 +1,7 @@
#include "MainFrame.hpp"
#include <wx/panel.h>
#include <wx/textentry.h>
#include <wx/notebook.h>
#include <wx/listbook.h>
#include <wx/simplebook.h>
@@ -701,8 +702,18 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_
}
return;}
#endif
// Orca: open the speed dial from any page. CmdDown() = Ctrl on Win/Linux, Cmd on macOS.
if (evt.CmdDown() && evt.GetKeyCode() == 'K') { wxGetApp().open_speed_dial(); return; }
// Orca: open the speed dial from any page with a bare Space. Only when no modifier is held (so
// editing shortcuts like Ctrl+Shift+Space in the canvas still reach it) and while no text field
// is focused, so typing a space into the search box or a parameter value isn't hijacked.
if (!evt.CmdDown() && !evt.ShiftDown() && !evt.AltDown() && evt.GetKeyCode() == WXK_SPACE) {
wxWindow* focus = wxWindow::FindFocus();
if (focus && dynamic_cast<wxTextEntryBase*>(focus)) {
evt.Skip(); // typing in a text field - let the space reach it
return;
}
wxGetApp().open_speed_dial();
return;
}
if (evt.CmdDown() && evt.GetKeyCode() == 'R') { if (m_slice_enable) { wxGetApp().plater()->update(true, true); wxPostEvent(m_plater, SimpleEvent(EVT_GLTOOLBAR_SLICE_PLATE)); this->m_tabpanel->SelectPageByName(TAB_ID_PREVIEW); } return; }
if (evt.CmdDown() && evt.ShiftDown() && evt.GetKeyCode() == 'G') {
m_plater->apply_background_progress();
@@ -3350,7 +3361,7 @@ void MainFrame::init_menubar_as_editor()
"", nullptr, []() { return true; }, this, 1);
parent_menu->AppendSeparator();
append_menu_item(
parent_menu, wxID_ANY, _L("Open speed dial...") + sep + ctrl_t + "K", "",
parent_menu, wxID_ANY, _L("Open speed dial...") + sep + "Space", "",
[](wxCommandEvent &) { wxGetApp().open_speed_dial(); },
"", nullptr, []() { return true; }, this);
//parent_menu->Insert(1, preference_item);
@@ -3378,7 +3389,7 @@ void MainFrame::init_menubar_as_editor()
top_menu->AppendSeparator();
append_menu_item(
top_menu, wxID_ANY, _L("Open speed dial...") + "\t" + ctrl + "K", "",
top_menu, wxID_ANY, _L("Open speed dial...") + "\t" + "Space", "",
[](wxCommandEvent &) { wxGetApp().open_speed_dial(); },
"", nullptr, []() { return true; }, this);
top_menu->AppendSeparator();
@@ -3522,7 +3533,7 @@ void MainFrame::init_menubar_as_editor()
// On Mac, the Apple menu ignores non-standard custom items, so add Preset Bundle to the File menu
fileMenu->AppendSeparator();
append_menu_item(
fileMenu, wxID_ANY, _L("Open speed dial...") + sep + ctrl_t + "K", "",
fileMenu, wxID_ANY, _L("Open speed dial...") + sep + "Space", "",
[](wxCommandEvent&) { wxGetApp().open_speed_dial(); },
"", nullptr, []() { return true; }, this);
append_menu_item(
+3 -1
View File
@@ -324,7 +324,9 @@ ParamsPanel::ParamsPanel( wxWindow* parent, wxWindowID id, const wxPoint& pos, c
wxID_ANY,
wxDefaultPosition,
wxDefaultSize,
wxVSCROLL) // hide hori-bar will cause hidden field mis-position
wxVSCROLL // hide hori-bar will cause hidden field mis-position
| wxTAB_TRAVERSAL // Allows for traversal via tab key
)
{
// ShowScrollBar(GetHandle(), SB_BOTH, FALSE);
Bind(wxEVT_SCROLL_CHANGED, [this](auto &e) {
+4
View File
@@ -148,6 +148,10 @@ public:
void show_dialog(Preset::Type type, wxWindow *parent, TextInput *input, wxWindow *ssearch_btn);
void dlg_sys_color_changed();
void dlg_msw_rescale();
// The full gated option set built by init() (after visibility/mode/printer-tech filtering).
// Used by the Speed Dial to materialise config settings as first-class actions.
const std::vector<Option>& all_options() const { return options; }
};
//------------------------------------------
+31 -30
View File
@@ -11,6 +11,8 @@
#include <libslic3r/Preset.hpp>
#include <boost/nowide/convert.hpp>
#include <algorithm>
#include <wx/display.h>
@@ -127,9 +129,14 @@ void SpeedDialWebDialog::handle_web_command(const nlohmann::json& payload)
if (command == "request_actions") {
m_page_ready = true;
send_actions();
} else if (command == "toggle_favourite")
wxGetApp().action_registry().set_favourite(payload.value("id", ""), payload.value("fav", false));
else if (command == "reorder_favourites") {
} else if (command == "toggle_favourite") {
// set_favourite() refuses once the bar hits kFavLimit; tell the page so it can undo the
// star and show a "favourites are full" hint instead of silently losing the pin.
const std::string fav_id = payload.value("id", "");
const bool ok = wxGetApp().action_registry().set_favourite(fav_id, payload.value("fav", false));
if (!ok)
call_web_handler({{"command", "favourite_full"}, {"limit", (int) ActionRegistry::kFavLimit}, {"id", fav_id}});
} else if (command == "reorder_favourites") {
std::vector<std::string> ids;
if (payload.contains("ids") && payload["ids"].is_array())
for (const auto& id : payload["ids"])
@@ -138,21 +145,6 @@ void SpeedDialWebDialog::handle_web_command(const nlohmann::json& payload)
wxGetApp().action_registry().reorder_favourites(ids);
} else if (command == "run_action")
run_action(payload.value("id", ""), payload.value("title", ""), payload.value("param", ""));
else if (command == "go_to_setting") {
// "Go to setting..." second phase: the page hands back the option it matched.
const std::string opt_key = payload.value("opt_key", "");
if (!opt_key.empty()) {
const int type = json_int_or(payload, "type", int(Preset::TYPE_INVALID));
const std::string label = payload.value("label", "");
const std::string group = payload.value("group", "");
const std::string cat = payload.value("category", "");
// Track it in the palette's recent-settings list before jumping (persisted).
wxGetApp().action_registry().record_setting_recent(opt_key, type, label, cat, group);
Hide();
wxGetApp().sidebar().jump_to_option(opt_key, Preset::Type(type), from_u8(cat).ToStdWstring());
}
} else if (command == "search_settings")
search_settings(payload.value("q", ""));
else if (command == "search_tabs")
search_tabs();
else if (command == "go_to_tab") {
@@ -163,6 +155,27 @@ void SpeedDialWebDialog::handle_web_command(const nlohmann::json& payload)
if (wxGetApp().mainframe)
wxGetApp().mainframe->select_tab(from_u8(tab_id));
}
} else if (command == "setting_descriptor") {
// Inline editor: hand the page the descriptor for the setting it's editing.
const std::string id = payload.value("id", "");
call_web_handler(
{{"command", "setting_descriptor"}, {"descriptor", wxGetApp().action_registry().setting_descriptor(id)}});
} else if (command == "set_setting") {
// Inline editor submit. Apply the value; on success close the dialog.
const std::string id = payload.value("id", "");
const nlohmann::json value = payload.contains("value") ? payload["value"] : nlohmann::json(nullptr);
if (id.empty() || !wxGetApp().action_registry().apply_setting(id, value)) {
call_web_handler({{"command", "apply_failed"}, {"id", id}});
return;
}
Hide();
} else if (command == "open_setting_in_sidebar") {
// Non-inline-editable setting (points, plugin-backed, float-or-percent): jump the sidebar.
Hide();
const std::string opt_key = payload.value("opt_key", "");
const std::string category = payload.value("category", "");
if (!opt_key.empty())
wxGetApp().sidebar().jump_to_option(opt_key, Preset::Type(payload.value("type", int(Preset::TYPE_INVALID))), boost::nowide::widen(category));
} else if (command == "resize")
resize_to_content(json_int_or(payload, "height", 0));
}
@@ -179,18 +192,6 @@ void SpeedDialWebDialog::search_tabs()
});
}
void SpeedDialWebDialog::search_settings(const std::string& query)
{
// Round-trip is async because the webview delivers script messages synchronously on the
// GTK/macOS stack; defer the (cheap) search and push the result back to the page.
wxGetApp().CallAfter([this, alive = m_alive, query]() {
if (!alive->load(std::memory_order_acquire))
return;
auto results = wxGetApp().action_registry().settings_search(query);
call_web_handler({{"command", "settings_results"}, {"results", std::move(results)}});
});
}
void SpeedDialWebDialog::resize_to_content(int height)
{
if (height <= 0)
-1
View File
@@ -22,7 +22,6 @@ private:
void resize_to_content(int height);
void run_action(const std::string& id, const std::string& title, const std::string& param = "");
void send_actions();
void search_settings(const std::string& query);
void search_tabs();
bool m_page_ready{false};