diff --git a/sandboxes/orca_dock_panel_plugin_any.py b/sandboxes/orca_dock_panel_plugin_any.py
new file mode 100644
index 0000000000..bb6a8143b4
--- /dev/null
+++ b/sandboxes/orca_dock_panel_plugin_any.py
@@ -0,0 +1,146 @@
+# /// script
+# requires-python = ">=3.12"
+#
+# [tool.orcaslicer.plugin]
+# name = "Dock Panel Demo"
+# description = "Opens a dockable panel beside the 3D view that lists the objects on the plate."
+# author = "OrcaSlicer"
+# version = "0.0.1"
+# ///
+"""Dock Panel Demo -- orca.host.ui.create_dock_panel().
+
+Run it from the Plugins dialog. It opens an HTML panel docked on the right of the 3D view, in the
+same dock area as the sidebar. Drag its caption to dock it on another side (or float it, where the
+platform allows), hide it from the page and run the plugin again to bring it back, or close it with
+its close button or from the page.
+
+ page --orca.postMessage({command: 'refresh'})--> plugin.on_message()
+ page --orca.postMessage({command: 'hide'})--> plugin.on_message() -> panel.hide()
+ page --orca.close()--> panel closes, plugin.on_close()
+ plugin --panel.post({command: 'objects', ...})--> page (orca.onMessage)
+"""
+import orca
+
+PAGE = """
+
+
+
Objects on the plate
+
Docked beside the 3D view. Drag the caption to move it.
+
+
+
+
+
+
+
+
+
Name
Parts
Copies
+
+
+
Waiting for the plugin...
+
+
+"""
+
+
+def plate_objects():
+ try:
+ model = orca.host.model()
+ except RuntimeError as error:
+ return {"command": "objects", "error": str(error)}
+ return {
+ "command": "objects",
+ "objects": [
+ {"name": obj.name or "(unnamed)", "volumes": obj.volume_count(), "instances": obj.instance_count()}
+ for obj in model.objects()
+ ],
+ }
+
+
+class DockPanelDemo(orca.script.ScriptPluginCapabilityBase):
+ panel = None
+
+ def get_name(self):
+ return "Dock Panel Demo"
+
+ def execute(self):
+ # The capability instance lives as long as the plugin, so a second run finds the open panel.
+ if self.panel is not None and self.panel.is_open():
+ self.panel.show()
+ return orca.ExecutionResult.success("Dock Panel Demo is already open.")
+ self.panel = orca.host.ui.create_dock_panel(
+ html=PAGE,
+ title="Dock Panel Demo",
+ dock="right",
+ width=320,
+ height=480,
+ on_message=self.on_message,
+ on_close=self.on_close,
+ )
+ return orca.ExecutionResult.success("Dock Panel Demo opened.")
+
+ # Called on the UI thread when the page posts.
+ def on_message(self, message):
+ command = (message or {}).get("command")
+ if command == "refresh":
+ self.panel.post(plate_objects())
+ elif command == "hide":
+ self.panel.hide()
+
+ def on_close(self):
+ self.panel = None
+
+
+@orca.plugin
+class DockPanelDemoPlugin(orca.base):
+ def register_capabilities(self):
+ orca.register_capability(DockPanelDemo)
diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt
index 691675a452..97e367faab 100644
--- a/src/slic3r/CMakeLists.txt
+++ b/src/slic3r/CMakeLists.txt
@@ -139,6 +139,8 @@ set(SLIC3R_GUI_SOURCES
GUI/PluginProgressDialog.hpp
GUI/PluginWebDialog.cpp
GUI/PluginWebDialog.hpp
+ GUI/PluginDockPanel.cpp
+ GUI/PluginDockPanel.hpp
GUI/DragCanvas.cpp
GUI/DragCanvas.hpp
GUI/EditGCodeDialog.cpp
diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp
index 0fdb9dcd94..be472b198a 100644
--- a/src/slic3r/GUI/Plater.cpp
+++ b/src/slic3r/GUI/Plater.cpp
@@ -86,6 +86,7 @@
#ifdef __WXGTK__
#include "LinuxDisplayBackend.hpp"
#endif
+#include "PluginDockPanel.hpp"
#include "GUI_Utils.hpp"
#include "GUI_Factories.hpp"
#include "wxExtensions.hpp"
@@ -6720,6 +6721,14 @@ struct Plater::priv
// GUI elements
AuiMgr m_aui_mgr;
+ // Live plugin panes. `on_close` runs when the user closes one from its close button; `shown` is
+ // what the plugin asked for.
+ struct PluginPane
+ {
+ std::function on_close;
+ bool shown{true};
+ };
+ std::map m_plugin_panes;
wxString m_default_window_layout;
wxPanel* current_panel{ nullptr };
std::vector panels;
@@ -6891,6 +6900,11 @@ struct Plater::priv
void update_sidebar(bool force_update = false);
void reset_window_layout();
Sidebar::DockingState get_sidebar_docking_state();
+ void add_plugin_pane(wxWindow* window, const std::string& name, const wxString& caption, const std::string& dock,
+ const wxSize& size, std::function on_close);
+ void remove_plugin_pane(wxWindow* window);
+ void show_plugin_pane(wxWindow* window, bool show);
+ bool plugin_pane_visible(const PluginPane& plugin_pane, const wxAuiPaneInfo& pane) const;
bool is_view3D_layers_editing_enabled() const { return (current_panel == view3D) && view3D->get_canvas3d()->is_layers_editing_enabled(); }
@@ -7471,6 +7485,18 @@ Plater::priv::priv(Plater *q, MainFrame *main_frame)
panel_3d->SetSizer(panel_sizer);
m_aui_mgr.AddPane(panel_3d, wxAuiPaneInfo().Name("main").CenterPane().PaneBorder(false));
+ q->Bind(wxEVT_AUI_PANE_CLOSE, [this](wxAuiManagerEvent& evt) {
+ const wxAuiPaneInfo* pane = evt.GetPane();
+ auto it = pane != nullptr ? m_plugin_panes.find(pane->window) : m_plugin_panes.end();
+ if (it != m_plugin_panes.end()) {
+ const std::function on_close = std::move(it->second.on_close);
+ m_plugin_panes.erase(it);
+ if (on_close)
+ on_close();
+ }
+ evt.Skip();
+ });
+
m_default_window_layout = m_aui_mgr.SavePerspective();
{
@@ -8141,6 +8167,14 @@ void Plater::priv::update_sidebar(bool force_update) {
}
}
+ for (const auto& [window, plugin_pane] : m_plugin_panes) {
+ wxAuiPaneInfo& pane = m_aui_mgr.GetPane(window);
+ if (pane.IsOk() && pane.IsShown() != plugin_pane_visible(plugin_pane, pane)) {
+ pane.Show(!pane.IsShown());
+ needs_update = true;
+ }
+ }
+
if (needs_update) {
notification_manager->set_sidebar_collapsed(sidebar.IsShown());
m_aui_mgr.Update();
@@ -8150,10 +8184,95 @@ void Plater::priv::update_sidebar(bool force_update) {
void Plater::priv::reset_window_layout()
{
m_aui_mgr.LoadPerspective(m_default_window_layout, false);
+ // Loading a layout hides every pane it does not list, and the default layout lists no plugin panes.
+ for (const auto& [window, plugin_pane] : m_plugin_panes)
+ if (wxAuiPaneInfo& pane = m_aui_mgr.GetPane(window); pane.IsOk())
+ pane.Show(plugin_pane.shown);
sidebar_layout.is_collapsed = false;
update_sidebar(true);
}
+bool Plater::priv::plugin_pane_visible(const PluginPane& plugin_pane, const wxAuiPaneInfo& pane) const
+{
+ // A floating pane is a top-level window, so it does not hide with the Plater on other tabs.
+ return plugin_pane.shown && (!pane.IsFloating() || sidebar_layout.show);
+}
+
+void Plater::priv::add_plugin_pane(wxWindow* window, const std::string& name, const wxString& caption, const std::string& dock,
+ const wxSize& size, std::function on_close)
+{
+ const wxString base_name = wxString::FromUTF8(name);
+ wxString unique_name = base_name;
+ for (int i = 2; m_aui_mgr.GetPane(unique_name).IsOk(); ++i)
+ unique_name = base_name + wxString::Format("#%d", i);
+
+ // A restored layout below already holds pixels.
+ const wxSize pixels = q->FromDIP(size);
+ wxAuiPaneInfo info;
+ info.Name(unique_name).Caption(caption).BestSize(pixels).FloatingSize(pixels).DestroyOnClose(true);
+ if (dock == "left")
+ info.Left();
+ else if (dock == "bottom")
+ info.Bottom();
+ else
+ info.Right();
+ if (dock == "float")
+ info.Float();
+
+ // Put the pane back where it was the last time the window layout was saved with it open.
+ const std::string saved = plugin_pane_layout_entry(wxGetApp().app_config->get("window_layout"), unique_name.utf8_string());
+ if (!saved.empty()) {
+ m_aui_mgr.LoadPaneInfo(wxString::FromUTF8(saved), info);
+ info.Caption(caption).DestroyOnClose(true).Show();
+ }
+
+ // Floating is disabled on Wayland.
+ if ((m_aui_mgr.GetFlags() & wxAUI_MGR_ALLOW_FLOATING) == 0) {
+ info.Dock().Floatable(false);
+ if (info.dock_direction == wxAUI_DOCK_NONE)
+ info.Right();
+ }
+
+ const PluginPane& plugin_pane = m_plugin_panes[window] = PluginPane{std::move(on_close)};
+ info.Show(plugin_pane_visible(plugin_pane, info));
+ m_aui_mgr.AddPane(window, info);
+
+ // wxAUI does not record a dragged sash in best_size, so track the docked size like the sidebar
+ // does, for the saved layout.
+ window->Bind(wxEVT_IDLE, [this, window](wxIdleEvent& evt) {
+ wxAuiPaneInfo& pane = m_aui_mgr.GetPane(window);
+ if (pane.IsOk() && pane.IsShown() && pane.IsDocked() && pane.rect.GetWidth() > 0 && pane.rect.GetHeight() > 0) {
+ const bool horizontal = pane.dock_direction == wxAUI_DOCK_TOP || pane.dock_direction == wxAUI_DOCK_BOTTOM;
+ pane.BestSize(horizontal ? pane.best_size.GetWidth() : pane.rect.GetWidth(),
+ horizontal ? pane.rect.GetHeight() : pane.best_size.GetHeight());
+ }
+ evt.Skip();
+ });
+
+ m_aui_mgr.Update();
+}
+
+void Plater::priv::remove_plugin_pane(wxWindow* window)
+{
+ m_plugin_panes.erase(window);
+ if (m_aui_mgr.DetachPane(window))
+ m_aui_mgr.Update();
+ window->Destroy();
+}
+
+void Plater::priv::show_plugin_pane(wxWindow* window, bool show)
+{
+ const auto it = m_plugin_panes.find(window);
+ wxAuiPaneInfo& pane = m_aui_mgr.GetPane(window);
+ if (it == m_plugin_panes.end() || !pane.IsOk())
+ return;
+ it->second.shown = show;
+ if (pane.IsShown() == plugin_pane_visible(it->second, pane))
+ return;
+ pane.Show(!pane.IsShown());
+ m_aui_mgr.Update();
+}
+
Sidebar::DockingState Plater::priv::get_sidebar_docking_state() {
if (!sidebar_layout.is_enabled) {
return Sidebar::None;
@@ -17710,6 +17829,14 @@ Sidebar::DockingState Plater::get_sidebar_docking_state() const { return p->get_
void Plater::reset_window_layout() { p->reset_window_layout(); }
+void Plater::add_plugin_pane(wxWindow* window, const std::string& name, const wxString& caption, const std::string& dock,
+ const wxSize& size, std::function on_close)
+{
+ p->add_plugin_pane(window, name, caption, dock, size, std::move(on_close));
+}
+void Plater::remove_plugin_pane(wxWindow* window) { p->remove_plugin_pane(window); }
+void Plater::show_plugin_pane(wxWindow* window, bool show) { p->show_plugin_pane(window, show); }
+
//BBS
void Plater::select_curr_plate_all() { p->select_curr_plate_all(); }
void Plater::remove_curr_plate_all() { p->remove_curr_plate_all(); }
diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp
index 84e64acea0..121e9c10eb 100644
--- a/src/slic3r/GUI/Plater.hpp
+++ b/src/slic3r/GUI/Plater.hpp
@@ -475,6 +475,14 @@ public:
void reset_window_layout();
+ // Plugin panes dock alongside the sidebar; `window` must be a child of the Plater. `dock` is
+ // "left", "right", "bottom" or "float", and `size` is in DIPs. A pane closed from its own close
+ // button is destroyed after on_close runs; remove_plugin_pane() destroys it without calling on_close.
+ void add_plugin_pane(wxWindow* window, const std::string& name, const wxString& caption, const std::string& dock,
+ const wxSize& size, std::function on_close);
+ void remove_plugin_pane(wxWindow* window);
+ void show_plugin_pane(wxWindow* window, bool show);
+
// Called after the Preferences dialog is closed and the program settings are saved.
// Update the UI based on the current preferences.
void update_ui_from_settings();
diff --git a/src/slic3r/GUI/PluginDockPanel.cpp b/src/slic3r/GUI/PluginDockPanel.cpp
new file mode 100644
index 0000000000..8fd6fe8e08
--- /dev/null
+++ b/src/slic3r/GUI/PluginDockPanel.cpp
@@ -0,0 +1,170 @@
+#include "PluginDockPanel.hpp"
+
+#include "GUI.hpp"
+#include "GUI_App.hpp"
+#include "Plater.hpp"
+#include "PluginWebDialog.hpp"
+#include "Widgets/WebView.hpp"
+#include "Widgets/WebViewHostDialog.hpp"
+
+#include
+
+#include
+#include
+
+#include
+
+#include
+#include
+
+namespace Slic3r { namespace GUI {
+
+std::string plugin_pane_name(const std::string& plugin_key, const std::string& title)
+{
+ std::string name = "plugin:" + plugin_key + ":" + title;
+ std::replace_if(name.begin(), name.end(), [](char c) { return c == '|' || c == ';' || c == '=' || c == '\\'; }, '_');
+ return name;
+}
+
+std::string plugin_pane_layout_entry(const std::string& layout, const std::string& pane_name)
+{
+ // Panes are separated by '|'; SavePerspective() escapes a '|' inside a caption as "\|".
+ const std::string prefix = "name=" + pane_name + ";";
+ size_t begin = 0;
+ for (size_t i = 0; i <= layout.size(); ++i) {
+ if (i < layout.size() && (layout[i] != '|' || (i > 0 && layout[i - 1] == '\\')))
+ continue;
+ if (layout.compare(begin, prefix.size(), prefix) == 0)
+ return layout.substr(begin, i - begin);
+ begin = i + 1;
+ }
+ return {};
+}
+
+PluginDockPanel::PluginDockPanel(wxWindow* parent,
+ const std::string& html,
+ MessageHandler on_message,
+ CloseHandler on_close,
+ CloseHandler on_destroyed)
+ : wxPanel(parent, wxID_ANY)
+ , m_html(html)
+ , m_on_message(std::move(on_message))
+ , m_on_close(std::move(on_close))
+ , m_on_destroyed(std::move(on_destroyed))
+{
+ SetBackgroundColour(wxGetApp().get_window_default_clr());
+ auto* sizer = new wxBoxSizer(wxVERTICAL);
+ SetSizer(sizer);
+
+ const std::string bootstrap = (boost::filesystem::path(resources_dir()) / PluginWebDialog::BOOTSTRAP_PAGE).make_preferred().string();
+ m_browser = WebView::CreateWebView(this, wxString("file://") + from_u8(bootstrap));
+ if (m_browser == nullptr) {
+ BOOST_LOG_TRIVIAL(error) << "Could not create the web view for a plugin dock panel";
+ return;
+ }
+
+ m_browser->SetBackgroundColour(GetBackgroundColour());
+ m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::theme_user_script()));
+ m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::plugin_defaults_user_script()));
+ m_browser->AddUserScript(PluginWebDialog::bridge_user_script());
+ m_browser->Bind(wxEVT_WEBVIEW_LOADED, &PluginDockPanel::on_bootstrap_event, this);
+ m_browser->Bind(wxEVT_WEBVIEW_ERROR, &PluginDockPanel::on_bootstrap_event, this);
+ m_browser->Bind(wxEVT_WEBVIEW_NEWWINDOW, [](wxWebViewEvent& event) { event.Veto(); });
+ m_browser->Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, &PluginDockPanel::on_script_message, this);
+ m_browser->Bind(EVT_WEBVIEW_RECREATED, &PluginDockPanel::on_webview_recreated, this);
+ sizer->Add(m_browser, 1, wxEXPAND);
+}
+
+PluginDockPanel::~PluginDockPanel()
+{
+ if (m_on_destroyed)
+ m_on_destroyed();
+}
+
+void PluginDockPanel::on_bootstrap_event(wxWebViewEvent& event)
+{
+ if (!m_content_loaded) {
+ m_content_loaded = true;
+ m_browser->SetPage(wxString::FromUTF8(m_html), PluginWebDialog::content_base_url());
+ }
+ event.Skip();
+}
+
+void PluginDockPanel::on_script_message(wxWebViewEvent& event)
+{
+ const nlohmann::json payload = nlohmann::json::parse(event.GetString().utf8_string(), nullptr, false);
+ if (!payload.is_object() || payload.value("channel", std::string()) != "orca")
+ return;
+
+ const std::string kind = payload.value("kind", std::string());
+ if (kind == "message") {
+ if (m_on_message)
+ m_on_message(payload.contains("data") ? payload["data"] : nlohmann::json());
+ } else if (kind == "close") {
+ request_close();
+ }
+}
+
+void PluginDockPanel::on_webview_recreated(wxCommandEvent&)
+{
+ SetBackgroundColour(wxGetApp().get_window_default_clr());
+ m_browser->SetBackgroundColour(GetBackgroundColour());
+ Refresh();
+ // Handled without Skip(), so WebView::RecreateAll() does not reload the plugin page.
+ WebView::RunScript(m_browser, wxString::FromUTF8(WebViewHostDialog::theme_apply_script()));
+}
+
+void PluginDockPanel::push_message(const nlohmann::json& data)
+{
+ if (m_browser == nullptr || m_closing)
+ return;
+
+ nlohmann::json envelope;
+ envelope["data"] = data;
+ // The page may still be loading, so wait briefly for the bridge to define __orcaDispatch.
+ WebView::RunScript(m_browser, wxString::Format(
+ "(function dispatch(payload, attempts) {\n"
+ " if (typeof window.__orcaDispatch === 'function') { window.__orcaDispatch(payload); return; }\n"
+ " if (attempts < 100) window.setTimeout(function() { dispatch(payload, attempts + 1); }, 25);\n"
+ "})(%s, 0);",
+ wxString::FromUTF8(envelope.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace))));
+}
+
+void PluginDockPanel::fire_close()
+{
+ if (m_closing)
+ return;
+ m_closing = true;
+ if (m_on_close) {
+ CloseHandler on_close = std::move(m_on_close);
+ m_on_close = nullptr;
+ on_close();
+ }
+}
+
+void PluginDockPanel::request_close()
+{
+ if (m_closing)
+ return;
+ fire_close();
+ // A close requested by the page arrives inside the web view's own script-message callback,
+ // which must return before the web view is destroyed.
+ CallAfter([this]() { remove_pane(); });
+}
+
+void PluginDockPanel::destroy_for_plugin()
+{
+ m_closing = true;
+ m_on_close = nullptr;
+ remove_pane();
+}
+
+void PluginDockPanel::remove_pane()
+{
+ if (Plater* plater = wxGetApp().plater())
+ plater->remove_plugin_pane(this);
+ else
+ Destroy();
+}
+
+}} // namespace Slic3r::GUI
diff --git a/src/slic3r/GUI/PluginDockPanel.hpp b/src/slic3r/GUI/PluginDockPanel.hpp
new file mode 100644
index 0000000000..60c553779b
--- /dev/null
+++ b/src/slic3r/GUI/PluginDockPanel.hpp
@@ -0,0 +1,63 @@
+#pragma once
+
+#include
+
+#include
+#include
+
+#include
+#include
+
+namespace Slic3r { namespace GUI {
+
+// Name of a plugin's pane in the Plater's dock manager. It stays the same across sessions, so the
+// saved window layout can put the pane back, and it never contains a wxAuiManager layout delimiter.
+std::string plugin_pane_name(const std::string& plugin_key, const std::string& title);
+
+// The pane part saved for `pane_name` in a wxAuiManager layout string, in the form
+// wxAuiManager::LoadPaneInfo() takes, or empty when the layout has no such pane.
+std::string plugin_pane_layout_entry(const std::string& layout, const std::string& pane_name);
+
+// Plugin-supplied HTML hosted in a pane of the Plater's dock manager, bridged to the plugin through
+// the same window.orca API as PluginWebDialog. Python-agnostic for the same reason: it can be
+// destroyed on the main thread without the GIL, so its hooks must not capture pybind11 objects.
+class PluginDockPanel : public wxPanel
+{
+public:
+ using MessageHandler = std::function;
+ using CloseHandler = std::function;
+
+ // on_close fires once, on a user or page initiated close. on_destroyed runs from the destructor
+ // on every path and must touch host-side state only.
+ PluginDockPanel(wxWindow* parent,
+ const std::string& html,
+ MessageHandler on_message,
+ CloseHandler on_close,
+ CloseHandler on_destroyed);
+ ~PluginDockPanel() override;
+
+ // Main thread only.
+ void push_message(const nlohmann::json& data);
+ // Fires on_close, then removes the pane.
+ void request_close();
+ // Removes the pane without firing on_close, for plugin unload.
+ void destroy_for_plugin();
+ // Fires on_close at most once. The Plater calls it when the pane's own close button is used.
+ void fire_close();
+
+private:
+ void on_bootstrap_event(wxWebViewEvent& event);
+ void on_script_message(wxWebViewEvent& event);
+ void on_webview_recreated(wxCommandEvent& event);
+ void remove_pane();
+
+ wxWebView* m_browser{nullptr};
+ std::string m_html;
+ bool m_content_loaded{false};
+ bool m_closing{false};
+ MessageHandler m_on_message;
+ CloseHandler m_on_close;
+ CloseHandler m_on_destroyed;
+};
+
+}} // namespace Slic3r::GUI
diff --git a/src/slic3r/GUI/PluginWebDialog.cpp b/src/slic3r/GUI/PluginWebDialog.cpp
index d89aac7270..442ad7ba84 100644
--- a/src/slic3r/GUI/PluginWebDialog.cpp
+++ b/src/slic3r/GUI/PluginWebDialog.cpp
@@ -57,6 +57,10 @@ wxString web_base_url()
} // namespace
+const char* PluginWebDialog::bridge_user_script() { return ORCA_BRIDGE_JS; }
+
+wxString PluginWebDialog::content_base_url() { return web_base_url(); }
+
PluginWebDialog::PluginWebDialog(wxWindow* parent,
const wxString& title,
const std::string& html,
@@ -75,7 +79,7 @@ PluginWebDialog::PluginWebDialog(wxWindow* parent,
{
// A tiny bundled bootstrap page brings the webview up; the real plugin HTML
// is swapped in via SetPage once the bootstrap finishes loading.
- create_webview("web/dialog/PluginWebDialog/blank.html", title, size, wxSize(320, 240));
+ create_webview(BOOTSTRAP_PAGE, title, size, wxSize(320, 240));
// Paint the window/webview in the themed background so there is no white
// flash before the (transparent) bootstrap page and plugin HTML render.
diff --git a/src/slic3r/GUI/PluginWebDialog.hpp b/src/slic3r/GUI/PluginWebDialog.hpp
index 05f77f5148..836ace7331 100644
--- a/src/slic3r/GUI/PluginWebDialog.hpp
+++ b/src/slic3r/GUI/PluginWebDialog.hpp
@@ -47,6 +47,12 @@ public:
static void request_close(PluginWebDialog* dialog);
static void destroy_for_plugin(PluginWebDialog* dialog);
+ // Shared with PluginDockPanel: the bundled blank page a plugin web view loads before the plugin
+ // HTML is swapped in, the window.orca bridge, and the base URL the plugin HTML is loaded against.
+ static constexpr const char* BOOTSTRAP_PAGE = "web/dialog/PluginWebDialog/blank.html";
+ static const char* bridge_user_script();
+ static wxString content_base_url();
+
// Push a payload to the page; delivered to handlers registered via
// window.orca.onMessage(). MAIN-THREAD ONLY (the plugin layer marshals).
void push_message(const nlohmann::json& data);
diff --git a/src/slic3r/GUI/Widgets/WebViewHostDialog.cpp b/src/slic3r/GUI/Widgets/WebViewHostDialog.cpp
index 044fe33cde..e78ddc035b 100644
--- a/src/slic3r/GUI/Widgets/WebViewHostDialog.cpp
+++ b/src/slic3r/GUI/Widgets/WebViewHostDialog.cpp
@@ -75,6 +75,8 @@ if(document.documentElement)
} // namespace
+std::string WebViewHostDialog::theme_apply_script() { return host_theme_apply_js(); }
+
// Document-start user script: injects the contract