diff --git a/sandboxes/orca_dock_panel_plugin_any.py b/sandboxes/orca_dock_panel_plugin_any.py
new file mode 100644
index 0000000000..514f6c3068
--- /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",
+ width=320,
+ height=480,
+ on_message=self.on_message,
+ on_close=self.on_close,
+ dock="right",
+ )
+ 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 656d41f338..cac614e477 100644
--- a/src/slic3r/CMakeLists.txt
+++ b/src/slic3r/CMakeLists.txt
@@ -139,6 +139,10 @@ set(SLIC3R_GUI_SOURCES
GUI/PluginProgressDialog.hpp
GUI/PluginWebDialog.cpp
GUI/PluginWebDialog.hpp
+ GUI/PluginDockPanel.cpp
+ GUI/PluginDockPanel.hpp
+ GUI/PluginWebPanel.cpp
+ GUI/PluginWebPanel.hpp
GUI/DragCanvas.cpp
GUI/DragCanvas.hpp
GUI/EditGCodeDialog.cpp
diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp
index 5f36323d6e..1ed2ba160d 100644
--- a/src/slic3r/GUI/MainFrame.cpp
+++ b/src/slic3r/GUI/MainFrame.cpp
@@ -1114,6 +1114,8 @@ void MainFrame::shutdown()
if (m_project != nullptr)
m_project->shutdown();
m_plugin_pages.shutdown();
+ if (m_plater != nullptr)
+ m_plater->remove_plugin_panes();
#ifdef __WXGTK__
// Edge panels are child windows — wxWidgets destroys them automatically.
m_edge_bottom = nullptr;
diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp
index 06d953aa25..02cbbad789 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;
@@ -17712,6 +17831,19 @@ 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::remove_plugin_panes()
+{
+ while (!p->m_plugin_panes.empty())
+ p->remove_plugin_pane(p->m_plugin_panes.begin()->first);
+}
+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..6b61424628 100644
--- a/src/slic3r/GUI/Plater.hpp
+++ b/src/slic3r/GUI/Plater.hpp
@@ -475,6 +475,17 @@ 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);
+ // Removes every plugin pane without calling on_close, for MainFrame::shutdown() (app exit and a
+ // language switch), while the Plater and any floating frames still exist.
+ void remove_plugin_panes();
+
// 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..b7e33a00e2
--- /dev/null
+++ b/src/slic3r/GUI/PluginDockPanel.cpp
@@ -0,0 +1,111 @@
+#include "PluginDockPanel.hpp"
+
+#include "GUI_App.hpp"
+#include "Plater.hpp"
+
+#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)
+ : PluginWebPanel(parent, orca_bridge_script())
+ , m_html(html)
+ , m_on_message(std::move(on_message))
+ , m_on_close(std::move(on_close))
+ , m_on_destroyed(std::move(on_destroyed))
+{
+ // A link asking for a new window has nowhere to open from a docked panel.
+ browser()->Bind(wxEVT_WEBVIEW_NEWWINDOW, [](wxWebViewEvent& event) { event.Veto(); });
+}
+
+PluginDockPanel::~PluginDockPanel()
+{
+ if (m_on_destroyed)
+ m_on_destroyed();
+}
+
+bool PluginDockPanel::on_page_message(const std::string& kind, const nlohmann::json& data)
+{
+ if (kind == "message") {
+ if (m_on_message)
+ m_on_message(data);
+ return true;
+ }
+ if (kind == "close") {
+ request_close();
+ return true;
+ }
+ return false;
+}
+
+void PluginDockPanel::push_message(const nlohmann::json& data)
+{
+ if (!m_closing)
+ post_to_page(data.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..336a92bf02
--- /dev/null
+++ b/src/slic3r/GUI/PluginDockPanel.hpp
@@ -0,0 +1,62 @@
+#pragma once
+
+#include "PluginWebPanel.hpp"
+
+#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);
+
+// A PluginWebPanel docked in the Plater's dock manager, using the same window.orca bridge as
+// PluginWebDialog (without submit, which only a plugin window handles). 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 PluginWebPanel
+{
+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. Unlike request_close() it destroys
+ // the panel at once, which is safe because nothing a plugin page can call unloads a plugin: unload
+ // comes from the host (Plugins dialog, install, logout), never from this panel's own callbacks.
+ 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();
+
+protected:
+ std::optional page_html() override { return m_html; }
+ bool on_page_message(const std::string& kind, const nlohmann::json& data) override;
+
+private:
+ void remove_pane();
+
+ std::string m_html;
+ 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..bb623df9f9 100644
--- a/src/slic3r/GUI/PluginWebDialog.cpp
+++ b/src/slic3r/GUI/PluginWebDialog.cpp
@@ -1,11 +1,7 @@
#include "PluginWebDialog.hpp"
-#include "slic3r/GUI/GUI.hpp"
#include "slic3r/GUI/GUI_App.hpp"
-
-#include
-
-#include
+#include "slic3r/GUI/PluginWebPanel.hpp"
#include
@@ -13,50 +9,6 @@
namespace Slic3r { namespace GUI {
-namespace {
-
-// Injected into the top-level page at document start (before the plugin's own
-// scripts). Defines window.orca as the only host surface the page may use. It
-// references window.wx lazily (at call time) so it never races the backend's
-// deferred registration of the "wx" message handler. Guarded against
-// double-injection so it is harmless if also prepended.
-constexpr char ORCA_BRIDGE_JS[] = R"JS(
-(function () {
- if (window.top !== window.self) return;
- if (window.orca) return;
- var handlers = [];
- function send(kind, data) {
- try {
- window.wx.postMessage(JSON.stringify({
- channel: 'orca', kind: kind, data: (data === undefined ? null : data)
- }));
- } catch (e) { /* bridge not ready yet */ }
- }
- window.orca = {
- postMessage: function (d) { send('message', d); },
- submit: function (d) { send('submit', d); },
- close: function () { send('close'); },
- onMessage: function (cb) { if (typeof cb === 'function') handlers.push(cb); }
- };
- window.__orcaDispatch = function (payload) {
- var data = payload ? payload.data : null;
- for (var i = 0; i < handlers.length; i++) {
- try { handlers[i](data); } catch (e) {}
- }
- };
-})();
-)JS";
-
-// file:// base URL for plugin HTML loaded via SetPage, so self-referencing
-// relative URLs resolve against the bundled web resources directory.
-wxString web_base_url()
-{
- const std::string dir = (boost::filesystem::path(resources_dir()) / "web").make_preferred().string();
- return wxString("file://") + from_u8(dir) + "/";
-}
-
-} // namespace
-
PluginWebDialog::PluginWebDialog(wxWindow* parent,
const wxString& title,
const std::string& html,
@@ -75,7 +27,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(PluginWebPanel::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.
@@ -97,7 +49,7 @@ void PluginWebDialog::add_user_scripts()
{
if (wxWebView* wv = browser()) {
wv->AddUserScript(wxString::FromUTF8(WebViewHostDialog::plugin_defaults_user_script()));
- wv->AddUserScript(ORCA_BRIDGE_JS);
+ wv->AddUserScript(wxString::FromUTF8(PluginWebPanel::orca_bridge_script()));
}
}
@@ -151,7 +103,7 @@ void PluginWebDialog::load_plugin_content()
return;
m_content_loaded = true;
if (wxWebView* wv = browser())
- wv->SetPage(wxString::FromUTF8(m_html), web_base_url());
+ wv->SetPage(wxString::FromUTF8(m_html), PluginWebPanel::content_base_url());
}
void PluginWebDialog::on_script_message(const nlohmann::json& payload)
diff --git a/src/slic3r/GUI/PluginWebPanel.cpp b/src/slic3r/GUI/PluginWebPanel.cpp
new file mode 100644
index 0000000000..6e79058bf2
--- /dev/null
+++ b/src/slic3r/GUI/PluginWebPanel.cpp
@@ -0,0 +1,132 @@
+#include "PluginWebPanel.hpp"
+
+#include "GUI.hpp"
+#include "GUI_App.hpp"
+#include "Widgets/WebView.hpp"
+#include "Widgets/WebViewHostDialog.hpp"
+
+#include
+
+#include
+#include
+
+#include
+
+namespace Slic3r { namespace GUI {
+
+namespace {
+
+// Injected into the top-level page at document start (before the plugin's own
+// scripts). Defines window.orca as the only host surface the page may use. It
+// references window.wx lazily (at call time) so it never races the backend's
+// deferred registration of the "wx" message handler. Guarded against
+// double-injection so it is harmless if also prepended.
+constexpr char ORCA_BRIDGE_JS[] = R"JS(
+(function () {
+ if (window.top !== window.self) return;
+ if (window.orca) return;
+ var handlers = [];
+ function send(kind, data) {
+ try {
+ window.wx.postMessage(JSON.stringify({
+ channel: 'orca', kind: kind, data: (data === undefined ? null : data)
+ }));
+ } catch (e) { /* bridge not ready yet */ }
+ }
+ window.orca = {
+ postMessage: function (d) { send('message', d); },
+ submit: function (d) { send('submit', d); },
+ close: function () { send('close'); },
+ onMessage: function (cb) { if (typeof cb === 'function') handlers.push(cb); }
+ };
+ window.__orcaDispatch = function (payload) {
+ var data = payload ? payload.data : null;
+ for (var i = 0; i < handlers.length; i++) {
+ try { handlers[i](data); } catch (e) {}
+ }
+ };
+})();
+)JS";
+
+} // namespace
+
+wxString PluginWebPanel::content_base_url()
+{
+ const std::string dir = (boost::filesystem::path(resources_dir()) / "web").make_preferred().string();
+ return wxString("file://") + from_u8(dir) + "/";
+}
+
+const char* PluginWebPanel::orca_bridge_script() { return ORCA_BRIDGE_JS; }
+
+PluginWebPanel::PluginWebPanel(wxWindow* parent, const char* bridge_script)
+ : wxPanel(parent, wxID_ANY)
+{
+ SetBackgroundColour(wxGetApp().get_window_default_clr());
+ auto* sizer = new wxBoxSizer(wxVERTICAL);
+ SetSizer(sizer);
+
+ // Never null: WebView::CreateWebView substitutes a placeholder view when no backend is available.
+ const std::string bootstrap = (boost::filesystem::path(resources_dir()) / BOOTSTRAP_PAGE).make_preferred().string();
+ m_browser = WebView::CreateWebView(this, wxString("file://") + from_u8(bootstrap));
+ 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(wxString::FromUTF8(bridge_script));
+ m_browser->Bind(wxEVT_WEBVIEW_LOADED, &PluginWebPanel::on_load_event, this);
+ m_browser->Bind(wxEVT_WEBVIEW_ERROR, &PluginWebPanel::on_load_event, this);
+ m_browser->Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, &PluginWebPanel::on_script_message, this);
+ m_browser->Bind(EVT_WEBVIEW_RECREATED, &PluginWebPanel::on_webview_recreated, this);
+ sizer->Add(m_browser, 1, wxEXPAND);
+}
+
+void PluginWebPanel::on_load_event(wxWebViewEvent& event)
+{
+ if (!m_content_loaded) {
+ // The first bootstrap load (or its error) triggers the swap to the plugin HTML.
+ m_content_loaded = true;
+ if (const std::optional html = page_html())
+ m_browser->SetPage(wxString::FromUTF8(*html), content_base_url());
+ } else if (event.GetEventType() == wxEVT_WEBVIEW_LOADED) {
+ // The document-start theme script keeps the theme the web view was created with, so bring
+ // every later document (the plugin page, or one a link loaded) onto the current app theme.
+ apply_theme();
+ }
+ event.Skip();
+}
+
+void PluginWebPanel::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 (!on_page_message(kind, payload.contains("data") ? payload["data"] : nlohmann::json()))
+ BOOST_LOG_TRIVIAL(warning) << "Plugin web panel ignored a window.orca '" << kind << "' call; this host does not support it";
+}
+
+void PluginWebPanel::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.
+ apply_theme();
+}
+
+void PluginWebPanel::apply_theme()
+{
+ WebView::RunScript(m_browser, wxString::FromUTF8(WebViewHostDialog::theme_apply_script()));
+}
+
+void PluginWebPanel::post_to_page(const std::string& json)
+{
+ 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"
+ "})({data: %s}, 0);",
+ wxString::FromUTF8(json)));
+}
+
+}} // namespace Slic3r::GUI
diff --git a/src/slic3r/GUI/PluginWebPanel.hpp b/src/slic3r/GUI/PluginWebPanel.hpp
new file mode 100644
index 0000000000..a919ed4c24
--- /dev/null
+++ b/src/slic3r/GUI/PluginWebPanel.hpp
@@ -0,0 +1,56 @@
+#pragma once
+
+#include
+
+#include
+#include
+#include
+
+#include
+#include
+
+namespace Slic3r { namespace GUI {
+
+// A panel hosting plugin-supplied HTML in a web view, shared by plugin Pages tabs and docked panels.
+// It loads the bundled bootstrap page, swaps the plugin HTML in once that has loaded, registers the
+// host theme, the plugin element defaults and the given window.orca bridge, keeps the page on the
+// app theme, and routes the bridge's messages to on_page_message().
+class PluginWebPanel : public wxPanel
+{
+public:
+ PluginWebPanel(wxWindow* parent, const char* bridge_script);
+
+ // Shared by every plugin web view, PluginWebDialog included.
+ // The bundled blank page a plugin web view loads before the plugin HTML is swapped in.
+ static constexpr const char* BOOTSTRAP_PAGE = "web/dialog/PluginWebDialog/blank.html";
+ // The file:// base URL plugin HTML is loaded against, so relative URLs resolve against the
+ // bundled web resources.
+ static wxString content_base_url();
+ // The window.orca bridge of plugin windows and docked panels: postMessage, submit, close and
+ // onMessage. Each host acts only on the message kinds it supports.
+ static const char* orca_bridge_script();
+
+protected:
+ wxWebView* browser() const { return m_browser; }
+
+ // Delivers an already serialised JSON value to the page's window.orca.onMessage handlers,
+ // waiting briefly for the bridge while the page is still loading. Main thread only.
+ void post_to_page(const std::string& json);
+
+ // The plugin HTML to show once the bootstrap page has loaded; std::nullopt leaves it blank.
+ virtual std::optional page_html() = 0;
+ // A window.orca message from the page. Returns false for a kind this host does not handle,
+ // which is logged so a dropped call is diagnosable.
+ virtual bool on_page_message(const std::string& kind, const nlohmann::json& data) = 0;
+
+private:
+ void on_load_event(wxWebViewEvent& event);
+ void on_script_message(wxWebViewEvent& event);
+ void on_webview_recreated(wxCommandEvent& event);
+ void apply_theme();
+
+ wxWebView* m_browser{nullptr};
+ bool m_content_loaded{false};
+};
+
+}} // namespace Slic3r::GUI
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