From 2876374b45d17292972ce13bd9eca83f5c3461db Mon Sep 17 00:00:00 2001
From: HanifKoh <76276251+HanifKoh@users.noreply.github.com>
Date: Tue, 22 Sep 2026 14:52:25 +0800
Subject: [PATCH] Add a Dockable HTML Panel API for Plugins (#15736)
orca.host.ui.create_dock_panel(html, title, width, height, on_message,
on_close, dock) hosts plugin HTML in a pane of the Plater's dock manager,
next to the sidebar, and returns a UiDockPanel handle
(post/show/hide/close/is_open). The arguments follow create_window(). The
panel uses the window.orca bridge of plugin windows, restores its position
and size from the saved window layout, hides with the Plater off the Prepare
and Preview tabs when floating, and is closed with its plugin; plugin panes
are removed in MainFrame::shutdown().
The web view hosting moves out of PluginPage into a shared WebPanel base:
bootstrap page and swap to the plugin HTML, theme, element-default and
bridge scripts, window.orca message parsing, delivery to the page, and live
re-theming, also re-applied on every load after the swap. Pages tabs and
docked panels both derive from it. Pages tabs now re-theme in place on a
theme change instead of being reloaded, and a window.orca call a host does
not support is logged.
What the hosts share no longer lives in one of them: the bootstrap page, the
base URL and the plugin-window bridge move to Widgets/WebHosting, used by
WebDialog and WebPanel alike. The Plater restores plugin panes with a new
saved-layout parser, GUI/AuiPaneLayout, kept in its own small header so
slic3rutils can test it without pulling in the Plater.
The web hosting classes carry no plugin name, so other hosts can reuse them:
PluginWebDialog becomes WebDialog (its bootstrap page moves to
resources/web/dialog/WebDialog), and destroy_for_plugin(),
load_plugin_content() and plugin_defaults_user_script() become
destroy_silently(), load_page_html() and element_defaults_user_script().
Includes a sample plugin (sandboxes/orca_dock_panel_plugin_any.py) and
binding and layout-helper tests in slic3rutils.
---
.../{PluginWebDialog => WebDialog}/blank.html | 2 +-
sandboxes/orca_dock_panel_plugin_any.py | 146 ++++++++++++++++
src/slic3r/CMakeLists.txt | 12 +-
src/slic3r/GUI/AuiPaneLayout.cpp | 20 +++
src/slic3r/GUI/AuiPaneLayout.hpp | 11 ++
src/slic3r/GUI/DockPanel.cpp | 103 +++++++++++
src/slic3r/GUI/DockPanel.hpp | 54 ++++++
src/slic3r/GUI/MainFrame.cpp | 2 +
src/slic3r/GUI/Plater.cpp | 133 +++++++++++++++
src/slic3r/GUI/Plater.hpp | 11 ++
.../{PluginWebDialog.cpp => WebDialog.cpp} | 131 ++++----------
.../{PluginWebDialog.hpp => WebDialog.hpp} | 36 ++--
src/slic3r/GUI/WebPanel.cpp | 111 ++++++++++++
src/slic3r/GUI/WebPanel.hpp | 48 ++++++
src/slic3r/GUI/Widgets/WebHosting.cpp | 69 ++++++++
src/slic3r/GUI/Widgets/WebHosting.hpp | 25 +++
src/slic3r/GUI/Widgets/WebViewHostDialog.cpp | 4 +-
src/slic3r/GUI/Widgets/WebViewHostDialog.hpp | 6 +-
src/slic3r/plugin/host/PluginHostUi.cpp | 160 +++++++++++++++---
src/slic3r/plugin/host/PluginPages.cpp | 98 +++--------
src/slic3r/plugin/host/PluginPages.hpp | 18 +-
tests/slic3rutils/test_plugin_host_api.cpp | 74 ++++++++
22 files changed, 1038 insertions(+), 236 deletions(-)
rename resources/web/dialog/{PluginWebDialog => WebDialog}/blank.html (76%)
create mode 100644 sandboxes/orca_dock_panel_plugin_any.py
create mode 100644 src/slic3r/GUI/AuiPaneLayout.cpp
create mode 100644 src/slic3r/GUI/AuiPaneLayout.hpp
create mode 100644 src/slic3r/GUI/DockPanel.cpp
create mode 100644 src/slic3r/GUI/DockPanel.hpp
rename src/slic3r/GUI/{PluginWebDialog.cpp => WebDialog.cpp} (53%)
rename src/slic3r/GUI/{PluginWebDialog.hpp => WebDialog.hpp} (74%)
create mode 100644 src/slic3r/GUI/WebPanel.cpp
create mode 100644 src/slic3r/GUI/WebPanel.hpp
create mode 100644 src/slic3r/GUI/Widgets/WebHosting.cpp
create mode 100644 src/slic3r/GUI/Widgets/WebHosting.hpp
diff --git a/resources/web/dialog/PluginWebDialog/blank.html b/resources/web/dialog/WebDialog/blank.html
similarity index 76%
rename from resources/web/dialog/PluginWebDialog/blank.html
rename to resources/web/dialog/WebDialog/blank.html
index 5047c5ba7b..928d754fab 100644
--- a/resources/web/dialog/PluginWebDialog/blank.html
+++ b/resources/web/dialog/WebDialog/blank.html
@@ -1,5 +1,5 @@
-
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 d925ca6333..7ac307c87b 100644
--- a/src/slic3r/CMakeLists.txt
+++ b/src/slic3r/CMakeLists.txt
@@ -139,8 +139,16 @@ set(SLIC3R_GUI_SOURCES
GUI/TerminalDialog.hpp
GUI/PluginProgressDialog.cpp
GUI/PluginProgressDialog.hpp
- GUI/PluginWebDialog.cpp
- GUI/PluginWebDialog.hpp
+ GUI/WebDialog.cpp
+ GUI/WebDialog.hpp
+ GUI/DockPanel.cpp
+ GUI/DockPanel.hpp
+ GUI/WebPanel.cpp
+ GUI/WebPanel.hpp
+ GUI/Widgets/WebHosting.cpp
+ GUI/Widgets/WebHosting.hpp
+ GUI/AuiPaneLayout.cpp
+ GUI/AuiPaneLayout.hpp
GUI/DragCanvas.cpp
GUI/DragCanvas.hpp
GUI/EditGCodeDialog.cpp
diff --git a/src/slic3r/GUI/AuiPaneLayout.cpp b/src/slic3r/GUI/AuiPaneLayout.cpp
new file mode 100644
index 0000000000..dc93352971
--- /dev/null
+++ b/src/slic3r/GUI/AuiPaneLayout.cpp
@@ -0,0 +1,20 @@
+#include "AuiPaneLayout.hpp"
+
+namespace Slic3r { namespace GUI {
+
+std::string aui_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 {};
+}
+
+}} // namespace Slic3r::GUI
diff --git a/src/slic3r/GUI/AuiPaneLayout.hpp b/src/slic3r/GUI/AuiPaneLayout.hpp
new file mode 100644
index 0000000000..e18b5ff6e1
--- /dev/null
+++ b/src/slic3r/GUI/AuiPaneLayout.hpp
@@ -0,0 +1,11 @@
+#pragma once
+
+#include
+
+namespace Slic3r { namespace GUI {
+
+// The part a wxAuiManager layout string (wxAuiManager::SavePerspective) holds for `pane_name`, in the
+// form wxAuiManager::LoadPaneInfo() takes, or empty when the layout has no such pane.
+std::string aui_pane_layout_entry(const std::string& layout, const std::string& pane_name);
+
+}} // namespace Slic3r::GUI
diff --git a/src/slic3r/GUI/DockPanel.cpp b/src/slic3r/GUI/DockPanel.cpp
new file mode 100644
index 0000000000..0a5d25c4e7
--- /dev/null
+++ b/src/slic3r/GUI/DockPanel.cpp
@@ -0,0 +1,103 @@
+#include "DockPanel.hpp"
+
+#include "GUI_App.hpp"
+#include "Plater.hpp"
+#include "Widgets/WebHosting.hpp"
+
+#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;
+}
+
+DockPanel::DockPanel(wxWindow* parent,
+ const std::string& html,
+ MessageHandler on_message,
+ CloseHandler on_close,
+ CloseHandler on_destroyed)
+ : WebPanel(parent, web_hosting::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(); });
+}
+
+DockPanel::~DockPanel()
+{
+ if (m_on_destroyed)
+ m_on_destroyed();
+}
+
+bool DockPanel::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 DockPanel::push_message(const nlohmann::json& data)
+{
+ if (!m_closing)
+ post_to_page(data.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace));
+}
+
+void DockPanel::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 DockPanel::request_close()
+{
+ if (m_closing)
+ return;
+ fire_close();
+ // A page-requested close arrives inside the web view's script callback, so destroy later; another
+ // close path may have destroyed the panel by then.
+ wxWeakRef self(this);
+ CallAfter([self]() {
+ if (self)
+ self->remove_pane();
+ });
+}
+
+void DockPanel::destroy_silently()
+{
+ m_closing = true;
+ m_on_close = nullptr;
+ remove_pane();
+}
+
+void DockPanel::remove_pane()
+{
+ if (Plater* plater = wxGetApp().plater())
+ plater->remove_dock_pane(this);
+ else
+ Destroy();
+}
+
+}} // namespace Slic3r::GUI
diff --git a/src/slic3r/GUI/DockPanel.hpp b/src/slic3r/GUI/DockPanel.hpp
new file mode 100644
index 0000000000..a9a0a0d6b3
--- /dev/null
+++ b/src/slic3r/GUI/DockPanel.hpp
@@ -0,0 +1,54 @@
+#pragma once
+
+#include "WebPanel.hpp"
+
+#include
+#include
+
+namespace Slic3r { namespace GUI {
+
+// Stable across sessions so the saved layout finds the pane; free of wxAuiManager layout delimiters.
+std::string plugin_pane_name(const std::string& plugin_key, const std::string& title);
+
+// A WebPanel docked in the Plater, on the plugin-window bridge minus submit. It can be destroyed
+// without the GIL, so its hooks must not capture pybind11 objects.
+class DockPanel : public WebPanel
+{
+public:
+ using MessageHandler = std::function;
+ using CloseHandler = std::function;
+
+ // on_close fires once, on a user or page close. on_destroyed runs on every destruction and must
+ // touch host-side state only.
+ DockPanel(wxWindow* parent,
+ const std::string& html,
+ MessageHandler on_message,
+ CloseHandler on_close,
+ CloseHandler on_destroyed);
+ ~DockPanel() 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 on_close, for plugin unload. Destroys at once: unload always comes from
+ // the host, never from this panel's own callbacks.
+ void destroy_silently();
+ // Fires on_close at most once; also run by the pane's own close button.
+ 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/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp
index ec82786432..5ac664f41c 100644
--- a/src/slic3r/GUI/MainFrame.cpp
+++ b/src/slic3r/GUI/MainFrame.cpp
@@ -1189,6 +1189,8 @@ void MainFrame::shutdown()
if (m_project != nullptr)
m_project->shutdown();
m_plugin_pages.shutdown();
+ if (m_plater != nullptr)
+ m_plater->remove_dock_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 6258cf19af..76156011dd 100644
--- a/src/slic3r/GUI/Plater.cpp
+++ b/src/slic3r/GUI/Plater.cpp
@@ -87,6 +87,7 @@
#ifdef __WXGTK__
#include "LinuxDisplayBackend.hpp"
#endif
+#include "AuiPaneLayout.hpp"
#include "GUI_Utils.hpp"
#include "GUI_Factories.hpp"
#include "wxExtensions.hpp"
@@ -6748,6 +6749,14 @@ struct Plater::priv
// GUI elements
AuiMgr m_aui_mgr;
+ // Live dock panes. `on_close` runs when the user closes one from its close button; `shown` is
+ // what the owner asked for.
+ struct DockPane
+ {
+ std::function on_close;
+ bool shown{true};
+ };
+ std::map m_dock_panes;
wxString m_default_window_layout;
wxPanel* current_panel{ nullptr };
std::vector panels;
@@ -6920,6 +6929,11 @@ struct Plater::priv
void update_sidebar(bool force_update = false);
void reset_window_layout();
Sidebar::DockingState get_sidebar_docking_state();
+ void add_dock_pane(wxWindow* window, const std::string& name, const wxString& caption, const std::string& dock,
+ const wxSize& size, std::function on_close);
+ void remove_dock_pane(wxWindow* window);
+ void show_dock_pane(wxWindow* window, bool show);
+ bool dock_pane_visible(const DockPane& dock_pane, const wxAuiPaneInfo& pane) const;
bool is_view3D_layers_editing_enabled() const { return (current_panel == view3D) && view3D->get_canvas3d()->is_layers_editing_enabled(); }
@@ -7500,6 +7514,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_dock_panes.find(pane->window) : m_dock_panes.end();
+ if (it != m_dock_panes.end()) {
+ const std::function on_close = std::move(it->second.on_close);
+ m_dock_panes.erase(it);
+ if (on_close)
+ on_close();
+ }
+ evt.Skip();
+ });
+
m_default_window_layout = m_aui_mgr.SavePerspective();
{
@@ -8165,6 +8191,14 @@ void Plater::priv::update_sidebar(bool force_update) {
}
}
+ for (const auto& [window, dock_pane] : m_dock_panes) {
+ wxAuiPaneInfo& pane = m_aui_mgr.GetPane(window);
+ if (pane.IsOk() && pane.IsShown() != dock_pane_visible(dock_pane, pane)) {
+ pane.Show(!pane.IsShown());
+ needs_update = true;
+ }
+ }
+
if (needs_update) {
notification_manager->set_sidebar_collapsed(sidebar.IsShown());
m_aui_mgr.Update();
@@ -8174,10 +8208,96 @@ 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 docks and hides every pane it does not list, and the default layout lists no
+ // dock panes: a floating dock pane is docked again, like the rest of the window.
+ for (const auto& [window, dock_pane] : m_dock_panes)
+ if (wxAuiPaneInfo& pane = m_aui_mgr.GetPane(window); pane.IsOk())
+ pane.Show(dock_pane_visible(dock_pane, pane));
sidebar_layout.is_collapsed = false;
update_sidebar(true);
}
+bool Plater::priv::dock_pane_visible(const DockPane& dock_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 dock_pane.shown && (!pane.IsFloating() || sidebar_layout.show);
+}
+
+void Plater::priv::add_dock_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 = aui_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 DockPane& dock_pane = m_dock_panes[window] = DockPane{std::move(on_close)};
+ info.Show(dock_pane_visible(dock_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_dock_pane(wxWindow* window)
+{
+ m_dock_panes.erase(window);
+ if (m_aui_mgr.DetachPane(window))
+ m_aui_mgr.Update();
+ window->Destroy();
+}
+
+void Plater::priv::show_dock_pane(wxWindow* window, bool show)
+{
+ const auto it = m_dock_panes.find(window);
+ wxAuiPaneInfo& pane = m_aui_mgr.GetPane(window);
+ if (it == m_dock_panes.end() || !pane.IsOk())
+ return;
+ it->second.shown = show;
+ if (pane.IsShown() == dock_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;
@@ -17772,6 +17892,19 @@ Sidebar::DockingState Plater::get_sidebar_docking_state() const { return p->get_
void Plater::reset_window_layout() { p->reset_window_layout(); }
+void Plater::add_dock_pane(wxWindow* window, const std::string& name, const wxString& caption, const std::string& dock,
+ const wxSize& size, std::function on_close)
+{
+ p->add_dock_pane(window, name, caption, dock, size, std::move(on_close));
+}
+void Plater::remove_dock_pane(wxWindow* window) { p->remove_dock_pane(window); }
+void Plater::remove_dock_panes()
+{
+ while (!p->m_dock_panes.empty())
+ p->remove_dock_pane(p->m_dock_panes.begin()->first);
+}
+void Plater::show_dock_pane(wxWindow* window, bool show) { p->show_dock_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 ae70a6a0bb..b30f0298ee 100644
--- a/src/slic3r/GUI/Plater.hpp
+++ b/src/slic3r/GUI/Plater.hpp
@@ -476,6 +476,17 @@ public:
void reset_window_layout();
+ // Dock panes sit 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_dock_pane() destroys it without calling on_close.
+ void add_dock_pane(wxWindow* window, const std::string& name, const wxString& caption, const std::string& dock,
+ const wxSize& size, std::function on_close);
+ void remove_dock_pane(wxWindow* window);
+ void show_dock_pane(wxWindow* window, bool show);
+ // Removes every dock 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_dock_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/PluginWebDialog.cpp b/src/slic3r/GUI/WebDialog.cpp
similarity index 53%
rename from src/slic3r/GUI/PluginWebDialog.cpp
rename to src/slic3r/GUI/WebDialog.cpp
index 7d696dd8db..8bcb517d87 100644
--- a/src/slic3r/GUI/PluginWebDialog.cpp
+++ b/src/slic3r/GUI/WebDialog.cpp
@@ -1,80 +1,23 @@
-#include "PluginWebDialog.hpp"
+#include "WebDialog.hpp"
-#include "slic3r/GUI/GUI.hpp"
#include "slic3r/GUI/GUI_App.hpp"
-
-#include
-
-#include
+#include "slic3r/GUI/Widgets/WebHosting.hpp"
#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";
-
-// 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) + "/";
-}
-
-// Whether a loaded document is the plugin HTML's own base URL. The web view reports the URL it
-// parsed, so any fragment the page navigated to is ignored and the escaping it applies to what the
-// resources path holds (a space, a non-ASCII character) is undone first.
-bool is_content_url(const wxString& url)
-{
- return wxURI::Unescape(url.BeforeFirst('#')) == web_base_url();
-}
-
-} // namespace
-
-PluginWebDialog::PluginWebDialog(wxWindow* parent,
- const wxString& title,
- const std::string& html,
- const wxSize& size,
- MessageHandler on_message,
- SubmitHandler on_submit,
- CloseHandler on_close,
- CloseHandler on_destroyed,
- long wx_style)
+WebDialog::WebDialog(wxWindow* parent,
+ const wxString& title,
+ const std::string& html,
+ const wxSize& size,
+ MessageHandler on_message,
+ SubmitHandler on_submit,
+ CloseHandler on_close,
+ CloseHandler on_destroyed,
+ long wx_style)
: WebViewHostDialog(parent, wxID_ANY, title, wxDefaultPosition, size, wx_style)
, m_html(html)
, m_on_message(std::move(on_message))
@@ -84,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(web_hosting::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.
@@ -96,22 +39,22 @@ PluginWebDialog::PluginWebDialog(wxWindow* parent,
// create_webview() via add_user_scripts(); nothing to add here.
// Swap in the plugin HTML once the bootstrap page settles. Bind ERROR too so a
// missing/blocked bootstrap resource (e.g. a packaged build) still triggers it.
- Bind(wxEVT_WEBVIEW_LOADED, &PluginWebDialog::on_bootstrap_event, this, wv->GetId());
- Bind(wxEVT_WEBVIEW_ERROR, &PluginWebDialog::on_bootstrap_event, this, wv->GetId());
- Bind(wxEVT_WEBVIEW_NAVIGATED, &PluginWebDialog::on_navigated, this, wv->GetId());
+ Bind(wxEVT_WEBVIEW_LOADED, &WebDialog::on_bootstrap_event, this, wv->GetId());
+ Bind(wxEVT_WEBVIEW_ERROR, &WebDialog::on_bootstrap_event, this, wv->GetId());
+ Bind(wxEVT_WEBVIEW_NAVIGATED, &WebDialog::on_navigated, this, wv->GetId());
}
- Bind(wxEVT_CLOSE_WINDOW, &PluginWebDialog::on_close_window, this);
+ Bind(wxEVT_CLOSE_WINDOW, &WebDialog::on_close_window, this);
}
-void PluginWebDialog::add_user_scripts()
+void WebDialog::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(WebViewHostDialog::element_defaults_user_script()));
+ wv->AddUserScript(wxString::FromUTF8(web_hosting::orca_bridge_script()));
}
}
-PluginWebDialog::~PluginWebDialog()
+WebDialog::~WebDialog()
{
// Runs on every destruction path. Deliberately NOT a wxEVT_DESTROY handler:
// that event is sent from the base ~wxDialog(), after this subclass's members
@@ -121,19 +64,19 @@ PluginWebDialog::~PluginWebDialog()
m_on_destroyed();
}
-void PluginWebDialog::post_message(PluginWebDialog* dialog, const nlohmann::json& data)
+void WebDialog::post_message(WebDialog* dialog, const nlohmann::json& data)
{
if (dialog != nullptr && dialog->is_open())
dialog->push_message(data);
}
-void PluginWebDialog::request_close(PluginWebDialog* dialog)
+void WebDialog::request_close(WebDialog* dialog)
{
if (dialog != nullptr)
dialog->Close();
}
-void PluginWebDialog::destroy_for_plugin(PluginWebDialog* dialog)
+void WebDialog::destroy_silently(WebDialog* dialog)
{
if (dialog == nullptr)
return;
@@ -147,42 +90,42 @@ void PluginWebDialog::destroy_for_plugin(PluginWebDialog* dialog)
dialog->Destroy();
}
-void PluginWebDialog::on_bootstrap_event(wxWebViewEvent& event)
+void WebDialog::on_bootstrap_event(wxWebViewEvent& event)
{
const bool loaded = event.GetEventType() == wxEVT_WEBVIEW_LOADED;
// The first bootstrap load (or its error) triggers the swap to plugin HTML.
if (!m_content_loaded)
- load_plugin_content();
+ load_page_html();
// WebKit reloads the SetPage base URL, so a committed load of it that we did not start is a reload.
// A failed navigation is reported against the page that stayed but never commits. Edge ignores the
// base URL and restores SetPage content itself, so nothing matches there.
- else if (is_content_url(event.GetURL())) {
+ else if (web_hosting::is_content_url(event.GetURL())) {
if (m_own_page_load)
m_own_page_load = false;
else if (loaded && m_content_navigated)
- load_plugin_content();
+ load_page_html();
}
if (loaded)
m_content_navigated = false;
event.Skip();
}
-void PluginWebDialog::on_navigated(wxWebViewEvent& event)
+void WebDialog::on_navigated(wxWebViewEvent& event)
{
- m_content_navigated = is_content_url(event.GetURL());
+ m_content_navigated = web_hosting::is_content_url(event.GetURL());
event.Skip();
}
-void PluginWebDialog::load_plugin_content()
+void WebDialog::load_page_html()
{
m_content_loaded = true;
if (wxWebView* wv = browser()) {
m_own_page_load = true;
- wv->SetPage(wxString::FromUTF8(m_html), web_base_url());
+ wv->SetPage(wxString::FromUTF8(m_html), web_hosting::content_base_url());
}
}
-void PluginWebDialog::on_script_message(const nlohmann::json& payload)
+void WebDialog::on_script_message(const nlohmann::json& payload)
{
if (payload.value("channel", std::string()) == "orca") {
const std::string kind = payload.value("kind", std::string());
@@ -202,7 +145,7 @@ void PluginWebDialog::on_script_message(const nlohmann::json& payload)
handle_common_script_command(payload);
}
-void PluginWebDialog::push_message(const nlohmann::json& data)
+void WebDialog::push_message(const nlohmann::json& data)
{
if (!m_open)
return;
@@ -211,7 +154,7 @@ void PluginWebDialog::push_message(const nlohmann::json& data)
call_web_handler(envelope, wxT("__orcaDispatch"));
}
-void PluginWebDialog::finish(bool submitted, const nlohmann::json& data)
+void WebDialog::finish(bool submitted, const nlohmann::json& data)
{
if (!m_open)
return;
@@ -230,7 +173,7 @@ void PluginWebDialog::finish(bool submitted, const nlohmann::json& data)
Close();
}
-void PluginWebDialog::on_close_window(wxCloseEvent&)
+void WebDialog::on_close_window(wxCloseEvent&)
{
if (!m_open) {
// finish() already dispatched submit/close and requested the close.
@@ -250,7 +193,7 @@ void PluginWebDialog::on_close_window(wxCloseEvent&)
Destroy();
}
-void PluginWebDialog::fire_submit(const nlohmann::json& data)
+void WebDialog::fire_submit(const nlohmann::json& data)
{
if (m_on_submit) {
SubmitHandler cb = std::move(m_on_submit);
@@ -258,7 +201,7 @@ void PluginWebDialog::fire_submit(const nlohmann::json& data)
}
}
-void PluginWebDialog::fire_close()
+void WebDialog::fire_close()
{
if (m_close_fired)
return;
diff --git a/src/slic3r/GUI/PluginWebDialog.hpp b/src/slic3r/GUI/WebDialog.hpp
similarity index 74%
rename from src/slic3r/GUI/PluginWebDialog.hpp
rename to src/slic3r/GUI/WebDialog.hpp
index a62e0b260c..aec5de18ef 100644
--- a/src/slic3r/GUI/PluginWebDialog.hpp
+++ b/src/slic3r/GUI/WebDialog.hpp
@@ -1,5 +1,5 @@
-#ifndef slic3r_GUI_PluginWebDialog_hpp_
-#define slic3r_GUI_PluginWebDialog_hpp_
+#ifndef slic3r_GUI_WebDialog_hpp_
+#define slic3r_GUI_WebDialog_hpp_
#include "Widgets/WebViewHostDialog.hpp"
@@ -21,7 +21,7 @@ namespace Slic3r { namespace GUI {
// GIL held; the plugin layer wraps any Python callables in a GIL-safe holder.
//
// Usable both modally (ShowModal -> read result()) and modelessly (Show()).
-class PluginWebDialog : public Slic3r::GUI::WebViewHostDialog
+class WebDialog : public Slic3r::GUI::WebViewHostDialog
{
public:
using MessageHandler = std::function;
@@ -32,20 +32,20 @@ public:
// user/JS-initiated close (while the window is alive). on_destroyed runs from
// the destructor on every path and must touch host-side state only (no Python
// / no derived members).
- PluginWebDialog(wxWindow* parent,
- const wxString& title,
- const std::string& html,
- const wxSize& size,
- MessageHandler on_message,
- SubmitHandler on_submit,
- CloseHandler on_close,
- CloseHandler on_destroyed,
- long wx_style = wxSYSTEM_MENU | wxCAPTION | wxCLOSE_BOX | wxMAXIMIZE_BOX | wxRESIZE_BORDER);
- ~PluginWebDialog() override;
+ WebDialog(wxWindow* parent,
+ const wxString& title,
+ const std::string& html,
+ const wxSize& size,
+ MessageHandler on_message,
+ SubmitHandler on_submit,
+ CloseHandler on_close,
+ CloseHandler on_destroyed,
+ long wx_style = wxSYSTEM_MENU | wxCAPTION | wxCLOSE_BOX | wxMAXIMIZE_BOX | wxRESIZE_BORDER);
+ ~WebDialog() override;
- static void post_message(PluginWebDialog* dialog, const nlohmann::json& data);
- static void request_close(PluginWebDialog* dialog);
- static void destroy_for_plugin(PluginWebDialog* dialog);
+ static void post_message(WebDialog* dialog, const nlohmann::json& data);
+ static void request_close(WebDialog* dialog);
+ static void destroy_silently(WebDialog* dialog);
// Push a payload to the page; delivered to handlers registered via
// window.orca.onMessage(). MAIN-THREAD ONLY (the plugin layer marshals).
@@ -65,7 +65,7 @@ protected:
private:
void on_bootstrap_event(wxWebViewEvent& event);
void on_navigated(wxWebViewEvent& event);
- void load_plugin_content();
+ void load_page_html();
void on_close_window(wxCloseEvent& event);
void fire_submit(const nlohmann::json& data);
void fire_close();
@@ -86,4 +86,4 @@ private:
}} // namespace Slic3r::GUI
-#endif // slic3r_GUI_PluginWebDialog_hpp_
+#endif // slic3r_GUI_WebDialog_hpp_
diff --git a/src/slic3r/GUI/WebPanel.cpp b/src/slic3r/GUI/WebPanel.cpp
new file mode 100644
index 0000000000..a8cef9d941
--- /dev/null
+++ b/src/slic3r/GUI/WebPanel.cpp
@@ -0,0 +1,111 @@
+#include "WebPanel.hpp"
+
+#include "GUI_App.hpp"
+#include "Widgets/WebHosting.hpp"
+#include "Widgets/WebView.hpp"
+#include "Widgets/WebViewHostDialog.hpp"
+
+#include
+
+#include
+
+namespace Slic3r { namespace GUI {
+
+WebPanel::WebPanel(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.
+ m_browser = WebView::CreateWebView(this, web_hosting::bootstrap_url());
+ m_browser->SetBackgroundColour(GetBackgroundColour());
+ m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::theme_user_script()));
+ m_browser->AddUserScript(wxString::FromUTF8(WebViewHostDialog::element_defaults_user_script()));
+ m_browser->AddUserScript(wxString::FromUTF8(bridge_script));
+ m_browser->Bind(wxEVT_WEBVIEW_LOADED, &WebPanel::on_load_event, this);
+ m_browser->Bind(wxEVT_WEBVIEW_ERROR, &WebPanel::on_load_event, this);
+ m_browser->Bind(wxEVT_WEBVIEW_NAVIGATED, &WebPanel::on_navigated, this);
+ m_browser->Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, &WebPanel::on_script_message, this);
+ m_browser->Bind(EVT_WEBVIEW_RECREATED, &WebPanel::on_webview_recreated, this);
+ sizer->Add(m_browser, 1, wxEXPAND);
+}
+
+void WebPanel::on_load_event(wxWebViewEvent& event)
+{
+ const bool loaded = event.GetEventType() == wxEVT_WEBVIEW_LOADED;
+ if (!m_content_loaded) {
+ // The first bootstrap load (or its error) triggers the swap to the plugin HTML.
+ m_content_loaded = true;
+ load_page_html();
+ } else if (!web_hosting::is_content_url(event.GetURL())) {
+ // Not our document (a linked page, a substituted error page), or any document on Edge, which ignores
+ // the base URL and restores SetPage content on a reload itself; either way it takes the app theme.
+ if (loaded)
+ apply_theme();
+ } else if (m_own_page_load) {
+ m_own_page_load = false;
+ // The document-start theme script is fixed at creation, so re-apply the app theme.
+ if (loaded)
+ apply_theme();
+ } else if (loaded && m_content_navigated) {
+ // WebKit reloads the SetPage base URL, so a committed load of it that we did not start is a
+ // reload. A failed navigation is reported against the page that stayed but never commits.
+ load_page_html();
+ }
+ if (loaded)
+ m_content_navigated = false;
+ event.Skip();
+}
+
+void WebPanel::on_navigated(wxWebViewEvent& event)
+{
+ m_content_navigated = web_hosting::is_content_url(event.GetURL());
+ event.Skip();
+}
+
+void WebPanel::load_page_html()
+{
+ if (const std::optional html = page_html()) {
+ m_own_page_load = true;
+ m_browser->SetPage(wxString::FromUTF8(*html), web_hosting::content_base_url());
+ }
+}
+
+void WebPanel::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) << "WebPanel ignored a window.orca '" << kind << "' call; this host does not support it";
+}
+
+void WebPanel::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 WebPanel::apply_theme()
+{
+ WebView::RunScript(m_browser, wxString::FromUTF8(WebViewHostDialog::theme_apply_script()));
+}
+
+void WebPanel::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/WebPanel.hpp b/src/slic3r/GUI/WebPanel.hpp
new file mode 100644
index 0000000000..d4c493dbc1
--- /dev/null
+++ b/src/slic3r/GUI/WebPanel.hpp
@@ -0,0 +1,48 @@
+#pragma once
+
+#include
+
+#include
+#include
+
+#include
+#include
+
+namespace Slic3r { namespace GUI {
+
+// Host-supplied HTML in a web view panel, for any window that embeds or derives from it (today plugin
+// Pages tabs and docked panels): bootstrap page and swap, theme and bridge scripts, live re-theming, and
+// window.orca messages routed to on_page_message().
+class WebPanel : public wxPanel
+{
+public:
+ WebPanel(wxWindow* parent, const char* 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, and again when WebKit reloads it;
+ // std::nullopt leaves it blank.
+ virtual std::optional page_html() = 0;
+ // A window.orca message from the page; false for a kind this host does not handle (logged).
+ virtual bool on_page_message(const std::string& kind, const nlohmann::json& data) = 0;
+
+private:
+ void on_load_event(wxWebViewEvent& event);
+ void on_navigated(wxWebViewEvent& event);
+ void on_script_message(wxWebViewEvent& event);
+ void on_webview_recreated(wxCommandEvent& event);
+ void apply_theme();
+ void load_page_html();
+
+ wxWebView* m_browser{nullptr};
+ bool m_content_loaded{false};
+ bool m_own_page_load{false}; // a SetPage of the plugin HTML is in flight
+ bool m_content_navigated{false}; // a navigation to the base URL has committed
+};
+
+}} // namespace Slic3r::GUI
diff --git a/src/slic3r/GUI/Widgets/WebHosting.cpp b/src/slic3r/GUI/Widgets/WebHosting.cpp
new file mode 100644
index 0000000000..a1b0df496a
--- /dev/null
+++ b/src/slic3r/GUI/Widgets/WebHosting.cpp
@@ -0,0 +1,69 @@
+#include "WebHosting.hpp"
+
+#include "slic3r/GUI/GUI.hpp"
+
+#include
+
+#include
+
+#include
+
+namespace Slic3r { namespace GUI { namespace web_hosting {
+
+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 bootstrap_url()
+{
+ return wxString("file://") + from_u8((boost::filesystem::path(resources_dir()) / BOOTSTRAP_PAGE).make_preferred().string());
+}
+
+wxString content_base_url()
+{
+ const std::string dir = (boost::filesystem::path(resources_dir()) / "web").make_preferred().string();
+ return wxString("file://") + from_u8(dir) + "/";
+}
+
+bool is_content_url(const wxString& url)
+{
+ // The web view reports the URL it parsed, which escapes anything the resources path holds
+ // (a space, a non-ASCII character), while content_base_url() is the raw path.
+ return wxURI::Unescape(url.BeforeFirst('#')) == content_base_url();
+}
+
+const char* orca_bridge_script() { return ORCA_BRIDGE_JS; }
+
+}}} // namespace Slic3r::GUI::web_hosting
diff --git a/src/slic3r/GUI/Widgets/WebHosting.hpp b/src/slic3r/GUI/Widgets/WebHosting.hpp
new file mode 100644
index 0000000000..5bf0ebc166
--- /dev/null
+++ b/src/slic3r/GUI/Widgets/WebHosting.hpp
@@ -0,0 +1,25 @@
+#pragma once
+
+#include
+
+namespace Slic3r { namespace GUI { namespace web_hosting {
+
+// Shared by the hosts that show plugin HTML: WebDialog and WebPanel.
+
+// The bundled blank page a plugin web view loads before the plugin HTML is swapped in.
+constexpr const char* BOOTSTRAP_PAGE = "web/dialog/WebDialog/blank.html";
+
+// The file:// URL of BOOTSTRAP_PAGE.
+wxString bootstrap_url();
+
+// The file:// base URL plugin HTML is loaded against, so relative URLs resolve to bundled resources.
+wxString content_base_url();
+
+// Whether `url` is the plugin HTML's base URL, ignoring any fragment. WebKit reports it for the
+// injected page, a reload and a failed navigation alike, so a match alone is not a new document.
+bool is_content_url(const wxString& url);
+
+// The window.orca bridge of plugin windows and docked panels. Pages tabs ship their own.
+const char* orca_bridge_script();
+
+}}} // namespace Slic3r::GUI::web_hosting
diff --git a/src/slic3r/GUI/Widgets/WebViewHostDialog.cpp b/src/slic3r/GUI/Widgets/WebViewHostDialog.cpp
index 044fe33cde..e4e3d2878a 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