diff --git a/resources/web/dialog/SpeedDial/speeddial.js b/resources/web/dialog/SpeedDial/speeddial.js index 51ad65e1be..436a8c9a00 100644 --- a/resources/web/dialog/SpeedDial/speeddial.js +++ b/resources/web/dialog/SpeedDial/speeddial.js @@ -2,7 +2,7 @@ // node vm can exercise the pure helpers (filterActions / parseId / nextSel). // ---- state (populated by the C++ bridge via window.HandleStudio) ---- -var ACTIONS = []; // [{id,title,pkg,shortcut}], already frecency-sorted by C++ +var ACTIONS = []; // [{id,title,source,shortcut}], already frecency-sorted by C++ var FAVS = []; // [id...] var query = ""; var sel = { zone: "list", i: 0 }; // zone: 'list' | 'fav' @@ -27,10 +27,10 @@ function filterActions(actions, query) { for (var i = 0; i < list.length; i++) { var a = list[i]; var titleMatch = FuzzyRanges(a.title, q, false); - var pkgMatch = FuzzyRanges(a.pkg, q, false); - if (!titleMatch && !pkgMatch) + var sourceMatch = FuzzyRanges(a.source, q, false); + if (!titleMatch && !sourceMatch) continue; - matchIndex[a.id] = { title: titleMatch, pkg: pkgMatch, useTitle: !!titleMatch }; + matchIndex[a.id] = { title: titleMatch, source: sourceMatch, useTitle: !!titleMatch }; out.push(a); } return out; @@ -68,19 +68,19 @@ function selectedActionId(sel, actions, favIds) { function foldLabel(s) { return String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, ""); } -// Title-case a package for display: "GCODE OPTIMIZER"/"iRoNiNg pRo" -> "Gcode Optimizer"/"Ironing Pro". -function prettyPkg(pkg) { - return String(pkg || "").toLowerCase().replace(/\b\w/g, function (c) { return c.toUpperCase(); }); +// Title-case a source for display: "GCODE OPTIMIZER"/"iRoNiNg pRo" -> "Gcode Optimizer"/"Ironing Pro". +function prettySource(source) { + return String(source || "").toLowerCase().replace(/\b\w/g, function (c) { return c.toUpperCase(); }); } -// Accessible label "Title from Pretty Pkg", disambiguated with the plugin key when another action -// shares the same title+pkg (case/separator-insensitive) - so two rows never read out identically. +// Accessible label "Title from Pretty Source", disambiguated with the plugin key when another action +// shares the same title+source (case/separator-insensitive) - so two rows never read out identically. function actionLabel(action, actions) { - var label = action.title + " from " + prettyPkg(action.pkg); + var label = action.title + " from " + prettySource(action.source); if (actions && actions.length) { - var mine = foldLabel(action.title) + "|" + foldLabel(action.pkg); + var mine = foldLabel(action.title) + "|" + foldLabel(action.source); var clash = actions.some(function (o) { - return o.id !== action.id && foldLabel(o.title) + "|" + foldLabel(o.pkg) === mine; + return o.id !== action.id && foldLabel(o.title) + "|" + foldLabel(o.source) === mine; }); if (clash) label += " (" + parseId(action.id).key + ")"; @@ -88,7 +88,7 @@ function actionLabel(action, actions) { return label; } -// Monogram code for a tile: title initial, escalated on collision by PREPENDING the pkg +// Monogram code for a tile: title initial, escalated on collision by PREPENDING the source // initial (pi+ti, e.g. "GC"), then a 1-based ordinal - so same-titled actions stay distinct. // why: ordinal is assigned by id, not by ACTIONS order - ACTIONS is frecency-sorted and // reshuffles as usage changes, which would otherwise flip who's "1" and who's "2" across runs. @@ -98,13 +98,13 @@ function tileCode(action, actions) { var sameTitle = list.filter(function (o) { return (o.title || " ").charAt(0).toUpperCase() === ti; }); if (sameTitle.length <= 1) return ti; - var pi = (action.pkg || " ").charAt(0).toUpperCase(); - var samePkg = sameTitle.filter(function (o) { return (o.pkg || " ").charAt(0).toUpperCase() === pi; }); - if (samePkg.length <= 1) + var pi = (action.source || " ").charAt(0).toUpperCase(); + var sameSource = sameTitle.filter(function (o) { return (o.source || " ").charAt(0).toUpperCase() === pi; }); + if (sameSource.length <= 1) return pi + ti; - samePkg.sort(function (a, b) { return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; }); - for (var i = 0; i < samePkg.length; i++) - if (samePkg[i].id === action.id) + sameSource.sort(function (a, b) { return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; }); + for (var i = 0; i < sameSource.length; i++) + if (sameSource[i].id === action.id) return pi + ti + (i + 1); return pi + ti; } @@ -203,7 +203,7 @@ function hue(id) { } // Build a
with the search-match ranges wrapped in . Used for both the -// title and the pkg eyebrow. Pure (only touches the document factory), so the node-vm test never +// title and the source eyebrow. Pure (only touches the document factory), so the node-vm test never // calls it and load-time stays DOM-free. function markedText(className, text, match) { var node = document.createElement("div"); @@ -359,7 +359,7 @@ function renderList() { var left = document.createElement("div"); left.className = "row-left"; var mi = matchIndex[a.id]; - var pkg = markedText("row-eyebrow", a.pkg, mi ? mi.pkg : null); + var sourceEl = markedText("row-eyebrow", a.source, mi ? mi.source : null); var line = document.createElement("div"); line.className = "row-line"; var name = markedText("row-name", a.title, mi ? mi.title : null); @@ -374,7 +374,7 @@ function renderList() { }); line.appendChild(sc); } - left.appendChild(pkg); + left.appendChild(sourceEl); left.appendChild(line); row.appendChild(tile); row.appendChild(left); diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index ffdc3d9830..605ac738bd 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -122,6 +122,9 @@ set(SLIC3R_GUI_SOURCES GUI/SpeedDialDialog.hpp GUI/ActionRegistry.cpp GUI/ActionRegistry.hpp + GUI/IActionSource.hpp + GUI/ScriptActionSource.cpp + GUI/ScriptActionSource.hpp GUI/SpeedDialActionId.hpp GUI/ProcessRunner.cpp GUI/ProcessRunner.hpp diff --git a/src/slic3r/GUI/ActionRegistry.cpp b/src/slic3r/GUI/ActionRegistry.cpp index f37023979b..d1b5b4b66b 100644 --- a/src/slic3r/GUI/ActionRegistry.cpp +++ b/src/slic3r/GUI/ActionRegistry.cpp @@ -1,20 +1,14 @@ #include "ActionRegistry.hpp" #include "GUI_App.hpp" -#include "SpeedDialActionId.hpp" -#include "slic3r/plugin/PluginManager.hpp" #include -#include -#include - #include #include #include #include -#include namespace Slic3r { namespace GUI { @@ -58,51 +52,41 @@ double frecency_score(int count, long long last, long long now) return count * std::pow(2.0, -age / HALF_LIFE_DAYS); } -std::string find_loaded_package_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; -} - } // namespace -PluginScriptAction::PluginScriptAction(std::string plugin_key, std::string capability, std::string package_name) - : AppAction(speed_dial_action_id(plugin_key, capability), // id - capability.empty() ? plugin_key : capability, // title - std::move(package_name)), // pkg = source (package name) - plugin_key(std::move(plugin_key)), capability(std::move(capability)) -{} - -AppActionRunResult PluginScriptAction::run() const +void ActionRegistry::init() { - ScriptRunOutcome o = run_script_plugin_capability(plugin_key, capability); - AppActionRunResult::Level level = AppActionRunResult::Level::Info; - switch (o.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, o.message }; + assert(wxThread::IsMain()); + for (auto& source : m_sources) + source->start(*this); } -// ---- build / enumeration ---------------------------------------------------- - -std::shared_ptr ActionRegistry::make_action(const std::string& plugin_key, const std::string& capability, - const std::function& package_name_for) const +void ActionRegistry::add_source(std::unique_ptr source) { - PluginLoader& loader = PluginManager::instance().get_loader(); - if (!loader.is_plugin_loaded(plugin_key)) - return nullptr; - auto cap = loader.get_plugin_capability_by_name(plugin_key, PluginCapabilityType::Script, capability); - if (!cap || !cap->enabled) - return nullptr; + assert(wxThread::IsMain()); + if (source) + m_sources.push_back(std::move(source)); +} - auto a = std::make_shared(plugin_key, capability, package_name_for(plugin_key)); - seed_state(*a); - return a; +void ActionRegistry::upsert(std::shared_ptr action) +{ + assert(wxThread::IsMain()); + if (!action) + return; + + seed_state(*action); + auto existing = std::find_if(m_actions.begin(), m_actions.end(), + [&](const auto& current) { return current->id == action->id; }); + if (existing == m_actions.end()) + m_actions.push_back(std::move(action)); + else + *existing = std::move(action); +} + +void ActionRegistry::remove(const std::string& id) +{ + assert(wxThread::IsMain()); + erase_id(id); } void ActionRegistry::seed_state(AppAction& a) const @@ -121,32 +105,6 @@ void ActionRegistry::seed_state(AppAction& a) const } } -void ActionRegistry::build() -{ - assert(wxThread::IsMain()); // why: UI-thread only; m_actions is unguarded (confinement) - m_actions.clear(); - PluginLoader& loader = PluginManager::instance().get_loader(); - - std::unordered_map package_names; - for (const PluginDescriptor& desc : loader.get_all_loaded_plugin_descriptors()) - if (!desc.name.empty()) - package_names.emplace(desc.plugin_key, desc.name); - - // why: build sees many capabilities, so package display names are indexed once instead of - // scanning the loaded descriptors for every capability. - auto package_name_for = [&package_names](const std::string& plugin_key) { - auto it = package_names.find(plugin_key); - return it == package_names.end() ? plugin_key : it->second; - }; - - for (const auto& cap : loader.get_plugin_capabilities_by_type(PluginCapabilityType::Script)) { - if (!cap || !cap->enabled) - continue; - if (auto a = make_action(cap->plugin_key, cap->name, package_name_for)) - m_actions.push_back(std::move(a)); - } -} - // ---- read surface ----------------------------------------------------------- const AppAction* ActionRegistry::by_id(const std::string& id) const @@ -168,63 +126,6 @@ void ActionRegistry::erase_id(const std::string& id) m_actions.end()); } -// ---- incremental maintenance (UI thread, via CallAfter) --------------------- - -void ActionRegistry::refresh_capability(const std::string& plugin_key, const std::string& capability, ActionChange change) -{ - assert(wxThread::IsMain()); - const std::string id = speed_dial_action_id(plugin_key, capability); - if (change == ActionChange::Removed) { - erase_id(id); - return; - } - PluginLoader& loader = PluginManager::instance().get_loader(); - const std::string package_name = find_loaded_package_name(loader, plugin_key); - // why: make_action only ever resolves this single plugin_key here, unlike build()'s - // multi-plugin lookup, so the callback can just return the already-resolved name. - auto package_name_for = [&package_name](const std::string&) { return package_name; }; - - auto a = make_action(plugin_key, capability, package_name_for); - if (!a) { // why: disabled/unloaded capabilities leave the dial. - erase_id(id); - return; - } - auto it = std::find_if(m_actions.begin(), m_actions.end(), [&](const auto& e) { return e->id == id; }); - if (it != m_actions.end()) - *it = std::move(a); - else - m_actions.push_back(std::move(a)); -} - -void ActionRegistry::refresh_package(const std::string& plugin_key, ActionChange change) -{ - assert(wxThread::IsMain()); - // Drop every action of this package, then (on add/update) re-add its current script caps. - // why: this is the producer path (loader-driven), so it legitimately knows it deals with - // PluginScriptAction - the one type it builds; the cast is not the dispatch-time switch - // that run() polymorphism removed. - m_actions.erase(std::remove_if(m_actions.begin(), m_actions.end(), - [&](const auto& a) { - auto* p = dynamic_cast(a.get()); - return p && p->plugin_key == plugin_key; - }), - m_actions.end()); - if (change == ActionChange::Removed) - return; - PluginLoader& loader = PluginManager::instance().get_loader(); - const std::string package_name = find_loaded_package_name(loader, plugin_key); - // why: every capability iterated below belongs to this single plugin_key, so the - // callback can just return the already-resolved name (see refresh_capability). - auto package_name_for = [&package_name](const std::string&) { return package_name; }; - - for (const auto& cap : loader.get_plugin_capabilities_by_type(plugin_key, PluginCapabilityType::Script)) { - if (!cap || !cap->enabled) - continue; - if (auto a = make_action(plugin_key, cap->name, package_name_for)) - m_actions.push_back(std::move(a)); - } -} - // ---- dispatch + write-through ---------------------------------------------- AppActionRunResult ActionRegistry::run(const std::string& id) @@ -233,10 +134,9 @@ AppActionRunResult ActionRegistry::run(const std::string& id) auto it = std::find_if(m_actions.begin(), m_actions.end(), [&](const auto& e) { return e->id == id; }); if (it == m_actions.end()) return {}; // default Info, empty message - // why: hold a shared_ptr keep-alive, never a bare vector element. The script runner pumps - // a nested event loop (plugin modal UI); a queued refresh_* landing during that loop may - // erase this action from m_actions - the keep-alive keeps the heap object valid until run - // returns. (Replaces the old stack-copy; an abstract AppAction cannot be sliced.) + // why: hold a shared_ptr keep-alive, never a bare vector element. A runner may pump + // a nested event loop; a queued source refresh can erase this action from m_actions, + // while the keep-alive preserves the object until run returns. std::shared_ptr keep = *it; AppActionRunResult o = keep->run(); if (o.level == AppActionRunResult::Level::Busy) @@ -326,7 +226,7 @@ nlohmann::json ActionRegistry::snapshot() const for (const AppAction* a : sorted) actions.push_back({{"id", a->id}, {"title", a->title}, - {"pkg", a->pkg}, + {"source", a->source}, {"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 diff --git a/src/slic3r/GUI/ActionRegistry.hpp b/src/slic3r/GUI/ActionRegistry.hpp index 42bcf26b45..e6f6dd65cf 100644 --- a/src/slic3r/GUI/ActionRegistry.hpp +++ b/src/slic3r/GUI/ActionRegistry.hpp @@ -1,28 +1,22 @@ #pragma once -#include "PluginScriptRunner.hpp" // wxString + ScriptRunOutcome (mapped in the .cpp, not exposed here) +#include "IActionSource.hpp" #include +#include #include #include -#include #include #include +#include #include namespace Slic3r { namespace GUI { -enum class ActionChange { Added, Removed, Updated }; - -// Result of running an AppAction, in the ACTION layer's own vocabulary. -// why: run() must not leak plugin-specific types through the action abstraction, so -// this is deliberately NOT ScriptRunOutcome (the script/plugin layer's type) and is -// neither derived from nor composed with it. Each subclass POPULATES this from -// whatever its underlying runner returns (PluginScriptAction maps a ScriptRunOutcome -// into it). The shape mirrors ScriptRunOutcome for now; keeping them separate lets the -// two diverge freely as non-plugin action sources land. +// Result of running an AppAction, in the action layer's own vocabulary. Concrete +// actions translate their runner-specific result into this generic shape. struct AppActionRunResult { enum class Level { Success, Info, Error, Busy }; @@ -32,19 +26,17 @@ struct AppActionRunResult }; // A speed-dial action: identity + user-state seeded from config + how to run itself. -// Abstract base - the only virtual is run(); a concrete subclass (e.g. -// PluginScriptAction) knows how to run and what its pkg/source is. +// Abstract base - the only virtual is run(); concrete subclasses know how to run +// and what their source is. // note: named AppAction, not Action - Slic3r::GUI::Action is already taken by // UnsavedChangesDialog's exit-action enum, and this header reaches most GUI TUs. struct AppAction { std::string id; // speed_dial_action_id(...) - stable identity + AppConfig key std::string title; // display name - // pkg is the action's SOURCE - where it comes from. For a plugin action it is - // the owning package's display name (row eyebrow); a future app-command action - // would set its own label. Each subclass sets pkg in its constructor (below), - // so the base cannot be built without one. - std::string pkg; + // Where the action comes from. Each subclass sets it in its constructor, so the + // base cannot be built without one. + std::string source; // seeded from AppConfig for the snapshot / sort: bool favourite = false; @@ -55,45 +47,43 @@ struct AppAction virtual AppActionRunResult run() const = 0; // re-resolves + runs (UI thread) protected: - // why: protected + no default forces every subclass to supply id/title/pkg. A - // virtual compute_pkg() can't be used here - virtual dispatch from a base ctor - // resolves to the base, so this constructor-passing is the enforced substitute. - AppAction(std::string id, std::string title, std::string pkg) - : id(std::move(id)), title(std::move(title)), pkg(std::move(pkg)) {} + // why: protected + no default forces every subclass to supply id/title/source. + AppAction(std::string id, std::string title, std::string source) + : id(std::move(id)), title(std::move(title)), source(std::move(source)) {} }; -// The one action source today: a runnable SCRIPT plugin capability. Holds a weak -// (plugin_key, capability) handle, never a live capability pointer - run() -// re-resolves through the loader. -struct PluginScriptAction : AppAction -{ - std::string plugin_key; // weak handle: owning package - std::string capability; // weak handle: capability name - - PluginScriptAction(std::string plugin_key, std::string capability, std::string package_name); - AppActionRunResult run() const override; -}; - -// Single source of runnable actions for the app session, owned by GUI_App. -// Always clean when read: built once at startup, then maintained incrementally by -// loader callbacks (via CallAfter on the UI thread). The action vector is UI-thread -// confined and deliberately unguarded. +// Generic sink and single owner of runnable actions for the app session. +// +// Workflow: +// 1. GUI_App transfers each concrete source to the registry with add_source(). +// 2. init() starts every source; each source enumerates its current actions and +// subscribes to future changes. +// 3. Sources push those changes through upsert() and remove(). The registry keeps +// the only action list and restores persisted user state as actions arrive. +// 4. Consumers use by_id(), snapshot(), and run() without knowing which source +// created an action. class ActionRegistry { public: - // Full sync from the loader. UI thread only. - void build(); + // Starts all registered sources. Call once on the UI thread after sources are + // added; source startup both populates the initial list and wires live updates. + void init(); + + // Transfers ownership of a source to the registry. The source remains dormant + // until init() starts it. + void add_source(std::unique_ptr source); + + // Seeds persisted state, then inserts the action or replaces the action with the + // same id. A null action is ignored. + void upsert(std::shared_ptr action); + + // Removes the action with this id. Missing ids are a harmless no-op. + void remove(const std::string& id); // Always-clean read surface. UI thread only. const std::vector>& all() const { assert(wxThread::IsMain()); return m_actions; } const AppAction* by_id(const std::string& id) const; - // Targeted incremental maintenance, reached via CallAfter from loader callbacks. - // refresh_package syncs every script capability of one package (load/unload). - // refresh_capability syncs a single capability (enable/disable/key migration). - void refresh_package(const std::string& plugin_key, ActionChange change); - void refresh_capability(const std::string& plugin_key, const std::string& capability, ActionChange change); - // Dispatch + write-through (registry is the only thing that touches AppConfig). AppActionRunResult run(const std::string& id); // runs + bumps stats void set_favourite(const std::string& id, bool on); @@ -110,16 +100,13 @@ private: void seed_state(AppAction& a) const; // favourite/stats from config AppAction* find(const std::string& id); void erase_id(const std::string& id); - // Builds the action for one loaded+enabled script capability; returns nullptr - // when the capability is unloaded, missing, or disabled. - std::shared_ptr make_action(const std::string& plugin_key, const std::string& capability, - const std::function& package_name_for) const; // why: vector storage favors the popup path, which snapshots by iterating, sorting, and - // serializing small action sets. Id lookups are event-driven and build is startup-only, so + // serializing small action sets. Id lookups are event-driven and initialization is startup-only, so // a persistent map would add hashing/allocation cost without helping render. // shared_ptr: AppAction is abstract (stored by pointer), and run() takes a keep-alive - // copy so a queued refresh can't erase the object mid-run (nested plugin event loop). + // copy so a queued source refresh cannot erase the object mid-run. + std::vector> m_sources; std::vector> m_actions; // UI-thread confined; no lock }; diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 7ed404e40e..27d000657c 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -142,6 +142,7 @@ #include "PluginsDialog.hpp" #include "SpeedDialDialog.hpp" +#include "ScriptActionSource.hpp" #include "TerminalDialog.hpp" //#ifdef WIN32 @@ -3171,32 +3172,6 @@ bool GUI_App::on_init_inner() refresh_plugins_dialog(); }); - // why: keep the speed-dial ActionRegistry always-clean. Each loader callback marshals - // a TARGETED refresh onto the UI thread (the callback may fire on a worker thread, so - // it must not touch the registry's vector directly). No custom event - CallAfter is - // the pubsub channel. - auto refresh_pkg = [](const std::string& key, ActionChange change) { - if (!wxTheApp || wxGetApp().is_closing()) - return; - wxGetApp().CallAfter([key, change] { - if (!wxGetApp().is_closing()) - wxGetApp().action_registry().refresh_package(key, change); - }); - }; - auto refresh_cap = [](const PluginCapabilityIdentifier& cap, ActionChange change) { - if (cap.type != PluginCapabilityType::Script || !wxTheApp || wxGetApp().is_closing()) - return; - const std::string key = cap.plugin_key, name = cap.name; - wxGetApp().CallAfter([key, name, change] { - if (!wxGetApp().is_closing()) - wxGetApp().action_registry().refresh_capability(key, name, change); - }); - }; - plugin_mgr.get_loader().subscribe_on_load_callback([refresh_pkg](const std::string& key) { refresh_pkg(key, ActionChange::Added); }); - plugin_mgr.get_loader().subscribe_on_unload_callback([refresh_pkg](const std::string& key) { refresh_pkg(key, ActionChange::Removed); }); - plugin_mgr.get_loader().subscribe_on_capability_load_callback([refresh_cap](const PluginCapabilityIdentifier& c) { refresh_cap(c, ActionChange::Added); }); - plugin_mgr.get_loader().subscribe_on_capability_unload_callback([refresh_cap](const PluginCapabilityIdentifier& c) { refresh_cap(c, ActionChange::Removed); }); - for (const std::string& plugin_key : plugin_mgr.get_catalog().get_enabled_plugin_keys()) { if (!plugin_mgr.get_loader().is_plugin_loaded(plugin_key)) { plugin_mgr.get_loader().load_plugin(plugin_mgr.get_catalog(), plugin_key, false); @@ -3204,10 +3179,8 @@ bool GUI_App::on_init_inner() } } - // why: seed the registry once on the UI thread after startup auto-load. The deferred - // per-plugin callbacks above are idempotent (upsert by id), so re-processing the same - // packages is harmless; this guarantees a populated set even before any callback lands. - m_action_registry.build(); + m_action_registry.add_source(std::make_unique()); + m_action_registry.init(); if (m_agent) plugin_mgr.set_cloud_agent(std::dynamic_pointer_cast(m_agent->get_cloud_agent())); diff --git a/src/slic3r/GUI/IActionSource.hpp b/src/slic3r/GUI/IActionSource.hpp new file mode 100644 index 0000000000..f76e6a281f --- /dev/null +++ b/src/slic3r/GUI/IActionSource.hpp @@ -0,0 +1,14 @@ +#pragma once + +namespace Slic3r { namespace GUI { + +class ActionRegistry; + +class IActionSource +{ +public: + virtual ~IActionSource() = default; + virtual void start(ActionRegistry& sink) = 0; +}; + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/ScriptActionSource.cpp b/src/slic3r/GUI/ScriptActionSource.cpp new file mode 100644 index 0000000000..8f5887b698 --- /dev/null +++ b/src/slic3r/GUI/ScriptActionSource.cpp @@ -0,0 +1,205 @@ +#include "ScriptActionSource.hpp" + +#include "GUI_App.hpp" +#include "PluginScriptRunner.hpp" +#include "SpeedDialActionId.hpp" +#include "slic3r/plugin/PluginManager.hpp" + +#include +#include + +#include + +#include +#include +#include + +namespace Slic3r { namespace GUI { + +namespace { + +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; +} + +} // namespace + +PluginScriptAction::PluginScriptAction(std::string plugin_key, std::string capability, std::string source) + : AppAction(speed_dial_action_id(plugin_key, capability), + capability.empty() ? plugin_key : capability, + std::move(source)), + plugin_key(std::move(plugin_key)), capability(std::move(capability)) +{} + +AppActionRunResult PluginScriptAction::run() const +{ + 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}; +} + +void ScriptActionSource::start(ActionRegistry& sink) +{ + assert(wxThread::IsMain()); + assert(m_sink == nullptr); + m_sink = &sink; + + PluginLoader& loader = PluginManager::instance().get_loader(); + + auto refresh_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 refresh_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 in between the + // initial snapshot and callback registration. Duplicate notifications are safe: + // the sink upserts by id and tracking is de-duplicated. + loader.subscribe_on_load_callback( + [refresh_source](const std::string& key) { refresh_source(key, ActionChange::Added); }); + loader.subscribe_on_unload_callback( + [refresh_source](const std::string& key) { refresh_source(key, ActionChange::Removed); }); + loader.subscribe_on_capability_load_callback( + [refresh_capability](const PluginCapabilityIdentifier& capability) { + refresh_capability(capability, ActionChange::Added); + }); + loader.subscribe_on_capability_unload_callback( + [refresh_capability](const PluginCapabilityIdentifier& capability) { + refresh_capability(capability, ActionChange::Removed); + }); + + enumerate(); +} + +void ScriptActionSource::enumerate() +{ + assert(wxThread::IsMain()); + assert(m_sink != nullptr); + + PluginLoader& loader = PluginManager::instance().get_loader(); + std::unordered_map 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 source = source_names.find(capability->plugin_key); + const std::string& source_name = source == source_names.end() ? capability->plugin_key : source->second; + if (auto action = make_action(capability->plugin_key, capability->name, source_name)) { + track(capability->plugin_key, action->id); + m_sink->upsert(std::move(action)); + } + } +} + +std::shared_ptr ScriptActionSource::make_action(const std::string& plugin_key, + const std::string& capability, + const std::string& source) const +{ + 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_shared(plugin_key, capability, source); +} + +void ScriptActionSource::refresh_capability(const std::string& plugin_key, const std::string& capability, + ActionChange change) +{ + assert(wxThread::IsMain()); + assert(m_sink != nullptr); + + const std::string id = speed_dial_action_id(plugin_key, capability); + if (change == ActionChange::Removed) { + m_sink->remove(id); + untrack(plugin_key, id); + return; + } + + PluginLoader& loader = PluginManager::instance().get_loader(); + auto action = make_action(plugin_key, capability, find_loaded_source_name(loader, plugin_key)); + if (!action) { + m_sink->remove(id); + untrack(plugin_key, id); + return; + } + + track(plugin_key, id); + m_sink->upsert(std::move(action)); +} + +void ScriptActionSource::refresh_source(const std::string& plugin_key, ActionChange change) +{ + assert(wxThread::IsMain()); + assert(m_sink != nullptr); + + auto tracked = m_ids_by_plugin.find(plugin_key); + if (tracked != m_ids_by_plugin.end()) { + for (const std::string& id : tracked->second) + m_sink->remove(id); + m_ids_by_plugin.erase(tracked); + } + 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)) { + track(plugin_key, action->id); + m_sink->upsert(std::move(action)); + } + } +} + +void ScriptActionSource::track(const std::string& plugin_key, const std::string& id) +{ + auto& ids = m_ids_by_plugin[plugin_key]; + if (std::find(ids.begin(), ids.end(), id) == ids.end()) + ids.push_back(id); +} + +void ScriptActionSource::untrack(const std::string& plugin_key, const std::string& id) +{ + auto tracked = m_ids_by_plugin.find(plugin_key); + if (tracked == m_ids_by_plugin.end()) + return; + + auto& ids = tracked->second; + ids.erase(std::remove(ids.begin(), ids.end(), id), ids.end()); + if (ids.empty()) + m_ids_by_plugin.erase(tracked); +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/ScriptActionSource.hpp b/src/slic3r/GUI/ScriptActionSource.hpp new file mode 100644 index 0000000000..85949f601d --- /dev/null +++ b/src/slic3r/GUI/ScriptActionSource.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include "ActionRegistry.hpp" + +#include +#include +#include +#include + +namespace Slic3r { namespace GUI { + +enum class ActionChange { Added, Removed }; + +struct PluginScriptAction : AppAction +{ + std::string plugin_key; + std::string capability; + + PluginScriptAction(std::string plugin_key, std::string capability, std::string source); + AppActionRunResult run() const override; +}; + +class ScriptActionSource final : public IActionSource +{ +public: + void start(ActionRegistry& sink) override; + +private: + void enumerate(); + void refresh_source(const std::string& plugin_key, ActionChange change); + void refresh_capability(const std::string& plugin_key, const std::string& capability, ActionChange change); + std::shared_ptr make_action(const std::string& plugin_key, const std::string& capability, + const std::string& source) const; + void track(const std::string& plugin_key, const std::string& id); + void untrack(const std::string& plugin_key, const std::string& id); + + ActionRegistry* m_sink = nullptr; + std::unordered_map> m_ids_by_plugin; +}; + +}} // namespace Slic3r::GUI diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 490f56a097..e122e4e760 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -1,6 +1,7 @@ get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME) add_executable(${_TEST_NAME}_tests ${_TEST_NAME}_tests_main.cpp + test_action_source.cpp test_plugin_host_api.cpp test_plugin_capability_identifier.cpp test_plugin_install.cpp diff --git a/tests/slic3rutils/test_action_source.cpp b/tests/slic3rutils/test_action_source.cpp new file mode 100644 index 0000000000..8a90d2ccb1 --- /dev/null +++ b/tests/slic3rutils/test_action_source.cpp @@ -0,0 +1,35 @@ +#include + +#include "slic3r/GUI/ActionRegistry.hpp" +#include "slic3r/GUI/IActionSource.hpp" + +#include + +using Slic3r::GUI::ActionRegistry; +using Slic3r::GUI::IActionSource; + +namespace { + +class RecordingActionSource final : public IActionSource +{ +public: + explicit RecordingActionSource(bool& started) : m_started(started) {} + + void start(ActionRegistry&) override { m_started = true; } + +private: + bool& m_started; +}; + +} // namespace + +TEST_CASE("ActionRegistry starts registered action sources", "[speeddial][actions]") +{ + bool started = false; + ActionRegistry registry; + registry.add_source(std::make_unique(started)); + + registry.init(); + + CHECK(started); +}