Merge branch 'feat/plugin-feature' into feature/speed-dial

This commit is contained in:
Ian Chua
2026-07-17 21:29:25 +08:00
committed by GitHub
6 changed files with 142 additions and 106 deletions

View File

@@ -18,7 +18,7 @@ usable) with a sidebar of sections, each exercising one part of the API:
Config the complete merged full_config as a searchable table
Model Model -> Object -> Volume/Instance tree with lazy mesh geometry
Assembly objects grouped by module_name, per-instance assemble transforms
UI Toolkit live demos of message/show_dialog/create_window/ProgressDialog
UI Toolkit live demos of message/create_window/ProgressDialog
The page and the plugin talk JSON through the injected `window.orca` bridge:
@@ -1006,8 +1006,8 @@ function renderUikit() {
'<option>error</option><option selected>question</option></select></div>' +
'<div class="frow"><input id="ui-text" style="flex:1" value="Shall we inspect some hosts?">' +
'<button class="small" data-act="ui" data-ui="message">Show</button></div></div>' +
'<div class="card"><h3>Modal HTML dialog · ui.show_dialog()</h3>' +
'<p class="muted">Blocks until closed; returns the <span class="mono">orca.submit()</span> payload.</p>' +
'<div class="card"><h3>Modal HTML dialog · ui.create_window()</h3>' +
'<p class="muted">Uses WINDOW_MODAL; messages and the final <span class="mono">orca.submit()</span> payload are callbacks.</p>' +
'<button class="small" data-act="ui" data-ui="modal">Open modal dialog</button></div>' +
'<div class="card"><h3>Progress dialog · ui.ProgressDialog</h3>' +
'<p class="muted">Determinate update() loop (cancellable) and start_pulse()/stop_pulse().</p>' +
@@ -1107,13 +1107,13 @@ show('overview');
"""
# The modal page demoed from the UI Toolkit tab: orca.submit(payload) resolves the
# blocking show_dialog() call with that payload; orca.close() resolves it with None.
# The modal page demoed from the UI Toolkit tab: create_window(style=WINDOW_MODAL)
# keeps the handle alive for callbacks; orca.submit(payload) invokes on_submit.
MODAL_PAGE = r"""<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body style="padding:18px">
<h3 style="margin-top:0">Modal dialog</h3>
<p>show_dialog() blocks the plugin until this closes, then returns the submitted payload.</p>
<p>create_window(style=WINDOW_MODAL) keeps this page interactive until it submits or closes.</p>
<p><input id="note" style="width:100%" value="hello from the modal"></p>
<p style="text-align:right">
<button style="background:transparent;color:var(--orca-fg);border-color:var(--orca-border)"
@@ -1149,6 +1149,7 @@ CHILD_PAGE = r"""<!DOCTYPE html>
class OrcaInspectorPanel(orca.script.ScriptPluginCapabilityBase):
win = None
child = None
modal = None
def get_name(self):
return "Orca Inspector"
@@ -1162,6 +1163,9 @@ class OrcaInspectorPanel(orca.script.ScriptPluginCapabilityBase):
if self.child is not None:
self.child.close()
self.child = None
if self.modal is not None:
self.modal.close()
self.modal = None
# Non-modal: returns immediately. The window is host-owned and lives on
# after execute() returns; on_message keeps firing when the page posts.
self.win = orca.host.ui.create_window(
@@ -1238,14 +1242,21 @@ class OrcaInspectorPanel(orca.script.ScriptPluginCapabilityBase):
icon=msg.get("icon", "info"))
report(f"user clicked {clicked!r}")
elif action == "modal":
# We are on the UI thread, so this nests a modal event loop and
# blocks right here until the dialog closes. on_message still
# fires while the dialog is open (the log updates live).
result = orca.host.ui.show_dialog(
if self.modal is not None and self.modal.is_open():
report("modal already open")
return
# The modal window is created asynchronously on the UI thread,
# but the handle remains usable for post()/close() and callbacks.
self.modal = orca.host.ui.create_window(
html=MODAL_PAGE, title="Orca Inspector — modal", width=420, height=280,
style=orca.host.ui.WINDOW_MODAL,
on_message=lambda m: self.win.post(
{**reply, "ok": True, "result": f"modal posted {m!r} while open"}))
report(f"show_dialog returned {result!r}")
{**reply, "ok": True, "result": f"modal posted {m!r} while open"}),
on_submit=lambda result: self.win.post(
{**reply, "ok": True, "result": f"modal submitted {result!r}"}),
on_close=lambda: self.win.post(
{**reply, "ok": True, "result": "modal closed"}))
report("modal opened")
elif action == "progress":
threading.Thread(target=self.progress_demo, daemon=True).start()
report("started on a worker thread…")

View File

@@ -94,11 +94,14 @@ PluginWebDialog::PluginWebDialog(wxWindow* parent,
const std::string& html,
const wxSize& size,
MessageHandler on_message,
SubmitHandler on_submit,
CloseHandler on_close,
CloseHandler on_destroyed)
: WebViewHostDialog(parent, wxID_ANY, title, wxDefaultPosition, size)
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))
, m_on_submit(std::move(on_submit))
, m_on_close(std::move(on_close))
, m_on_destroyed(std::move(on_destroyed))
{
@@ -140,29 +143,6 @@ PluginWebDialog::~PluginWebDialog()
m_on_destroyed();
}
std::optional<nlohmann::json> PluginWebDialog::show_modal_dialog(wxWindow* parent,
const wxString& title,
const std::string& html,
const wxSize& size,
MessageHandler on_message)
{
PluginWebDialog dlg(parent, title, html, size, std::move(on_message), nullptr, nullptr);
dlg.ShowModal();
return dlg.result();
}
PluginWebDialog* PluginWebDialog::create_modeless_dialog(wxWindow* parent,
const wxString& title,
const std::string& html,
const wxSize& size,
MessageHandler on_message,
CloseHandler on_close,
CloseHandler on_destroyed)
{
return new PluginWebDialog(parent, title, html, size, std::move(on_message), std::move(on_close),
std::move(on_destroyed));
}
void PluginWebDialog::post_message(PluginWebDialog* dialog, const nlohmann::json& data)
{
if (dialog != nullptr && dialog->is_open())
@@ -175,6 +155,20 @@ void PluginWebDialog::request_close(PluginWebDialog* dialog)
dialog->Close();
}
void PluginWebDialog::destroy_for_plugin(PluginWebDialog* dialog)
{
if (dialog == nullptr)
return;
// Forced plugin teardown must not invoke Python close callbacks. End a modal
// loop first, otherwise destroying the window can leave ShowModal() running.
if (dialog->IsModal()) {
dialog->m_open = false;
dialog->EndModal(wxID_CANCEL);
}
dialog->Destroy();
}
void PluginWebDialog::on_bootstrap_event(wxWebViewEvent& event)
{
// The first bootstrap load (or its error) triggers the swap to plugin HTML;
@@ -226,10 +220,13 @@ void PluginWebDialog::finish(bool submitted, const nlohmann::json& data)
if (!m_open)
return;
m_open = false;
if (submitted)
if (submitted) {
m_result = data;
else
fire_submit(data);
} else {
m_result.reset();
fire_close();
}
if (IsModal())
EndModal(submitted ? wxID_OK : wxID_CANCEL);
@@ -239,17 +236,32 @@ void PluginWebDialog::finish(bool submitted, const nlohmann::json& data)
void PluginWebDialog::on_close_window(wxCloseEvent&)
{
m_open = false;
if (IsModal()) {
EndModal(m_result.has_value() ? wxID_OK : wxID_CANCEL);
if (!m_open) {
// finish() already dispatched submit/close and requested the close.
// Modeless windows still need to be destroyed after that request.
if (!IsModal())
Destroy();
return;
}
// Modeless: the window is still fully alive here (unlike in wxEVT_DESTROY), so
// it is safe to invoke the plugin's on_close before destroying the window.
m_open = false;
m_result.reset();
fire_close();
if (IsModal()) {
EndModal(wxID_CANCEL);
return;
}
Destroy();
}
void PluginWebDialog::fire_submit(const nlohmann::json& data)
{
if (m_on_submit) {
SubmitHandler cb = std::move(m_on_submit);
cb(data);
}
}
void PluginWebDialog::fire_close()
{
if (m_close_fired)

View File

@@ -25,35 +25,27 @@ class PluginWebDialog : public Slic3r::GUI::WebViewHostDialog
{
public:
using MessageHandler = std::function<void(const nlohmann::json& data)>;
using SubmitHandler = std::function<void(const nlohmann::json& data)>;
using CloseHandler = std::function<void()>;
// on_close fires only on a 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).
// on_submit fires once for window.orca.submit(). on_close fires only on a
// 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);
CloseHandler on_destroyed,
long wx_style = wxSYSTEM_MENU | wxCAPTION | wxCLOSE_BOX | wxMAXIMIZE_BOX | wxRESIZE_BORDER);
~PluginWebDialog() override;
// Convenience helpers for plugin-host callers. MAIN-THREAD ONLY.
static std::optional<nlohmann::json> show_modal_dialog(wxWindow* parent,
const wxString& title,
const std::string& html,
const wxSize& size,
MessageHandler on_message);
static PluginWebDialog* create_modeless_dialog(wxWindow* parent,
const wxString& title,
const std::string& html,
const wxSize& size,
MessageHandler on_message,
CloseHandler on_close,
CloseHandler on_destroyed);
static void post_message(PluginWebDialog* dialog, const nlohmann::json& data);
static void request_close(PluginWebDialog* dialog);
static void destroy_for_plugin(PluginWebDialog* dialog);
// Push a payload to the page; delivered to handlers registered via
// window.orca.onMessage(). MAIN-THREAD ONLY (the plugin layer marshals).
@@ -74,6 +66,7 @@ private:
void on_bootstrap_event(wxWebViewEvent& event);
void load_plugin_content();
void on_close_window(wxCloseEvent& event);
void fire_submit(const nlohmann::json& data);
void fire_close();
void finish(bool submitted, const nlohmann::json& data);
@@ -83,6 +76,7 @@ private:
bool m_close_fired{false};
std::optional<nlohmann::json> m_result;
MessageHandler m_on_message;
SubmitHandler m_on_submit;
CloseHandler m_on_close;
CloseHandler m_on_destroyed;
};

View File

@@ -85,6 +85,24 @@ GUI::PluginWebDialog::MessageHandler make_message_adapter(py::object on_message)
};
}
GUI::PluginWebDialog::SubmitHandler make_submit_adapter(py::object on_submit)
{
CallablePtr holder = make_holder(std::move(on_submit));
if (!holder)
return nullptr;
return [holder](const json& data) {
PythonGILState gil;
if (!gil)
return;
try {
holder->fn(json_to_py(data));
} catch (py::error_already_set& e) {
BOOST_LOG_TRIVIAL(error) << "orca.host.ui on_submit 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.
@@ -242,27 +260,11 @@ std::string ui_message(const std::string& text, const std::string& title,
}
// --------------------------------------------------------------------------
// orca.host.ui.show_dialog (modal)
// --------------------------------------------------------------------------
py::object ui_show_dialog(const std::string& html, const std::string& title,
int width, int height, py::object on_message)
{
auto handler = make_message_adapter(std::move(on_message));
const int w = width > 0 ? width : 820;
const int h = height > 0 ? height : 600;
constexpr long WINDOW_MODELESS = 0L;
constexpr long WINDOW_MODAL = 1L << 0;
constexpr long PLUGIN_WX_STYLE = wxSYSTEM_MENU | wxCAPTION | wxCLOSE_BOX | wxMAXIMIZE_BOX | wxRESIZE_BORDER;
std::optional<json> result = run_on_ui_blocking([&]() -> std::optional<json> {
return GUI::PluginWebDialog::show_modal_dialog(ui_parent(), wxString::FromUTF8(title), html, wxSize(w, h),
std::move(handler));
});
if (!result.has_value())
return py::none();
return json_to_py(*result); // GIL held in the binding body
}
// --------------------------------------------------------------------------
// orca.host.ui.create_window (non-modal) + UiWindow handle
// orca.host.ui.create_window + UiWindow handle
// --------------------------------------------------------------------------
struct UiWindowHandle
{
@@ -275,13 +277,18 @@ struct UiProgressHandle
};
py::object ui_create_window(const std::string& html, const std::string& title, int width, int height,
py::object on_message, py::object on_close)
py::object on_message, py::object on_close, long style, py::object on_submit)
{
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 : 820;
const int h = height > 0 ? height : 600;
auto msg_adapter = make_message_adapter(std::move(on_message));
auto submit_adapter = make_submit_adapter(std::move(on_submit));
CallablePtr close_holder = make_holder(std::move(on_close));
const std::string plugin_key = PluginAuditManager::instance().current_plugin();
const int w = width > 0 ? width : 820;
const int h = height > 0 ? height : 600;
if ((style & ~WINDOW_MODAL) != 0)
throw std::invalid_argument("unsupported orca.host.ui.create_window style flags");
const bool modal = (style & WINDOW_MODAL) != 0;
if (wxTheApp == nullptr)
throw std::runtime_error("OrcaSlicer application is not initialized");
@@ -308,7 +315,8 @@ py::object ui_create_window(const std::string& html, const std::string& title, i
GUI::wxGetApp().CallAfter([new_id, plugin_key, html, title, w, h,
msg_adapter = std::move(msg_adapter),
close_holder = std::move(close_holder)]() mutable {
submit_adapter = std::move(submit_adapter),
close_holder = std::move(close_holder), modal]() mutable {
// Torn down (plugin unload / app shutdown) before the window materialized.
if (!UiRegistry::instance().is_open(new_id))
return;
@@ -332,11 +340,19 @@ py::object ui_create_window(const std::string& html, const std::string& title, i
// Registry cleanup: GIL-free, runs from the dialog destructor on every path.
auto on_destroyed = [new_id]() { UiRegistry::instance().remove(new_id); };
auto* dlg = GUI::PluginWebDialog::create_modeless_dialog(ui_parent(), wxString::FromUTF8(title), html,
wxSize(w, h), std::move(msg_adapter),
std::move(on_close), std::move(on_destroyed));
auto* dlg = new GUI::PluginWebDialog(ui_parent(), wxString::FromUTF8(title), html,
wxSize(w, h), std::move(msg_adapter), std::move(submit_adapter),
std::move(on_close), std::move(on_destroyed), PLUGIN_WX_STYLE);
UiRegistry::instance().bind(new_id, dlg, plugin_key);
dlg->Show();
if (modal) {
dlg->ShowModal();
// Plugin teardown may already have removed and destroyed this dialog
// while its modal loop was running.
if (UiRegistry::instance().is_open(new_id))
dlg->Destroy();
} else {
dlg->Show();
}
});
return py::cast(UiWindowHandle{new_id});
@@ -450,12 +466,6 @@ void PluginHostUi::RegisterBindings(pybind11::module_& host)
"(\"ok\"/\"cancel\"/\"yes\"/\"no\"). buttons: \"ok\"|\"ok_cancel\"|\"yes_no\"|\"yes_no_cancel\"; "
"icon: \"info\"|\"warning\"|\"error\"|\"question\".");
ui.def("show_dialog", &ui_show_dialog, py::arg("html"), py::arg("title") = "OrcaSlicer", py::arg("width") = 820,
py::arg("height") = 600, py::arg("on_message") = py::none(),
"Show a modal dialog rendering the given raw HTML. The page talks to the plugin via "
"window.orca (postMessage/onMessage/submit/close). Blocks until closed; returns the "
"orca.submit() payload as a dict, or None.");
ui.attr("PD_APP_MODAL") = py::int_(wxPD_APP_MODAL);
ui.attr("PD_AUTO_HIDE") = py::int_(wxPD_AUTO_HIDE);
ui.attr("PD_CAN_ABORT") = py::int_(wxPD_CAN_ABORT);
@@ -463,8 +473,10 @@ void PluginHostUi::RegisterBindings(pybind11::module_& host)
ui.attr("PD_ELAPSED_TIME") = py::int_(wxPD_ELAPSED_TIME);
ui.attr("PD_ESTIMATED_TIME") = py::int_(wxPD_ESTIMATED_TIME);
ui.attr("PD_REMAINING_TIME") = py::int_(wxPD_REMAINING_TIME);
ui.attr("WINDOW_MODELESS") = py::int_(WINDOW_MODELESS);
ui.attr("WINDOW_MODAL") = py::int_(WINDOW_MODAL);
py::class_<UiWindowHandle>(ui, "UiWindow", "Handle to a non-modal plugin window created by create_window().")
py::class_<UiWindowHandle>(ui, "UiWindow", "Handle to a plugin HTML window or modal dialog created by create_window().")
.def_property_readonly("id", [](const UiWindowHandle& h) { return h.id; })
.def(
"post", [](const UiWindowHandle& h, py::object data) { handle_post(h.id, std::move(data)); },
@@ -477,9 +489,10 @@ void PluginHostUi::RegisterBindings(pybind11::module_& host)
ui.def("create_window", &ui_create_window, py::arg("html"), py::arg("title") = "OrcaSlicer", py::arg("width") = 820,
py::arg("height") = 600, py::arg("on_message") = py::none(), py::arg("on_close") = py::none(),
"Open a non-modal, persistent HTML window and return a UiWindow. on_message(data) is called on "
"the UI thread when the page posts; offload heavy work to a thread and push results back with "
"window.post().");
py::arg("style") = WINDOW_MODELESS, py::arg("on_submit") = py::none(),
"Open a persistent HTML window or modal dialog and return a UiWindow. style is WINDOW_MODELESS "
"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_<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,
@@ -528,7 +541,9 @@ void PluginHostUi::close_windows_for_plugin(const std::string& plugin_key)
// Destroy() bypasses wxEVT_CLOSE, so the plugin's on_close is not fired on
// forced teardown (intended); the resource destructor still cleans the registry.
for (auto* window : UiRegistry::instance().take_for_plugin(plugin_key)) {
if (window != nullptr)
if (auto* dialog = dynamic_cast<GUI::PluginWebDialog*>(window))
GUI::PluginWebDialog::destroy_for_plugin(dialog);
else if (window != nullptr)
window->Destroy();
}
};

View File

@@ -7,8 +7,8 @@
namespace Slic3r {
// Binds the `orca.host.ui` submodule: native message boxes, progress dialogs,
// and interactive HTML windows for plugins. All calls run on the main/UI thread
// (marshaled from the plugin worker thread) and the host owns every window.
// and interactive HTML windows/dialogs for plugins. All calls run on the main/UI
// thread (marshaled from the plugin worker thread) and the host owns every window.
//
// Not safe to call from a slicing pipeline hook (SlicingPipelinePluginCapability):
// that hook runs on the slicing worker thread, which the UI thread can itself be

View File

@@ -134,8 +134,12 @@ TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app i
py::object ui = host.attr("ui");
CHECK(has_attr(ui, "message"));
CHECK(has_attr(ui, "show_dialog"));
CHECK_FALSE(has_attr(ui, "show_dialog"));
CHECK(has_attr(ui, "create_window"));
CHECK(has_attr(ui, "WINDOW_MODELESS"));
CHECK(has_attr(ui, "WINDOW_MODAL"));
CHECK(ui.attr("WINDOW_MODELESS").cast<long>() == 0);
CHECK(ui.attr("WINDOW_MODAL").cast<long>() == 1);
CHECK(has_attr(ui, "UiWindow"));
// With no wx application the UI calls marshal to a main thread that does not