#include "ActionRegistry.hpp" #include "GUI.hpp" #include "GUI_App.hpp" #include "I18N.hpp" #include "MainFrame.hpp" #include "NativeCommands.hpp" #include "Notebook.hpp" #include "Plater.hpp" #include "Search.hpp" #include "Tab.hpp" #include "slic3r/plugin/PluginManager.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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 read_string_array(const char* key) { auto j = read_section(key, nlohmann::json::array()); std::vector v; for (auto& e : j) if (e.is_string()) v.push_back(e.get()); 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 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(plugin_key, capability, source_name); } // ---- built-in command actions (the speed dial "commands" section) ------ constexpr const char* kCommandPrefix = "orca_command"; constexpr const char* kOrcaSourceKey = "orca"; constexpr const char* kOrcaSourceName = "OrcaSlicer"; 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. 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 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); } AppActionRunResult run(const std::string& /*param*/) const override { wxGetApp().sidebar().jump_to_option(opt_key, type, category); return {AppActionRunResult::Level::Success}; } }; // A built-in command action. Thin value: identity + presentation come from the NativeCommands // catalog, and run() routes back to it - the catalog is the single source of truth for its // behaviour. The id is keyed by the stable catalog key (NOT the display title), so a rename or a // UI-language switch never re-keys the action; the title is display-only. struct CommandAction : AppAction { static std::unique_ptr make(const NativeCommand& c) { return std::unique_ptr(new CommandAction(c)); } std::string command_key; AppActionRunResult run(const std::string& param) const override { return NativeCommands::run(command_key, param); } private: explicit CommandAction(const NativeCommand& c) : AppAction(AppActionId{AppAction::compose_id(kCommandPrefix, c.key, kOrcaSourceKey)}, c.title, kOrcaSourceKey, kOrcaSourceName) , command_key(c.key) { this->kind = AppActionKind::Command; this->group = c.group; this->input = c.input; } }; // 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 = _u8L("Plate"); } AppActionRunResult run(const std::string& /*param*/) const override { return NativeCommands::run("plate_goto", std::to_string(plate_index)); } }; // A dynamic "Open recent project " 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 = _u8L("Recent Projects"); } 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 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 || !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. 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 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)); } // 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 lives in NativeCommands - the registry // only materialises thin CommandAction values from it. for (const NativeCommand& c : NativeCommands::catalog()) upsert(CommandAction::make(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 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)); } } 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::upsert(std::unique_ptr action) { assert(wxThread::IsMain()); if (!action) return; seed_state(*action); std::string id = action->id(); std::shared_ptr 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; } } // ---- 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(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 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 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 favs = read_string_array("favourite_actions"); std::vector 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& ids) { assert(wxThread::IsMain()); auto cur = read_string_array("favourite_actions"); std::vector 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 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& 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 favs = favourite_ids(); std::unordered_set seen; for (const Search::Option& opt : options) { 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(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); } auto const action_id = action->id(); auto const app_action = std::shared_ptr(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. 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; } } 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). for (auto it = m_actions.begin(); it != m_actions.end();) { if (it->first.rfind(kPlateGotoPrefix, 0) == 0) it = m_actions.erase(it); else ++it; } 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 = read_section("stats", nlohmann::json::object()); if (!stats.is_object()) stats = nlohmann::json::object(); const std::vector favs = favourite_ids(); const std::vector& list = plater->get_partplate_list().get_plate_list(); std::unordered_set 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. std::string title(_u8L("Go to Plate")); title += " " + std::to_string(i + 1); const std::string name = plate->get_plate_name(); if (!name.empty()) title += " (" + name + ")"; auto action = std::make_unique(int(i), title, kOrcaSourceName); 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); } auto const action_id = action->id(); auto const app_action = std::shared_ptr(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). for (auto it = m_actions.begin(); it != m_actions.end();) { if (it->first.rfind(kPlateGotoPrefix, 0) == 0 && !seen.count(it->first)) it = m_actions.erase(it); else ++it; } } 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 = read_section("stats", nlohmann::json::object()); if (!stats.is_object()) stats = nlohmann::json::object(); const std::vector favs = favourite_ids(); // app_config stores recents oldest-first; the palette shows newest-first. std::vector recents = wxGetApp().app_config->get_recent_projects(); std::reverse(recents.begin(), recents.end()); std::unordered_set 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(path, std::move(title), 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); } auto const action_id = action->id(); auto const app_action = std::shared_ptr(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. for (auto it = m_actions.begin(); it != m_actions.end();) { if (it->first.rfind(kRecentProjectPrefix, 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()); 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() { 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 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(); }); auto action_to_json = [](const AppAction* a) { return nlohmann::json({{"id", a->id()}, {"title", a->title()}, {"source", a->source_name()}, {"group", a->group}, {"input", a->input}, {"shortcut", ""}}); }; nlohmann::json actions = nlohmann::json::array(); for (const AppAction* a : sorted) actions.push_back(action_to_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 favs = favourite_ids(); std::vector 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). constexpr size_t kRecentLimit = 5; std::vector 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() > kRecentLimit) recent.resize(kRecentLimit); nlohmann::json recent_json = nlohmann::json::array(); for (const AppAction* a : recent) recent_json.push_back(action_to_json(a)); return {{"actions", std::move(actions)}, {"favourites", std::move(favourites)}, {"recent", std::move(recent_json)}}; } // ---- 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; Notebook* notebook = mf->m_tabpanel; for (size_t i = 0; i < notebook->GetPageCount(); ++i) { const wxString id = notebook->GetPageName(i); if (id.empty()) continue; out.push_back({{"id", id.ToStdString()}, {"title", notebook->GetPageText(i).ToStdString()}}); } return out; } }} // namespace Slic3r::GUI