diff --git a/sandboxes/orca_dock_panel_plugin_any.py b/sandboxes/orca_dock_panel_plugin_any.py new file mode 100644 index 0000000000..bb6a8143b4 --- /dev/null +++ b/sandboxes/orca_dock_panel_plugin_any.py @@ -0,0 +1,146 @@ +# /// script +# requires-python = ">=3.12" +# +# [tool.orcaslicer.plugin] +# name = "Dock Panel Demo" +# description = "Opens a dockable panel beside the 3D view that lists the objects on the plate." +# author = "OrcaSlicer" +# version = "0.0.1" +# /// +"""Dock Panel Demo -- orca.host.ui.create_dock_panel(). + +Run it from the Plugins dialog. It opens an HTML panel docked on the right of the 3D view, in the +same dock area as the sidebar. Drag its caption to dock it on another side (or float it, where the +platform allows), hide it from the page and run the plugin again to bring it back, or close it with +its close button or from the page. + + page --orca.postMessage({command: 'refresh'})--> plugin.on_message() + page --orca.postMessage({command: 'hide'})--> plugin.on_message() -> panel.hide() + page --orca.close()--> panel closes, plugin.on_close() + plugin --panel.post({command: 'objects', ...})--> page (orca.onMessage) +""" +import orca + +PAGE = """ + + +

Objects on the plate

+

Docked beside the 3D view. Drag the caption to move it.

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