mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-08-30 21:36:57 +00:00
feat: sidebar plugin config UI
This commit is contained in:
64
resources/web/dialog/PluginsConfigDialog/index.html
Normal file
64
resources/web/dialog/PluginsConfigDialog/index.html
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Plugin configuration</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<!-- Same three shared sheets every resources/web/dialog/* page links (see PluginsDialog/index.html):
|
||||||
|
global.css for buttons/scrollbars, common.css for the base reset, theme.css last so its
|
||||||
|
host-injected --orca-* contract wins and themes the .config-* rules ported into styles.css. -->
|
||||||
|
<link rel="stylesheet" type="text/css" href="../../include/global.css">
|
||||||
|
<link rel="stylesheet" type="text/css" href="../css/common.css">
|
||||||
|
<link rel="stylesheet" type="text/css" href="../css/theme.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="page">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1 id="pageTitle" class="page-title">Plugin configuration</h1>
|
||||||
|
<p id="pageSubtitle" class="page-subtitle"></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="configEmpty" class="detail-empty">This preset does not use any plugin capabilities</div>
|
||||||
|
|
||||||
|
<div id="configLayout" class="config-layout" hidden>
|
||||||
|
<div id="configSidebar" class="config-sidebar thin-scroll" role="listbox"
|
||||||
|
aria-label="Capabilities used by this preset"></div>
|
||||||
|
<div class="config-view">
|
||||||
|
<div id="configError" class="config-error" role="status" aria-live="polite" hidden></div>
|
||||||
|
<div id="configMeta" class="config-meta" hidden>
|
||||||
|
<span id="configSourceBadge" class="config-source-badge"></span>
|
||||||
|
</div>
|
||||||
|
<div id="configEditor" class="config-editor" hidden>
|
||||||
|
<textarea id="configText" class="config-textarea thin-scroll" spellcheck="false"
|
||||||
|
autocomplete="off" autocapitalize="off" aria-label="Capability configuration (JSON)"></textarea>
|
||||||
|
</div>
|
||||||
|
<!-- Custom capability UI. Sandboxed without allow-same-origin, so the plugin's HTML runs in
|
||||||
|
an opaque origin: it cannot touch this page, and the only host surface it gets is the
|
||||||
|
window.orca getConfig/saveConfig bridge injected into srcdoc. -->
|
||||||
|
<iframe id="configCustom" class="config-custom" title="Plugin configuration"
|
||||||
|
sandbox="allow-scripts" referrerpolicy="no-referrer" hidden></iframe>
|
||||||
|
<div id="configFooter" class="config-view-footer" hidden>
|
||||||
|
<span id="configValidation" class="config-validation" role="status" aria-live="polite"></span>
|
||||||
|
<div class="config-actions">
|
||||||
|
<button id="configUseGlobalBtn" class="ButtonStyleRegular ButtonTypeChoice" type="button"
|
||||||
|
title="Remove the preset override and use the global configuration again">
|
||||||
|
Use global
|
||||||
|
</button>
|
||||||
|
<button id="configRestoreBtn" class="ButtonStyleRegular ButtonTypeChoice" type="button"
|
||||||
|
title="Write the plugin defaults into this preset override">
|
||||||
|
Restore defaults
|
||||||
|
</button>
|
||||||
|
<button id="configSaveBtn" class="ButtonStyleConfirm ButtonTypeChoice" type="button">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer id="statusBar" class="status-bar is-empty">
|
||||||
|
<span id="statusText" class="status-text"></span>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
<script src="index.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
499
resources/web/dialog/PluginsConfigDialog/index.js
Normal file
499
resources/web/dialog/PluginsConfigDialog/index.js
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
// The capabilities the active preset uses, as sent by PluginsConfigDialog::send_capabilities.
|
||||||
|
// Each row is {plugin_key, name, type, type_key, has_config_ui} — the shape
|
||||||
|
// PluginConfig::capabilities_payload emits, shared with the Plugins dialog's Config tab.
|
||||||
|
let capabilities = [];
|
||||||
|
|
||||||
|
// The selected row's full identity. plugin_key is part of it because this list spans plugins,
|
||||||
|
// unlike the Plugins dialog's config tab where every row belongs to the one selected plugin.
|
||||||
|
let selectedPluginKey = "";
|
||||||
|
let selectedCapabilityName = "";
|
||||||
|
let selectedCapabilityType = "";
|
||||||
|
let selectedHasPresetOverride = false;
|
||||||
|
let selectedReadOnly = false;
|
||||||
|
|
||||||
|
function SafeJsonParse(text) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SendWXMessage(message) {
|
||||||
|
if (window.wx && typeof window.wx.postMessage === "function")
|
||||||
|
window.wx.postMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SendMessage(command, payload = {}) {
|
||||||
|
const message = {
|
||||||
|
sequence_id: Math.round(Date.now() / 1000),
|
||||||
|
command: command
|
||||||
|
};
|
||||||
|
Object.keys(payload).forEach((key) => {
|
||||||
|
message[key] = payload[key];
|
||||||
|
});
|
||||||
|
SendWXMessage(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
function HandleStudio(value) {
|
||||||
|
const payload = (typeof value === "string") ? SafeJsonParse(value) : value;
|
||||||
|
if (!payload || typeof payload !== "object")
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (payload.command === "list_capabilities") {
|
||||||
|
ApplyCapabilities(payload);
|
||||||
|
} else if (payload.command === "status_message") {
|
||||||
|
ShowStatusMessage(String(payload.message || ""), String(payload.level || "info"));
|
||||||
|
} else if (payload.command === "capability_config") {
|
||||||
|
ApplyCapabilityConfig(payload);
|
||||||
|
} else if (payload.command === "capability_config_saved") {
|
||||||
|
ApplyCapabilityConfigSaved(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShowStatusMessage(message, level) {
|
||||||
|
const bar = document.getElementById("statusBar");
|
||||||
|
const text = document.getElementById("statusText");
|
||||||
|
if (!bar || !text)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const normalizedLevel = ["success", "warn", "error", "info"].includes(level) ? level : "info";
|
||||||
|
text.textContent = message;
|
||||||
|
text.title = message;
|
||||||
|
bar.classList.remove("is-empty", "level-success", "level-warn", "level-error", "level-info");
|
||||||
|
bar.classList.add(`level-${normalizedLevel}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApplyCapabilities(payload) {
|
||||||
|
capabilities = Array.isArray(payload.data) ? payload.data : [];
|
||||||
|
|
||||||
|
const title = document.getElementById("pageTitle");
|
||||||
|
if (title)
|
||||||
|
title.textContent = String(payload.title || "Plugin configuration");
|
||||||
|
|
||||||
|
const subtitle = document.getElementById("pageSubtitle");
|
||||||
|
if (subtitle)
|
||||||
|
subtitle.textContent = String(payload.preset_name || "");
|
||||||
|
|
||||||
|
RenderCapabilities();
|
||||||
|
}
|
||||||
|
|
||||||
|
function IsSameCapability(capability) {
|
||||||
|
return String(capability.plugin_key || "") === selectedPluginKey
|
||||||
|
&& String(capability.name || "") === selectedCapabilityName
|
||||||
|
&& String(capability.type_key || "") === selectedCapabilityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RenderCapabilities() {
|
||||||
|
const empty = document.getElementById("configEmpty");
|
||||||
|
const layout = document.getElementById("configLayout");
|
||||||
|
const sidebar = document.getElementById("configSidebar");
|
||||||
|
if (!empty || !layout || !sidebar)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (capabilities.length === 0) {
|
||||||
|
empty.hidden = false;
|
||||||
|
layout.hidden = true;
|
||||||
|
sidebar.replaceChildren();
|
||||||
|
ClearCapabilityConfigView();
|
||||||
|
selectedPluginKey = "";
|
||||||
|
selectedCapabilityName = "";
|
||||||
|
selectedCapabilityType = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
empty.hidden = true;
|
||||||
|
layout.hidden = false;
|
||||||
|
|
||||||
|
// Keep the selection across a refresh when the capability is still there; otherwise fall back to
|
||||||
|
// the first one, which is also the initial selection.
|
||||||
|
if (!capabilities.some(IsSameCapability)) {
|
||||||
|
selectedPluginKey = String(capabilities[0].plugin_key || "");
|
||||||
|
selectedCapabilityName = String(capabilities[0].name || "");
|
||||||
|
selectedCapabilityType = String(capabilities[0].type_key || "");
|
||||||
|
ClearCapabilityConfigView();
|
||||||
|
RequestCapabilityConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
sidebar.replaceChildren();
|
||||||
|
for (const capability of capabilities) {
|
||||||
|
const item = document.createElement("button");
|
||||||
|
item.type = "button";
|
||||||
|
item.className = "config-cap";
|
||||||
|
item.dataset.pluginKey = String(capability.plugin_key || "");
|
||||||
|
item.dataset.capabilityName = String(capability.name || "");
|
||||||
|
item.dataset.capabilityType = String(capability.type_key || "");
|
||||||
|
item.setAttribute("role", "option");
|
||||||
|
|
||||||
|
const isSelected = IsSameCapability(capability);
|
||||||
|
item.classList.toggle("selected", isSelected);
|
||||||
|
item.setAttribute("aria-selected", isSelected ? "true" : "false");
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "config-cap-name";
|
||||||
|
label.textContent = String(capability.name || "");
|
||||||
|
item.appendChild(label);
|
||||||
|
|
||||||
|
const type = document.createElement("span");
|
||||||
|
type.className = "config-cap-type";
|
||||||
|
type.textContent = String(capability.type || "");
|
||||||
|
item.appendChild(type);
|
||||||
|
|
||||||
|
sidebar.appendChild(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function OnConfigSidebarClick(event) {
|
||||||
|
const item = event.target.closest(".config-cap");
|
||||||
|
if (!item)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const pluginKey = String(item.dataset.pluginKey || "");
|
||||||
|
const name = String(item.dataset.capabilityName || "");
|
||||||
|
const typeKey = String(item.dataset.capabilityType || "");
|
||||||
|
if (!name || (pluginKey === selectedPluginKey && name === selectedCapabilityName && typeKey === selectedCapabilityType))
|
||||||
|
return;
|
||||||
|
|
||||||
|
selectedPluginKey = pluginKey;
|
||||||
|
selectedCapabilityName = name;
|
||||||
|
selectedCapabilityType = typeKey;
|
||||||
|
|
||||||
|
// Drop the outgoing capability's view immediately: the native reply is asynchronous and its
|
||||||
|
// content must never appear under the newly selected capability.
|
||||||
|
ClearCapabilityConfigView();
|
||||||
|
RequestCapabilityConfig();
|
||||||
|
RenderCapabilities();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config editor
|
||||||
|
//
|
||||||
|
// The right side shows either the host's JSON editor or, when the capability ships one, its own
|
||||||
|
// HTML UI in a sandboxed frame. Both edit the same stored config: the page holds no config state
|
||||||
|
// of its own, it renders what the native side sends and sends back what the user saves. Ported
|
||||||
|
// from PluginsDialog/index.js, whose config tab lives inside a single selected plugin (it reads
|
||||||
|
// selectedPluginId from a page-global); this list spans plugins, so every row carries its own
|
||||||
|
// plugin_key and the page tracks selectedPluginKey instead.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// A reply is only applied when it still matches the selected row. The native side is asynchronous,
|
||||||
|
// and unlike the Plugins dialog this list spans plugins, so plugin_key is part of the match.
|
||||||
|
function IsCurrentCapability(payload) {
|
||||||
|
return String(payload?.plugin_key || "") === selectedPluginKey
|
||||||
|
&& String(payload?.capability_name || "") === selectedCapabilityName
|
||||||
|
&& String(payload?.capability_type || "") === selectedCapabilityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequestCapabilityConfig() {
|
||||||
|
if (!selectedPluginKey || !selectedCapabilityName)
|
||||||
|
return;
|
||||||
|
|
||||||
|
SendMessage("get_capability_config", {
|
||||||
|
plugin_key: selectedPluginKey,
|
||||||
|
capability_name: selectedCapabilityName,
|
||||||
|
capability_type: selectedCapabilityType
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empties both editors, so nothing from the previously selected capability can linger while the
|
||||||
|
// 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");
|
||||||
|
const meta = document.getElementById("configMeta");
|
||||||
|
|
||||||
|
if (editor)
|
||||||
|
editor.hidden = true;
|
||||||
|
if (custom) {
|
||||||
|
custom.hidden = true;
|
||||||
|
custom.removeAttribute("srcdoc");
|
||||||
|
}
|
||||||
|
if (text)
|
||||||
|
text.value = "";
|
||||||
|
if (error) {
|
||||||
|
error.hidden = true;
|
||||||
|
error.textContent = "";
|
||||||
|
}
|
||||||
|
if (footer)
|
||||||
|
footer.hidden = true;
|
||||||
|
if (meta)
|
||||||
|
meta.hidden = true;
|
||||||
|
selectedHasPresetOverride = false;
|
||||||
|
selectedReadOnly = false;
|
||||||
|
SetConfigValidation("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function UpdateConfigMeta(payload) {
|
||||||
|
selectedHasPresetOverride = payload?.has_preset_override === true;
|
||||||
|
selectedReadOnly = payload?.read_only === true;
|
||||||
|
|
||||||
|
const meta = document.getElementById("configMeta");
|
||||||
|
const badge = document.getElementById("configSourceBadge");
|
||||||
|
const useGlobal = document.getElementById("configUseGlobalBtn");
|
||||||
|
const save = document.getElementById("configSaveBtn");
|
||||||
|
const restore = document.getElementById("configRestoreBtn");
|
||||||
|
if (!meta || !badge || !useGlobal)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const source = String(payload?.source || "none");
|
||||||
|
badge.textContent = source === "preset" ? "Preset override" :
|
||||||
|
(source === "base" ? "Inherited from global configuration" : "No saved configuration");
|
||||||
|
useGlobal.hidden = !selectedHasPresetOverride;
|
||||||
|
useGlobal.disabled = selectedReadOnly;
|
||||||
|
if (save)
|
||||||
|
save.disabled = selectedReadOnly;
|
||||||
|
if (restore)
|
||||||
|
restore.disabled = selectedReadOnly;
|
||||||
|
meta.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApplyCapabilityConfig(payload) {
|
||||||
|
if (!IsCurrentCapability(payload))
|
||||||
|
return;
|
||||||
|
|
||||||
|
const editor = document.getElementById("configEditor");
|
||||||
|
const custom = document.getElementById("configCustom");
|
||||||
|
const text = document.getElementById("configText");
|
||||||
|
const error = document.getElementById("configError");
|
||||||
|
|
||||||
|
const message = String(payload?.error || "");
|
||||||
|
if (error) {
|
||||||
|
error.textContent = message;
|
||||||
|
error.hidden = message === "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = payload && Object.prototype.hasOwnProperty.call(payload, "config") ? payload.config : {};
|
||||||
|
const html = String(payload?.custom_html || "");
|
||||||
|
UpdateConfigMeta(payload);
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
custom.hidden = false;
|
||||||
|
custom.srcdoc = BuildCustomConfigDocument(html, config);
|
||||||
|
}
|
||||||
|
if (editor)
|
||||||
|
editor.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default editor. The native side already reported why a custom UI is unavailable (if it was
|
||||||
|
// meant to have one) in payload.error, and we fall back to editing the same config here.
|
||||||
|
if (custom) {
|
||||||
|
custom.hidden = true;
|
||||||
|
custom.removeAttribute("srcdoc");
|
||||||
|
}
|
||||||
|
if (editor)
|
||||||
|
editor.hidden = false;
|
||||||
|
if (text)
|
||||||
|
text.value = JSON.stringify(config, null, 2);
|
||||||
|
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");
|
||||||
|
if (node) {
|
||||||
|
node.textContent = message;
|
||||||
|
node.classList.toggle("invalid", message !== "");
|
||||||
|
}
|
||||||
|
// Invalid JSON can never be saved: the button is the only way to persist, and it is disabled
|
||||||
|
// while the text does not parse. The native side re-validates regardless.
|
||||||
|
if (save)
|
||||||
|
save.disabled = selectedReadOnly || message !== "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ValidateConfigText() {
|
||||||
|
const text = document.getElementById("configText");
|
||||||
|
if (!text)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
JSON.parse(text.value);
|
||||||
|
SetConfigValidation("");
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
SetConfigValidation(String(err?.message || "Invalid JSON"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SaveCapabilityConfig() {
|
||||||
|
if (!selectedPluginKey || !selectedCapabilityName)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const text = document.getElementById("configText");
|
||||||
|
if (!text)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!ValidateConfigText())
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Sent as text on purpose: the native side is the authority on validity and parses it itself.
|
||||||
|
SendMessage("save_capability_config", {
|
||||||
|
plugin_key: selectedPluginKey,
|
||||||
|
capability_name: selectedCapabilityName,
|
||||||
|
capability_type: selectedCapabilityType,
|
||||||
|
config: text.value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (!selectedPluginKey || !selectedCapabilityName)
|
||||||
|
return;
|
||||||
|
|
||||||
|
SendMessage("restore_preset_defaults", {
|
||||||
|
plugin_key: selectedPluginKey,
|
||||||
|
capability_name: selectedCapabilityName,
|
||||||
|
capability_type: selectedCapabilityType
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function UseGlobalCapabilityConfig() {
|
||||||
|
if (!selectedPluginKey || !selectedCapabilityName || !selectedHasPresetOverride)
|
||||||
|
return;
|
||||||
|
|
||||||
|
SendMessage("remove_preset_override", {
|
||||||
|
plugin_key: selectedPluginKey,
|
||||||
|
capability_name: selectedCapabilityName,
|
||||||
|
capability_type: selectedCapabilityType
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApplyCapabilityConfigSaved(payload) {
|
||||||
|
if (!IsCurrentCapability(payload))
|
||||||
|
return;
|
||||||
|
|
||||||
|
const error = document.getElementById("configError");
|
||||||
|
const message = String(payload?.error || "");
|
||||||
|
if (error) {
|
||||||
|
error.textContent = message;
|
||||||
|
error.hidden = message === "";
|
||||||
|
}
|
||||||
|
if (payload?.ok !== true)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Reload from what was actually persisted, so both editors show the stored state rather than
|
||||||
|
// whatever was typed.
|
||||||
|
const config = payload && Object.prototype.hasOwnProperty.call(payload, "config") ? payload.config : {};
|
||||||
|
const custom = document.getElementById("configCustom");
|
||||||
|
const text = document.getElementById("configText");
|
||||||
|
|
||||||
|
if (custom && !custom.hidden && custom.contentWindow)
|
||||||
|
custom.contentWindow.postMessage({ __orca: "config", config: config }, "*");
|
||||||
|
else if (text)
|
||||||
|
text.value = JSON.stringify(config, null, 2);
|
||||||
|
|
||||||
|
SetConfigValidation("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole host surface a custom config UI gets: read the config it was opened with, save a new
|
||||||
|
// one, and be told when a save lands. Everything else about the dialog stays out of reach — the
|
||||||
|
// frame is sandboxed into an opaque origin, so this bridge is its only way to talk to the host.
|
||||||
|
function BuildCustomConfigDocument(html, config) {
|
||||||
|
// The config is inlined into a <script>, so a stored string containing "</script>" would
|
||||||
|
// otherwise close the tag early and inject the rest as markup. Escaping "<" keeps the literal
|
||||||
|
// valid JSON while making that impossible.
|
||||||
|
const seed = JSON.stringify(config).replace(/</g, "\\u003c");
|
||||||
|
const bridge = `<script>
|
||||||
|
(function () {
|
||||||
|
var handlers = [];
|
||||||
|
var current = ${seed};
|
||||||
|
window.orca = {
|
||||||
|
getConfig: function () { return current; },
|
||||||
|
saveConfig: function (cfg) { parent.postMessage({ __orca: "save", config: cfg }, "*"); },
|
||||||
|
onConfig: function (cb) {
|
||||||
|
if (typeof cb !== "function") return;
|
||||||
|
handlers.push(cb);
|
||||||
|
try { cb(current); } catch (e) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("message", function (event) {
|
||||||
|
if (!event.data || event.data.__orca !== "config") return;
|
||||||
|
current = event.data.config || {};
|
||||||
|
handlers.forEach(function (handler) {
|
||||||
|
try { handler(current); } catch (e) {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
<\/script>`;
|
||||||
|
return bridge + html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OnCustomConfigMessage(event) {
|
||||||
|
const custom = document.getElementById("configCustom");
|
||||||
|
// Only the frame we created, and only while it is actually showing.
|
||||||
|
if (!custom || custom.hidden || !custom.contentWindow || event.source !== custom.contentWindow)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const data = event.data;
|
||||||
|
if (!data || data.__orca !== "save")
|
||||||
|
return;
|
||||||
|
if (!selectedPluginKey || !selectedCapabilityName)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// The custom UI persists through the same native command as the JSON editor, so there is one
|
||||||
|
// stored config and one code path that writes it.
|
||||||
|
SendMessage("save_capability_config", {
|
||||||
|
plugin_key: selectedPluginKey,
|
||||||
|
capability_name: selectedCapabilityName,
|
||||||
|
capability_type: selectedCapabilityType,
|
||||||
|
config: data.config === undefined ? {} : data.config
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const sidebar = document.getElementById("configSidebar");
|
||||||
|
if (sidebar)
|
||||||
|
sidebar.addEventListener("click", OnConfigSidebarClick);
|
||||||
|
|
||||||
|
const saveBtn = document.getElementById("configSaveBtn");
|
||||||
|
if (saveBtn)
|
||||||
|
saveBtn.addEventListener("click", SaveCapabilityConfig);
|
||||||
|
|
||||||
|
const restoreBtn = document.getElementById("configRestoreBtn");
|
||||||
|
if (restoreBtn)
|
||||||
|
restoreBtn.addEventListener("click", RestoreCapabilityConfig);
|
||||||
|
|
||||||
|
const useGlobalBtn = document.getElementById("configUseGlobalBtn");
|
||||||
|
if (useGlobalBtn)
|
||||||
|
useGlobalBtn.addEventListener("click", UseGlobalCapabilityConfig);
|
||||||
|
|
||||||
|
const text = document.getElementById("configText");
|
||||||
|
if (text)
|
||||||
|
text.addEventListener("input", ValidateConfigText);
|
||||||
|
|
||||||
|
// The custom capability UI is sandboxed into an opaque origin, so it reaches us only through
|
||||||
|
// postMessage. OnCustomConfigMessage matches on the frame's own contentWindow rather than the
|
||||||
|
// origin (which is "null" for a sandboxed frame) and ignores anything else on the channel.
|
||||||
|
window.addEventListener("message", OnCustomConfigMessage);
|
||||||
|
|
||||||
|
SendMessage("request_capabilities");
|
||||||
|
});
|
||||||
278
resources/web/dialog/PluginsConfigDialog/styles.css
Normal file
278
resources/web/dialog/PluginsConfigDialog/styles.css
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/* Buttons (ButtonStyleRegular/ButtonStyleConfirm/ButtonTypeChoice), scrollbars (.thin-scroll) and
|
||||||
|
the host-injected theme contract (--bg, --text, --border, --panel, --muted, --plugin-status-*,
|
||||||
|
...) all come from the shared sheets linked in index.html (global.css, common.css, theme.css —
|
||||||
|
the same three every resources/web/dialog/* page links). This file only carries what is unique
|
||||||
|
to this page: the fixed-size reset those shared sheets assume, the new page chrome, and the
|
||||||
|
config-related rules ported from PluginsDialog/styles.css (its Config tab uses the same classes).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* common.css hardcodes body to the PluginsDialog-era fixed 820x660 with overflow:hidden; this
|
||||||
|
dialog is resizable/maximizable, so neutralize that the same way PluginsDialog/styles.css does. */
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header { flex: 0 0 auto; }
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Ported from resources/web/dialog/PluginsDialog/styles.css (Config tab) ---- */
|
||||||
|
|
||||||
|
.detail-empty {
|
||||||
|
padding: 18px 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-empty[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-layout[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
border-right: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-cap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-cap:hover {
|
||||||
|
background: var(--row-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-cap.selected {
|
||||||
|
background: var(--row-selected);
|
||||||
|
border-color: var(--row-selected-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-cap-name {
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-cap-type {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-error {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--plugin-status-warn-bg);
|
||||||
|
color: var(--plugin-status-warn);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-error[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-meta[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-source-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-editor {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-editor[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-textarea {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* 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. */
|
||||||
|
-webkit-user-select: text;
|
||||||
|
user-select: text;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: none;
|
||||||
|
white-space: pre;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--main-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-custom {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-custom[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-view-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-view-footer[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-actions > button[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-validation {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-validation.invalid {
|
||||||
|
color: var(--plugin-status-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer status bar: single-line, fixed-height strip mirroring PluginsDialog's. */
|
||||||
|
.status-bar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar.level-success .status-text {
|
||||||
|
color: var(--plugin-status-ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar.level-error .status-text {
|
||||||
|
color: var(--plugin-status-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar.level-warn .status-text {
|
||||||
|
color: var(--plugin-status-warn);
|
||||||
|
}
|
||||||
@@ -504,9 +504,8 @@ function HasMixedCapabilityState(plugin) {
|
|||||||
String(capability?.type_key || "")
|
String(capability?.type_key || "")
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasEnabled = toggleableCapabilities.some((capability) => capability?.enabled === true);
|
|
||||||
const hasDisabled = toggleableCapabilities.some((capability) => capability?.enabled !== true);
|
const hasDisabled = toggleableCapabilities.some((capability) => capability?.enabled !== true);
|
||||||
return hasEnabled && hasDisabled;
|
return toggleableCapabilities.length > 0 && hasDisabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
function IsPluginLoading(plugin) {
|
function IsPluginLoading(plugin) {
|
||||||
@@ -826,12 +825,12 @@ function RenderDetails() {
|
|||||||
// native side sends and sends back what the user saves.
|
// native side sends and sends back what the user saves.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Every capability is configurable — it always gets at least the default JSON editor over its
|
// The config sidebar's rows, built natively by PluginConfig::capabilities_payload and shared with
|
||||||
// stored config — so the sidebar lists them all. The only exception is the descriptor-only rows
|
// PluginsConfigDialog. Only loaded, addressable capabilities appear: the descriptor-only rows the
|
||||||
// shown for a plugin that is not activated: those carry no capability name, so there is nothing to
|
// list tab shows for a plugin that is not activated carry no capability name, so there is nothing to
|
||||||
// address on the native side and nothing to configure yet.
|
// configure and nothing to address on the native side.
|
||||||
function GetConfigurableCapabilities(plugin) {
|
function GetConfigurableCapabilities(plugin) {
|
||||||
return GetCapabilities(plugin).filter((capability) => String(capability?.name || ""));
|
return Array.isArray(plugin?.config_capabilities) ? plugin.config_capabilities : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function RenderConfig(plugin) {
|
function RenderConfig(plugin) {
|
||||||
|
|||||||
@@ -2228,6 +2228,8 @@ public:
|
|||||||
// Vector value, but edited as a single string.
|
// Vector value, but edited as a single string.
|
||||||
one_string,
|
one_string,
|
||||||
plugin_picker,
|
plugin_picker,
|
||||||
|
// Raw JSON string value, edited through a dialog behind a button rather than in the row.
|
||||||
|
plugin_config,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Identifier of this option. It is stored here so that it is accessible through the by_serialization_key_ordinal map.
|
// Identifier of this option. It is stored here so that it is accessible through the by_serialization_key_ordinal map.
|
||||||
|
|||||||
@@ -1194,6 +1194,7 @@ static std::vector<std::string> s_Preset_print_options{
|
|||||||
"post_process",
|
"post_process",
|
||||||
"slicing_pipeline_plugin",
|
"slicing_pipeline_plugin",
|
||||||
"plugins",
|
"plugins",
|
||||||
|
"plugin_preference_overrides",
|
||||||
"process_change_extrusion_role_gcode",
|
"process_change_extrusion_role_gcode",
|
||||||
"min_length_factor",
|
"min_length_factor",
|
||||||
"wall_maximum_resolution",
|
"wall_maximum_resolution",
|
||||||
@@ -1345,7 +1346,8 @@ static std::vector<std::string> s_Preset_filament_options {/*"filament_colour",
|
|||||||
"filament_long_retractions_when_cut","filament_retraction_distances_when_cut", "idle_temperature",
|
"filament_long_retractions_when_cut","filament_retraction_distances_when_cut", "idle_temperature",
|
||||||
//BBS filament change length while the extruder color
|
//BBS filament change length while the extruder color
|
||||||
"filament_change_length","filament_flush_volumetric_speed","filament_flush_temp", "filament_cooling_before_tower",
|
"filament_change_length","filament_flush_volumetric_speed","filament_flush_temp", "filament_cooling_before_tower",
|
||||||
"long_retractions_when_ec", "retraction_distances_when_ec"
|
"long_retractions_when_ec", "retraction_distances_when_ec",
|
||||||
|
"plugin_preference_overrides"
|
||||||
};
|
};
|
||||||
|
|
||||||
static std::vector<std::string> s_Preset_machine_limits_options {
|
static std::vector<std::string> s_Preset_machine_limits_options {
|
||||||
@@ -1384,7 +1386,8 @@ static std::vector<std::string> s_Preset_printer_options {
|
|||||||
"cooling_tube_length", "high_current_on_filament_swap", "parking_pos_retraction", "extra_loading_move", "wipe_tower_type", "purge_in_prime_tower", "enable_filament_ramming", "tool_change_on_wipe_tower",
|
"cooling_tube_length", "high_current_on_filament_swap", "parking_pos_retraction", "extra_loading_move", "wipe_tower_type", "purge_in_prime_tower", "enable_filament_ramming", "tool_change_on_wipe_tower",
|
||||||
"z_offset",
|
"z_offset",
|
||||||
"disable_m73", "preferred_orientation", "emit_machine_limits_to_gcode", "pellet_modded_printer", "support_multi_bed_types", "use_3mf", "default_bed_type", "bed_mesh_min","bed_mesh_max","bed_mesh_probe_distance", "adaptive_bed_mesh_margin", "enable_long_retraction_when_cut","long_retractions_when_cut","retraction_distances_when_cut",
|
"disable_m73", "preferred_orientation", "emit_machine_limits_to_gcode", "pellet_modded_printer", "support_multi_bed_types", "use_3mf", "default_bed_type", "bed_mesh_min","bed_mesh_max","bed_mesh_probe_distance", "adaptive_bed_mesh_margin", "enable_long_retraction_when_cut","long_retractions_when_cut","retraction_distances_when_cut",
|
||||||
"bed_temperature_formula", "nozzle_flush_dataset"
|
"bed_temperature_formula", "nozzle_flush_dataset",
|
||||||
|
"plugin_preference_overrides"
|
||||||
};
|
};
|
||||||
|
|
||||||
static std::vector<std::string> s_Preset_sla_print_options {
|
static std::vector<std::string> s_Preset_sla_print_options {
|
||||||
|
|||||||
@@ -929,6 +929,17 @@ void PrintConfigDef::init_common_params()
|
|||||||
def = this->add("preset_name", coString);
|
def = this->add("preset_name", coString);
|
||||||
def->set_default_value(new ConfigOptionString());
|
def->set_default_value(new ConfigOptionString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def = this->add("plugin_preference_overrides", coString);
|
||||||
|
def->label = L("Plugin configuration");
|
||||||
|
def->tooltip = L("Configuration for the plugin capabilities this preset uses, overriding the global "
|
||||||
|
"plugin configuration. Stored as a raw JSON array and edited through the dialog "
|
||||||
|
"behind the button, never typed in directly.");
|
||||||
|
// Never shown as a text field: plugin_config renders a button that opens PluginsConfigDialog.
|
||||||
|
def->gui_type = ConfigOptionDef::GUIType::plugin_config;
|
||||||
|
def->mode = comAdvanced;
|
||||||
|
def->cli = ConfigOptionDef::nocli;
|
||||||
|
def->set_default_value(new ConfigOptionString(""));
|
||||||
}
|
}
|
||||||
|
|
||||||
void PrintConfigDef::init_fff_params()
|
void PrintConfigDef::init_fff_params()
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ set(SLIC3R_GUI_SOURCES
|
|||||||
GUI/PluginPickerDialog.hpp
|
GUI/PluginPickerDialog.hpp
|
||||||
GUI/PluginsDialog.cpp
|
GUI/PluginsDialog.cpp
|
||||||
GUI/PluginsDialog.hpp
|
GUI/PluginsDialog.hpp
|
||||||
|
GUI/PluginsConfigDialog.cpp
|
||||||
|
GUI/PluginsConfigDialog.hpp
|
||||||
GUI/ProcessRunner.cpp
|
GUI/ProcessRunner.cpp
|
||||||
GUI/ProcessRunner.hpp
|
GUI/ProcessRunner.hpp
|
||||||
GUI/TerminalDialog.cpp
|
GUI/TerminalDialog.cpp
|
||||||
@@ -613,8 +615,12 @@ set(SLIC3R_GUI_SOURCES
|
|||||||
plugin/CloudPluginService.hpp
|
plugin/CloudPluginService.hpp
|
||||||
plugin/PluginFsUtils.cpp
|
plugin/PluginFsUtils.cpp
|
||||||
plugin/PluginFsUtils.hpp
|
plugin/PluginFsUtils.hpp
|
||||||
|
plugin/CapabilityConfigDocument.cpp
|
||||||
|
plugin/CapabilityConfigDocument.hpp
|
||||||
plugin/PluginConfig.cpp
|
plugin/PluginConfig.cpp
|
||||||
plugin/PluginConfig.hpp
|
plugin/PluginConfig.hpp
|
||||||
|
plugin/PresetPluginConfig.cpp
|
||||||
|
plugin/PresetPluginConfig.hpp
|
||||||
plugin/PythonJsonUtils.hpp
|
plugin/PythonJsonUtils.hpp
|
||||||
plugin/PluginCatalog.cpp
|
plugin/PluginCatalog.cpp
|
||||||
plugin/PluginCatalog.hpp
|
plugin/PluginCatalog.hpp
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
#include "OG_CustomCtrl.hpp"
|
#include "OG_CustomCtrl.hpp"
|
||||||
#include "MsgDialog.hpp"
|
#include "MsgDialog.hpp"
|
||||||
#include "BitmapComboBox.hpp"
|
#include "BitmapComboBox.hpp"
|
||||||
|
#include "PluginsConfigDialog.hpp"
|
||||||
|
#include "Widgets/Button.hpp"
|
||||||
|
|
||||||
// BBS
|
// BBS
|
||||||
#include "Notebook.hpp"
|
#include "Notebook.hpp"
|
||||||
@@ -2308,6 +2310,136 @@ void PluginField::msw_rescale()
|
|||||||
rebuild_ui();
|
rebuild_ui();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// The stored text as a document, or an empty array when it is absent or unparseable. Used to compare
|
||||||
|
// two versions of the value semantically: re-serializing an unchanged document can reorder keys or
|
||||||
|
// drop whitespace, and that alone must not be allowed to mark the preset dirty.
|
||||||
|
nlohmann::json plugin_overrides_as_json(const std::string& text)
|
||||||
|
{
|
||||||
|
if (text.empty())
|
||||||
|
return nlohmann::json::array();
|
||||||
|
return nlohmann::json::parse(text, nullptr, /* allow_exceptions */ false);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void PluginConfigField::BUILD()
|
||||||
|
{
|
||||||
|
// The button *is* the field's window (the ColourPicker idiom). Wrapping it in a panel would make
|
||||||
|
// this a container that OG_CustomCtrl sizes but never lays out, leaving the button unsized — and
|
||||||
|
// a zero-height row collapses its whole option group out of the page.
|
||||||
|
m_button = new ::Button(m_parent, _L("Configure"));
|
||||||
|
// ButtonType::Parameter is the style for a button sitting next to parameter boxes: it is what
|
||||||
|
// gives the button the same height as the fields above it, which a bare wxButton does not.
|
||||||
|
m_button->SetStyle(ButtonStyle::Regular, ButtonType::Parameter);
|
||||||
|
|
||||||
|
wxSize size(def_width_wider() * m_em_unit, m_button->GetMinSize().GetHeight());
|
||||||
|
if (m_opt.height >= 0) size.SetHeight(m_opt.height * m_em_unit);
|
||||||
|
if (m_opt.width >= 0) size.SetWidth(m_opt.width * m_em_unit);
|
||||||
|
m_button->SetMinSize(size);
|
||||||
|
m_button->SetSize(size);
|
||||||
|
|
||||||
|
m_button->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { open_dialog(); });
|
||||||
|
m_button->SetToolTip(get_tooltip_text(_L("Configure")));
|
||||||
|
|
||||||
|
window = m_button;
|
||||||
|
|
||||||
|
if (const ConfigOptionString* def = m_opt.get_default_value<ConfigOptionString>())
|
||||||
|
m_json = def->value;
|
||||||
|
|
||||||
|
update_button_label();
|
||||||
|
m_value = m_json;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PluginConfigField::update_button_label()
|
||||||
|
{
|
||||||
|
if (m_button == nullptr)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const nlohmann::json entries = plugin_overrides_as_json(m_json);
|
||||||
|
const size_t count = entries.is_array() ? entries.size() : 0;
|
||||||
|
|
||||||
|
// The count is the whole state this row can show: which capabilities they belong to, and what
|
||||||
|
// they hold, is the dialog's job.
|
||||||
|
m_button->SetLabel(count == 0 ? _L("Configure")
|
||||||
|
: wxString::Format(_L("Configure (%d)"), int(count)));
|
||||||
|
}
|
||||||
|
|
||||||
|
void PluginConfigField::open_dialog()
|
||||||
|
{
|
||||||
|
const Preset::Type type = static_cast<Preset::Type>(m_preset_type);
|
||||||
|
if (type != Preset::TYPE_PRINT && type != Preset::TYPE_FILAMENT && type != Preset::TYPE_PRINTER)
|
||||||
|
return;
|
||||||
|
|
||||||
|
std::string edited;
|
||||||
|
{
|
||||||
|
PluginsConfigDialog dlg(m_button, type, m_json);
|
||||||
|
dlg.ShowModal();
|
||||||
|
edited = dlg.overrides_json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a semantic change is a change: reopening the dialog and closing it must not dirty the
|
||||||
|
// preset just because the document round-tripped through the serializer.
|
||||||
|
if (plugin_overrides_as_json(m_json) == plugin_overrides_as_json(edited))
|
||||||
|
return;
|
||||||
|
|
||||||
|
m_json = edited;
|
||||||
|
m_value = m_json;
|
||||||
|
update_button_label();
|
||||||
|
// The one call that makes this behave like every other setting: it drives change_opt_value into
|
||||||
|
// the preset config, Tab::update_dirty(), and the revert arrow.
|
||||||
|
on_change_field();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PluginConfigField::set_value(const boost::any& value, bool change_event)
|
||||||
|
{
|
||||||
|
m_disable_change_event = !change_event;
|
||||||
|
|
||||||
|
if (value.type() == typeid(wxString))
|
||||||
|
m_json = into_u8(boost::any_cast<wxString>(value));
|
||||||
|
else if (value.type() == typeid(std::string))
|
||||||
|
m_json = boost::any_cast<std::string>(value);
|
||||||
|
|
||||||
|
m_value = m_json;
|
||||||
|
update_button_label();
|
||||||
|
|
||||||
|
m_disable_change_event = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
boost::any& PluginConfigField::get_value()
|
||||||
|
{
|
||||||
|
// std::string, not wxString: change_opt_value any_casts a coString to std::string.
|
||||||
|
m_value = m_json;
|
||||||
|
return m_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PluginConfigField::enable()
|
||||||
|
{
|
||||||
|
if (m_button)
|
||||||
|
m_button->Enable();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PluginConfigField::disable()
|
||||||
|
{
|
||||||
|
if (m_button)
|
||||||
|
m_button->Disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PluginConfigField::msw_rescale()
|
||||||
|
{
|
||||||
|
Field::msw_rescale();
|
||||||
|
if (m_button == nullptr)
|
||||||
|
return;
|
||||||
|
|
||||||
|
m_button->SetStyle(ButtonStyle::Regular, ButtonType::Parameter);
|
||||||
|
|
||||||
|
wxSize size(def_width_wider() * m_em_unit, m_button->GetMinSize().GetHeight());
|
||||||
|
if (m_opt.height >= 0) size.SetHeight(m_opt.height * m_em_unit);
|
||||||
|
if (m_opt.width >= 0) size.SetWidth(m_opt.width * m_em_unit);
|
||||||
|
m_button->SetMinSize(size);
|
||||||
|
}
|
||||||
|
|
||||||
void ColourPicker::BUILD()
|
void ColourPicker::BUILD()
|
||||||
{
|
{
|
||||||
auto size = wxSize(def_width_wider() * m_em_unit, -1); // ORCA match color picker width
|
auto size = wxSize(def_width_wider() * m_em_unit, -1); // ORCA match color picker width
|
||||||
|
|||||||
@@ -32,6 +32,10 @@
|
|||||||
#define wxMSW false
|
#define wxMSW false
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Orca's styled button (Widgets/Button.hpp), used by PluginConfigField. Declared at global scope,
|
||||||
|
// which is where the widget lives.
|
||||||
|
class Button;
|
||||||
|
|
||||||
namespace Slic3r { namespace GUI {
|
namespace Slic3r { namespace GUI {
|
||||||
|
|
||||||
class Field;
|
class Field;
|
||||||
@@ -517,6 +521,47 @@ private:
|
|||||||
std::function<std::string()> m_selector;
|
std::function<std::string()> m_selector;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A settings row whose value is a raw JSON document that nobody should type by hand: the row shows a
|
||||||
|
// button, the button opens PluginsConfigDialog, and the document it hands back becomes the field's
|
||||||
|
// value. Routing the edit through the ordinary Field value/on_change_field path is what earns the row
|
||||||
|
// the same dirty state and revert arrow as every other setting — the dialog itself never touches the
|
||||||
|
// preset.
|
||||||
|
class PluginConfigField : public Field {
|
||||||
|
using Field::Field;
|
||||||
|
public:
|
||||||
|
PluginConfigField(const ConfigOptionDef& opt, const t_config_option_key& id) : Field(opt, id) {}
|
||||||
|
PluginConfigField(wxWindow* parent, const ConfigOptionDef& opt, const t_config_option_key& id) : Field(parent, opt, id) {}
|
||||||
|
~PluginConfigField() {}
|
||||||
|
|
||||||
|
void BUILD() override;
|
||||||
|
|
||||||
|
// Which preset's capabilities the dialog lists. Supplied by the option group, which already
|
||||||
|
// carries its Tab's type; an int for the same reason OptionsGroup::m_config_type is one — it
|
||||||
|
// keeps Preset.hpp out of this header.
|
||||||
|
void set_preset_type(int type) { m_preset_type = type; }
|
||||||
|
|
||||||
|
void set_value(const boost::any& value, bool change_event = false) override;
|
||||||
|
boost::any& get_value() override;
|
||||||
|
|
||||||
|
void enable() override;
|
||||||
|
void disable() override;
|
||||||
|
|
||||||
|
// Window-field: the button is the whole field, so it is the window the option group sizes and
|
||||||
|
// positions (the ColourPicker idiom). A container panel would be sized but never laid out.
|
||||||
|
wxWindow* getWindow() override { return window; }
|
||||||
|
|
||||||
|
void msw_rescale() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void open_dialog();
|
||||||
|
void update_button_label();
|
||||||
|
|
||||||
|
wxWindow* window { nullptr }; // == m_button; the base class hands this to the option group
|
||||||
|
::Button* m_button { nullptr };
|
||||||
|
std::string m_json; // the option's raw text; "" when the preset overrides nothing
|
||||||
|
int m_preset_type { -1 };
|
||||||
|
};
|
||||||
|
|
||||||
class ColourPicker : public Field {
|
class ColourPicker : public Field {
|
||||||
using Field::Field;
|
using Field::Field;
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const t_field& OptionsGroup::build_field(const t_config_option_key& id, const Co
|
|||||||
break;
|
break;
|
||||||
case ConfigOptionDef::GUIType::one_string: m_fields.emplace(id, TextCtrl::Create<TextCtrl>(this->ctrl_parent(), opt, id)); break;
|
case ConfigOptionDef::GUIType::one_string: m_fields.emplace(id, TextCtrl::Create<TextCtrl>(this->ctrl_parent(), opt, id)); break;
|
||||||
case ConfigOptionDef::GUIType::plugin_picker: m_fields.emplace(id, PluginField::Create<PluginField>(this->ctrl_parent(), opt, id)); break;
|
case ConfigOptionDef::GUIType::plugin_picker: m_fields.emplace(id, PluginField::Create<PluginField>(this->ctrl_parent(), opt, id)); break;
|
||||||
|
case ConfigOptionDef::GUIType::plugin_config: m_fields.emplace(id, PluginConfigField::Create<PluginConfigField>(this->ctrl_parent(), opt, id)); break;
|
||||||
default:
|
default:
|
||||||
switch (opt.type) {
|
switch (opt.type) {
|
||||||
case coFloatOrPercent:
|
case coFloatOrPercent:
|
||||||
@@ -126,6 +127,13 @@ const t_field& OptionsGroup::build_field(const t_config_option_key& id, const Co
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The dialog behind the button edits one preset's overrides, so it has to know which preset. The
|
||||||
|
// group already carries its Tab's type, and fields are built lazily on activate() — too late for
|
||||||
|
// the Tab to reach in and set it afterwards.
|
||||||
|
if (auto plugin_config_field = dynamic_cast<PluginConfigField*>(field.get()))
|
||||||
|
if (auto config_group = dynamic_cast<ConfigOptionsGroup*>(this))
|
||||||
|
plugin_config_field->set_preset_type(config_group->config_type());
|
||||||
|
|
||||||
// assign function objects for callbacks, etc.
|
// assign function objects for callbacks, etc.
|
||||||
return field;
|
return field;
|
||||||
}
|
}
|
||||||
|
|||||||
238
src/slic3r/GUI/PluginsConfigDialog.cpp
Normal file
238
src/slic3r/GUI/PluginsConfigDialog.cpp
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
#include "PluginsConfigDialog.hpp"
|
||||||
|
|
||||||
|
#include "GUI_App.hpp"
|
||||||
|
#include "I18N.hpp"
|
||||||
|
#include "format.hpp"
|
||||||
|
|
||||||
|
#include <libslic3r/Preset.hpp>
|
||||||
|
#include <libslic3r/PresetBundle.hpp>
|
||||||
|
#include <slic3r/plugin/PluginConfig.hpp>
|
||||||
|
#include <slic3r/plugin/PluginManager.hpp>
|
||||||
|
#include <slic3r/plugin/PluginResolver.hpp>
|
||||||
|
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||||
|
|
||||||
|
#include <boost/log/trivial.hpp>
|
||||||
|
|
||||||
|
namespace Slic3r { namespace GUI {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
wxString preset_type_title(Preset::Type type)
|
||||||
|
{
|
||||||
|
switch (type) {
|
||||||
|
case Preset::TYPE_PRINT: return _L("Process plugins");
|
||||||
|
case Preset::TYPE_FILAMENT: return _L("Filament plugins");
|
||||||
|
case Preset::TYPE_PRINTER: return _L("Printer plugins");
|
||||||
|
default: return _L("Plugins");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
PluginsConfigDialog::PluginsConfigDialog(wxWindow* parent, Preset::Type type, const std::string& overrides_json)
|
||||||
|
: WebViewHostDialog(parent, wxID_ANY, preset_type_title(type))
|
||||||
|
, m_type(type)
|
||||||
|
{
|
||||||
|
// Unparseable text is kept, not replaced: parse_plugin_overrides leaves the document empty and
|
||||||
|
// every row goes read-only, so a preset we cannot understand is never silently overwritten.
|
||||||
|
if (!parse_plugin_overrides(overrides_json, m_overrides, m_parse_error))
|
||||||
|
BOOST_LOG_TRIVIAL(error) << "Plugins Config dialog: " << m_parse_error;
|
||||||
|
|
||||||
|
create_webview("web/dialog/PluginsConfigDialog/index.html", preset_type_title(type), wxSize(820, 660),
|
||||||
|
wxSize(640, 520));
|
||||||
|
}
|
||||||
|
|
||||||
|
PluginsConfigDialog::~PluginsConfigDialog() = default;
|
||||||
|
|
||||||
|
const Preset* PluginsConfigDialog::current_preset() const
|
||||||
|
{
|
||||||
|
const PresetBundle* bundle = wxGetApp().preset_bundle;
|
||||||
|
if (bundle == nullptr)
|
||||||
|
return nullptr;
|
||||||
|
|
||||||
|
switch (m_type) {
|
||||||
|
case Preset::TYPE_PRINT: return &bundle->prints.get_edited_preset();
|
||||||
|
case Preset::TYPE_PRINTER: return &bundle->printers.get_edited_preset();
|
||||||
|
case Preset::TYPE_FILAMENT: return &bundle->filaments.get_edited_preset();
|
||||||
|
default: return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PluginCapabilityIdentifier PluginsConfigDialog::identifier_from(const nlohmann::json& payload) const
|
||||||
|
{
|
||||||
|
return {plugin_capability_type_from_string(payload.value("capability_type", "")),
|
||||||
|
payload.value("capability_name", ""),
|
||||||
|
payload.value("plugin_key", "")};
|
||||||
|
}
|
||||||
|
|
||||||
|
void PluginsConfigDialog::on_script_message(const nlohmann::json& payload)
|
||||||
|
{
|
||||||
|
if (handle_common_script_command(payload))
|
||||||
|
return;
|
||||||
|
|
||||||
|
const std::string command = payload.value("command", "");
|
||||||
|
if (command == "request_capabilities") {
|
||||||
|
send_capabilities();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PluginCapabilityIdentifier id = identifier_from(payload);
|
||||||
|
|
||||||
|
if (command == "get_capability_config") {
|
||||||
|
send_capability_config(id);
|
||||||
|
} else if (command == "save_capability_config") {
|
||||||
|
if (!m_parse_error.empty()) {
|
||||||
|
send_save_error(id, m_parse_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json value = payload.contains("config") ? payload.at("config") : nlohmann::json::object();
|
||||||
|
if (value.is_string()) {
|
||||||
|
value = nlohmann::json::parse(value.get<std::string>(), nullptr, /* allow_exceptions */ false);
|
||||||
|
if (value.is_discarded()) {
|
||||||
|
send_save_error(id, into_u8(_L("The configuration is not valid JSON. Your changes were not saved.")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MutationResult result = m_service.set_preset_override(m_overrides, id, value);
|
||||||
|
if (!result.ok) {
|
||||||
|
send_save_error(id, result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send_capability_config(id);
|
||||||
|
show_status(_L("Configuration updated. Save the preset to persist it."), "success");
|
||||||
|
} else if (command == "restore_preset_defaults") {
|
||||||
|
if (!m_parse_error.empty()) {
|
||||||
|
send_save_error(id, m_parse_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int rc = wxMessageBox(wxString::Format(_L("Restore the default configuration for \"%s\"?\n\n"
|
||||||
|
"This writes the plugin defaults into the current preset override."),
|
||||||
|
from_u8(id.name)),
|
||||||
|
_L("Restore defaults"), wxYES_NO | wxNO_DEFAULT | wxICON_WARNING, this);
|
||||||
|
if (rc != wxYES)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const auto cap = PluginManager::instance().get_loader().get_plugin_capability_by_name(id);
|
||||||
|
if (!cap)
|
||||||
|
return;
|
||||||
|
|
||||||
|
nlohmann::json defaults;
|
||||||
|
try {
|
||||||
|
wxBusyCursor busy;
|
||||||
|
PythonGILState gil;
|
||||||
|
defaults = cap->instance->get_default_config();
|
||||||
|
} catch (const std::exception& ex) {
|
||||||
|
// A broken plugin must not be able to wipe the stored config: store nothing and say so.
|
||||||
|
send_save_error(id, into_u8(GUI::format_wxstr(_L("The plugin failed to supply its default configuration (%1%)."),
|
||||||
|
from_u8(ex.what()))));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MutationResult result = m_service.set_preset_override(m_overrides, id, defaults);
|
||||||
|
if (!result.ok) {
|
||||||
|
send_save_error(id, result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send_capability_config(id);
|
||||||
|
show_status(_L("Default configuration restored. Save the preset to persist it."), "success");
|
||||||
|
} else if (command == "remove_preset_override") {
|
||||||
|
if (!m_parse_error.empty()) {
|
||||||
|
send_save_error(id, m_parse_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MutationResult result = m_service.remove_preset_override(m_overrides, id);
|
||||||
|
if (!result.ok) {
|
||||||
|
send_save_error(id, result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send_capability_config(id);
|
||||||
|
show_status(_L("Using global configuration. Save the preset to persist it."), "success");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PluginsConfigDialog::send_capabilities()
|
||||||
|
{
|
||||||
|
const Preset* preset = current_preset();
|
||||||
|
if (preset == nullptr)
|
||||||
|
return;
|
||||||
|
|
||||||
|
nlohmann::json response;
|
||||||
|
response["command"] = "list_capabilities";
|
||||||
|
response["preset_type"] = static_cast<int>(m_type);
|
||||||
|
response["title"] = into_u8(preset_type_title(m_type));
|
||||||
|
response["preset_name"] = preset->name;
|
||||||
|
response["data"] = PluginConfig::capabilities_payload(capabilities_in_use(m_type, *preset));
|
||||||
|
|
||||||
|
BOOST_LOG_TRIVIAL(info) << "Prepared " << response["data"].size() << " capability rows for the Plugins Config dialog";
|
||||||
|
call_web_handler(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PluginsConfigDialog::send_capability_config(const PluginCapabilityIdentifier& id)
|
||||||
|
{
|
||||||
|
const Preset* preset = current_preset();
|
||||||
|
|
||||||
|
nlohmann::json response;
|
||||||
|
response["command"] = "capability_config";
|
||||||
|
response["plugin_key"] = id.plugin_key;
|
||||||
|
response["capability_name"] = id.name;
|
||||||
|
response["capability_type"] = plugin_capability_type_to_string(id.type);
|
||||||
|
response["config"] = nlohmann::json::object();
|
||||||
|
response["custom_html"] = "";
|
||||||
|
response["error"] = "";
|
||||||
|
|
||||||
|
const auto cap = PluginManager::instance().get_loader().get_plugin_capability_by_name(id);
|
||||||
|
if (!cap || preset == nullptr) {
|
||||||
|
response["error"] = into_u8(_L("This capability is no longer available."));
|
||||||
|
call_web_handler(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EffectiveCapabilityConfig effective = m_service.get_effective_config(m_overrides, id);
|
||||||
|
response["config"] = effective.config;
|
||||||
|
response["source"] = plugin_config_source_to_string(effective.source);
|
||||||
|
response["has_preset_override"] = effective.has_preset_override;
|
||||||
|
response["has_base_config"] = effective.has_base_config;
|
||||||
|
response["stored_plugin_version"] = effective.stored_plugin_version;
|
||||||
|
response["running_plugin_version"] = effective.running_plugin_version;
|
||||||
|
response["read_only"] = !m_parse_error.empty();
|
||||||
|
if (!m_parse_error.empty())
|
||||||
|
response["error"] = m_parse_error;
|
||||||
|
|
||||||
|
if (cap->has_config_ui) {
|
||||||
|
try {
|
||||||
|
wxBusyCursor busy;
|
||||||
|
PythonGILState gil;
|
||||||
|
response["custom_html"] = cap->instance->get_config_ui();
|
||||||
|
} catch (const std::exception& ex) {
|
||||||
|
response["error"] = into_u8(GUI::format_wxstr(_L("The plugin's configuration UI failed to load (%1%). Showing the default editor."),
|
||||||
|
from_u8(ex.what())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
call_web_handler(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PluginsConfigDialog::send_save_error(const PluginCapabilityIdentifier& id, const std::string& error)
|
||||||
|
{
|
||||||
|
call_web_handler({{"command", "capability_config_saved"},
|
||||||
|
{"plugin_key", id.plugin_key},
|
||||||
|
{"capability_name", id.name},
|
||||||
|
{"capability_type", plugin_capability_type_to_string(id.type)},
|
||||||
|
{"ok", false},
|
||||||
|
{"error", error}});
|
||||||
|
}
|
||||||
|
|
||||||
|
void PluginsConfigDialog::show_status(const wxString& message, const char* level)
|
||||||
|
{
|
||||||
|
nlohmann::json payload;
|
||||||
|
payload["command"] = "status_message";
|
||||||
|
payload["level"] = level;
|
||||||
|
payload["message"] = into_u8(message);
|
||||||
|
call_web_handler(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
}} // namespace Slic3r::GUI
|
||||||
50
src/slic3r/GUI/PluginsConfigDialog.hpp
Normal file
50
src/slic3r/GUI/PluginsConfigDialog.hpp
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <slic3r/GUI/Widgets/WebViewHostDialog.hpp>
|
||||||
|
#include <libslic3r/Preset.hpp>
|
||||||
|
#include <slic3r/plugin/PresetPluginConfig.hpp>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace Slic3r { namespace GUI {
|
||||||
|
|
||||||
|
// The config half of the Plugins dialog, scoped to one preset instead of one plugin: it lists the
|
||||||
|
// capabilities the edited preset of `m_type` actually uses (see capabilities_in_use) and edits each
|
||||||
|
// one's stored config, falling back to the global config where the preset has no override.
|
||||||
|
//
|
||||||
|
// It is a pure editor over a JSON document: it never writes to the preset and never writes to the
|
||||||
|
// base config file. The caller seeds it with the preset's raw override text and reads the edited
|
||||||
|
// text back from overrides_json(). PluginConfigField, which owns the value, then feeds that through
|
||||||
|
// the normal field/dirty pipeline — which is what makes the revert arrow behave like any other
|
||||||
|
// setting. The capability list and config payloads are shared with PluginsDialog's Config tab
|
||||||
|
// through PluginConfig's statics.
|
||||||
|
class PluginsConfigDialog : public WebViewHostDialog
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
PluginsConfigDialog(wxWindow* parent, Preset::Type type, const std::string& overrides_json);
|
||||||
|
~PluginsConfigDialog() override;
|
||||||
|
|
||||||
|
// The edited overrides as compact JSON text; "" once no override remains.
|
||||||
|
std::string overrides_json() const { return serialize_plugin_overrides(m_overrides); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
void on_script_message(const nlohmann::json& payload) override;
|
||||||
|
|
||||||
|
const Preset* current_preset() const;
|
||||||
|
void send_capabilities();
|
||||||
|
void send_capability_config(const PluginCapabilityIdentifier& id);
|
||||||
|
void send_save_error(const PluginCapabilityIdentifier& id, const std::string& error);
|
||||||
|
void show_status(const wxString& message, const char* level);
|
||||||
|
|
||||||
|
PluginCapabilityIdentifier identifier_from(const nlohmann::json& payload) const;
|
||||||
|
|
||||||
|
Preset::Type m_type = Preset::TYPE_INVALID;
|
||||||
|
PresetPluginConfigService m_service;
|
||||||
|
// The working copy the dialog edits. Seeded from the preset's raw text, read back by the caller.
|
||||||
|
CapabilityConfigDocument m_overrides;
|
||||||
|
// Set when the preset's stored text could not be parsed: the rows are shown read-only rather
|
||||||
|
// than silently replacing data we did not understand.
|
||||||
|
std::string m_parse_error;
|
||||||
|
};
|
||||||
|
|
||||||
|
}} // namespace Slic3r::GUI
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
#include "GUI_App.hpp"
|
#include "GUI_App.hpp"
|
||||||
#include "I18N.hpp"
|
#include "I18N.hpp"
|
||||||
#include "OrcaCloudServiceAgent.hpp"
|
#include "OrcaCloudServiceAgent.hpp"
|
||||||
|
#include "slic3r/plugin/PluginConfig.hpp"
|
||||||
#include "slic3r/plugin/PluginFsUtils.hpp"
|
#include "slic3r/plugin/PluginFsUtils.hpp"
|
||||||
#include "slic3r/plugin/PluginManager.hpp"
|
#include "slic3r/plugin/PluginManager.hpp"
|
||||||
#include "slic3r/plugin/PythonInterpreter.hpp"
|
#include "slic3r/plugin/PythonInterpreter.hpp"
|
||||||
@@ -16,11 +17,8 @@
|
|||||||
#include <slic3r/GUI/format.hpp>
|
#include <slic3r/GUI/format.hpp>
|
||||||
|
|
||||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
|
||||||
#include <slic3r/plugin/PluginLoader.hpp>
|
#include <slic3r/plugin/PluginLoader.hpp>
|
||||||
|
|
||||||
#include <pybind11/embed.h>
|
|
||||||
|
|
||||||
#include <boost/filesystem.hpp>
|
#include <boost/filesystem.hpp>
|
||||||
#include <boost/log/trivial.hpp>
|
#include <boost/log/trivial.hpp>
|
||||||
|
|
||||||
@@ -222,6 +220,17 @@ nlohmann::json build_plugin_payload_item(const PluginDialogItem& dialog_item)
|
|||||||
}
|
}
|
||||||
payload_item["capabilities"] = std::move(caps);
|
payload_item["capabilities"] = std::move(caps);
|
||||||
|
|
||||||
|
// The Config tab's sidebar, built by the shared builder so it stays identical to
|
||||||
|
// PluginsConfigDialog's. Deliberately not the `capabilities` array above: that one is the list
|
||||||
|
// tab's, carrying enable/run state and descriptor-only rows for plugins that are not activated
|
||||||
|
// yet — neither of which the config view has any use for.
|
||||||
|
std::vector<PluginCapabilityIdentifier> config_ids;
|
||||||
|
for (const PluginCapabilityView& capability : dialog_item.capabilities)
|
||||||
|
if (!capability.name.empty())
|
||||||
|
config_ids.push_back(PluginCapabilityIdentifier{plugin_capability_type_from_string(capability.type_key),
|
||||||
|
capability.name, dialog_item.plugin_key});
|
||||||
|
payload_item["config_capabilities"] = PluginConfig::capabilities_payload(config_ids);
|
||||||
|
|
||||||
nlohmann::json changelog = nlohmann::json::array();
|
nlohmann::json changelog = nlohmann::json::array();
|
||||||
for (const PluginChangelogView& entry : dialog_item.changelog) {
|
for (const PluginChangelogView& entry : dialog_item.changelog) {
|
||||||
nlohmann::json c;
|
nlohmann::json c;
|
||||||
@@ -918,153 +927,32 @@ wxString PluginsDialog::plugin_display_name(const std::string& plugin_key) const
|
|||||||
return from_u8(plugin_key);
|
return from_u8(plugin_key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replies to the web page with one capability's stored config, plus the custom HTML UI when the
|
|
||||||
// capability provides one. Config is sent as a JSON value, not text: the default editor
|
|
||||||
// pretty-prints it into its textarea, and a custom UI receives it as-is through window.orca.
|
|
||||||
void PluginsDialog::send_capability_config(const std::string& plugin_key,
|
void PluginsDialog::send_capability_config(const std::string& plugin_key,
|
||||||
PluginCapabilityType type,
|
PluginCapabilityType type,
|
||||||
const std::string& capability_name)
|
const std::string& capability_name)
|
||||||
{
|
{
|
||||||
nlohmann::json response;
|
call_web_handler(PluginConfig::get_config_response({type, capability_name, plugin_key}));
|
||||||
response["command"] = "capability_config";
|
|
||||||
response["plugin_key"] = plugin_key;
|
|
||||||
response["capability_name"] = capability_name;
|
|
||||||
response["capability_type"] = plugin_capability_type_to_string(type);
|
|
||||||
response["config"] = nlohmann::json::object();
|
|
||||||
response["custom_html"] = "";
|
|
||||||
response["error"] = "";
|
|
||||||
|
|
||||||
// Scoped to (plugin_key, type, capability_name), so a stale request from a page that has not
|
|
||||||
// caught up with a refresh cannot read a different plugin's config — it just misses.
|
|
||||||
auto cap = get_capability(plugin_key, type, capability_name);
|
|
||||||
if (!cap) {
|
|
||||||
BOOST_LOG_TRIVIAL(warning) << "Ignoring config request 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
response["config"] = PluginManager::instance().get_config().get_config(plugin_key, capability_name).config;
|
|
||||||
|
|
||||||
if (cap->has_config_ui) {
|
|
||||||
// Plugin-authored HTML. A raising or empty get_config_ui() costs the capability only its
|
|
||||||
// custom UI: we report the failure and let the page fall back to the default JSON editor,
|
|
||||||
// which edits the very same stored config.
|
|
||||||
std::string html;
|
|
||||||
std::string error;
|
|
||||||
{
|
|
||||||
wxBusyCursor busy;
|
|
||||||
try {
|
|
||||||
PythonGILState gil;
|
|
||||||
html = cap->instance->get_config_ui();
|
|
||||||
} catch (const std::exception& ex) {
|
|
||||||
error = ex.what();
|
|
||||||
} catch (...) {
|
|
||||||
error = "Unknown error";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!error.empty()) {
|
|
||||||
BOOST_LOG_TRIVIAL(error) << "Plugin capability get_config_ui() failed. plugin_key=" << plugin_key
|
|
||||||
<< " capability_name=" << capability_name << " error=" << error;
|
|
||||||
response["error"] = into_u8(format_wxstr(_L("The plugin's configuration UI failed to load (%1%). Showing the default editor."),
|
|
||||||
from_u8(error)));
|
|
||||||
} else if (html.empty()) {
|
|
||||||
BOOST_LOG_TRIVIAL(warning) << "Plugin capability reports a config UI but returned no HTML. plugin_key=" << plugin_key
|
|
||||||
<< " capability_name=" << capability_name;
|
|
||||||
response["error"] = into_u8(_L("The plugin's configuration UI was empty. Showing the default editor."));
|
|
||||||
} else {
|
|
||||||
response["custom_html"] = html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
call_web_handler(response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persists one capability's config. `config` arrives either as text straight from the default
|
|
||||||
// editor (validated here, so invalid JSON can never reach the file) or as a structured value from
|
|
||||||
// a custom UI. Only cap_config is written; identity and version metadata stay host-managed.
|
|
||||||
void PluginsDialog::save_capability_config(const std::string& plugin_key,
|
void PluginsDialog::save_capability_config(const std::string& plugin_key,
|
||||||
PluginCapabilityType type,
|
PluginCapabilityType type,
|
||||||
const std::string& capability_name,
|
const std::string& capability_name,
|
||||||
const nlohmann::json& config)
|
const nlohmann::json& config)
|
||||||
{
|
{
|
||||||
nlohmann::json response;
|
const nlohmann::json response = PluginConfig::save_config_response({type, capability_name, plugin_key}, config);
|
||||||
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 save 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. Your changes were not saved."));
|
|
||||||
call_web_handler(response);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json parsed = config;
|
|
||||||
if (config.is_string()) {
|
|
||||||
// The page validates as the user types, but it is not the authority: re-parse here so a
|
|
||||||
// malformed document is rejected before it can reach config.json.
|
|
||||||
parsed = nlohmann::json::parse(config.get<std::string>(), nullptr, /* allow_exceptions */ false);
|
|
||||||
if (parsed.is_discarded()) {
|
|
||||||
response["error"] = into_u8(_L("The configuration is not valid JSON. Your changes were not saved."));
|
|
||||||
call_web_handler(response);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!PluginManager::instance().get_config().store_capability_config(plugin_key, capability_name, parsed)) {
|
|
||||||
BOOST_LOG_TRIVIAL(error) << "Failed to write the plugin config file. plugin_key=" << plugin_key
|
|
||||||
<< " capability_name=" << capability_name;
|
|
||||||
response["error"] = into_u8(_L("The configuration could not be written to disk. Your changes were not saved."));
|
|
||||||
call_web_handler(response);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
BOOST_LOG_TRIVIAL(info) << "Saved plugin capability config. plugin_key=" << plugin_key
|
|
||||||
<< " capability_name=" << capability_name;
|
|
||||||
|
|
||||||
// Echo the persisted value back so the editor reloads from what is actually stored rather
|
|
||||||
// than from what the user typed.
|
|
||||||
response["ok"] = true;
|
|
||||||
response["config"] = PluginManager::instance().get_config().get_config(plugin_key, capability_name).config;
|
|
||||||
call_web_handler(response);
|
call_web_handler(response);
|
||||||
|
|
||||||
show_status(_L("Configuration saved."), "success");
|
if (response.value("ok", false))
|
||||||
|
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,
|
void PluginsDialog::restore_capability_config(const std::string& plugin_key,
|
||||||
PluginCapabilityType type,
|
PluginCapabilityType type,
|
||||||
const std::string& capability_name)
|
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
|
// Discards whatever the user had stored, so confirm first — same as the other destructive
|
||||||
// actions in this dialog.
|
// actions in this dialog. The confirmation stays here rather than in PluginConfig: it needs a
|
||||||
|
// parent window, and it is the dialog's business to ask.
|
||||||
const int rc = wxMessageBox(wxString::Format(_L("Restore the default configuration for \"%s\"?\n\n"
|
const int rc = wxMessageBox(wxString::Format(_L("Restore the default configuration for \"%s\"?\n\n"
|
||||||
"This discards the settings currently saved for this capability."),
|
"This discards the settings currently saved for this capability."),
|
||||||
from_u8(capability_name)),
|
from_u8(capability_name)),
|
||||||
@@ -1072,49 +960,11 @@ void PluginsDialog::restore_capability_config(const std::string& plugin_key,
|
|||||||
if (rc != wxYES)
|
if (rc != wxYES)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
nlohmann::json defaults;
|
const nlohmann::json response = PluginConfig::restore_config_response({type, capability_name, plugin_key});
|
||||||
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);
|
call_web_handler(response);
|
||||||
|
|
||||||
show_status(_L("Default configuration restored."), "success");
|
if (response.value("ok", false))
|
||||||
|
show_status(_L("Default configuration restored."), "success");
|
||||||
}
|
}
|
||||||
|
|
||||||
void PluginsDialog::run_script_plugin(const std::string& plugin_key, const std::string& capability_name)
|
void PluginsDialog::run_script_plugin(const std::string& plugin_key, const std::string& capability_name)
|
||||||
|
|||||||
@@ -3086,6 +3086,13 @@ void TabPrint::build()
|
|||||||
option.opt.full_width = true;
|
option.opt.full_width = true;
|
||||||
optgroup->append_single_option_line(option, "others_settings_plugin_picker");
|
optgroup->append_single_option_line(option, "others_settings_plugin_picker");
|
||||||
|
|
||||||
|
// Its own group rather than the one above: that one hides its labels, and this row needs its
|
||||||
|
// label — and so the revert arrow beside it — to show. No label-width override either: a 0
|
||||||
|
// there means "no label column", which is what the neighbouring full-width groups want and
|
||||||
|
// what would collapse this one.
|
||||||
|
optgroup = page->new_optgroup(L("Plugin Configuration"), L"param_gcode");
|
||||||
|
optgroup->append_single_option_line("plugin_preference_overrides");
|
||||||
|
|
||||||
optgroup = page->new_optgroup(L("Notes"), "note", 0);
|
optgroup = page->new_optgroup(L("Notes"), "note", 0);
|
||||||
option = optgroup->get_option("notes");
|
option = optgroup->get_option("notes");
|
||||||
option.opt.full_width = true;
|
option.opt.full_width = true;
|
||||||
@@ -4481,6 +4488,9 @@ void TabFilament::build()
|
|||||||
option.opt.height = gcode_field_height;// 150;
|
option.opt.height = gcode_field_height;// 150;
|
||||||
optgroup->append_single_option_line(option);
|
optgroup->append_single_option_line(option);
|
||||||
|
|
||||||
|
optgroup = page->new_optgroup(L("Plugin Configuration"), L"param_gcode");
|
||||||
|
optgroup->append_single_option_line("plugin_preference_overrides");
|
||||||
|
|
||||||
page = add_options_page(L("Multimaterial"), "custom-gcode_multi_material"); // ORCA: icon only visible on placeholders
|
page = add_options_page(L("Multimaterial"), "custom-gcode_multi_material"); // ORCA: icon only visible on placeholders
|
||||||
optgroup = page->new_optgroup(L("Wipe tower parameters"), "param_tower");
|
optgroup = page->new_optgroup(L("Wipe tower parameters"), "param_tower");
|
||||||
optgroup->append_single_option_line("filament_minimal_purge_on_wipe_tower", "material_multimaterial#multimaterial-wipe-tower-parameters");
|
optgroup->append_single_option_line("filament_minimal_purge_on_wipe_tower", "material_multimaterial#multimaterial-wipe-tower-parameters");
|
||||||
@@ -4923,6 +4933,7 @@ void TabPrinter::build_fff()
|
|||||||
optgroup->append_single_option_line("use_firmware_retraction", "printer_basic_information_advanced#use-firmware-retraction");
|
optgroup->append_single_option_line("use_firmware_retraction", "printer_basic_information_advanced#use-firmware-retraction");
|
||||||
// optgroup->append_single_option_line("spaghetti_detector");
|
// optgroup->append_single_option_line("spaghetti_detector");
|
||||||
optgroup->append_single_option_line("time_cost", "printer_basic_information_advanced#time-cost");
|
optgroup->append_single_option_line("time_cost", "printer_basic_information_advanced#time-cost");
|
||||||
|
optgroup->append_single_option_line("plugin_preference_overrides");
|
||||||
|
|
||||||
optgroup = page->new_optgroup(L("Cooling Fan"), "param_cooling_fan");
|
optgroup = page->new_optgroup(L("Cooling Fan"), "param_cooling_fan");
|
||||||
Line line = Line{ L("Fan speed-up time"), optgroup->get_option("fan_speedup_time").opt.tooltip };
|
Line line = Line{ L("Fan speed-up time"), optgroup->get_option("fan_speedup_time").opt.tooltip };
|
||||||
|
|||||||
122
src/slic3r/plugin/CapabilityConfigDocument.cpp
Normal file
122
src/slic3r/plugin/CapabilityConfigDocument.cpp
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
#include "CapabilityConfigDocument.hpp"
|
||||||
|
|
||||||
|
namespace Slic3r {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr const char* KEY_PLUGIN = "plugin_key";
|
||||||
|
constexpr const char* KEY_CAPABILITY = "capability";
|
||||||
|
constexpr const char* KEY_VERSION = "plugin_version";
|
||||||
|
constexpr const char* KEY_CAP_CONFIG = "cap_config";
|
||||||
|
|
||||||
|
std::string string_field(const nlohmann::json& entry, const char* key)
|
||||||
|
{
|
||||||
|
const auto it = entry.find(key);
|
||||||
|
return it != entry.end() && it->is_string() ? it->get<std::string>() : std::string();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool is_recognized_entry(const nlohmann::json& entry, CapabilityConfigId& id)
|
||||||
|
{
|
||||||
|
if (!entry.is_object())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
id.plugin_key = string_field(entry, KEY_PLUGIN);
|
||||||
|
id.capability = string_field(entry, KEY_CAPABILITY);
|
||||||
|
return !id.plugin_key.empty() && !id.capability.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
CapabilityConfigEntry decode_entry(const CapabilityConfigId& id, const nlohmann::json& entry)
|
||||||
|
{
|
||||||
|
CapabilityConfigEntry result;
|
||||||
|
result.id = id;
|
||||||
|
result.plugin_version = string_field(entry, KEY_VERSION);
|
||||||
|
const auto cap_it = entry.find(KEY_CAP_CONFIG);
|
||||||
|
result.cap_config = cap_it != entry.end() ? *cap_it : nlohmann::json::object();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
CapabilityConfigDocument CapabilityConfigDocument::from_entries(const nlohmann::json& entries)
|
||||||
|
{
|
||||||
|
CapabilityConfigDocument document;
|
||||||
|
if (!entries.is_array())
|
||||||
|
return document;
|
||||||
|
|
||||||
|
for (const nlohmann::json& entry : entries) {
|
||||||
|
CapabilityConfigId id;
|
||||||
|
if (is_recognized_entry(entry, id))
|
||||||
|
document.m_entries[id] = entry;
|
||||||
|
else
|
||||||
|
document.m_opaque_entries.push_back(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
CapabilityConfigDocument CapabilityConfigDocument::from_root_json(const nlohmann::json& root)
|
||||||
|
{
|
||||||
|
const auto entries = root.find(KeyEntries);
|
||||||
|
return entries != root.end() ? from_entries(*entries) : CapabilityConfigDocument();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<CapabilityConfigEntry> CapabilityConfigDocument::find(const CapabilityConfigId& id) const
|
||||||
|
{
|
||||||
|
const auto it = m_entries.find(id);
|
||||||
|
if (it == m_entries.end())
|
||||||
|
return std::nullopt;
|
||||||
|
return decode_entry(it->first, it->second);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CapabilityConfigDocument::contains(const CapabilityConfigId& id) const
|
||||||
|
{
|
||||||
|
return m_entries.find(id) != m_entries.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CapabilityConfigDocument::upsert(CapabilityConfigEntry entry)
|
||||||
|
{
|
||||||
|
if (entry.id.plugin_key.empty() || entry.id.capability.empty())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
nlohmann::json serialized = nlohmann::json::object();
|
||||||
|
const auto existing = m_entries.find(entry.id);
|
||||||
|
if (existing != m_entries.end() && existing->second.is_object())
|
||||||
|
serialized = existing->second;
|
||||||
|
|
||||||
|
serialized[KEY_PLUGIN] = entry.id.plugin_key;
|
||||||
|
serialized[KEY_CAPABILITY] = entry.id.capability;
|
||||||
|
serialized[KEY_VERSION] = entry.plugin_version;
|
||||||
|
serialized[KEY_CAP_CONFIG] = entry.cap_config;
|
||||||
|
|
||||||
|
m_entries[entry.id] = std::move(serialized);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CapabilityConfigDocument::erase(const CapabilityConfigId& id)
|
||||||
|
{
|
||||||
|
return m_entries.erase(id) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CapabilityConfigDocument::empty() const
|
||||||
|
{
|
||||||
|
return m_entries.empty() && m_opaque_entries.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json CapabilityConfigDocument::serialize_entries() const
|
||||||
|
{
|
||||||
|
nlohmann::json result = nlohmann::json::array();
|
||||||
|
for (const auto& item : m_entries)
|
||||||
|
result.push_back(item.second);
|
||||||
|
for (const nlohmann::json& entry : m_opaque_entries)
|
||||||
|
result.push_back(entry);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json CapabilityConfigDocument::root_json() const
|
||||||
|
{
|
||||||
|
nlohmann::json root = nlohmann::json::object();
|
||||||
|
root[KeyEntries] = serialize_entries();
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Slic3r
|
||||||
57
src/slic3r/plugin/CapabilityConfigDocument.hpp
Normal file
57
src/slic3r/plugin/CapabilityConfigDocument.hpp
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <map>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace Slic3r {
|
||||||
|
|
||||||
|
struct CapabilityConfigId
|
||||||
|
{
|
||||||
|
std::string plugin_key;
|
||||||
|
std::string capability;
|
||||||
|
|
||||||
|
friend bool operator<(const CapabilityConfigId& lhs, const CapabilityConfigId& rhs)
|
||||||
|
{
|
||||||
|
return lhs.plugin_key < rhs.plugin_key ||
|
||||||
|
(lhs.plugin_key == rhs.plugin_key && lhs.capability < rhs.capability);
|
||||||
|
}
|
||||||
|
|
||||||
|
friend bool operator==(const CapabilityConfigId& lhs, const CapabilityConfigId& rhs)
|
||||||
|
{
|
||||||
|
return lhs.plugin_key == rhs.plugin_key && lhs.capability == rhs.capability;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CapabilityConfigEntry
|
||||||
|
{
|
||||||
|
CapabilityConfigId id;
|
||||||
|
std::string plugin_version;
|
||||||
|
nlohmann::json cap_config = nlohmann::json::object();
|
||||||
|
};
|
||||||
|
|
||||||
|
class CapabilityConfigDocument
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
static constexpr const char* KeyEntries = "config";
|
||||||
|
|
||||||
|
static CapabilityConfigDocument from_root_json(const nlohmann::json& root);
|
||||||
|
static CapabilityConfigDocument from_entries(const nlohmann::json& entries);
|
||||||
|
|
||||||
|
std::optional<CapabilityConfigEntry> find(const CapabilityConfigId& id) const;
|
||||||
|
bool contains(const CapabilityConfigId& id) const;
|
||||||
|
bool upsert(CapabilityConfigEntry entry);
|
||||||
|
bool erase(const CapabilityConfigId& id);
|
||||||
|
bool empty() const;
|
||||||
|
nlohmann::json serialize_entries() const;
|
||||||
|
nlohmann::json root_json() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::map<CapabilityConfigId, nlohmann::json> m_entries;
|
||||||
|
std::vector<nlohmann::json> m_opaque_entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Slic3r
|
||||||
@@ -4,54 +4,21 @@
|
|||||||
#include <boost/log/trivial.hpp>
|
#include <boost/log/trivial.hpp>
|
||||||
#include <boost/nowide/fstream.hpp>
|
#include <boost/nowide/fstream.hpp>
|
||||||
|
|
||||||
|
#include <slic3r/GUI/GUI.hpp>
|
||||||
|
#include <slic3r/GUI/I18N.hpp>
|
||||||
|
#include <slic3r/GUI/format.hpp>
|
||||||
|
#include <slic3r/plugin/PluginLoader.hpp>
|
||||||
#include <slic3r/plugin/PluginManager.hpp>
|
#include <slic3r/plugin/PluginManager.hpp>
|
||||||
|
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <utility>
|
|
||||||
|
#include <wx/utils.h>
|
||||||
|
|
||||||
namespace Slic3r {
|
namespace Slic3r {
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
constexpr const char* KEY_ENTRIES = "config";
|
|
||||||
constexpr const char* KEY_PLUGIN = "plugin_key";
|
|
||||||
constexpr const char* KEY_CAPABILITY = "capability";
|
|
||||||
constexpr const char* KEY_VERSION = "plugin_version";
|
|
||||||
constexpr const char* KEY_CAP_CONFIG = "cap_config";
|
|
||||||
|
|
||||||
std::string string_field(const nlohmann::json& entry, const char* key)
|
|
||||||
{
|
|
||||||
const auto it = entry.find(key);
|
|
||||||
return it != entry.end() && it->is_string() ? it->get<std::string>() : std::string();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rejects entries missing an identity, which could never be looked up again.
|
|
||||||
bool entry_to_config(const nlohmann::json& entry, BaseConfig& out)
|
|
||||||
{
|
|
||||||
if (!entry.is_object())
|
|
||||||
return false;
|
|
||||||
|
|
||||||
out.plugin_key = string_field(entry, KEY_PLUGIN);
|
|
||||||
out.capability_name = string_field(entry, KEY_CAPABILITY);
|
|
||||||
out.plugin_version = string_field(entry, KEY_VERSION);
|
|
||||||
if (out.empty())
|
|
||||||
return false;
|
|
||||||
|
|
||||||
const auto cap_config = entry.find(KEY_CAP_CONFIG);
|
|
||||||
out.config = cap_config != entry.end() ? *cap_config : nlohmann::json::object();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json config_to_entry(const BaseConfig& config)
|
|
||||||
{
|
|
||||||
return nlohmann::json{
|
|
||||||
{KEY_PLUGIN, config.plugin_key},
|
|
||||||
{KEY_CAPABILITY, config.capability_name},
|
|
||||||
{KEY_VERSION, config.plugin_version},
|
|
||||||
{KEY_CAP_CONFIG, config.config},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// The version of the plugin package currently running. PluginDescriptor::version is
|
// The version of the plugin package currently running. PluginDescriptor::version is
|
||||||
// overwritten with the latest cloud version when a cloud merge happens, so it can name a
|
// overwritten with the latest cloud version when a cloud merge happens, so it can name a
|
||||||
// version that is not the one on disk; installed_version is what actually loaded.
|
// version that is not the one on disk; installed_version is what actually loaded.
|
||||||
@@ -83,7 +50,7 @@ void PluginConfig::load()
|
|||||||
const std::string path = plugin_config_file();
|
const std::string path = plugin_config_file();
|
||||||
|
|
||||||
std::lock_guard<std::mutex> lock(m_mutex);
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
m_storage.clear();
|
m_document = CapabilityConfigDocument();
|
||||||
m_dirty = false;
|
m_dirty = false;
|
||||||
|
|
||||||
boost::system::error_code ec;
|
boost::system::error_code ec;
|
||||||
@@ -99,20 +66,14 @@ void PluginConfig::load()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto entries = root.find(KEY_ENTRIES);
|
const auto entries = root.find(CapabilityConfigDocument::KeyEntries);
|
||||||
if (entries == root.end() || !entries->is_array()) {
|
if (entries == root.end() || !entries->is_array()) {
|
||||||
BOOST_LOG_TRIVIAL(warning) << "PluginConfig: " << path << " has no \"" << KEY_ENTRIES << "\" array; starting with an empty config";
|
BOOST_LOG_TRIVIAL(warning) << "PluginConfig: " << path << " has no \"" << CapabilityConfigDocument::KeyEntries
|
||||||
|
<< "\" array; starting with an empty config";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const auto& entry : *entries) {
|
m_document = CapabilityConfigDocument::from_root_json(root);
|
||||||
BaseConfig config;
|
|
||||||
if (!entry_to_config(entry, config)) {
|
|
||||||
BOOST_LOG_TRIVIAL(warning) << "PluginConfig: skipping entry without a plugin key and capability name";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
m_storage[{config.plugin_key, config.capability_name}] = std::move(config);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool PluginConfig::save()
|
bool PluginConfig::save()
|
||||||
@@ -120,11 +81,10 @@ bool PluginConfig::save()
|
|||||||
const std::string path = plugin_config_file();
|
const std::string path = plugin_config_file();
|
||||||
|
|
||||||
std::lock_guard<std::mutex> lock(m_mutex);
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
if (!m_dirty)
|
||||||
|
return true;
|
||||||
|
|
||||||
nlohmann::json root;
|
const nlohmann::json root = m_document.root_json();
|
||||||
root[KEY_ENTRIES] = nlohmann::json::array();
|
|
||||||
for (const auto& [id, config] : m_storage)
|
|
||||||
root[KEY_ENTRIES].push_back(config_to_entry(config));
|
|
||||||
|
|
||||||
boost::system::error_code ec;
|
boost::system::error_code ec;
|
||||||
boost::filesystem::create_directories(boost::filesystem::path(path).parent_path(), ec);
|
boost::filesystem::create_directories(boost::filesystem::path(path).parent_path(), ec);
|
||||||
@@ -177,21 +137,37 @@ void PluginConfig::save_config(const BaseConfig& config)
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::lock_guard<std::mutex> lock(m_mutex);
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
m_storage[{config.plugin_key, config.capability_name}] = config;
|
m_dirty = m_document.upsert(CapabilityConfigEntry{{config.plugin_key, config.capability_name}, config.plugin_version, config.config}) || m_dirty;
|
||||||
m_dirty = true;
|
}
|
||||||
|
|
||||||
|
bool PluginConfig::erase_capability_config(const std::string& plugin_key, const std::string& capability_name)
|
||||||
|
{
|
||||||
|
if (plugin_key.empty() || capability_name.empty())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
if (!m_document.erase({plugin_key, capability_name}))
|
||||||
|
return true;
|
||||||
|
m_dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return save();
|
||||||
}
|
}
|
||||||
|
|
||||||
BaseConfig PluginConfig::get_config(const std::string& plugin_key, const std::string& capability_name) const
|
BaseConfig PluginConfig::get_config(const std::string& plugin_key, const std::string& capability_name) const
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(m_mutex);
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
const auto it = m_storage.find({plugin_key, capability_name});
|
const auto entry = m_document.find({plugin_key, capability_name});
|
||||||
return it != m_storage.end() ? it->second : BaseConfig();
|
if (!entry)
|
||||||
|
return BaseConfig();
|
||||||
|
return BaseConfig{entry->id.plugin_key, entry->id.capability, entry->plugin_version, entry->cap_config};
|
||||||
}
|
}
|
||||||
|
|
||||||
bool PluginConfig::has_config(const std::string& plugin_key, const std::string& capability_name) const
|
bool PluginConfig::has_config(const std::string& plugin_key, const std::string& capability_name) const
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(m_mutex);
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
return m_storage.count({plugin_key, capability_name}) != 0;
|
return m_document.contains({plugin_key, capability_name});
|
||||||
}
|
}
|
||||||
|
|
||||||
bool PluginConfig::dirty() const
|
bool PluginConfig::dirty() const
|
||||||
@@ -223,4 +199,194 @@ bool capability_save_config(const PluginCapabilityInterface& capability, const n
|
|||||||
return PluginManager::instance().get_config().store_capability_config(plugin_key, capability_name, config);
|
return PluginManager::instance().get_config().store_capability_config(plugin_key, capability_name, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nlohmann::json PluginConfig::capabilities_payload(const std::vector<PluginCapabilityIdentifier>& caps)
|
||||||
|
{
|
||||||
|
PluginLoader& loader = PluginManager::instance().get_loader();
|
||||||
|
|
||||||
|
nlohmann::json payload = nlohmann::json::array();
|
||||||
|
for (const PluginCapabilityIdentifier& id : caps) {
|
||||||
|
// Read has_config_ui off the live capability rather than trusting the caller's copy: a
|
||||||
|
// capability that has been unloaded since the list was built has nothing to configure.
|
||||||
|
const auto capability = loader.get_plugin_capability_by_name(id);
|
||||||
|
if (!capability)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
nlohmann::json entry;
|
||||||
|
entry["plugin_key"] = id.plugin_key;
|
||||||
|
entry["name"] = id.name;
|
||||||
|
entry["type"] = plugin_capability_type_display_name(id.type);
|
||||||
|
entry["type_key"] = plugin_capability_type_to_string(id.type);
|
||||||
|
entry["has_config_ui"] = capability->has_config_ui;
|
||||||
|
payload.push_back(std::move(entry));
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replies with one capability's stored config, plus the custom HTML UI when the capability provides
|
||||||
|
// one. Config is sent as a JSON value, not text: the default editor pretty-prints it into its
|
||||||
|
// textarea, and a custom UI receives it as-is through window.orca.
|
||||||
|
nlohmann::json PluginConfig::get_config_response(const PluginCapabilityIdentifier& id)
|
||||||
|
{
|
||||||
|
nlohmann::json response;
|
||||||
|
response["command"] = "capability_config";
|
||||||
|
response["plugin_key"] = id.plugin_key;
|
||||||
|
response["capability_name"] = id.name;
|
||||||
|
response["capability_type"] = plugin_capability_type_to_string(id.type);
|
||||||
|
response["config"] = nlohmann::json::object();
|
||||||
|
response["custom_html"] = "";
|
||||||
|
response["error"] = "";
|
||||||
|
|
||||||
|
// Scoped to the full identity, so a stale request from a page that has not caught up with a
|
||||||
|
// refresh cannot read a different plugin's config — it just misses.
|
||||||
|
const auto cap = PluginManager::instance().get_loader().get_plugin_capability_by_name(id);
|
||||||
|
if (!cap) {
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << "Ignoring config request for a capability that is no longer loaded. plugin_key="
|
||||||
|
<< id.plugin_key << " capability_name=" << id.name;
|
||||||
|
response["error"] = GUI::into_u8(_L("This capability is no longer available."));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
response["config"] = PluginManager::instance().get_config().get_config(id.plugin_key, id.name).config;
|
||||||
|
|
||||||
|
if (cap->has_config_ui) {
|
||||||
|
// Plugin-authored HTML. A raising or empty get_config_ui() costs the capability only its
|
||||||
|
// custom UI: we report the failure and let the page fall back to the default JSON editor,
|
||||||
|
// which edits the very same stored config.
|
||||||
|
std::string html;
|
||||||
|
std::string error;
|
||||||
|
{
|
||||||
|
wxBusyCursor busy;
|
||||||
|
try {
|
||||||
|
PythonGILState gil;
|
||||||
|
html = cap->instance->get_config_ui();
|
||||||
|
} catch (const std::exception& ex) {
|
||||||
|
error = ex.what();
|
||||||
|
} catch (...) {
|
||||||
|
error = "Unknown error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!error.empty()) {
|
||||||
|
BOOST_LOG_TRIVIAL(error) << "Plugin capability get_config_ui() failed. plugin_key=" << id.plugin_key
|
||||||
|
<< " capability_name=" << id.name << " error=" << error;
|
||||||
|
response["error"] = GUI::into_u8(GUI::format_wxstr(_L("The plugin's configuration UI failed to load (%1%). Showing the default editor."),
|
||||||
|
GUI::from_u8(error)));
|
||||||
|
} else if (html.empty()) {
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << "Plugin capability reports a config UI but returned no HTML. plugin_key=" << id.plugin_key
|
||||||
|
<< " capability_name=" << id.name;
|
||||||
|
response["error"] = GUI::into_u8(_L("The plugin's configuration UI was empty. Showing the default editor."));
|
||||||
|
} else {
|
||||||
|
response["custom_html"] = html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json PluginConfig::save_config_response(const PluginCapabilityIdentifier& id, const nlohmann::json& config)
|
||||||
|
{
|
||||||
|
nlohmann::json response;
|
||||||
|
response["command"] = "capability_config_saved";
|
||||||
|
response["plugin_key"] = id.plugin_key;
|
||||||
|
response["capability_name"] = id.name;
|
||||||
|
response["capability_type"] = plugin_capability_type_to_string(id.type);
|
||||||
|
response["ok"] = false;
|
||||||
|
response["error"] = "";
|
||||||
|
|
||||||
|
const auto cap = PluginManager::instance().get_loader().get_plugin_capability_by_name(id);
|
||||||
|
if (!cap) {
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << "Refusing to save config for a capability that is no longer loaded. plugin_key="
|
||||||
|
<< id.plugin_key << " capability_name=" << id.name;
|
||||||
|
response["error"] = GUI::into_u8(_L("This capability is no longer available. Your changes were not saved."));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json parsed = config;
|
||||||
|
if (config.is_string()) {
|
||||||
|
// The page validates as the user types, but it is not the authority: re-parse here so a
|
||||||
|
// malformed document is rejected before it can reach config.json.
|
||||||
|
parsed = nlohmann::json::parse(config.get<std::string>(), nullptr, /* allow_exceptions */ false);
|
||||||
|
if (parsed.is_discarded()) {
|
||||||
|
response["error"] = GUI::into_u8(_L("The configuration is not valid JSON. Your changes were not saved."));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PluginManager::instance().get_config().store_capability_config(id.plugin_key, id.name, parsed)) {
|
||||||
|
BOOST_LOG_TRIVIAL(error) << "Failed to write the plugin config file. plugin_key=" << id.plugin_key
|
||||||
|
<< " capability_name=" << id.name;
|
||||||
|
response["error"] = GUI::into_u8(_L("The configuration could not be written to disk. Your changes were not saved."));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_LOG_TRIVIAL(info) << "Saved plugin capability config. plugin_key=" << id.plugin_key << " capability_name=" << id.name;
|
||||||
|
|
||||||
|
// Echo the persisted value back so the editor reloads from what is actually stored rather than
|
||||||
|
// from what the user typed.
|
||||||
|
response["ok"] = true;
|
||||||
|
response["config"] = PluginManager::instance().get_config().get_config(id.plugin_key, id.name).config;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The host does not invent the default: a capability that does not override get_default_config()
|
||||||
|
// restores an empty config, which is exactly right for one that applies its own defaults on read.
|
||||||
|
nlohmann::json PluginConfig::restore_config_response(const PluginCapabilityIdentifier& id)
|
||||||
|
{
|
||||||
|
nlohmann::json response;
|
||||||
|
response["command"] = "capability_config_saved";
|
||||||
|
response["plugin_key"] = id.plugin_key;
|
||||||
|
response["capability_name"] = id.name;
|
||||||
|
response["capability_type"] = plugin_capability_type_to_string(id.type);
|
||||||
|
response["ok"] = false;
|
||||||
|
response["error"] = "";
|
||||||
|
|
||||||
|
const auto cap = PluginManager::instance().get_loader().get_plugin_capability_by_name(id);
|
||||||
|
if (!cap) {
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << "Refusing to restore config for a capability that is no longer loaded. plugin_key="
|
||||||
|
<< id.plugin_key << " capability_name=" << id.name;
|
||||||
|
response["error"] = GUI::into_u8(_L("This capability is no longer available."));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
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=" << id.plugin_key
|
||||||
|
<< " capability_name=" << id.name << " error=" << error;
|
||||||
|
response["error"] = GUI::into_u8(GUI::format_wxstr(_L("The plugin could not supply a default configuration (%1%). "
|
||||||
|
"Nothing was changed."),
|
||||||
|
GUI::from_u8(error)));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PluginManager::instance().get_config().store_capability_config(id.plugin_key, id.name, defaults)) {
|
||||||
|
BOOST_LOG_TRIVIAL(error) << "Failed to write the plugin config file while restoring defaults. plugin_key="
|
||||||
|
<< id.plugin_key << " capability_name=" << id.name;
|
||||||
|
response["error"] = GUI::into_u8(_L("The configuration could not be written to disk. Nothing was changed."));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_LOG_TRIVIAL(info) << "Restored default plugin capability config. plugin_key=" << id.plugin_key
|
||||||
|
<< " capability_name=" << id.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(id.plugin_key, id.name).config;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace Slic3r
|
} // namespace Slic3r
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
#include <libslic3r/Utils.hpp>
|
#include <libslic3r/Utils.hpp>
|
||||||
#include <boost/filesystem.hpp>
|
#include <boost/filesystem.hpp>
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
|
#include <slic3r/plugin/CapabilityConfigDocument.hpp>
|
||||||
#include <slic3r/plugin/PluginFsUtils.hpp>
|
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||||
|
#include <slic3r/plugin/PluginLoader.hpp>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#define PLUGIN_CONFIG_DIR "config.json"
|
#define PLUGIN_CONFIG_DIR "config.json"
|
||||||
|
|
||||||
@@ -15,6 +18,12 @@ namespace Slic3r {
|
|||||||
|
|
||||||
class PluginCapabilityInterface;
|
class PluginCapabilityInterface;
|
||||||
|
|
||||||
|
enum class PluginConfigSource {
|
||||||
|
None,
|
||||||
|
Base,
|
||||||
|
Preset,
|
||||||
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Example config.json shape
|
Example config.json shape
|
||||||
{
|
{
|
||||||
@@ -95,6 +104,7 @@ public:
|
|||||||
// untouched, so saving one capability cannot disturb another's config.
|
// untouched, so saving one capability cannot disturb another's config.
|
||||||
// The single mutation entry point for both the Plugins dialog and the Python binding.
|
// The single mutation entry point for both the Plugins dialog and the Python binding.
|
||||||
bool store_capability_config(const std::string& plugin_key, const std::string& capability_name, const nlohmann::json& config);
|
bool store_capability_config(const std::string& plugin_key, const std::string& capability_name, const nlohmann::json& config);
|
||||||
|
bool erase_capability_config(const std::string& plugin_key, const std::string& capability_name);
|
||||||
|
|
||||||
// Returns a default-constructed BaseConfig (see BaseConfig::empty) when the capability has
|
// Returns a default-constructed BaseConfig (see BaseConfig::empty) when the capability has
|
||||||
// no stored config.
|
// no stored config.
|
||||||
@@ -103,12 +113,35 @@ public:
|
|||||||
|
|
||||||
bool dirty() const;
|
bool dirty() const;
|
||||||
|
|
||||||
private:
|
// ---- Webview-facing helpers, shared by PluginsDialog's Config tab and PluginsConfigDialog ----
|
||||||
// (plugin_key, capability_name) -> entry. Ordered, so config.json serializes stably.
|
//
|
||||||
using CapabilityId = std::pair<std::string, std::string>;
|
// These build the payloads both dialogs' config views speak, so the two pages stay in step and
|
||||||
|
// neither dialog owns the config protocol. They are static because a capability's config is
|
||||||
|
// addressed globally by (plugin_key, capability_name) through PluginManager's store, not through
|
||||||
|
// any one PluginConfig instance.
|
||||||
|
//
|
||||||
|
// The caller owns the UI: it confirms destructive restores and shows status toasts. These only
|
||||||
|
// touch the store, the loaded capability, and the payload.
|
||||||
|
|
||||||
|
// The config sidebar's rows: one entry per capability, in the order given. Capabilities that are
|
||||||
|
// no longer loaded are skipped — the sidebar only offers what can actually be configured.
|
||||||
|
static nlohmann::json capabilities_payload(const std::vector<PluginCapabilityIdentifier>& caps);
|
||||||
|
|
||||||
|
// One capability's stored config, plus its custom HTML UI when it provides one.
|
||||||
|
static nlohmann::json get_config_response(const PluginCapabilityIdentifier& id);
|
||||||
|
|
||||||
|
// Persists one capability's config. `config` is either text straight from the default editor
|
||||||
|
// (re-parsed here, so malformed JSON can never reach config.json) or a structured value from a
|
||||||
|
// custom UI.
|
||||||
|
static nlohmann::json save_config_response(const PluginCapabilityIdentifier& id, const nlohmann::json& config);
|
||||||
|
|
||||||
|
// Overwrites one capability's stored config with its get_default_config(). The caller must have
|
||||||
|
// confirmed with the user first — this does not ask.
|
||||||
|
static nlohmann::json restore_config_response(const PluginCapabilityIdentifier& id);
|
||||||
|
|
||||||
|
private:
|
||||||
mutable std::mutex m_mutex;
|
mutable std::mutex m_mutex;
|
||||||
std::map<CapabilityId, BaseConfig> m_storage;
|
CapabilityConfigDocument m_document;
|
||||||
bool m_dirty = false;
|
bool m_dirty = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
#include <boost/log/trivial.hpp>
|
#include <boost/log/trivial.hpp>
|
||||||
#include <libslic3r/Config.hpp>
|
#include <libslic3r/Config.hpp>
|
||||||
#include <libslic3r/PresetBundle.hpp>
|
#include <libslic3r/PresetBundle.hpp>
|
||||||
|
#include <slic3r/plugin/PluginLoader.hpp>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <wx/utils.h>
|
#include <wx/utils.h>
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
#include <map>
|
#include <map>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
|
#include <tuple>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
|
||||||
namespace Slic3r {
|
namespace Slic3r {
|
||||||
@@ -92,6 +94,96 @@ static bool is_tracked_type(Preset::Type type)
|
|||||||
return type == Preset::TYPE_PRINT || type == Preset::TYPE_PRINTER || type == Preset::TYPE_FILAMENT;
|
return type == Preset::TYPE_PRINT || type == Preset::TYPE_PRINTER || type == Preset::TYPE_FILAMENT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::vector<PluginCapabilityRef> referenced_capabilities(Preset::Type type, const Preset& preset)
|
||||||
|
{
|
||||||
|
if (!is_tracked_type(type))
|
||||||
|
return {};
|
||||||
|
|
||||||
|
const auto* manifest = dynamic_cast<const ConfigOptionStrings*>(preset.config.option("plugins"));
|
||||||
|
if (manifest == nullptr)
|
||||||
|
return {};
|
||||||
|
|
||||||
|
std::vector<PluginCapabilityRef> refs;
|
||||||
|
for (const std::string& entry : manifest->values) {
|
||||||
|
const auto ref = parse_capability_ref(entry);
|
||||||
|
if (!ref)
|
||||||
|
continue;
|
||||||
|
// The same test the missing-plugin path uses: an entry no option points at is not in use.
|
||||||
|
if (find_option_for_capability(type, preset, *ref).empty())
|
||||||
|
continue;
|
||||||
|
refs.push_back(*ref);
|
||||||
|
}
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Resolve one preset's referenced capabilities to loaded capability identifiers, appending to `out`.
|
||||||
|
// A ref whose plugin is not in the catalog, or whose capability is not currently loaded, is dropped:
|
||||||
|
// there is no instance to ask for a config UI or defaults, so there is nothing to configure.
|
||||||
|
void collect_capabilities_in_use(Preset::Type type, const Preset& preset, std::vector<PluginCapabilityIdentifier>& out)
|
||||||
|
{
|
||||||
|
PluginLoader& loader = PluginManager::instance().get_loader();
|
||||||
|
const PluginCatalog& catalog = PluginManager::instance().get_catalog();
|
||||||
|
|
||||||
|
for (const PluginCapabilityRef& ref : referenced_capabilities(type, preset)) {
|
||||||
|
// Cloud plugins resolve by UUID, local plugins by plugin_key — the rule refresh_missing_plugins uses.
|
||||||
|
const std::string key = ref.uuid.empty() ? ref.name : ref.uuid;
|
||||||
|
if (key.empty())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
PluginDescriptor descriptor;
|
||||||
|
if (!catalog.try_get_plugin_descriptor(key, descriptor))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// The loaded capability is also what supplies the type: the manifest ref does not carry one.
|
||||||
|
for (const auto& capability : loader.get_loaded_plugin_capabilities(descriptor.plugin_key))
|
||||||
|
if (capability && capability->name == ref.capability_name)
|
||||||
|
out.push_back(PluginCapabilityIdentifier{capability->type, capability->name, capability->plugin_key});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::vector<PluginCapabilityIdentifier> capabilities_in_use(const PresetBundle& preset_bundle, Preset::Type type)
|
||||||
|
{
|
||||||
|
if (!is_tracked_type(type))
|
||||||
|
return {};
|
||||||
|
|
||||||
|
std::vector<PluginCapabilityIdentifier> result;
|
||||||
|
if (type == Preset::TYPE_PRINT) {
|
||||||
|
collect_capabilities_in_use(type, preset_bundle.prints.get_edited_preset(), result);
|
||||||
|
} else if (type == Preset::TYPE_PRINTER) {
|
||||||
|
collect_capabilities_in_use(type, preset_bundle.printers.get_edited_preset(), result);
|
||||||
|
} else {
|
||||||
|
// Filament: every selected filament preset, tested against its own config. (Note that
|
||||||
|
// refresh_missing_plugins cannot do this — it unions the manifests and loses the preset.)
|
||||||
|
for (const std::string& filament_name : preset_bundle.filament_presets)
|
||||||
|
if (const Preset* filament = preset_bundle.filaments.find_preset(filament_name))
|
||||||
|
collect_capabilities_in_use(type, *filament, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::sort(result.begin(), result.end(), [](const PluginCapabilityIdentifier& a, const PluginCapabilityIdentifier& b) {
|
||||||
|
return std::tie(a.plugin_key, a.name, a.type) < std::tie(b.plugin_key, b.name, b.type);
|
||||||
|
});
|
||||||
|
result.erase(std::unique(result.begin(), result.end()), result.end());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<PluginCapabilityIdentifier> capabilities_in_use(Preset::Type type, const Preset& preset)
|
||||||
|
{
|
||||||
|
std::vector<PluginCapabilityIdentifier> result;
|
||||||
|
if (!is_tracked_type(type))
|
||||||
|
return result;
|
||||||
|
|
||||||
|
collect_capabilities_in_use(type, preset, result);
|
||||||
|
std::sort(result.begin(), result.end(), [](const PluginCapabilityIdentifier& a, const PluginCapabilityIdentifier& b) {
|
||||||
|
return std::tie(a.plugin_key, a.name, a.type) < std::tie(b.plugin_key, b.name, b.type);
|
||||||
|
});
|
||||||
|
result.erase(std::unique(result.begin(), result.end()), result.end());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
static std::string resolve_cloud_base_url()
|
static std::string resolve_cloud_base_url()
|
||||||
{
|
{
|
||||||
std::string cloud_base_url = "https://cloud.orcaslicer.com";
|
std::string cloud_base_url = "https://cloud.orcaslicer.com";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <libslic3r/Config.hpp> // PluginCapabilityRef, parse_capability_ref
|
#include <libslic3r/Config.hpp> // PluginCapabilityRef, parse_capability_ref
|
||||||
#include <libslic3r/Preset.hpp> // Preset::Type
|
#include <libslic3r/Preset.hpp> // Preset::Type
|
||||||
#include <libslic3r/PresetBundle.hpp>
|
#include <libslic3r/PresetBundle.hpp>
|
||||||
|
#include <slic3r/plugin/PluginLoader.hpp> // PluginCapabilityIdentifier
|
||||||
#include <slic3r/plugin/PythonPluginInterface.hpp> // PluginCapabilityType
|
#include <slic3r/plugin/PythonPluginInterface.hpp> // PluginCapabilityType
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
@@ -81,6 +82,21 @@ void open_missing_plugins_on_cloud(const std::vector<std::string>& local_refs);
|
|||||||
std::string create_full_ref(const PluginCapabilityRef& ref);
|
std::string create_full_ref(const PluginCapabilityRef& ref);
|
||||||
std::string resolve_recovery_url(const PluginCapabilityRef& ref);
|
std::string resolve_recovery_url(const PluginCapabilityRef& ref);
|
||||||
|
|
||||||
|
// The capabilities `preset`'s "plugins" manifest declares AND that one of its plugin-backed options
|
||||||
|
// (ConfigOptionDef::is_plugin_backed) currently references. A manifest entry nobody points at is not
|
||||||
|
// in use. Pure preset logic — the plugin catalog and loader are not consulted. Empty for untracked
|
||||||
|
// preset types.
|
||||||
|
std::vector<PluginCapabilityRef> referenced_capabilities(Preset::Type type, const Preset& preset);
|
||||||
|
std::vector<PluginCapabilityIdentifier> capabilities_in_use(Preset::Type type, const Preset& preset);
|
||||||
|
|
||||||
|
// The capabilities the active preset(s) of `type` reference (see referenced_capabilities) and that
|
||||||
|
// are loaded right now: the set that can actually be configured. Missing and broken refs are absent,
|
||||||
|
// having no instance to ask for a config UI or defaults. A loaded-but-disabled capability IS listed —
|
||||||
|
// it still has stored config worth editing, and disabling it is not a reason to hide that.
|
||||||
|
// TYPE_FILAMENT unions every selected filament preset; a capability used by two extruders is listed
|
||||||
|
// once. Deduped on the full identity, so two plugins exposing a same-named capability stay distinct.
|
||||||
|
std::vector<PluginCapabilityIdentifier> capabilities_in_use(const PresetBundle& preset_bundle, Preset::Type type);
|
||||||
|
|
||||||
bool check_capability_in_use(const std::string& capability_refs);
|
bool check_capability_in_use(const std::string& capability_refs);
|
||||||
|
|
||||||
} // namespace Slic3r
|
} // namespace Slic3r
|
||||||
|
|||||||
128
src/slic3r/plugin/PresetPluginConfig.cpp
Normal file
128
src/slic3r/plugin/PresetPluginConfig.cpp
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
#include "PresetPluginConfig.hpp"
|
||||||
|
|
||||||
|
#include "PluginManager.hpp"
|
||||||
|
|
||||||
|
namespace Slic3r {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
CapabilityConfigId make_id(const PluginCapabilityIdentifier& id)
|
||||||
|
{
|
||||||
|
return CapabilityConfigId{id.plugin_key, id.name};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string running_plugin_version(const std::string& plugin_key)
|
||||||
|
{
|
||||||
|
PluginDescriptor descriptor;
|
||||||
|
if (!PluginManager::instance().get_catalog().try_get_valid_plugin_descriptor(plugin_key, descriptor))
|
||||||
|
return {};
|
||||||
|
return descriptor.installed_version.empty() ? descriptor.version : descriptor.installed_version;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string plugin_overrides_of(const Preset& preset)
|
||||||
|
{
|
||||||
|
const auto* opt = dynamic_cast<const ConfigOptionString*>(preset.config.option(PLUGIN_OVERRIDES_OPTION_KEY));
|
||||||
|
return opt == nullptr ? std::string() : opt->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool parse_plugin_overrides(const std::string& raw, CapabilityConfigDocument& document, std::string& error)
|
||||||
|
{
|
||||||
|
document = CapabilityConfigDocument();
|
||||||
|
error.clear();
|
||||||
|
|
||||||
|
if (raw.empty())
|
||||||
|
return true;
|
||||||
|
|
||||||
|
const nlohmann::json parsed = nlohmann::json::parse(raw, nullptr, /* allow_exceptions */ false);
|
||||||
|
if (parsed.is_discarded()) {
|
||||||
|
error = "The preset stores invalid plugin capability configuration JSON.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!parsed.is_array()) {
|
||||||
|
error = "The preset's plugin capability configuration is not an array and cannot be edited.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
document = CapabilityConfigDocument::from_entries(parsed);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string serialize_plugin_overrides(const CapabilityConfigDocument& document)
|
||||||
|
{
|
||||||
|
return document.empty() ? std::string() : document.serialize_entries().dump();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string plugin_config_source_to_string(PluginConfigSource source)
|
||||||
|
{
|
||||||
|
switch (source) {
|
||||||
|
case PluginConfigSource::Preset: return "preset";
|
||||||
|
case PluginConfigSource::Base: return "base";
|
||||||
|
default: return "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EffectiveCapabilityConfig PresetPluginConfigService::get_effective_config(const CapabilityConfigDocument& overrides,
|
||||||
|
const PluginCapabilityIdentifier& id) const
|
||||||
|
{
|
||||||
|
EffectiveCapabilityConfig result;
|
||||||
|
result.id = make_id(id);
|
||||||
|
result.running_plugin_version = running_plugin_version(id.plugin_key);
|
||||||
|
|
||||||
|
const BaseConfig base = PluginManager::instance().get_config().get_config(id.plugin_key, id.name);
|
||||||
|
result.has_base_config = !base.empty();
|
||||||
|
|
||||||
|
if (const auto entry = overrides.find(result.id)) {
|
||||||
|
result.has_preset_override = true;
|
||||||
|
result.source = PluginConfigSource::Preset;
|
||||||
|
result.config = entry->cap_config;
|
||||||
|
result.stored_plugin_version = entry->plugin_version;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.has_base_config) {
|
||||||
|
result.source = PluginConfigSource::Base;
|
||||||
|
result.config = base.config;
|
||||||
|
result.stored_plugin_version = base.plugin_version;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
MutationResult PresetPluginConfigService::set_preset_override(CapabilityConfigDocument& overrides,
|
||||||
|
const PluginCapabilityIdentifier& id,
|
||||||
|
const nlohmann::json& value) const
|
||||||
|
{
|
||||||
|
MutationResult result;
|
||||||
|
const CapabilityConfigId config_id = make_id(id);
|
||||||
|
const std::string version = running_plugin_version(id.plugin_key);
|
||||||
|
|
||||||
|
// A no-op is a successful unchanged result, not a reason to rewrite the preset: re-saving the
|
||||||
|
// displayed value must not be able to mark it dirty.
|
||||||
|
const auto existing = overrides.find(config_id);
|
||||||
|
if (existing && existing->cap_config == value && existing->plugin_version == version) {
|
||||||
|
result.ok = true;
|
||||||
|
result.effective = get_effective_config(overrides, id);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
overrides.upsert({config_id, version, value});
|
||||||
|
|
||||||
|
result.ok = true;
|
||||||
|
result.changed = true;
|
||||||
|
result.effective = get_effective_config(overrides, id);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
MutationResult PresetPluginConfigService::remove_preset_override(CapabilityConfigDocument& overrides,
|
||||||
|
const PluginCapabilityIdentifier& id) const
|
||||||
|
{
|
||||||
|
MutationResult result;
|
||||||
|
result.ok = true;
|
||||||
|
result.changed = overrides.erase(make_id(id));
|
||||||
|
// Reads back as the base value now that the override is gone.
|
||||||
|
result.effective = get_effective_config(overrides, id);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Slic3r
|
||||||
70
src/slic3r/plugin/PresetPluginConfig.hpp
Normal file
70
src/slic3r/plugin/PresetPluginConfig.hpp
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CapabilityConfigDocument.hpp"
|
||||||
|
#include "PluginConfig.hpp"
|
||||||
|
|
||||||
|
#include <libslic3r/Preset.hpp>
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace Slic3r {
|
||||||
|
|
||||||
|
// A preset keeps its plugin capability overrides as one raw JSON string in this ordinary
|
||||||
|
// ConfigOptionString, so the whole preset lifecycle — load, save, diff/dirty, inheritance, 3MF,
|
||||||
|
// sync — carries it for free. The plugin layer is the only thing that gives that string meaning.
|
||||||
|
inline constexpr const char* PLUGIN_OVERRIDES_OPTION_KEY = "plugin_preference_overrides";
|
||||||
|
|
||||||
|
// The preset's raw override text, or "" when it stores none.
|
||||||
|
std::string plugin_overrides_of(const Preset& preset);
|
||||||
|
|
||||||
|
// An empty string is a valid, empty document. Returns false and fills `error` when the text is
|
||||||
|
// present but is not a JSON array of entries; the caller then shows it and edits nothing.
|
||||||
|
bool parse_plugin_overrides(const std::string& raw, CapabilityConfigDocument& document, std::string& error);
|
||||||
|
|
||||||
|
// The document as compact JSON text, and "" once it holds no entries. Empty text — rather than a
|
||||||
|
// removed option — is what records "cleared here" against an inheriting parent that has overrides.
|
||||||
|
std::string serialize_plugin_overrides(const CapabilityConfigDocument& document);
|
||||||
|
|
||||||
|
struct EffectiveCapabilityConfig
|
||||||
|
{
|
||||||
|
CapabilityConfigId id;
|
||||||
|
nlohmann::json config = nlohmann::json::object();
|
||||||
|
PluginConfigSource source = PluginConfigSource::None;
|
||||||
|
|
||||||
|
bool has_preset_override = false;
|
||||||
|
bool has_base_config = false;
|
||||||
|
std::string stored_plugin_version;
|
||||||
|
std::string running_plugin_version;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct MutationResult
|
||||||
|
{
|
||||||
|
bool ok = false;
|
||||||
|
bool changed = false;
|
||||||
|
std::string error;
|
||||||
|
EffectiveCapabilityConfig effective;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::string plugin_config_source_to_string(PluginConfigSource source);
|
||||||
|
|
||||||
|
// Resolves a capability's effective config as `preset override -> base config -> none`, and mutates
|
||||||
|
// the override layer.
|
||||||
|
//
|
||||||
|
// It works on a CapabilityConfigDocument the caller owns, never on a Preset and never on the base
|
||||||
|
// config file. That is what keeps the two layers from writing to each other: PluginConfigField holds
|
||||||
|
// the document, and feeds the edited text back through the normal field/dirty pipeline, so the
|
||||||
|
// preset is written exactly the way every other setting is.
|
||||||
|
class PresetPluginConfigService
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
EffectiveCapabilityConfig get_effective_config(const CapabilityConfigDocument& overrides,
|
||||||
|
const PluginCapabilityIdentifier& id) const;
|
||||||
|
MutationResult set_preset_override(CapabilityConfigDocument& overrides,
|
||||||
|
const PluginCapabilityIdentifier& id,
|
||||||
|
const nlohmann::json& value) const;
|
||||||
|
MutationResult remove_preset_override(CapabilityConfigDocument& overrides,
|
||||||
|
const PluginCapabilityIdentifier& id) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace Slic3r
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
#include "PythonPluginBridge.hpp"
|
#include "PythonPluginBridge.hpp"
|
||||||
|
|
||||||
#include <boost/log/trivial.hpp>
|
#include <boost/log/trivial.hpp>
|
||||||
|
#include <exception>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <slic3r/plugin/PluginAuditManager.hpp>
|
#include <slic3r/plugin/PluginAuditManager.hpp>
|
||||||
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
|
||||||
#include <pybind11/embed.h>
|
#include <pybind11/embed.h>
|
||||||
@@ -343,33 +345,41 @@ void bind_python_api(pybind11::module_& m)
|
|||||||
"get_default_config",
|
"get_default_config",
|
||||||
[](const PluginCapabilityInterface& self) {
|
[](const PluginCapabilityInterface& self) {
|
||||||
nlohmann::json config = self.get_default_config();
|
nlohmann::json config = self.get_default_config();
|
||||||
return json_to_py(config); // GIL held (binding body)
|
return config.dump();
|
||||||
},
|
},
|
||||||
"Override to return the config that the Config tab's \"Restore defaults\" action writes\n"
|
"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"
|
"back, as a dict. Optional: without it the action stores an empty config, which already\n"
|
||||||
"defaults of a capability that keeps its stored config sparse and applies its own\n"
|
"restores the defaults of a capability that keeps its stored config sparse and applies\n"
|
||||||
"defaults on read. Override it to write an explicit starting config instead.")
|
"its own defaults on read. Override it to write an explicit starting config instead.\n"
|
||||||
|
"Calling it returns that config as a JSON string.")
|
||||||
.def(
|
.def(
|
||||||
"get_config",
|
"get_config",
|
||||||
[](const PluginCapabilityInterface& self) {
|
[](const PluginCapabilityInterface& self) {
|
||||||
nlohmann::json config = capability_get_config(self);
|
nlohmann::json config = capability_get_config(self);
|
||||||
return json_to_py(config); // GIL held (binding body)
|
return config.dump();
|
||||||
},
|
},
|
||||||
"Return this capability's stored config. An empty dict if it has never been saved.")
|
"Return this capability's stored config as a JSON string — json.loads() it to use.\n"
|
||||||
|
"\"{}\" if it has never been saved, so the parsed result is always indexable.")
|
||||||
.def(
|
.def(
|
||||||
"get_config_version",
|
"get_config_version", [](const PluginCapabilityInterface& self) { return capability_get_config_version(self); },
|
||||||
[](const PluginCapabilityInterface& self) { return capability_get_config_version(self); },
|
|
||||||
"Return the plugin version that last wrote this capability's config, so a newer\n"
|
"Return the plugin version that last wrote this capability's config, so a newer\n"
|
||||||
"release can spot a stale config and migrate it. Empty string if never saved.")
|
"release can spot a stale config and migrate it. Empty string if never saved.")
|
||||||
.def(
|
.def(
|
||||||
"save_config",
|
"save_config",
|
||||||
[](const PluginCapabilityInterface& self, const py::object& config) {
|
[](const PluginCapabilityInterface& self, const std::string& config_str) {
|
||||||
return capability_save_config(self, py_to_json(config)); // GIL held (binding body)
|
nlohmann::json config = nlohmann::json::parse(config_str, nullptr, /* allow_exceptions */ false);
|
||||||
|
if (config.is_discarded()) {
|
||||||
|
// Refused rather than stored: the caller gets False, and the previously stored
|
||||||
|
// config is left alone. Logged because False alone does not say why.
|
||||||
|
BOOST_LOG_TRIVIAL(error) << "save_config: capability '" << self.get_name() << "' passed a config that is not valid JSON";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return capability_save_config(self, config);
|
||||||
},
|
},
|
||||||
py::arg("config"),
|
py::arg("config"),
|
||||||
"Persist this capability's config, given as a JSON-compatible value (usually a dict).\n"
|
"Persist this capability's config, given as a JSON string (e.g. json.dumps(cfg)).\n"
|
||||||
"The plugin key, capability name and version are supplied by the host. Returns False if\n"
|
"The plugin key, capability name and version are supplied by the host. Returns False if\n"
|
||||||
"the config file could not be written.");
|
"the string is not valid JSON, or if the config file could not be written.");
|
||||||
|
|
||||||
// Expose the package marker base as orca.base. @orca.plugin later verifies that the
|
// Expose the package marker base as orca.base. @orca.plugin later verifies that the
|
||||||
// decorated class derives from this exact pybind-registered C++ type.
|
// decorated class derives from this exact pybind-registered C++ type.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ add_executable(${_TEST_NAME}_tests
|
|||||||
test_plugin_capability_config.cpp
|
test_plugin_capability_config.cpp
|
||||||
test_plugin_capability_identifier.cpp
|
test_plugin_capability_identifier.cpp
|
||||||
test_plugin_config.cpp
|
test_plugin_config.cpp
|
||||||
|
test_plugin_capabilities_in_use.cpp
|
||||||
test_plugin_install.cpp
|
test_plugin_install.cpp
|
||||||
test_slicing_pipeline_bindings.cpp
|
test_slicing_pipeline_bindings.cpp
|
||||||
test_plugin_sort.cpp
|
test_plugin_sort.cpp
|
||||||
|
|||||||
75
tests/slic3rutils/test_plugin_capabilities_in_use.cpp
Normal file
75
tests/slic3rutils/test_plugin_capabilities_in_use.cpp
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
#include <catch2/catch_all.hpp>
|
||||||
|
|
||||||
|
#include <libslic3r/Preset.hpp>
|
||||||
|
#include <libslic3r/PrintConfig.hpp>
|
||||||
|
#include <slic3r/plugin/PluginResolver.hpp>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using namespace Slic3r;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// A print preset carrying a "plugins" manifest and the one plugin-backed print option
|
||||||
|
// (slicing_pipeline_plugin, a coStrings vector).
|
||||||
|
Preset make_print_preset(const std::vector<std::string>& manifest, const std::vector<std::string>& pipeline)
|
||||||
|
{
|
||||||
|
Preset preset(Preset::TYPE_PRINT, "test-print");
|
||||||
|
const std::unique_ptr<DynamicPrintConfig> defaults(
|
||||||
|
DynamicPrintConfig::new_from_defaults_keys({"plugins", "slicing_pipeline_plugin"}));
|
||||||
|
preset.config = *defaults;
|
||||||
|
preset.config.option<ConfigOptionStrings>("plugins")->values = manifest;
|
||||||
|
preset.config.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values = pipeline;
|
||||||
|
return preset;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> capability_names(const std::vector<PluginCapabilityRef>& refs)
|
||||||
|
{
|
||||||
|
std::vector<std::string> names;
|
||||||
|
for (const PluginCapabilityRef& ref : refs)
|
||||||
|
names.push_back(ref.capability_name);
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("referenced_capabilities keeps only manifest entries an option points at", "[PluginResolver]")
|
||||||
|
{
|
||||||
|
// CapB is declared in the manifest but no option references it, so it is not in use.
|
||||||
|
const Preset preset = make_print_preset({"acme;;CapA", "acme;;CapB"}, {"CapA"});
|
||||||
|
|
||||||
|
CHECK(capability_names(referenced_capabilities(Preset::TYPE_PRINT, preset)) == std::vector<std::string>{"CapA"});
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("referenced_capabilities matches every value of a vector option", "[PluginResolver]")
|
||||||
|
{
|
||||||
|
const Preset preset = make_print_preset({"acme;;CapA", "acme;;CapB", "acme;;CapC"}, {"CapA", "CapC"});
|
||||||
|
|
||||||
|
CHECK(capability_names(referenced_capabilities(Preset::TYPE_PRINT, preset)) ==
|
||||||
|
std::vector<std::string>{"CapA", "CapC"});
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("referenced_capabilities is empty when the manifest is empty", "[PluginResolver]")
|
||||||
|
{
|
||||||
|
const Preset preset = make_print_preset({}, {"CapA"});
|
||||||
|
|
||||||
|
CHECK(referenced_capabilities(Preset::TYPE_PRINT, preset).empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("referenced_capabilities ignores untracked preset types", "[PluginResolver]")
|
||||||
|
{
|
||||||
|
Preset preset = make_print_preset({"acme;;CapA"}, {"CapA"});
|
||||||
|
preset.type = Preset::TYPE_SLA_PRINT;
|
||||||
|
|
||||||
|
CHECK(referenced_capabilities(Preset::TYPE_SLA_PRINT, preset).empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("referenced_capabilities skips malformed manifest entries", "[PluginResolver]")
|
||||||
|
{
|
||||||
|
// parse_capability_ref rejects entries that are not "name;uuid;capability".
|
||||||
|
const Preset preset = make_print_preset({"garbage", "acme;;CapA"}, {"CapA"});
|
||||||
|
|
||||||
|
CHECK(capability_names(referenced_capabilities(Preset::TYPE_PRINT, preset)) == std::vector<std::string>{"CapA"});
|
||||||
|
}
|
||||||
@@ -73,6 +73,12 @@ std::shared_ptr<PluginCapabilityInterface> as_interface(const py::object& instan
|
|||||||
// PluginManager singleton, so that is where the assertions read from.
|
// PluginManager singleton, so that is where the assertions read from.
|
||||||
PluginConfig& host_config() { return PluginManager::instance().get_config(); }
|
PluginConfig& host_config() { return PluginManager::instance().get_config(); }
|
||||||
|
|
||||||
|
// The Python config API speaks JSON text, not dicts: get_config()/get_default_config() hand back a
|
||||||
|
// JSON string and save_config() takes one. These helpers keep the tests written in terms of values.
|
||||||
|
json py_get_config(const py::object& cap) { return json::parse(cap.attr("get_config")().cast<std::string>()); }
|
||||||
|
|
||||||
|
bool py_save_config(const py::object& cap, const json& value) { return cap.attr("save_config")(value.dump()).cast<bool>(); }
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
TEST_CASE("Capability config API is exposed on every Python capability", "[PluginConfig][Python]")
|
TEST_CASE("Capability config API is exposed on every Python capability", "[PluginConfig][Python]")
|
||||||
@@ -103,26 +109,23 @@ TEST_CASE("get_config returns only cap_config and save_config persists it", "[Pl
|
|||||||
|
|
||||||
py::object cap = make_capability("RoundTripCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a");
|
py::object cap = make_capability("RoundTripCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a");
|
||||||
|
|
||||||
// Nothing stored yet: an empty dict, not None, so a plugin can index it unconditionally.
|
// Nothing stored yet: the JSON text of an empty object, not None, so a plugin can json.loads()
|
||||||
|
// and index it unconditionally.
|
||||||
py::object initial = cap.attr("get_config")();
|
py::object initial = cap.attr("get_config")();
|
||||||
REQUIRE(py::isinstance<py::dict>(initial));
|
REQUIRE(py::isinstance<py::str>(initial));
|
||||||
CHECK(py::len(initial) == 0);
|
CHECK(json::parse(initial.cast<std::string>()) == json::object());
|
||||||
CHECK(cap.attr("get_config_version")().cast<std::string>().empty());
|
CHECK(cap.attr("get_config_version")().cast<std::string>().empty());
|
||||||
|
|
||||||
py::dict value;
|
REQUIRE(py_save_config(cap, json{{"speed", 5}, {"name", "fast"}}));
|
||||||
value["speed"] = 5;
|
|
||||||
value["name"] = "fast";
|
|
||||||
REQUIRE(cap.attr("save_config")(value).cast<bool>());
|
|
||||||
|
|
||||||
// Persisted through PluginConfig, under this capability's identity only.
|
// Persisted through PluginConfig, under this capability's identity only.
|
||||||
const BaseConfig stored = host_config().get_config("plugin_a", "cap_a");
|
const BaseConfig stored = host_config().get_config("plugin_a", "cap_a");
|
||||||
REQUIRE_FALSE(stored.empty());
|
REQUIRE_FALSE(stored.empty());
|
||||||
CHECK(stored.config == json{{"speed", 5}, {"name", "fast"}});
|
CHECK(stored.config == json{{"speed", 5}, {"name", "fast"}});
|
||||||
|
|
||||||
// And read back through Python as a dict of exactly cap_config — no host metadata.
|
// And read back through Python as exactly cap_config — no host metadata.
|
||||||
py::object reloaded = cap.attr("get_config")();
|
const json reloaded = py_get_config(cap);
|
||||||
REQUIRE(py::isinstance<py::dict>(reloaded));
|
CHECK(reloaded.size() == 2);
|
||||||
CHECK(py::len(reloaded) == 2);
|
|
||||||
CHECK(reloaded.contains("speed"));
|
CHECK(reloaded.contains("speed"));
|
||||||
CHECK_FALSE(reloaded.contains("plugin_key"));
|
CHECK_FALSE(reloaded.contains("plugin_key"));
|
||||||
CHECK_FALSE(reloaded.contains("capability"));
|
CHECK_FALSE(reloaded.contains("capability"));
|
||||||
@@ -130,6 +133,21 @@ TEST_CASE("get_config returns only cap_config and save_config persists it", "[Pl
|
|||||||
CHECK_FALSE(reloaded.contains("plugin_version"));
|
CHECK_FALSE(reloaded.contains("plugin_version"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST_CASE("save_config rejects a string that is not valid JSON", "[PluginConfig][Python]")
|
||||||
|
{
|
||||||
|
ScopedDataDir data_dir_guard("plugin-config-py-badjson");
|
||||||
|
host_config().load();
|
||||||
|
|
||||||
|
py::object cap = make_capability("BadJsonCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a");
|
||||||
|
|
||||||
|
REQUIRE(py_save_config(cap, json{{"keep", "me"}}));
|
||||||
|
|
||||||
|
// The binding parses the string it is handed. Unparseable text is refused, and refusing it must
|
||||||
|
// leave the previously stored config alone rather than clobbering it with nothing.
|
||||||
|
CHECK_FALSE(cap.attr("save_config")("{not json").cast<bool>());
|
||||||
|
CHECK(host_config().get_config("plugin_a", "cap_a").config == json{{"keep", "me"}});
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("Saving one capability's config does not touch another's", "[PluginConfig][Python]")
|
TEST_CASE("Saving one capability's config does not touch another's", "[PluginConfig][Python]")
|
||||||
{
|
{
|
||||||
ScopedDataDir data_dir_guard("plugin-config-py-isolation");
|
ScopedDataDir data_dir_guard("plugin-config-py-isolation");
|
||||||
@@ -142,25 +160,19 @@ TEST_CASE("Saving one capability's config does not touch another's", "[PluginCon
|
|||||||
py::object a_cap2 = make_capability("IsoCapA2", body, "plugin_a", "cap_b");
|
py::object a_cap2 = make_capability("IsoCapA2", body, "plugin_a", "cap_b");
|
||||||
py::object b_cap1 = make_capability("IsoCapB1", body, "plugin_b", "cap_a");
|
py::object b_cap1 = make_capability("IsoCapB1", body, "plugin_b", "cap_a");
|
||||||
|
|
||||||
py::dict one, two, three;
|
REQUIRE(py_save_config(a_cap1, json{{"value", 1}}));
|
||||||
one["value"] = 1;
|
REQUIRE(py_save_config(a_cap2, json{{"value", 2}}));
|
||||||
two["value"] = 2;
|
REQUIRE(py_save_config(b_cap1, json{{"value", 3}}));
|
||||||
three["value"] = 3;
|
|
||||||
REQUIRE(a_cap1.attr("save_config")(one).cast<bool>());
|
|
||||||
REQUIRE(a_cap2.attr("save_config")(two).cast<bool>());
|
|
||||||
REQUIRE(b_cap1.attr("save_config")(three).cast<bool>());
|
|
||||||
|
|
||||||
py::dict updated;
|
REQUIRE(py_save_config(a_cap1, json{{"value", 99}}));
|
||||||
updated["value"] = 99;
|
|
||||||
REQUIRE(a_cap1.attr("save_config")(updated).cast<bool>());
|
|
||||||
|
|
||||||
CHECK(host_config().get_config("plugin_a", "cap_a").config == json{{"value", 99}});
|
CHECK(host_config().get_config("plugin_a", "cap_a").config == json{{"value", 99}});
|
||||||
CHECK(host_config().get_config("plugin_a", "cap_b").config == json{{"value", 2}});
|
CHECK(host_config().get_config("plugin_a", "cap_b").config == json{{"value", 2}});
|
||||||
CHECK(host_config().get_config("plugin_b", "cap_a").config == json{{"value", 3}});
|
CHECK(host_config().get_config("plugin_b", "cap_a").config == json{{"value", 3}});
|
||||||
|
|
||||||
// Each capability still reads back its own value.
|
// Each capability still reads back its own value.
|
||||||
CHECK(a_cap2.attr("get_config")()["value"].cast<int>() == 2);
|
CHECK(py_get_config(a_cap2).at("value") == 2);
|
||||||
CHECK(b_cap1.attr("get_config")()["value"].cast<int>() == 3);
|
CHECK(py_get_config(b_cap1).at("value") == 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("Config API refuses a capability the host never materialized", "[PluginConfig][Python]")
|
TEST_CASE("Config API refuses a capability the host never materialized", "[PluginConfig][Python]")
|
||||||
@@ -174,7 +186,7 @@ TEST_CASE("Config API refuses a capability the host never materialized", "[Plugi
|
|||||||
|
|
||||||
CHECK_THROWS(orphan.attr("get_config")());
|
CHECK_THROWS(orphan.attr("get_config")());
|
||||||
CHECK_THROWS(orphan.attr("get_config_version")());
|
CHECK_THROWS(orphan.attr("get_config_version")());
|
||||||
CHECK_THROWS(orphan.attr("save_config")(py::dict()));
|
CHECK_THROWS(orphan.attr("save_config")(json::object().dump()));
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("Custom config UI hooks dispatch to the Python override", "[PluginConfig][Python]")
|
TEST_CASE("Custom config UI hooks dispatch to the Python override", "[PluginConfig][Python]")
|
||||||
@@ -206,9 +218,7 @@ TEST_CASE("A capability that omits the config UI hooks gets the default editor",
|
|||||||
CHECK_FALSE(iface->has_config_ui()); // -> default JSON editor
|
CHECK_FALSE(iface->has_config_ui()); // -> default JSON editor
|
||||||
CHECK(iface->get_config_ui().empty());
|
CHECK(iface->get_config_ui().empty());
|
||||||
|
|
||||||
py::dict value;
|
REQUIRE(py_save_config(bare, json{{"speed", 5}}));
|
||||||
value["speed"] = 5;
|
|
||||||
REQUIRE(bare.attr("save_config")(value).cast<bool>());
|
|
||||||
CHECK(host_config().get_config("plugin_a", "cap_a").config == json{{"speed", 5}});
|
CHECK(host_config().get_config("plugin_a", "cap_a").config == json{{"speed", 5}});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,10 +289,9 @@ TEST_CASE("Restoring defaults overwrites only the target capability", "[PluginCo
|
|||||||
py::object target = make_capability("RestoreTargetCap", defaults_body, "plugin_a", "cap_a");
|
py::object target = make_capability("RestoreTargetCap", defaults_body, "plugin_a", "cap_a");
|
||||||
py::object bystander = make_capability("RestoreBystanderCap", defaults_body, "plugin_b", "cap_a");
|
py::object bystander = make_capability("RestoreBystanderCap", defaults_body, "plugin_b", "cap_a");
|
||||||
|
|
||||||
py::dict edited;
|
const json edited = json{{"speed", 99}};
|
||||||
edited["speed"] = 99;
|
REQUIRE(py_save_config(target, edited));
|
||||||
REQUIRE(target.attr("save_config")(edited).cast<bool>());
|
REQUIRE(py_save_config(bystander, edited));
|
||||||
REQUIRE(bystander.attr("save_config")(edited).cast<bool>());
|
|
||||||
|
|
||||||
// What PluginsDialog::restore_capability_config does: ask the capability, store the answer.
|
// What PluginsDialog::restore_capability_config does: ask the capability, store the answer.
|
||||||
auto iface = as_interface(target);
|
auto iface = as_interface(target);
|
||||||
@@ -303,9 +312,7 @@ TEST_CASE("A raising get_default_config leaves the stored config untouched", "[P
|
|||||||
" def get_default_config(self): raise RuntimeError('boom')\n",
|
" def get_default_config(self): raise RuntimeError('boom')\n",
|
||||||
"plugin_a", "cap_a");
|
"plugin_a", "cap_a");
|
||||||
|
|
||||||
py::dict value;
|
REQUIRE(py_save_config(cap, json{{"keep", "me"}}));
|
||||||
value["keep"] = "me";
|
|
||||||
REQUIRE(cap.attr("save_config")(value).cast<bool>());
|
|
||||||
|
|
||||||
auto iface = as_interface(cap);
|
auto iface = as_interface(cap);
|
||||||
REQUIRE(iface);
|
REQUIRE(iface);
|
||||||
|
|||||||
Reference in New Issue
Block a user