Files
OrcaSlicer/src/slic3r/GUI/ActionRegistry.cpp
Andrew c816e48b78 refactor: collapse speed-dial action source abstraction
Only one action source ever existed, so the
IActionSource interface and ScriptActionSource
are gone. ActionRegistry now subscribes to the
plugin loader and enumerates actions directly
in init() - no polymorphism for one impl.

Replace the opaque FNV-hash SpeedDialActionId
with a readable composed id of the form
prefix:title:source_key. Split AppAction's
single source field into source_key (stable
identity, e.g. plugin_key) and source_name
(display), so identity and display no longer
share one field.
2026-07-16 13:08:48 +08:00

390 lines
15 KiB
C++

#include "ActionRegistry.hpp"
#include "GUI_App.hpp"
#include "PluginScriptRunner.hpp"
#include "slic3r/plugin/PluginManager.hpp"
#include <libslic3r/AppConfig.hpp>
#include <slic3r/plugin/PluginLoader.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <wx/thread.h>
#include <algorithm>
#include <cmath>
#include <ctime>
#include <unordered_map>
namespace Slic3r { namespace GUI {
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(PluginLoader& loader, const std::string& plugin_key)
{
for (const PluginDescriptor& desc : loader.get_all_loaded_plugin_descriptors())
if (desc.plugin_key == plugin_key)
return desc.name.empty() ? plugin_key : desc.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 override
{
ScriptRunOutcome outcome = run_script_plugin_capability(plugin_key, capability);
AppActionRunResult::Level level = AppActionRunResult::Level::Info;
switch (outcome.level) {
case ScriptRunOutcome::Level::Success: level = AppActionRunResult::Level::Success; break;
case ScriptRunOutcome::Level::Error: level = AppActionRunResult::Level::Error; break;
case ScriptRunOutcome::Level::Busy: level = AppActionRunResult::Level::Busy; break;
case ScriptRunOutcome::Level::Info: break;
}
return {level, outcome.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)
{
PluginLoader& loader = PluginManager::instance().get_loader();
if (!loader.is_plugin_loaded(plugin_key))
return nullptr;
auto loaded = loader.get_plugin_capability_by_name(plugin_key, PluginCapabilityType::Script, capability);
if (!loaded || !loaded->enabled)
return nullptr;
return std::make_unique<PluginScriptAction>(plugin_key, capability, source_name);
}
} // namespace
void ActionRegistry::init()
{
assert(wxThread::IsMain());
assert(!m_started);
m_started = true;
PluginLoader& loader = PluginManager::instance().get_loader();
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 PluginCapabilityIdentifier& capability, ActionChange change) {
if (capability.type != PluginCapabilityType::Script || !wxTheApp || wxGetApp().is_closing())
return;
const std::string plugin_key = capability.plugin_key;
const std::string name = capability.name;
wxGetApp().CallAfter([this, plugin_key, name, change] {
if (!wxGetApp().is_closing())
this->refresh_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.
loader.subscribe_on_load_callback(
[on_source](const std::string& key) { on_source(key, ActionChange::Added); });
loader.subscribe_on_unload_callback(
[on_source](const std::string& key) { on_source(key, ActionChange::Removed); });
loader.subscribe_on_capability_load_callback(
[on_capability](const PluginCapabilityIdentifier& capability) {
on_capability(capability, ActionChange::Added);
});
loader.subscribe_on_capability_unload_callback(
[on_capability](const PluginCapabilityIdentifier& capability) {
on_capability(capability, ActionChange::Removed);
});
// enumerate current script capabilities
std::unordered_map<std::string, std::string> source_names;
for (const PluginDescriptor& desc : loader.get_all_loaded_plugin_descriptors())
if (!desc.name.empty())
source_names.emplace(desc.plugin_key, desc.name);
for (const auto& capability : loader.get_plugin_capabilities_by_type(PluginCapabilityType::Script)) {
if (!capability || !capability->enabled)
continue;
auto it = source_names.find(capability->plugin_key);
const std::string& source_name = it == source_names.end() ? capability->plugin_key : it->second;
if (auto action = make_action(capability->plugin_key, capability->name, source_name))
upsert(std::move(action));
}
}
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;
PluginLoader& loader = PluginManager::instance().get_loader();
const std::string source_name = find_loaded_source_name(loader, plugin_key);
for (const auto& capability : loader.get_plugin_capabilities_by_type(plugin_key, PluginCapabilityType::Script)) {
if (!capability || !capability->enabled)
continue;
if (auto action = make_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;
}
PluginLoader& loader = PluginManager::instance().get_loader();
if (auto action = make_action(plugin_key, capability, find_loaded_source_name(loader, 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
{
auto favs = read_string_array("favourite_actions");
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;
}
}
// ---- 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)
{
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();
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;
}
void ActionRegistry::set_favourite(const std::string& id, bool on)
{
assert(wxThread::IsMain());
auto favs = read_string_array("favourite_actions");
auto it = std::find(favs.begin(), favs.end(), id);
if (on && it == favs.end())
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;
}
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);
write_section("favourite_actions", nlohmann::json(next));
}
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));
}
// ---- snapshot ---------------------------------------------------------------
nlohmann::json ActionRegistry::snapshot() const
{
assert(wxThread::IsMain());
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({{"id", a->id()},
{"title", a->title()},
{"source", a->source_name()},
{"shortcut", ""}});
// 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"));
return {{"actions", std::move(actions)}, {"favourites", std::move(favourites)}};
}
}} // namespace Slic3r::GUI