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