Files
OrcaSlicer/src/slic3r/GUI/ActionRegistry.cpp
T

1000 lines
42 KiB
C++

#include "ActionRegistry.hpp"
#include "GUI.hpp"
#include "GUI_App.hpp"
#include "I18N.hpp"
#include "MainFrame.hpp"
#include "NativeCommands.hpp"
#include "Notebook.hpp"
#include "OptionsGroup.hpp"
#include "Plater.hpp"
#include "SettingsIndex.hpp"
#include "Tab.hpp"
#include "slic3r/plugin/PluginManager.hpp"
#include <cassert>
#include <boost/filesystem/operations.hpp>
#include <libslic3r/AppConfig.hpp>
#include <libslic3r/Config.hpp>
#include "slic3r/plugin/PluginDescriptor.hpp"
#include <memory>
#include "libslic3r/Preset.hpp"
#include "slic3r/GUI/PartPlate.hpp"
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <vector>
#include <utility>
#include <wx/app.h>
#include <wx/thread.h>
#include <boost/filesystem.hpp>
#include <boost/nowide/convert.hpp>
#include <algorithm>
#include <cmath>
#include <ctime>
#include <exception>
#include <iterator>
#include <string>
#include <unordered_map>
#include <unordered_set>
namespace Slic3r { namespace GUI {
std::vector<std::string> cap_favourites(const std::vector<std::string>& ids, size_t limit)
{
std::vector<std::string> out;
out.reserve(std::min(ids.size(), limit));
for (const auto& id : ids) {
if (out.size() >= limit)
break;
if (std::find(out.begin(), out.end(), id) == out.end())
out.push_back(id);
}
return out;
}
namespace {
constexpr const char* kConfigSection = "speed_dial";
nlohmann::json parse_config_json(const std::string& value, nlohmann::json fallback)
{
nlohmann::json parsed = nlohmann::json::parse(value, nullptr, false);
return parsed.is_discarded() ? std::move(fallback) : parsed;
}
nlohmann::json read_section(const char* key, nlohmann::json fallback)
{ return parse_config_json(wxGetApp().app_config->get(kConfigSection, key), std::move(fallback)); }
void write_section(const char* key, const nlohmann::json& j) { wxGetApp().app_config->set(kConfigSection, key, j.dump()); }
std::vector<std::string> read_string_array(const char* key)
{
auto j = read_section(key, nlohmann::json::array());
std::vector<std::string> v;
for (auto& e : j)
if (e.is_string())
v.push_back(e.get<std::string>());
return v;
}
// frecency = frequency + recency; score halves every 30 idle days.
double frecency_score(int count, long long last, long long now)
{
if (count <= 0)
return 0.0;
constexpr double HALF_LIFE_DAYS = 30.0;
double age = std::max(0.0, double(now - last) / 86400.0);
return count * std::pow(2.0, -age / HALF_LIFE_DAYS);
}
// ---- script-plugin action source (the one and only source) ------------------
std::string find_loaded_source_name(PluginManager& manager, const std::string& plugin_key)
{
PluginDescriptor descriptor;
if (manager.try_get_plugin_descriptor(plugin_key, descriptor) && !descriptor.name.empty())
return descriptor.name;
return plugin_key;
}
// A runnable script capability exposed as a speed-dial action. source_key = plugin_key
// (identity), so a plugin display-name change does not re-key the action.
struct PluginScriptAction : AppAction
{
static constexpr const char* kIdPrefix = "plugin_script_action";
std::string plugin_key;
std::string capability;
// The id an action for (plugin_key, capability) would have - lets refresh_capability
// remove a gone capability without materialising the action.
static std::string id_for(const std::string& plugin_key, const std::string& capability)
{ return AppAction::compose_id(kIdPrefix, capability.empty() ? plugin_key : capability, plugin_key); }
PluginScriptAction(std::string plugin_key_in, std::string capability_in, std::string source_name)
: AppAction(kIdPrefix,
capability_in.empty() ? plugin_key_in : capability_in, // title
plugin_key_in, // source_key
std::move(source_name))
, plugin_key(std::move(plugin_key_in))
, capability(std::move(capability_in))
{}
AppActionRunResult run(const std::string& /*param*/) const override
{
std::string error;
const ExecutionResult result = PluginManager::instance().run_script_capability(plugin_key, capability, error);
if (!error.empty())
return {AppActionRunResult::Level::Error, from_u8(error)};
const bool skipped = result.status == PluginResult::Skipped;
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
return {skipped ? AppActionRunResult::Level::Info : AppActionRunResult::Level::Success,
result.message.empty() ? fallback : from_u8(result.message)};
}
};
// Builds an action for a capability, or nullptr if it is not a currently-loaded,
// enabled script capability.
std::unique_ptr<AppAction> make_action(const std::string& plugin_key, const std::string& capability, const std::string& source_name)
{
PluginManager& manager = PluginManager::instance();
if (!manager.is_plugin_loaded(plugin_key))
return nullptr;
// only_enabled defaults true, so a disabled capability resolves to nullptr here.
if (!manager.get_plugin_capability({PluginCapabilityType::Script, capability, plugin_key}))
return nullptr;
return std::make_unique<PluginScriptAction>(plugin_key, capability, source_name);
}
// A plugin page capability exposed as a speed-dial action. source_key = plugin_key
// (identity), so a plugin display-name change does not re-key the action.
struct PluginPageAction : AppAction
{
static constexpr const char* kIdPrefix = "plugin_page_action";
std::string plugin_key;
std::string capability;
// The id an action for (plugin_key, capability) would have - lets refresh_page_capability
// remove a gone capability without materialising the action.
static std::string id_for(const std::string& plugin_key, const std::string& capability)
{ return AppAction::compose_id(kIdPrefix, capability.empty() ? plugin_key : capability, plugin_key); }
PluginPageAction(std::string plugin_key_in, std::string capability_in, std::string source_name)
: AppAction(kIdPrefix,
capability_in.empty() ? plugin_key_in : capability_in, // title
plugin_key_in, // source_key
std::move(source_name))
, plugin_key(std::move(plugin_key_in))
, capability(std::move(capability_in))
{
// Stay classified as a plugin: grouped under "Plugins" and gated by the same run-confirm.
this->kind = AppActionKind::Plugin;
// Icon is left empty on purpose: the webview builds resources/images/<icon>.svg, which a
// plugin filesystem icon path would not resolve to.
}
AppActionRunResult run(const std::string& /*param*/) const override
{
MainFrame* mf = wxGetApp().mainframe;
if (mf)
mf->plugin_pages().select_page({PluginCapabilityType::Pages, capability, plugin_key});
return {AppActionRunResult::Level::Success};
}
};
// Builds an action for a page capability, or nullptr if it is not a currently-loaded,
// enabled page capability.
std::unique_ptr<AppAction> make_page_action(const std::string& plugin_key, const std::string& capability, const std::string& source_name)
{
PluginManager& manager = PluginManager::instance();
if (!manager.is_plugin_loaded(plugin_key))
return nullptr;
// only_enabled defaults true, so a disabled capability resolves to nullptr here.
if (!manager.get_plugin_capability({PluginCapabilityType::Pages, capability, plugin_key}))
return nullptr;
return std::make_unique<PluginPageAction>(plugin_key, capability, source_name);
}
// ---- built-in command actions (the speed dial "commands" section) ------
constexpr const char* kSettingPrefix = "orca_setting";
constexpr const char* kPlateGotoPrefix = "orca_plate_goto";
constexpr const char* kRecentProjectPrefix = "orca_recent_project";
// 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. Untranslated:
// the caller translates it for the displayed breadcrumb and keeps it for the English one.
const char* setting_type_context(Preset::Type type)
{
switch (type) {
case Preset::TYPE_FILAMENT:
case Preset::TYPE_SLA_MATERIAL: return L("Filament");
case Preset::TYPE_PRINTER: return L("Printer");
case Preset::TYPE_PRINT:
case Preset::TYPE_SLA_PRINT:
default: return L("Process");
}
}
// A setting's text in one language. The displayed text and its English search aliases both come from
// here, so in an English UI the two are equal and no alias is sent.
struct SettingText
{
std::string title; // the label the settings row draws
std::string full_label; // the descriptive ConfigOptionDef name when it differs from title: a
// search-only alias ("overhang reversal" still finds "Reverse on even")
std::string source; // the full settings path "Process : Quality : Layers". The JS renders
// group || source and searches every field, so putting the whole path in
// source both displays it and makes it matchable by any segment.
};
SettingText setting_text(const std::wstring& def_label, const std::string& display_label, const wxString& row_label, bool row_multi,
const std::string& type_name, const std::wstring& category, const std::wstring& group)
{
SettingText text;
const std::string def = boost::nowide::narrow(def_label);
text.title = into_u8(Search::resolve_setting_title(from_u8(display_label), row_label, row_multi));
if (text.title.empty())
text.title = def;
if (text.title != def)
text.full_label = def;
std::wstring path = boost::nowide::widen(type_name);
if (!category.empty())
path += L" : " + category;
if (!group.empty())
path += L" : " + group;
text.source = boost::nowide::narrow(path);
return text;
}
// Stable, non-localized mode token for the webview, which maps it to a badge ("Developer" etc.).
const char* mode_key(ConfigOptionMode mode)
{
switch (mode) {
case comAdvanced: return "advanced";
case comExpert: return "expert";
case comDevelop: return "develop";
default: return "simple";
}
}
// 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; // English category, forwarded to jump_to_option (it localizes)
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,
ConfigOptionMode mode_in)
: 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 single-phase command: activating it jumps the sidebar to the option
// (like the sidebar's own settings search), then the dial closes. run() performs the jump.
this->kind = AppActionKind::Command;
this->group = std::move(group);
this->required_mode = mode_in;
}
AppActionRunResult run(const std::string& /*param*/) const override
{
wxGetApp().sidebar().jump_to_option(opt_key, type, category);
return {AppActionRunResult::Level::Success};
}
};
// Seed one action's persisted state (favourite flag + frecency counters) from an already-parsed
// stats blob and capped favourite list. Shared by the dynamic materialisers.
void seed_from(const nlohmann::json& stats, const std::vector<std::string>& favs, const std::string& id, AppAction& a)
{
a.favourite = std::find(favs.begin(), favs.end(), id) != favs.end();
if (auto it = stats.find(id); it != stats.end() && it->is_object()) {
a.count = it->value("count", 0);
a.last = it->value("last", 0LL);
}
}
// Drop actions whose id starts with `prefix` but that were not seen in this pass (a stale materialisation).
void drop_stale(std::unordered_map<std::string, std::shared_ptr<AppAction>>& actions, const char* prefix,
const std::unordered_set<std::string>& seen)
{
for (auto it = actions.begin(); it != actions.end();) {
if (it->first.rfind(prefix, 0) == 0 && !seen.count(it->first))
it = actions.erase(it);
else
++it;
}
}
// A dynamic "Go to Plate N" action, one per live plate, rebuilt on every snapshot() (so a
// rename/move immediately shows up). id is keyed by plate index, NOT the display title, so
// renaming a plate never re-keys it - the same contract as SettingAction. A pinned "Go to
// Plate N" whose plate is deleted simply stops resolving (visibleFavourites drops dead pins).
struct PlateAction : AppAction
{
int plate_index;
static std::string id_for(int index) { return AppAction::compose_id(kPlateGotoPrefix, std::to_string(index), kOrcaSourceKey); }
PlateAction(int index, std::string title, std::string source_name)
: AppAction(AppActionId{id_for(index)}, std::move(title), kOrcaSourceKey, std::move(source_name)), plate_index(index)
{
this->kind = AppActionKind::Command;
this->group_en = L("Plate");
this->group = I18N::translate_utf8(this->group_en);
}
AppActionRunResult run(const std::string& /*param*/) const override
{ return NativeCommands::run("plate_goto", std::to_string(plate_index)); }
};
// A dynamic "Open recent project <name>" action, one per recent project file, rebuilt on every
// snapshot() (like PlateAction) so the list always reflects the current recents. The id is keyed
// by the file PATH, NOT the display title - the same contract as SettingAction/PlateAction, so a
// rename of a project (or a reordered recents list) never re-keys the action. A pinned recent whose
// file is deleted simply stops resolving (visibleFavourites drops dead pins). run() loads the
// project through MainFrame::open_recent_project so the existing missing-file handling is reused.
struct RecentProjectAction : AppAction
{
std::string file_path;
static std::string id_for(const std::string& path) { return AppAction::compose_id(kRecentProjectPrefix, path, kOrcaSourceKey); }
RecentProjectAction(std::string path, std::string title, std::string source)
: AppAction(AppActionId{id_for(path)}, std::move(title), kOrcaSourceKey, std::move(source)), file_path(std::move(path))
{
this->kind = AppActionKind::Command;
this->group_en = L("Recent Projects");
this->group = I18N::translate_utf8(this->group_en);
}
AppActionRunResult run(const std::string& /*param*/) const override
{
if (MainFrame* mf = wxGetApp().mainframe; mf)
mf->open_recent_project(size_t(-1), wxString::FromUTF8(file_path));
return {AppActionRunResult::Level::Success};
}
};
} // namespace
nlohmann::json action_json(const AppAction& a)
{
nlohmann::json j({{"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"},
{"input", a.input},
{"icon", a.icon},
{"mode", mode_key(a.required_mode)},
{"desc", a.tooltip},
{"wiki", !a.help_url.empty()}});
auto add_alias = [&j](const char* key, const std::string& english, const std::string& local) {
if (!english.empty() && english != local)
j[key] = english;
};
add_alias("title_en", a.title_en, a.title());
add_alias("group_en", a.group_en, a.group);
add_alias("source_en", a.source_en, a.source_name());
add_alias("full_label_en", a.full_label_en, a.full_label);
return j;
}
ActionRegistry::~ActionRegistry() = default;
void ActionRegistry::init()
{
assert(wxThread::IsMain());
assert(!m_started);
m_started = true;
PluginManager& manager = PluginManager::instance();
auto on_source = [this](const std::string& plugin_key, ActionChange change) {
if (!wxTheApp || wxGetApp().is_closing())
return;
wxGetApp().CallAfter([this, plugin_key, change] {
if (!wxGetApp().is_closing())
this->refresh_source(plugin_key, change);
});
};
auto on_capability = [this](const PluginCapabilityId& capability, ActionChange change) {
if (capability.type != PluginCapabilityType::Script && capability.type != PluginCapabilityType::Pages)
return;
if (!wxTheApp || wxGetApp().is_closing())
return;
const PluginCapabilityType type = capability.type;
const std::string plugin_key = capability.plugin_key;
const std::string name = capability.name;
wxGetApp().CallAfter([this, type, plugin_key, name, change] {
if (wxGetApp().is_closing())
return;
if (type == PluginCapabilityType::Script)
this->refresh_capability(plugin_key, name, change);
else
this->refresh_page_capability(plugin_key, name, change);
});
};
// Subscribe before enumerating so a concurrent load cannot land between the initial
// snapshot and callback registration. Duplicate notifications are safe: upsert is by
// id and the m_actions scan in refresh_source is idempotent.
manager.subscribe_on_load_callback([on_source](const std::string& key) { on_source(key, ActionChange::Added); });
manager.subscribe_on_unload_callback([on_source](const std::string& key) { on_source(key, ActionChange::Removed); });
manager.subscribe_on_capability_load_callback(
[on_capability](const PluginCapabilityId& capability) { on_capability(capability, ActionChange::Added); });
manager.subscribe_on_capability_unload_callback(
[on_capability](const PluginCapabilityId& capability) { on_capability(capability, ActionChange::Removed); });
// enumerate current script capabilities
std::unordered_map<std::string, std::string> source_names;
for (const PluginDescriptor& desc : manager.get_plugin_descriptors())
if (!desc.name.empty())
source_names.emplace(desc.plugin_key, desc.name);
for (const auto& capability : manager.get_plugin_capabilities("", PluginCapabilityType::Script)) {
if (!capability)
continue;
const std::string& key = capability->audit_plugin_key();
auto it = source_names.find(key);
const std::string& source_name = it == source_names.end() ? key : it->second;
if (auto action = make_action(key, capability->name(), source_name))
upsert(std::move(action));
}
for (const auto& capability : manager.get_plugin_capabilities("", PluginCapabilityType::Pages)) {
if (!capability)
continue;
const std::string& key = capability->audit_plugin_key();
auto it = source_names.find(key);
const std::string& source_name = it == source_names.end() ? key : it->second;
if (auto action = make_page_action(key, capability->name(), source_name))
upsert(std::move(action));
}
// Built-in palette commands (Save/Load, Preferences, Mode switch, Slice/Preview, Go to layer).
// Register after plugins so the plugin ids win on any (unlikely) id collision - ids are distinct
// by prefix, so this is order-independent. The catalog (and its thin AppAction adapter) lives in
// NativeCommands; the registry only stores and dispatches the result.
for (const NativeCommand& c : NativeCommands::catalog())
upsert(NativeCommands::make_action(c));
}
void ActionRegistry::relocalize_builtins()
{
assert(wxThread::IsMain());
if (!m_started)
return;
// Drop only the built-in commands; plugins and the dynamically materialised families are
// either unlocalized or rebuilt per snapshot. remove() only erases the map entry, and upsert()
// re-seeds favourites/stats from config, so key-based ids keep their pinned state. make_action()
// translates in the new locale.
std::vector<std::string> stale;
for (const auto& [id, action] : m_actions)
if (action->source_key() == kOrcaSourceKey && action->kind == AppActionKind::Command)
stale.push_back(id);
for (const std::string& id : stale)
remove(id);
for (const NativeCommand& c : NativeCommands::catalog())
upsert(NativeCommands::make_action(c));
}
void ActionRegistry::refresh_source(const std::string& plugin_key, ActionChange change)
{
assert(wxThread::IsMain());
// Remove this source's current actions. Collect first - erasing from m_actions while
// iterating invalidates the iterator. why: m_actions (not the loader) is the source of
// truth, so this is correct even after the plugin has already unloaded.
std::vector<std::string> stale;
for (const auto& [id, action] : m_actions)
if (action->source_key() == plugin_key)
stale.push_back(id);
for (const std::string& id : stale)
remove(id);
if (change == ActionChange::Removed)
return;
PluginManager& manager = PluginManager::instance();
const std::string source_name = find_loaded_source_name(manager, plugin_key);
for (const auto& capability : manager.get_plugin_capabilities(plugin_key, PluginCapabilityType::Script)) {
if (!capability)
continue;
if (auto action = make_action(plugin_key, capability->name(), source_name))
upsert(std::move(action));
}
for (const auto& capability : manager.get_plugin_capabilities(plugin_key, PluginCapabilityType::Pages)) {
if (!capability)
continue;
if (auto action = make_page_action(plugin_key, capability->name(), source_name))
upsert(std::move(action));
}
}
void ActionRegistry::refresh_capability(const std::string& plugin_key, const std::string& capability, ActionChange change)
{
assert(wxThread::IsMain());
const std::string id = PluginScriptAction::id_for(plugin_key, capability);
if (change == ActionChange::Removed) {
remove(id);
return;
}
PluginManager& manager = PluginManager::instance();
if (auto action = make_action(plugin_key, capability, find_loaded_source_name(manager, plugin_key)))
upsert(std::move(action));
else
remove(id);
}
void ActionRegistry::refresh_page_capability(const std::string& plugin_key, const std::string& capability, ActionChange change)
{
assert(wxThread::IsMain());
const std::string id = PluginPageAction::id_for(plugin_key, capability);
if (change == ActionChange::Removed) {
remove(id);
return;
}
PluginManager& manager = PluginManager::instance();
if (auto action = make_page_action(plugin_key, capability, find_loaded_source_name(manager, plugin_key)))
upsert(std::move(action));
else
remove(id);
}
void ActionRegistry::upsert(std::unique_ptr<AppAction> action)
{
assert(wxThread::IsMain());
if (!action)
return;
seed_state(*action);
std::string id = action->id();
std::shared_ptr<AppAction> stored = std::move(action);
m_actions.insert_or_assign(std::move(id), std::move(stored));
}
void ActionRegistry::remove(const std::string& id)
{
assert(wxThread::IsMain());
m_actions.erase(id);
}
void ActionRegistry::seed_state(AppAction& a) const
{
// 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());
auto it = stats.find(a.id());
if (it != stats.end() && it->is_object()) {
a.count = it->value("count", 0);
a.last = it->value("last", 0LL);
} else {
a.count = 0;
a.last = 0;
}
}
void ActionRegistry::load_persisted(nlohmann::json& stats, std::vector<std::string>& favs) const
{
stats = read_section("stats", nlohmann::json::object());
if (!stats.is_object())
stats = nlohmann::json::object();
favs = favourite_ids();
}
// ---- read surface -----------------------------------------------------------
const AppAction* ActionRegistry::by_id(const std::string& id) const
{
assert(wxThread::IsMain());
auto it = m_actions.find(id);
return it == m_actions.end() ? nullptr : it->second.get();
}
AppAction* ActionRegistry::find(const std::string& id) { return const_cast<AppAction*>(by_id(id)); }
// ---- dispatch + write-through ----------------------------------------------
AppActionRunResult ActionRegistry::run(const std::string& id, const std::string& param)
{
assert(wxThread::IsMain());
auto it = m_actions.find(id);
if (it == m_actions.end())
return {}; // default Info, empty message
// why: hold a shared_ptr keep-alive, never a bare map entry. A runner may pump a
// nested event loop; a queued source refresh can erase the entry while the
// keep-alive preserves the action until run returns.
std::shared_ptr<AppAction> keep = it->second;
AppActionRunResult o = keep->run(param);
if (o.level == AppActionRunResult::Level::Busy)
return o;
// Bump stats (write-through). Re-read to avoid clobbering a concurrent field.
nlohmann::json stats = read_section("stats", nlohmann::json::object());
if (!stats.is_object()) // corrupt (valid-JSON, non-object) value degrades to empty
stats = nlohmann::json::object();
nlohmann::json& e = stats[id];
if (!e.is_object())
e = nlohmann::json::object();
e["count"] = e.value("count", 0) + 1;
e["last"] = (long long) std::time(nullptr);
write_section("stats", stats);
if (AppAction* live = find(id)) {
live->count = e["count"];
live->last = e["last"];
}
return o;
}
bool ActionRegistry::set_favourite(const std::string& id, bool on)
{
assert(wxThread::IsMain());
// 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 (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.
return cap_favourites(read_string_array("favourite_actions"), kFavLimit);
}
void ActionRegistry::reorder_favourites(const std::vector<std::string>& ids)
{
assert(wxThread::IsMain());
auto cur = read_string_array("favourite_actions");
std::vector<std::string> next;
// keep the requested order, but only ids that are actually favourites (guard a bad payload)
for (const auto& id : ids)
if (std::find(cur.begin(), cur.end(), id) != cur.end() && std::find(next.begin(), next.end(), id) == next.end())
next.push_back(id);
// why: don't drop favourites the page omitted (e.g. pins with no live action hidden from the bar)
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
next = cap_favourites(next, kFavLimit);
write_section("favourite_actions", nlohmann::json(next));
}
void ActionRegistry::materialize_setting_actions()
{
assert(wxThread::IsMain());
// Reuse the Sidebar's live settings index: it's the only catalog whose group/category map is
// populated (Tab::add_key feeds it at build time), and it already mirrors the current
// configs/printer-technology. Use the all-modes view so the Speed Dial lists every setting,
// including those above the user's current mode, and can prompt to switch before jumping.
const std::vector<Search::Option>& options = wxGetApp().sidebar().settings_index().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;
std::vector<std::string> favs;
load_persisted(stats, favs);
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);
// The localized text the page shows, and its English search aliases from the untranslated row
// label, def name and breadcrumb.
const char* type_context = setting_type_context(opt.type);
SettingText local = setting_text(opt.label_local.empty() ? opt.label : opt.label_local, opt.display_label, row.label, row.multi,
I18N::translate_utf8(type_context), opt.category_local, opt.group_local);
SettingText english = setting_text(opt.label, opt.display_label_en, row.label_en, row.multi, type_context,
Tab::translate_category(opt.category, opt.type, false).ToStdWstring(), opt.group);
// group stays empty so the source path is the single display/search breadcrumb rather than
// being duplicated.
auto action = std::make_unique<SettingAction>(opt.opt_key(), opt.type, local.title, std::string(), opt.category, local.source,
opt.mode);
action->full_label = std::move(local.full_label);
action->title_en = std::move(english.title);
action->full_label_en = std::move(english.full_label);
action->source_en = std::move(english.source);
// 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() && 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.
action->tooltip = opt.tooltip;
if (!opt.wiki_path.empty())
action->help_url = into_u8(OptionsGroup::get_url(opt.wiki_path));
seed_from(stats, favs, id, *action);
auto const action_id = action->id();
auto const app_action = std::shared_ptr<AppAction>(std::move(action));
m_actions.insert_or_assign(action_id, app_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.
drop_stale(m_actions, kSettingPrefix, seen);
}
void ActionRegistry::materialize_plate_actions()
{
assert(wxThread::IsMain());
// Plates are a filament (FFF) feature: SLA has a single plate and no plate UI, and gcode-only
// mode has no editable project - so no "Go to Plate N" actions are offered there.
Plater* plater = wxTheApp ? wxGetApp().plater() : nullptr;
if (!plater || plater->printer_technology() != ptFFF || plater->only_gcode_mode()) {
// Drop any stale plate actions (e.g. the printer technology switched to SLA).
drop_stale(m_actions, kPlateGotoPrefix, {});
return;
}
// Persisted per-action state, read ONCE (mirrors materialize_setting_actions) so a relisted
// "Go to Plate N" keeps its recency/favourite when the plate is renamed - the id is index-keyed.
nlohmann::json stats;
std::vector<std::string> favs;
load_persisted(stats, favs);
const std::vector<PartPlate*>& list = plater->get_partplate_list().get_plate_list();
std::unordered_set<std::string> seen;
for (size_t i = 0; i < list.size(); ++i) {
PartPlate* plate = list[i];
if (!plate)
continue;
const std::string id = PlateAction::id_for(int(i));
seen.insert(id);
// "Go to Plate N" + " (name)" when the plate is named, matching the object-list label.
const char* go_to_plate = L("Go to Plate");
std::string suffix = " " + std::to_string(i + 1);
const std::string name = plate->get_plate_name();
if (!name.empty())
suffix += " (" + name + ")";
auto action = std::make_unique<PlateAction>(int(i), I18N::translate_utf8(go_to_plate) + suffix, kOrcaSourceName);
action->title_en = go_to_plate + suffix;
seed_from(stats, favs, id, *action);
auto const action_id = action->id();
auto const app_action = std::shared_ptr<AppAction>(std::move(action));
m_actions.insert_or_assign(action_id, app_action);
}
// Drop plate actions whose index no longer exists (a plate was deleted / moved to the front).
drop_stale(m_actions, kPlateGotoPrefix, seen);
}
void ActionRegistry::materialize_recent_project_actions()
{
assert(wxThread::IsMain());
// Persisted per-action state, read ONCE (mirrors materialize_plate_actions) so a relisted recent
// project keeps its recency/favourite when the recents list reorders - the id is path-keyed.
nlohmann::json stats;
std::vector<std::string> favs;
load_persisted(stats, favs);
// app_config stores recents oldest-first; the palette shows newest-first.
std::vector<std::string> recents = wxGetApp().app_config->get_recent_projects();
std::reverse(recents.begin(), recents.end());
std::unordered_set<std::string> seen;
for (const std::string& path : recents) {
// Skip projects whose file is gone; the stale id is dropped below.
boost::system::error_code ec;
if (path.empty() || !boost::filesystem::exists(boost::filesystem::path(path), ec))
continue;
const std::string id = RecentProjectAction::id_for(path);
seen.insert(id);
// Title = file basename; source/eyebrow = the full path so search can match either.
boost::filesystem::path p(path);
std::string title = p.filename().string();
if (title.empty())
title = path;
auto action = std::make_unique<RecentProjectAction>(path, std::move(title), path);
seed_from(stats, favs, id, *action);
auto const action_id = action->id();
auto const app_action = std::shared_ptr<AppAction>(std::move(action));
m_actions.insert_or_assign(action_id, app_action);
}
// Drop recent-project actions whose file no longer exists / was removed from the recents list.
drop_stale(m_actions, kRecentProjectPrefix, seen);
}
bool ActionRegistry::should_ask(const std::string& id) const
{
assert(wxThread::IsMain());
auto arr = read_string_array("ask_suppressed");
return std::find(arr.begin(), arr.end(), id) == arr.end();
}
void ActionRegistry::suppress_ask(const std::string& id)
{
assert(wxThread::IsMain());
auto arr = read_string_array("ask_suppressed");
if (std::find(arr.begin(), arr.end(), id) == arr.end())
arr.push_back(id);
write_section("ask_suppressed", nlohmann::json(arr));
}
bool ActionRegistry::tooltip_expanded() const
{
assert(wxThread::IsMain());
const nlohmann::json j = read_section("tooltip_expanded", nlohmann::json(true));
return j.is_boolean() ? j.get<bool>() : true;
}
void ActionRegistry::set_tooltip_expanded(bool expanded)
{
assert(wxThread::IsMain());
write_section("tooltip_expanded", nlohmann::json(expanded));
}
// ---- snapshot ---------------------------------------------------------------
nlohmann::json ActionRegistry::snapshot()
{
assert(wxThread::IsMain());
// Settings and plates are first-class actions; make sure the current visible option set and the
// live plate list are materialised before we serialise the pool (tabs_list is built by the time
// the palette opens).
materialize_setting_actions();
materialize_plate_actions();
materialize_recent_project_actions();
std::vector<const AppAction*> sorted;
sorted.reserve(m_actions.size());
for (const auto& entry : m_actions)
sorted.push_back(entry.second.get());
const long long now = (long long) std::time(nullptr);
std::sort(sorted.begin(), sorted.end(), [&](const AppAction* a, const AppAction* b) {
double sa = frecency_score(a->count, a->last, now);
double sb = frecency_score(b->count, b->last, now);
if (sa != sb)
return sa > sb;
if (a->title() != b->title())
return a->title() < b->title();
if (a->source_name() != b->source_name())
return a->source_name() < b->source_name();
return a->id() < b->id();
});
nlohmann::json actions = nlohmann::json::array();
for (const AppAction* a : sorted)
actions.push_back(action_json(*a));
// 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). Drop pins with no live action (an option hidden by the
// current mode, an unloaded plugin, a gone plate/project) and persist the pruned list, so
// invisible pins can't silently fill the quick-launch cap. Order is preserved.
std::vector<std::string> favs = favourite_ids();
std::vector<std::string> live_favs;
live_favs.reserve(favs.size());
for (const auto& id : favs)
if (m_actions.count(id))
live_favs.push_back(id);
if (live_favs.size() != favs.size())
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). 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<const AppAction*> recent;
for (const auto& entry : m_actions)
if (entry.second->last > 0)
recent.push_back(entry.second.get());
std::sort(recent.begin(), recent.end(), [](const AppAction* a, const AppAction* b) {
if (a->last != b->last)
return a->last > b->last;
return a->id() < b->id();
});
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_json(*a));
return {{"actions", std::move(actions)},
{"favourites", std::move(favourites)},
{"recent", std::move(recent_json)},
{"user_mode", mode_key(wxGetApp().get_mode())},
{"tooltip_expanded", tooltip_expanded()}};
}
// ---- tab options (enumerate the MainFrame notebook's current pages) ----------
nlohmann::json ActionRegistry::tab_options() const
{
assert(wxThread::IsMain());
nlohmann::json out = nlohmann::json::array();
if (!wxTheApp || wxGetApp().is_closing())
return out;
MainFrame* mf = wxGetApp().mainframe;
if (!mf || !mf->m_tabpanel)
return out;
// English labels of the built-in pages whose id is not already their English name, so the tab
// picker can be searched in English in any UI language.
static const std::unordered_map<std::string, const char*> english_labels{
{TAB_ID_MONITOR, L("Device")}, {TAB_ID_MONITOR_WEB, L("Device (Web)")}, {TAB_ID_MULTI_DEVICE, L("Multi-device")}};
Notebook* notebook = mf->m_tabpanel;
for (size_t i = 0; i < notebook->GetPageCount(); ++i) {
const wxString id = notebook->GetPageName(i);
if (id.empty())
continue;
const std::string title = notebook->GetPageLabel(i).ToStdString();
nlohmann::json tab({{"id", id.ToStdString()}, {"title", title}, {"icon", notebook->GetPageIcon(i)}});
if (auto it = english_labels.find(id.ToStdString()); it != english_labels.end() && title != it->second)
tab["title_en"] = it->second;
out.push_back(std::move(tab));
}
return out;
}
}} // namespace Slic3r::GUI