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() {}