diff --git a/resources/web/dialog/PluginsDialog/index.html b/resources/web/dialog/PluginsDialog/index.html index 881cd38e21..99ba76daa4 100644 --- a/resources/web/dialog/PluginsDialog/index.html +++ b/resources/web/dialog/PluginsDialog/index.html @@ -151,16 +151,27 @@ + + diff --git a/resources/web/dialog/PluginsDialog/index.js b/resources/web/dialog/PluginsDialog/index.js index 4e9333a8d0..d0ad2310a8 100644 --- a/resources/web/dialog/PluginsDialog/index.js +++ b/resources/web/dialog/PluginsDialog/index.js @@ -70,7 +70,15 @@ function OnInit() { document.getElementById("configSidebar")?.addEventListener("click", OnConfigSidebarClick); document.getElementById("configSaveBtn")?.addEventListener("click", SaveCapabilityConfig); - document.getElementById("configText")?.addEventListener("input", ValidateConfigText); + document.getElementById("configRestoreBtn")?.addEventListener("click", RestoreCapabilityConfig); + + const configText = document.getElementById("configText"); + // why: common.js installs a document-level onkeydown that cancels the default action of every key + // (returnValue=false) to block webview shortcuts; on the way up it also swallows typing. Stop + // the editor's keydowns from bubbling to it so the textarea stays editable, leaving the global + // guard intact. Same treatment as the search field (see plugin-search.js). + configText?.addEventListener("keydown", (event) => event.stopPropagation()); + configText?.addEventListener("input", ValidateConfigText); // The custom capability UI is sandboxed into an opaque origin, so it reaches us only through // postMessage. Match on the frame's own contentWindow rather than the origin (which is "null" // for a sandboxed frame) and ignore anything else on the channel. @@ -939,12 +947,14 @@ function RequestCapabilityConfig() { } // Empties both editors, so nothing from the previously selected capability can linger while the -// next one is still in flight. +// next one is still in flight. The footer goes with them: until a config has actually loaded there +// is nothing to save or restore. function ClearCapabilityConfigView() { const editor = document.getElementById("configEditor"); const custom = document.getElementById("configCustom"); const text = document.getElementById("configText"); const error = document.getElementById("configError"); + const footer = document.getElementById("configFooter"); if (editor) editor.hidden = true; @@ -958,6 +968,8 @@ function ClearCapabilityConfigView() { error.hidden = true; error.textContent = ""; } + if (footer) + footer.hidden = true; SetConfigValidation(""); } @@ -986,6 +998,10 @@ function ApplyCapabilityConfig(payload) { const config = (payload && typeof payload.config === "object" && payload.config !== null) ? payload.config : {}; const html = String(payload?.custom_html || ""); + // Restore is host chrome and applies to either editor; Save and the validation message belong to + // the JSON editor, since a custom UI saves through its own controls via the bridge. + ShowConfigFooter(!html); + if (html) { // A capability with its own UI: hand it the config through the bridge, never the raw file. if (custom) { @@ -1010,6 +1026,21 @@ function ApplyCapabilityConfig(payload) { SetConfigValidation(""); } +// Reveals the footer for the loaded capability. `withEditorControls` is false for a custom UI, +// leaving Restore on its own. +function ShowConfigFooter(withEditorControls) { + const footer = document.getElementById("configFooter"); + const save = document.getElementById("configSaveBtn"); + const validation = document.getElementById("configValidation"); + + if (footer) + footer.hidden = false; + if (save) + save.hidden = !withEditorControls; + if (validation) + validation.hidden = !withEditorControls; +} + function SetConfigValidation(message) { const node = document.getElementById("configValidation"); const save = document.getElementById("configSaveBtn"); @@ -1054,6 +1085,21 @@ function SaveCapabilityConfig() { }); } +// Asks the native side to write the capability's default config over whatever is stored. The +// defaults come from the capability's get_default_config(), never from this page — the host does not +// know what a given plugin considers default. The native side confirms before discarding anything, +// and replies with the same "saved" payload, so both editors reload from what was persisted. +function RestoreCapabilityConfig() { + if (!selectedPluginId || !selectedCapabilityName) + return; + + SendMessage("restore_capability_config", { + plugin_key: selectedPluginId, + capability_name: selectedCapabilityName, + capability_type: selectedCapabilityType + }); +} + function ApplyCapabilityConfigSaved(payload) { if (!IsCurrentCapability(payload)) return; diff --git a/resources/web/dialog/PluginsDialog/styles.css b/resources/web/dialog/PluginsDialog/styles.css index 4253cc4a3b..44938cd598 100644 --- a/resources/web/dialog/PluginsDialog/styles.css +++ b/resources/web/dialog/PluginsDialog/styles.css @@ -1182,9 +1182,9 @@ body { min-height: 0; padding: 8px; box-sizing: border-box; - /* common.css applies `user-select: none` to *, which in WebKit also stops a textarea taking a - caret — the editor would be focusable but impossible to type into. Editable surfaces have to - opt back in (same as include/xterm/xterm.css does for the terminal). */ + /* common.css applies `user-select: none` to *, so without this the user could type into the + editor but not select, drag or copy what they had typed. (What blocks typing is the global + onkeydown guard in common.js — see the keydown handler in index.js.) */ -webkit-user-select: text; user-select: text; border: 1px solid var(--border); @@ -1204,13 +1204,29 @@ body { border-color: var(--main-color); } -.config-editor-footer { +.config-view-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.config-view-footer[hidden] { + display: none; +} + +/* Restore sits immediately left of Save, both pinned right; the validation message takes the + remaining space on the left. */ +.config-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.config-actions > button[hidden] { + display: none; +} + .config-validation { color: var(--muted); font-size: 11px; diff --git a/src/slic3r/GUI/PluginsDialog.cpp b/src/slic3r/GUI/PluginsDialog.cpp index 1675871007..ee521a6254 100644 --- a/src/slic3r/GUI/PluginsDialog.cpp +++ b/src/slic3r/GUI/PluginsDialog.cpp @@ -536,6 +536,10 @@ void PluginsDialog::on_script_message(const nlohmann::json& payload) plugin_capability_type_from_string(payload.value("capability_type", "")), payload.value("capability_name", ""), payload.contains("config") ? payload.at("config") : nlohmann::json::object()); + } else if (command == "restore_capability_config") { + restore_capability_config(payload.value("plugin_key", ""), + plugin_capability_type_from_string(payload.value("capability_type", "")), + payload.value("capability_name", "")); } else if (command == "set_plugin_install_action") { const std::string action = payload.value("action", ""); if (action == "explore" || action == "install-local") @@ -1035,6 +1039,84 @@ void PluginsDialog::save_capability_config(const std::string& plugin_key, show_status(_L("Configuration saved."), "success"); } +// Overwrites one capability's stored config with the value its get_default_config() hands back. +// The host does not invent that value: a capability that does not override the hook restores an +// empty config, which is exactly right for one that applies its own defaults on read. +void PluginsDialog::restore_capability_config(const std::string& plugin_key, + PluginCapabilityType type, + const std::string& capability_name) +{ + nlohmann::json response; + response["command"] = "capability_config_saved"; + response["plugin_key"] = plugin_key; + response["capability_name"] = capability_name; + response["capability_type"] = plugin_capability_type_to_string(type); + response["ok"] = false; + response["error"] = ""; + + auto cap = get_capability(plugin_key, type, capability_name); + if (!cap) { + BOOST_LOG_TRIVIAL(warning) << "Refusing to restore config for a capability that is no longer loaded. plugin_key=" + << plugin_key << " capability_name=" << capability_name; + response["error"] = into_u8(_L("This capability is no longer available.")); + call_web_handler(response); + return; + } + + // Discards whatever the user had stored, so confirm first — same as the other destructive + // actions in this dialog. + const int rc = wxMessageBox(wxString::Format(_L("Restore the default configuration for \"%s\"?\n\n" + "This discards the settings currently saved for this capability."), + from_u8(capability_name)), + _L("Restore defaults"), wxYES_NO | wxNO_DEFAULT | wxICON_WARNING, this); + if (rc != wxYES) + return; + + nlohmann::json defaults; + std::string error; + { + wxBusyCursor busy; + try { + PythonGILState gil; + defaults = cap->instance->get_default_config(); + } catch (const std::exception& ex) { + error = ex.what(); + } catch (...) { + error = "Unknown error"; + } + } + + // A raising hook leaves the stored config exactly as it was: better to restore nothing than to + // wipe the user's settings on the strength of a broken plugin. + if (!error.empty()) { + BOOST_LOG_TRIVIAL(error) << "Plugin capability get_default_config() failed. plugin_key=" << plugin_key + << " capability_name=" << capability_name << " error=" << error; + response["error"] = into_u8(format_wxstr(_L("The plugin could not supply a default configuration (%1%). " + "Nothing was changed."), + from_u8(error))); + call_web_handler(response); + return; + } + + if (!PluginManager::instance().get_config().store_capability_config(plugin_key, capability_name, defaults)) { + BOOST_LOG_TRIVIAL(error) << "Failed to write the plugin config file while restoring defaults. plugin_key=" << plugin_key + << " capability_name=" << capability_name; + response["error"] = into_u8(_L("The configuration could not be written to disk. Nothing was changed.")); + call_web_handler(response); + return; + } + + BOOST_LOG_TRIVIAL(info) << "Restored default plugin capability config. plugin_key=" << plugin_key + << " capability_name=" << capability_name; + + // Reuses the saved reply, so both editors reload from what was actually persisted. + response["ok"] = true; + response["config"] = PluginManager::instance().get_config().get_config(plugin_key, capability_name).config; + call_web_handler(response); + + show_status(_L("Default configuration restored."), "success"); +} + void PluginsDialog::run_script_plugin(const std::string& plugin_key, const std::string& capability_name) { if (plugin_key.empty() || capability_name.empty()) { diff --git a/src/slic3r/GUI/PluginsDialog.hpp b/src/slic3r/GUI/PluginsDialog.hpp index 47f868f739..dc17ce5a82 100644 --- a/src/slic3r/GUI/PluginsDialog.hpp +++ b/src/slic3r/GUI/PluginsDialog.hpp @@ -75,6 +75,7 @@ private: PluginCapabilityType type, const std::string& capability_name, const nlohmann::json& config); + void restore_capability_config(const std::string& plugin_key, PluginCapabilityType type, const std::string& capability_name); // Pushes a one-line result into the web footer status bar (level: "success" | "warn" | "error" | "info"), // used for every plugin/capability operation instead of a modal box so the dialog stays non-disruptive. void show_status(const wxString& message, const char* level); diff --git a/src/slic3r/plugin/PyPluginTrampoline.hpp b/src/slic3r/plugin/PyPluginTrampoline.hpp index 1cb8a6f5ea..ba478de451 100644 --- a/src/slic3r/plugin/PyPluginTrampoline.hpp +++ b/src/slic3r/plugin/PyPluginTrampoline.hpp @@ -3,10 +3,13 @@ #include +#include + #include #include "PythonPluginInterface.hpp" #include "PythonInterpreter.hpp" +#include "PythonJsonUtils.hpp" #include "PluginAuditManager.hpp" // Trampoline variants of pybind11's override macros. Every C++->Python plugin call @@ -90,6 +93,40 @@ public: get_config_ui); } + // Hand-rolled rather than PYBIND11_OVERRIDE: the macro casts the Python result to the return + // type, and nlohmann::json has no pybind caster (config crosses this boundary through the + // explicit py_to_json/json_to_py helpers instead). Otherwise identical — same audit scope, and + // a Python exception is logged with its traceback and rethrown for the caller to handle. + // + // The hook is optional, and "not implemented" must mean an EMPTY config, never a null or a + // stray scalar landing in cap_config. Two ways to not implement it, both resolved here: + // - no override at all -> the base's empty object + // - an override that returns None, or any -> likewise. `def get_default_config(self): pass` + // non-object (a list, a string, a number) is the easy mistake, and it must not be able to + // write `"cap_config": null` to config.json. + nlohmann::json get_default_config() const override + { + ORCA_PY_AUDIT_SCOPE(::Slic3r::PluginAuditManager::AuditMode::Loading); + try { + pybind11::gil_scoped_acquire gil; + pybind11::function override = pybind11::get_override(static_cast(this), "get_default_config"); + if (!override) + return Base::get_default_config(); + + nlohmann::json config = ::Slic3r::py_to_json(override()); + if (!config.is_object()) { + BOOST_LOG_TRIVIAL(warning) + << "Plugin capability '" << this->audit_capability_name() << "' of plugin '" << this->audit_plugin_key() + << "': get_default_config() returned " << config.type_name() << ", not an object; restoring an empty config"; + return Base::get_default_config(); + } + return config; + } catch (pybind11::error_already_set& err) { + ::Slic3r::log_python_exception_keep(err); + throw; + } + } + // All plugins may define their own on_load/unload functions. void on_load() override { diff --git a/src/slic3r/plugin/PythonPluginBridge.cpp b/src/slic3r/plugin/PythonPluginBridge.cpp index 5480a3c4cf..1aa23c57e0 100644 --- a/src/slic3r/plugin/PythonPluginBridge.cpp +++ b/src/slic3r/plugin/PythonPluginBridge.cpp @@ -339,6 +339,16 @@ void bind_python_api(pybind11::module_& m) "Override to return the custom configuration UI as an HTML string. Only called when\n" "has_config_ui() is True; an empty result falls back to the default JSON editor.\n" "Inside the page, use window.orca.getConfig()/saveConfig() to reach this same config.") + .def( + "get_default_config", + [](const PluginCapabilityInterface& self) { + nlohmann::json config = self.get_default_config(); + return json_to_py(config); // GIL held (binding body) + }, + "Override to return the config that the Config tab's \"Restore defaults\" action writes\n" + "back. Optional: without it the action stores an empty dict, which already restores the\n" + "defaults of a capability that keeps its stored config sparse and applies its own\n" + "defaults on read. Override it to write an explicit starting config instead.") .def( "get_config", [](const PluginCapabilityInterface& self) { diff --git a/src/slic3r/plugin/PythonPluginInterface.hpp b/src/slic3r/plugin/PythonPluginInterface.hpp index b2cf95ec89..618445f503 100644 --- a/src/slic3r/plugin/PythonPluginInterface.hpp +++ b/src/slic3r/plugin/PythonPluginInterface.hpp @@ -6,6 +6,7 @@ #include #include +#include #include namespace Slic3r { @@ -116,6 +117,16 @@ public: // treated as "no custom UI" and falls back to the default JSON editor. virtual std::string get_config_ui() const { return ""; } + // The config the Config tab's "Restore defaults" action writes back. Optional. + // + // Not overridden -> an empty object, which is the right answer for a capability that keeps + // its stored config sparse and applies its own defaults on read: clearing the overrides + // *is* restoring the defaults, and it keeps a later release free to change them. + // Override it to write an explicit starting config instead (e.g. to seed a form UI with + // every field present). The host neither invents nor validates this value; it only stores + // whatever comes back, so a throwing override leaves the stored config untouched. + virtual nlohmann::json get_default_config() const { return nlohmann::json::object(); } + virtual void on_load() {} virtual void on_unload() {}