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.

+ +
+ + + +
+ + + + +
NamePartsCopies
+

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