Add a Dockable HTML Panel API for Plugins

orca.host.ui.create_dock_panel(html, title, width, height, on_message,
on_close, dock) hosts plugin HTML in a pane of the Plater's dock manager,
next to the sidebar, and returns a UiDockPanel handle
(post/show/hide/close/is_open). The arguments follow create_window(). The
panel uses the window.orca bridge of plugin windows, restores its position
and size from the saved window layout, hides with the Plater off the Prepare
and Preview tabs when floating, and is closed with its plugin; plugin panes
are removed in MainFrame::shutdown().

The web view hosting moves out of PluginPage into a shared PluginWebPanel
base: bootstrap page and swap to the plugin HTML, theme, element-default and
bridge scripts, window.orca message parsing, delivery to the page, and live
re-theming, also re-applied on every load after the swap. Pages tabs and
docked panels both derive from it. Pages tabs now re-theme in place on a
theme change instead of being reloaded, and a window.orca call a host does
not support is logged.

What the hosts share no longer lives in one of them: the bootstrap page, the
base URL and the plugin-window bridge move to Widgets/PluginWebHosting, used
by PluginWebDialog and PluginWebPanel alike. The Plater restores plugin panes
with a new saved-layout parser, GUI/AuiPaneLayout, kept in its own small
header so slic3rutils can test it without pulling in the Plater.

Includes a sample plugin (sandboxes/orca_dock_panel_plugin_any.py) and
binding and layout-helper tests in slic3rutils.
This commit is contained in:
Hanif Koh
2026-09-21 18:40:45 +08:00
parent 8b63628cf9
commit 1447ad9a4c
20 changed files with 974 additions and 173 deletions
@@ -3,8 +3,12 @@
#include <libslic3r/Model.hpp>
#include <libslic3r/PresetBundle.hpp>
#include <libslic3r/TriangleMesh.hpp>
#include <slic3r/GUI/PluginDockPanel.hpp>
#include <slic3r/GUI/AuiPaneLayout.hpp>
#include <slic3r/GUI/Widgets/PluginWebHosting.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp>
#include "plugin_test_utils.hpp"
#include "python_test_support.hpp"
#include <pybind11/embed.h>
@@ -141,6 +145,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 +157,74 @@ 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);
}
// Positional arguments follow create_window(): width and height come straight after the title.
try {
ui.attr("create_dock_panel")("<p>panel</p>", "Panel", 400, 300);
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));
}
// 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 pane's saved layout entry is found by pane name", "[PluginHost]")
{
using Slic3r::GUI::aui_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(aui_pane_layout_entry(layout, "plugin:demo:Scene") == plugin);
CHECK(aui_pane_layout_entry(layout, "sidebar") == sidebar);
CHECK(aui_pane_layout_entry(layout, "plugin:demo").empty());
CHECK(aui_pane_layout_entry("", "plugin:demo:Scene").empty());
}
TEST_CASE("A reloaded plugin page is recognised by its base URL, fragment aside", "[PluginHost]")
{
using namespace Slic3r::GUI::plugin_web;
// A resources path holding a space, which the web view reports escaped.
const Slic3r::ScopedResourcesDir resources("web content check");
// The swapped-in page, then after an in-page anchor and a reload.
CHECK(is_content_url(content_base_url()));
CHECK(is_content_url(content_base_url() + "#tab2"));
wxString escaped = content_base_url();
escaped.Replace(" ", "%20");
REQUIRE(escaped != content_base_url());
CHECK(is_content_url(escaped));
CHECK(is_content_url(escaped + "#tab2"));
// A page the plugin linked to keeps its own URL and must be left alone.
CHECK_FALSE(is_content_url(content_base_url() + "guide.html"));
CHECK_FALSE(is_content_url("https://example.com/"));
CHECK_FALSE(is_content_url(""));
}
TEST_CASE("Plugin host API exposes model geometry and structure to Python", "[PluginHost][Python]")