Compare commits

...
Author SHA1 Message Date
Hanif Koh 7c28701894 Add a Dockable HTML Panel API for Plugins
orca.host.ui.create_dock_panel() hosts plugin HTML in a pane of the
Plater's dock manager, next to the sidebar, and returns a UiDockPanel
handle (post/show/hide/close/is_open). The panel reuses the window.orca
bridge of plugin windows, restores its position and size from the saved
window layout, hides with the Plater off the Prepare and Preview tabs
when floating, and is closed with its plugin.

Includes a sample plugin (sandboxes/orca_dock_panel_plugin_any.py) and
binding and layout-helper tests in slic3rutils.
2026-09-15 18:52:58 +08:00
12 changed files with 697 additions and 21 deletions
+146
View File
@@ -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 = """
<style>
body { margin: 0; padding: 12px; font-size: 13px; }
h3 { margin: 0 0 4px; font-size: 14px; }
.note { margin: 0 0 12px; color: var(--orca-muted); font-size: 12px; }
.actions { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
.actions button.quiet { background: transparent; color: var(--orca-fg); border-color: var(--orca-border); }
table { width: 100%; border-collapse: collapse; }
td.count { text-align: right; font-variant-numeric: tabular-nums; }
#status { margin-top: 10px; color: var(--orca-muted); font-size: 12px; }
</style>
<h3>Objects on the plate</h3>
<p class="note">Docked beside the 3D view. Drag the caption to move it.</p>
<div class="actions">
<button type="button" id="refresh">Refresh</button>
<button type="button" id="hide" class="quiet">Hide</button>
<button type="button" id="close" class="quiet">Close</button>
</div>
<table>
<thead><tr><th>Name</th><th>Parts</th><th>Copies</th></tr></thead>
<tbody id="rows"></tbody>
</table>
<p id="status">Waiting for the plugin...</p>
<script>
(function () {
function text(value) {
var span = document.createElement("span");
span.textContent = value;
return span.innerHTML;
}
function render(message) {
var rows = document.getElementById("rows");
var status = document.getElementById("status");
if (message.error) {
rows.innerHTML = "";
status.textContent = message.error;
return;
}
rows.innerHTML = message.objects.map(function (object) {
return "<tr><td>" + text(object.name) + "</td><td class=\\"count\\">" + object.volumes +
"</td><td class=\\"count\\">" + object.instances + "</td></tr>";
}).join("");
status.textContent = message.objects.length + " object(s), refreshed " + new Date().toLocaleTimeString();
}
orca.onMessage(function (message) {
if (message && message.command === "objects")
render(message);
});
document.getElementById("refresh").addEventListener("click", function () {
orca.postMessage({ command: "refresh" });
});
document.getElementById("hide").addEventListener("click", function () {
orca.postMessage({ command: "hide" });
});
document.getElementById("close").addEventListener("click", function () {
orca.close();
});
orca.postMessage({ command: "refresh" });
})();
</script>
"""
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)
+2
View File
@@ -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
+127
View File
@@ -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<void()> on_close;
bool shown{true};
};
std::map<wxWindow*, PluginPane> m_plugin_panes;
wxString m_default_window_layout;
wxPanel* current_panel{ nullptr };
std::vector<wxPanel*> 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<void()> 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<void()> 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<void()> 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<void()> 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(); }
+8
View File
@@ -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<void()> 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();
+170
View File
@@ -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 <libslic3r/Utils.hpp>
#include <boost/filesystem.hpp>
#include <boost/log/trivial.hpp>
#include <wx/sizer.h>
#include <algorithm>
#include <utility>
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
+63
View File
@@ -0,0 +1,63 @@
#pragma once
#include <nlohmann/json.hpp>
#include <wx/panel.h>
#include <wx/webview.h>
#include <functional>
#include <string>
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<void(const nlohmann::json& data)>;
using CloseHandler = std::function<void()>;
// 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
+5 -1
View File
@@ -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.
+6
View File
@@ -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);
@@ -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 <style>, stamps data-orca-theme before
// first paint, and raises a JS flag so the legacy globalapi.js dark.css poll stands down for
// host-themed pages. The WebView2 timing guard lives in document_start_injector().
@@ -52,6 +52,8 @@ public:
// Shared by modeless Pages tabs and PluginWebDialog.
static std::string theme_user_script();
static std::string plugin_defaults_user_script();
// Re-themes an already-loaded page in place, for web views hosted outside a dialog.
static std::string theme_apply_script();
protected:
wxWebView* browser() const { return m_browser; }
+124 -20
View File
@@ -7,6 +7,8 @@
#include <slic3r/GUI/GUI_App.hpp>
#include <slic3r/GUI/MainFrame.hpp>
#include <slic3r/GUI/MsgDialog.hpp>
#include <slic3r/GUI/Plater.hpp>
#include <slic3r/GUI/PluginDockPanel.hpp>
#include <slic3r/GUI/PluginProgressDialog.hpp>
#include <slic3r/GUI/PluginWebDialog.hpp>
#include <slic3r/GUI/NotificationManager.hpp>
@@ -20,6 +22,7 @@
#include <wx/defs.h>
#include <wx/window.h>
#include <algorithm>
#include <atomic>
#include <cstdint>
#include <future>
@@ -109,6 +112,25 @@ GUI::PluginWebDialog::SubmitHandler make_submit_adapter(py::object on_submit)
};
}
// The plugin's on_close: fired only on a user/JS-initiated close (not forced teardown), while the
// window is alive. Empty if the plugin passed None.
std::function<void()> make_close_adapter(const CallablePtr& holder)
{
if (!holder)
return nullptr;
return [holder]() {
PythonGILState gil;
if (!gil)
return;
try {
holder->fn();
} catch (py::error_already_set& e) {
BOOST_LOG_TRIVIAL(error) << "orca.host.ui on_close handler raised: " << e.what();
PyErr_Clear();
}
};
}
// --------------------------------------------------------------------------
// Registry of live plugin UI resources. Keyed by an opaque id; tracks the
// owning plugin so all of a plugin's UI can be torn down on unload.
@@ -350,22 +372,7 @@ py::object ui_create_window(const std::string& html, const std::string& title, i
if (!UiRegistry::instance().is_open(new_id))
return;
// Plugin's on_close: fired only on a user/JS-initiated close (not forced
// teardown), while the dialog is alive. Empty if the plugin passed None.
GUI::PluginWebDialog::CloseHandler on_close;
if (close_holder) {
on_close = [close_holder]() {
PythonGILState gil;
if (!gil)
return;
try {
close_holder->fn();
} catch (py::error_already_set& e) {
BOOST_LOG_TRIVIAL(error) << "orca.host.ui on_close handler raised: " << e.what();
PyErr_Clear();
}
};
}
GUI::PluginWebDialog::CloseHandler on_close = make_close_adapter(close_holder);
// Registry cleanup: GIL-free, runs from the dialog destructor on every path.
auto on_destroyed = [new_id]() { UiRegistry::instance().remove(new_id); };
@@ -387,14 +394,69 @@ py::object ui_create_window(const std::string& html, const std::string& title, i
return py::cast(UiWindowHandle{new_id});
}
// --------------------------------------------------------------------------
// orca.host.ui.create_dock_panel + UiDockPanel handle
// --------------------------------------------------------------------------
constexpr const char* DOCK_POSITIONS[] = {"left", "right", "bottom", "float"};
struct UiDockPanelHandle
{
int id{0};
};
py::object ui_create_dock_panel(const std::string& html, const std::string& title, const std::string& dock, int width,
int height, py::object on_message, py::object on_close)
{
if (std::find(std::begin(DOCK_POSITIONS), std::end(DOCK_POSITIONS), dock) == std::end(DOCK_POSITIONS))
throw std::invalid_argument("orca.host.ui.create_dock_panel dock must be \"left\", \"right\", \"bottom\" or \"float\"");
auto msg_adapter = make_message_adapter(std::move(on_message));
CallablePtr close_holder = make_holder(std::move(on_close));
const std::string plugin_key = PluginAuditManager::instance().current_plugin();
const int w = width > 0 ? width : 320;
const int h = height > 0 ? height : 480;
if (wxTheApp == nullptr)
throw std::runtime_error("OrcaSlicer application is not initialized");
// Deferred and pre-bound for the same reasons as create_window().
const int new_id = UiRegistry::instance().reserve_id();
UiRegistry::instance().bind(new_id, nullptr, plugin_key);
GUI::wxGetApp().CallAfter([new_id, plugin_key, html, title, dock, w, h,
msg_adapter = std::move(msg_adapter),
close_holder = std::move(close_holder)]() mutable {
if (!UiRegistry::instance().is_open(new_id))
return;
GUI::Plater* plater = GUI::wxGetApp().plater();
if (plater == nullptr || GUI::wxGetApp().is_closing()) {
UiRegistry::instance().remove(new_id);
return;
}
auto on_destroyed = [new_id]() { UiRegistry::instance().remove(new_id); };
auto* panel = new GUI::PluginDockPanel(plater, html, std::move(msg_adapter), make_close_adapter(close_holder),
std::move(on_destroyed));
UiRegistry::instance().bind(new_id, panel, plugin_key);
plater->add_plugin_pane(panel, GUI::plugin_pane_name(plugin_key, title), wxString::FromUTF8(title), dock,
wxSize(w, h), [panel]() { panel->fire_close(); });
});
return py::cast(UiDockPanelHandle{new_id});
}
void handle_post(int id, py::object data)
{
if (wxTheApp == nullptr)
return;
json j = py_to_json(data); // GIL held (binding body)
GUI::wxGetApp().CallAfter([id, j = std::move(j)]() {
auto* d = UiRegistry::instance().get_as<GUI::PluginWebDialog>(id);
GUI::PluginWebDialog::post_message(d, j);
auto* window = UiRegistry::instance().get_as<wxWindow>(id);
if (auto* panel = dynamic_cast<GUI::PluginDockPanel*>(window))
panel->push_message(j);
else
GUI::PluginWebDialog::post_message(dynamic_cast<GUI::PluginWebDialog*>(window), j);
});
}
@@ -403,8 +465,23 @@ void handle_close(int id)
if (wxTheApp == nullptr)
return;
GUI::wxGetApp().CallAfter([id]() {
auto* d = UiRegistry::instance().get_as<GUI::PluginWebDialog>(id);
GUI::PluginWebDialog::request_close(d);
auto* window = UiRegistry::instance().get_as<wxWindow>(id);
if (auto* panel = dynamic_cast<GUI::PluginDockPanel*>(window))
panel->request_close();
else
GUI::PluginWebDialog::request_close(dynamic_cast<GUI::PluginWebDialog*>(window));
});
}
void handle_show(int id, bool show)
{
if (wxTheApp == nullptr)
return;
GUI::wxGetApp().CallAfter([id, show]() {
auto* panel = UiRegistry::instance().get_as<GUI::PluginDockPanel>(id);
GUI::Plater* plater = GUI::wxGetApp().plater();
if (panel != nullptr && plater != nullptr)
plater->show_plugin_pane(panel, show);
});
}
@@ -563,6 +640,31 @@ void PluginHostUi::RegisterBindings(pybind11::module_& host)
"or WINDOW_MODAL. on_message(data) is called on the UI thread when the page posts; on_submit(data) "
"is called when the page submits; offload heavy work to a thread and push results back with window.post().");
py::class_<UiDockPanelHandle>(ui, "UiDockPanel", "Handle to a dockable plugin HTML panel created by create_dock_panel().")
.def_property_readonly("id", [](const UiDockPanelHandle& h) { return h.id; })
.def(
"post", [](const UiDockPanelHandle& h, py::object data) { handle_post(h.id, std::move(data)); },
py::arg("data"), "Send a payload to the page (delivered to window.orca.onMessage handlers).")
.def(
"show", [](const UiDockPanelHandle& h) { handle_show(h.id, true); }, "Show the panel again after hide().")
.def(
"hide", [](const UiDockPanelHandle& h) { handle_show(h.id, false); }, "Hide the panel without closing it.")
.def(
"close", [](const UiDockPanelHandle& h) { handle_close(h.id); }, "Close the panel (fires on_close).")
.def(
"is_open", [](const UiDockPanelHandle& h) { return UiRegistry::instance().is_open(h.id); },
"Return True until the panel is closed; a hidden panel is still open.");
ui.def("create_dock_panel", &ui_create_dock_panel, py::arg("html"), py::arg("title") = "OrcaSlicer",
py::arg("dock") = "right", py::arg("width") = 320, py::arg("height") = 480,
py::arg("on_message") = py::none(), py::arg("on_close") = py::none(),
"Open an HTML panel docked beside the 3D view and return a UiDockPanel. dock is \"left\", \"right\", "
"\"bottom\" or \"float\", and width/height are in DIPs; the user can move and resize the panel, and the "
"saved window layout remembers both. The panel belongs to the Prepare and Preview tabs. on_message(data) "
"is called on the UI thread when the page posts; window.orca.close() or the panel's close button closes "
"it and calls on_close(). A post() made before the page has loaded can be dropped, so have the page "
"request its first data.");
py::class_<UiProgressHandle>(ui, "ProgressDialog", "Handle to a native progress dialog.")
.def(py::init(&new_progress_dialog), py::arg("title"), py::arg("message"), py::arg("maximum") = 100,
py::arg("style") = wxPD_APP_MODAL | wxPD_AUTO_HIDE)
@@ -632,6 +734,8 @@ void PluginHostUi::close_windows_for_plugin(const std::string& plugin_key)
for (auto* window : UiRegistry::instance().take_for_plugin(plugin_key)) {
if (auto* dialog = dynamic_cast<GUI::PluginWebDialog*>(window))
GUI::PluginWebDialog::destroy_for_plugin(dialog);
else if (auto* panel = dynamic_cast<GUI::PluginDockPanel*>(window))
panel->destroy_for_plugin();
else if (window != nullptr)
window->Destroy();
}
@@ -3,6 +3,7 @@
#include <libslic3r/Model.hpp>
#include <libslic3r/PresetBundle.hpp>
#include <libslic3r/TriangleMesh.hpp>
#include <slic3r/GUI/PluginDockPanel.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp>
#include "python_test_support.hpp"
@@ -141,6 +142,8 @@ TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app i
CHECK(ui.attr("WINDOW_MODELESS").cast<long>() == 0);
CHECK(ui.attr("WINDOW_MODAL").cast<long>() == 1);
CHECK(has_attr(ui, "UiWindow"));
CHECK(has_attr(ui, "create_dock_panel"));
CHECK(has_attr(ui, "UiDockPanel"));
// With no wx application the UI calls marshal to a main thread that does not
// exist here; they must fail cleanly with a clear error, not crash.
@@ -151,6 +154,45 @@ TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app i
CHECK(error.matches(PyExc_RuntimeError));
CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
}
try {
ui.attr("create_dock_panel")("<p>panel</p>");
FAIL("orca.host.ui.create_dock_panel unexpectedly succeeded without a wx application");
} catch (const py::error_already_set& error) {
CHECK(error.matches(PyExc_RuntimeError));
CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
}
// An unknown dock position is rejected before the application is needed.
try {
ui.attr("create_dock_panel")("<p>panel</p>", py::arg("dock") = "top");
FAIL("orca.host.ui.create_dock_panel accepted an unknown dock position");
} catch (const py::error_already_set& error) {
CHECK(error.matches(PyExc_ValueError));
}
}
TEST_CASE("Plugin pane names identify the plugin and title without layout delimiters", "[PluginHost]")
{
using Slic3r::GUI::plugin_pane_name;
CHECK(plugin_pane_name("dock_demo", "Scene") == "plugin:dock_demo:Scene");
CHECK(plugin_pane_name("key", "a|b;c=d\\e").find_first_of("|;=\\") == std::string::npos);
}
TEST_CASE("A plugin pane's saved layout entry is found by pane name", "[PluginHost]")
{
using Slic3r::GUI::plugin_pane_layout_entry;
const std::string sidebar = "name=sidebar;caption=;state=2099196;dir=4;layer=0;row=0;pos=0;bestw=390;besth=900";
// The caption holds an escaped '|', which must not end the entry.
const std::string plugin = "name=plugin:demo:Scene;caption=Scene \\| stats;state=2099198;dir=2;layer=0;row=1;pos=0;bestw=320;besth=480";
const std::string layout = "layout3|" + sidebar + "|" + plugin + "|dock_size(4,0,0)=392|";
CHECK(plugin_pane_layout_entry(layout, "plugin:demo:Scene") == plugin);
CHECK(plugin_pane_layout_entry(layout, "sidebar") == sidebar);
CHECK(plugin_pane_layout_entry(layout, "plugin:demo").empty());
CHECK(plugin_pane_layout_entry("", "plugin:demo:Scene").empty());
}
TEST_CASE("Plugin host API exposes model geometry and structure to Python", "[PluginHost][Python]")