Add actions speed dial

This commit is contained in:
Andrew
2026-07-09 12:52:30 +08:00
parent fb2c0ef237
commit 6daef171c1
18 changed files with 1309 additions and 147 deletions

View File

@@ -116,6 +116,10 @@ set(SLIC3R_GUI_SOURCES
GUI/PluginPickerDialog.hpp
GUI/PluginsDialog.cpp
GUI/PluginsDialog.hpp
GUI/PluginScriptRunner.cpp
GUI/PluginScriptRunner.hpp
GUI/SpeedDialDialog.cpp
GUI/SpeedDialDialog.hpp
GUI/ProcessRunner.cpp
GUI/ProcessRunner.hpp
GUI/TerminalDialog.cpp

View File

@@ -1034,6 +1034,7 @@ wxDEFINE_EVENT(EVT_GLCANVAS_ORIENT_PARTPLATE, SimpleEvent);
wxDEFINE_EVENT(EVT_GLCANVAS_SELECT_CURR_PLATE_ALL, SimpleEvent);
wxDEFINE_EVENT(EVT_GLCANVAS_SELECT_ALL, SimpleEvent);
wxDEFINE_EVENT(EVT_GLCANVAS_QUESTION_MARK, SimpleEvent);
wxDEFINE_EVENT(EVT_GLCANVAS_OPEN_SPEED_DIAL, SimpleEvent);
wxDEFINE_EVENT(EVT_GLCANVAS_INCREASE_INSTANCES, Event<int>);
wxDEFINE_EVENT(EVT_GLCANVAS_INSTANCE_MOVED, SimpleEvent);
wxDEFINE_EVENT(EVT_GLCANVAS_INSTANCE_ROTATED, SimpleEvent);
@@ -3502,6 +3503,11 @@ void GLCanvas3D::on_char(wxKeyEvent& evt)
break;
}
case '?': { post_event(SimpleEvent(EVT_GLCANVAS_QUESTION_MARK)); break; }
case ' ': {
if (m_canvas_type == ECanvasType::CanvasView3D)
post_event(SimpleEvent(EVT_GLCANVAS_OPEN_SPEED_DIAL));
break;
}
case 'A':
case 'a':
{

View File

@@ -166,6 +166,7 @@ wxDECLARE_EVENT(EVT_GLCANVAS_ORIENT_PARTPLATE, SimpleEvent);
wxDECLARE_EVENT(EVT_GLCANVAS_SELECT_CURR_PLATE_ALL, SimpleEvent);
wxDECLARE_EVENT(EVT_GLCANVAS_SELECT_ALL, SimpleEvent);
wxDECLARE_EVENT(EVT_GLCANVAS_QUESTION_MARK, SimpleEvent);
wxDECLARE_EVENT(EVT_GLCANVAS_OPEN_SPEED_DIAL, SimpleEvent);
wxDECLARE_EVENT(EVT_GLCANVAS_INCREASE_INSTANCES, Event<int>); // data: +1 => increase, -1 => decrease
wxDECLARE_EVENT(EVT_GLCANVAS_INSTANCE_MOVED, SimpleEvent);
wxDECLARE_EVENT(EVT_GLCANVAS_FORCE_UPDATE, SimpleEvent);

View File

@@ -141,6 +141,7 @@
#include "slic3r/Utils/bambu_networking.hpp"
#include "PluginsDialog.hpp"
#include "SpeedDialDialog.hpp"
#include "TerminalDialog.hpp"
//#ifdef WIN32
@@ -8246,6 +8247,8 @@ void GUI_App::open_terminal_dialog()
m_terminal_dlg->Raise();
}
void GUI_App::open_speed_dial() { open_speed_dial_popup(); }
void GUI_App::open_exportpresetbundledialog(size_t open_on_tab, const std::string& highlight_option)
{
bool app_layout_changed = false;

View File

@@ -624,6 +624,7 @@ public:
void open_presetbundledialog(size_t open_on_tab = 0, const std::string& highlight_option = std::string());
void open_plugins_dialog(size_t open_on_tab = 0, const std::string& highlight_option = std::string());
void open_terminal_dialog();
void open_speed_dial();
void open_exportpresetbundledialog(size_t open_on_tab = 0, const std::string& highlight_option = std::string());
virtual bool OnExceptionInMainLoop() override;
// Calls wxLaunchDefaultBrowser if user confirms in dialog.

View File

@@ -267,6 +267,7 @@ void KBShortcutsDialog::fill_shortcuts()
{ "O", L("Zoom out") },
{ "V", L("Toggle printable for object/part") },
{ L("Tab"), L("Switch between Prepare/Preview") },
{ L("Space"), L("Open actions speed dial") },
};
m_full_shortcuts.push_back({ { _L("Plater"), "" }, plater_shortcuts });

View File

@@ -5259,6 +5259,10 @@ Plater::priv::priv(Plater *q, MainFrame *main_frame)
view3D_canvas->Bind(EVT_GLCANVAS_SELECT_ALL, [this](SimpleEvent&) { this->q->select_all(); });
view3D_canvas->Bind(EVT_GLCANVAS_QUESTION_MARK, [](SimpleEvent&) { wxGetApp().keyboard_shortcuts(); });
view3D_canvas->Bind(EVT_GLCANVAS_OPEN_SPEED_DIAL, [this](SimpleEvent&) {
if (this->q->is_view3D_shown())
wxGetApp().open_speed_dial();
});
view3D_canvas->Bind(EVT_GLCANVAS_INCREASE_INSTANCES, [this](Event<int>& evt)
{ if (evt.data == 1) this->q->increase_instances(); else if (this->can_decrease_instances()) this->q->decrease_instances(); });
view3D_canvas->Bind(EVT_GLCANVAS_INSTANCE_MOVED, [this](SimpleEvent&) { update(); });

View File

@@ -0,0 +1,148 @@
#include "PluginScriptRunner.hpp"
#include "GUI.hpp"
#include "I18N.hpp"
#include "slic3r/plugin/PluginManager.hpp"
#include "slic3r/plugin/PythonInterpreter.hpp"
#include "slic3r/plugin/pluginTypes/script/ScriptPluginCapability.hpp"
#include <libslic3r/Utils.hpp>
#include <boost/log/trivial.hpp>
#include <memory>
#include <wx/utils.h>
namespace Slic3r { namespace GUI {
ScriptRunOutcome run_script_plugin_capability(const std::string& plugin_key, const std::string& capability_name)
{
if (plugin_key.empty() || capability_name.empty()) {
BOOST_LOG_TRIVIAL(warning) << "Ignoring script run request with an empty plugin key or capability name";
return {};
}
PluginManager& manager = PluginManager::instance();
auto cap = manager.get_loader().get_plugin_capability_by_name(plugin_key, Slic3r::PluginCapabilityType::Script, capability_name);
if (!cap) {
BOOST_LOG_TRIVIAL(warning) << "Ignoring stale run request for missing script capability. plugin_key=" << plugin_key
<< " capability_name=" << capability_name;
return {};
}
if (!cap->enabled) {
BOOST_LOG_TRIVIAL(warning) << "Ignoring stale run request for disabled script capability. plugin_key=" << plugin_key
<< " capability_name=" << capability_name;
return {};
}
// A plugin's modal orca.host.ui dialog or the result message box pumps a nested event
// loop; a launcher surface could re-dispatch a run command mid-run. Refuse the
// overlapping run. UI-thread only, so a plain static is a sufficient latch.
static bool s_script_running = false;
if (s_script_running) {
BOOST_LOG_TRIVIAL(info) << "Ignoring script run request; a plugin is already running. plugin_key=" << plugin_key;
return {ScriptRunOutcome::Level::Busy, wxString()};
}
s_script_running = true;
ScopeGuard running_guard([]() { s_script_running = false; });
BOOST_LOG_TRIVIAL(info) << "Run script plugin requested. plugin_key=" << plugin_key << " capability_name=" << capability_name;
auto complete_with_error = [&manager, &plugin_key](const std::string& plugin_error, const wxString& status_message) {
const std::string normalized_error = plugin_error.empty() ? "Script plugin failed." : plugin_error;
if (!manager.get_catalog().set_plugin_error(plugin_key, normalized_error))
BOOST_LOG_TRIVIAL(warning) << "Failed to record plugin error. plugin_key=" << plugin_key;
if (!manager.get_loader().unload_plugin(plugin_key))
BOOST_LOG_TRIVIAL(error) << "Failed to unload plugin after script error. plugin_key=" << plugin_key;
return ScriptRunOutcome{ScriptRunOutcome::Level::Error,
status_message.empty() ? from_u8(normalized_error) : status_message};
};
PluginDescriptor descriptor;
if (!manager.get_catalog().try_get_plugin_descriptor(plugin_key, descriptor)) {
BOOST_LOG_TRIVIAL(error) << "Cannot run script plugin because manifest was not found. plugin_key=" << plugin_key;
return complete_with_error("Plugin manifest was not found.", _L("Plugin manifest was not found."));
}
if (descriptor.has_error())
return complete_with_error(descriptor.normalized_error(), wxString());
// Should not reach here, handle for extra safety
if (!descriptor.is_metadata_valid()) {
std::string plugin_type_str = plugin_capability_type_to_string(descriptor.primary_capability_type());
std::string metadata_valid = descriptor.is_metadata_valid() ? "true" : "false";
const std::string plugin_error = "Cannot run plugin because its metadata is invalid:\n\tplugin type: " + plugin_type_str +
"\n\tmetadata_valid: " + metadata_valid;
BOOST_LOG_TRIVIAL(error) << "Cannot run plugin because its metadata is invalid. plugin_key=" << plugin_key
<< " is_metadata_valid=" << descriptor.is_metadata_valid()
<< " type=" << plugin_capability_type_to_string(descriptor.primary_capability_type());
return complete_with_error(plugin_error, _L("Only plugins with valid metadata can be run from this dialog."));
}
// Should not reach here as non-loaded plugins have disabled run buttons, handle for extra safety
if (!manager.get_loader().is_plugin_loaded(plugin_key)) {
BOOST_LOG_TRIVIAL(warning) << "Cannot run script plugin because it is not loaded. plugin_key=" << plugin_key;
return complete_with_error("Load the script plugin before running it: Cannot run script plugin because it is not loaded.",
_L("Load the script plugin before running it."));
}
auto plugin = std::dynamic_pointer_cast<Slic3r::ScriptPluginCapability>(cap->instance);
if (!plugin) {
BOOST_LOG_TRIVIAL(error) << "Loaded plugin does not implement ScriptPluginCapability. plugin_key=" << plugin_key;
return complete_with_error("The selected plugin is not a runnable script plugin: Loaded plugin does not implement ScriptPluginCapability.",
_L("The selected plugin is not a runnable script plugin."));
}
std::string error;
ExecutionResult result;
// Script plugins run on the main/UI thread (not a worker). They hold live, non-owning
// ModelObject*/ModelVolume*/ModelInstance* aliases into host data and can mint ObjectIDs,
// which libslic3r requires on the main thread (ObjectID.hpp's non-atomic s_last_id). Running
// here makes those reads/instantiations legal and means nothing mutates the model underneath
// a run. The trade-off is that a slow execute() freezes the UI: the contract (see
// plugin_development.md) is to keep execute() quick and offload heavy work to the plugin's own
// threading.Thread. orca.host.ui calls already no-op their main-thread marshaling here.
{
wxBusyCursor busy;
try {
PythonGILState gil;
result = plugin->execute();
} catch (const std::exception& ex) {
error = ex.what();
BOOST_LOG_TRIVIAL(error) << "Script plugin execution threw exception. plugin_key=" << plugin_key << " error=" << error;
} catch (...) {
error = "Unknown error";
BOOST_LOG_TRIVIAL(error) << "Script plugin execution threw unknown exception. plugin_key=" << plugin_key;
}
}
if (!error.empty()) {
plugin.reset();
cap.reset();
return complete_with_error(error, wxString());
}
BOOST_LOG_TRIVIAL(info) << "Script plugin execution completed. plugin_key=" << plugin_key
<< " status=" << static_cast<int>(result.status) << " message=" << result.message << " data=" << result.data;
const bool failed = result.status == PluginResult::RecoverableError || result.status == PluginResult::FatalError;
if (failed) {
plugin.reset();
cap.reset();
// complete_with_error normalizes an empty message to "Script plugin failed.".
return complete_with_error(result.message, wxString());
}
manager.clear_plugin_error(plugin_key);
const bool skipped = result.status == PluginResult::Skipped;
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
return {skipped ? ScriptRunOutcome::Level::Info : ScriptRunOutcome::Level::Success,
result.message.empty() ? fallback : from_u8(result.message)};
}
}} // namespace Slic3r::GUI

View File

@@ -0,0 +1,31 @@
#ifndef slic3r_PluginScriptRunner_hpp_
#define slic3r_PluginScriptRunner_hpp_
#include <string>
#include <wx/string.h>
namespace Slic3r { namespace GUI {
// Outcome of running one SCRIPT capability, for the caller's own status surface
// (PluginsDialog footer, speed-dial notification). An empty message means "nothing
// worth showing" (stale/disabled requests that were silently ignored).
struct ScriptRunOutcome
{
enum class Level { Success, Info, Error, Busy };
Level level = Level::Info;
wxString message;
};
// Runs a SCRIPT plugin capability on the calling (UI) thread: validates the request,
// executes under the Python GIL, and on failure records the plugin error in the catalog
// and unloads the package. Shared by every surface that can launch a script so the
// subtle parts (UI-thread requirement, GIL, re-entrancy latch, error bookkeeping) live
// in one place. Returns Level::Busy when a script is already running (callers ignore).
// Callers refresh their plugin views after any non-Busy outcome.
ScriptRunOutcome run_script_plugin_capability(const std::string& plugin_key, const std::string& capability_name);
}} // namespace Slic3r::GUI
#endif

View File

@@ -4,10 +4,9 @@
#include "GUI_App.hpp"
#include "I18N.hpp"
#include "OrcaCloudServiceAgent.hpp"
#include "PluginScriptRunner.hpp"
#include "slic3r/plugin/PluginFsUtils.hpp"
#include "slic3r/plugin/PluginManager.hpp"
#include "slic3r/plugin/PythonInterpreter.hpp"
#include "slic3r/plugin/pluginTypes/script/ScriptPluginCapability.hpp"
#include <libslic3r/Utils.hpp>
@@ -19,8 +18,6 @@
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <slic3r/plugin/PluginLoader.hpp>
#include <pybind11/embed.h>
#include <boost/filesystem.hpp>
#include <boost/log/trivial.hpp>
@@ -898,147 +895,22 @@ wxString PluginsDialog::plugin_display_name(const std::string& plugin_key) const
void PluginsDialog::run_script_plugin(const std::string& plugin_key, const std::string& capability_name)
{
if (plugin_key.empty() || capability_name.empty()) {
BOOST_LOG_TRIVIAL(warning) << "Ignoring run_script_plugin with an empty plugin key or capability name";
send_plugins();
// Shared with the speed dial; validation, GIL/UI-thread execution, re-entrancy latch
// and error bookkeeping all live in run_script_plugin_capability.
const ScriptRunOutcome outcome = run_script_plugin_capability(plugin_key, capability_name);
if (outcome.level == ScriptRunOutcome::Level::Busy)
return;
}
PluginManager& manager = PluginManager::instance();
auto cap = manager.get_loader().get_plugin_capability_by_name(
plugin_key, Slic3r::PluginCapabilityType::Script, capability_name);
if (!cap) {
BOOST_LOG_TRIVIAL(warning) << "Ignoring stale run request for missing script capability. plugin_key=" << plugin_key
<< " capability_name=" << capability_name;
send_plugins();
return;
}
if (!cap->enabled) {
BOOST_LOG_TRIVIAL(warning) << "Ignoring stale run request for disabled script capability. plugin_key=" << plugin_key
<< " capability_name=" << capability_name;
send_plugins();
return;
}
// A plugin's modal orca.host.ui dialog or the result message box pumps a nested event
// loop; the WebView could re-dispatch this command mid-run. Refuse the overlapping run.
if (m_script_running) {
BOOST_LOG_TRIVIAL(info) << "Ignoring run_script_plugin; a plugin is already running. plugin_key=" << plugin_key;
return;
}
m_script_running = true;
ScopeGuard running_guard([this]() { m_script_running = false; });
BOOST_LOG_TRIVIAL(info) << "Run script plugin requested from Plugins dialog. plugin_key=" << plugin_key
<< " capability_name=" << capability_name;
auto complete_with_error = [this, &manager, &plugin_key](const std::string& plugin_error, const wxString& status_message) {
const std::string normalized_error = plugin_error.empty() ? "Script plugin failed." : plugin_error;
if (!manager.get_catalog().set_plugin_error(plugin_key, normalized_error))
BOOST_LOG_TRIVIAL(warning) << "Failed to record plugin error. plugin_key=" << plugin_key;
if (!manager.get_loader().unload_plugin(plugin_key))
BOOST_LOG_TRIVIAL(error) << "Failed to unload plugin after script error. plugin_key=" << plugin_key;
send_plugins();
// The row now shows an "Error" status and the Diagnostics tab holds the full text, so surface
// the outcome in the footer status bar instead of a modal box (prefer the friendlier override).
const wxString message = status_message.empty() ? from_u8(normalized_error) : status_message;
show_status(message, "error");
};
PluginDescriptor descriptor;
if (!get_descriptor(plugin_key, descriptor)) {
BOOST_LOG_TRIVIAL(error) << "Cannot run script plugin because manifest was not found. plugin_key=" << plugin_key;
complete_with_error("Plugin manifest was not found.", _L("Plugin manifest was not found."));
return;
}
if (descriptor.has_error()) {
complete_with_error(descriptor.normalized_error(), wxString());
return;
}
// Should not reach here, handle for extra safety
if (!descriptor.is_metadata_valid()) {
std::string plugin_type_str = plugin_capability_type_to_string(descriptor.primary_capability_type());
std::string metadata_valid = descriptor.is_metadata_valid() ? "true" : "false";
const std::string plugin_error = "Cannot run plugin because its metadata is invalid:\n\tplugin type: " + plugin_type_str +
"\n\tmetadata_valid: " + metadata_valid;
BOOST_LOG_TRIVIAL(error) << "Cannot run plugin because its metadata is invalid. plugin_key=" << plugin_key
<< " is_metadata_valid=" << descriptor.is_metadata_valid()
<< " type=" << plugin_capability_type_to_string(descriptor.primary_capability_type());
complete_with_error(plugin_error, _L("Only plugins with valid metadata can be run from this dialog."));
return;
}
// Should not reach here as non-loaded plugins have disabled run buttons, handle for extra safety
if (!manager.get_loader().is_plugin_loaded(plugin_key)) {
BOOST_LOG_TRIVIAL(warning) << "Cannot run script plugin because it is not loaded. plugin_key=" << plugin_key;
complete_with_error("Load the script plugin before running it: Cannot run script plugin because it is not loaded.",
_L("Load the script plugin before running it."));
return;
}
auto plugin = std::dynamic_pointer_cast<Slic3r::ScriptPluginCapability>(cap->instance);
if (!plugin) {
BOOST_LOG_TRIVIAL(error) << "Loaded plugin does not implement ScriptPluginCapability. plugin_key=" << plugin_key;
complete_with_error("The selected plugin is not a runnable script plugin: Loaded plugin does not implement ScriptPluginCapability.",
_L("The selected plugin is not a runnable script plugin."));
return;
}
std::string error;
ExecutionResult result;
// Script plugins run on the main/UI thread (not a worker). They hold live, non-owning
// ModelObject*/ModelVolume*/ModelInstance* aliases into host data and can mint ObjectIDs,
// which libslic3r requires on the main thread (ObjectID.hpp's non-atomic s_last_id). Running
// here makes those reads/instantiations legal and means nothing mutates the model underneath
// a run. The trade-off is that a slow execute() freezes the UI: the contract (see
// plugin_development.md) is to keep execute() quick and offload heavy work to the plugin's own
// threading.Thread. orca.host.ui calls already no-op their main-thread marshaling here.
{
wxBusyCursor busy;
try {
PythonGILState gil;
result = plugin->execute();
} catch (const std::exception& ex) {
error = ex.what();
BOOST_LOG_TRIVIAL(error) << "Script plugin execution threw exception. plugin_key=" << plugin_key << " error=" << error;
} catch (...) {
error = "Unknown error";
BOOST_LOG_TRIVIAL(error) << "Script plugin execution threw unknown exception. plugin_key=" << plugin_key;
}
}
if (!error.empty()) {
plugin.reset();
cap.reset();
complete_with_error(error, wxString());
return;
}
BOOST_LOG_TRIVIAL(info) << "Script plugin execution completed. plugin_key=" << plugin_key
<< " status=" << static_cast<int>(result.status) << " message=" << result.message << " data=" << result.data;
const bool failed = result.status == PluginResult::RecoverableError || result.status == PluginResult::FatalError;
if (failed) {
plugin.reset();
cap.reset();
// complete_with_error normalizes an empty message to "Script plugin failed." and reports via the status bar.
complete_with_error(result.message, wxString());
return;
}
manager.clear_plugin_error(plugin_key);
send_plugins();
const bool skipped = result.status == PluginResult::Skipped;
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
const wxString message = result.message.empty() ? fallback : from_u8(result.message);
show_status(message, skipped ? "info" : "success");
// The row shows any "Error" status and the Diagnostics tab holds the full text, so surface
// the outcome in the footer status bar instead of a modal box (prefer the friendlier override).
if (outcome.message.empty())
return;
const char* level = outcome.level == ScriptRunOutcome::Level::Error ? "error" :
outcome.level == ScriptRunOutcome::Level::Success ? "success" :
"info";
show_status(outcome.message, level);
}
void PluginsDialog::update_plugin(const std::string& plugin_key)

View File

@@ -213,11 +213,6 @@ private:
PluginSortKey m_plugin_sort_key = PluginSortKey::None;
PluginSortOrder m_plugin_sort_order = PluginSortOrder::Asc;
// Serializes run_script_plugin. With main-thread execution a plugin's orca.host.ui modal
// (message/show_dialog) or the result message box pumps a nested event loop, which could
// re-dispatch the web "run_script_plugin" command and start a second, overlapping run.
bool m_script_running = false;
// Plugin whose asynchronous activation is in flight, awaited by resolve_pending_activation().
// Empty when no activation is pending.
std::string m_activating_plugin_key;

View File

@@ -0,0 +1,462 @@
#include "SpeedDialDialog.hpp"
#include "GUI.hpp"
#include "GUI_App.hpp"
#include "MainFrame.hpp"
#include "MsgDialog.hpp"
#include "NotificationManager.hpp"
#include "Plater.hpp"
#include "PluginScriptRunner.hpp"
#include "Widgets/PopupWindow.hpp"
#include "Widgets/WebView.hpp"
#include "Widgets/WebViewHostDialog.hpp"
#include "Widgets/StateColor.hpp"
#include "slic3r/plugin/PluginManager.hpp"
#include <libslic3r/AppConfig.hpp>
#include <slic3r/plugin/PluginLoader.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <boost/filesystem.hpp>
#include <algorithm>
#include <cmath>
#include <ctime>
#include <map>
#include <wx/display.h>
#include <wx/sizer.h>
#include <wx/stattext.h>
#include <wx/webview.h>
namespace Slic3r { namespace GUI {
namespace {
// AppConfig section for the speed dial (favourites, run stats, ask suppression).
constexpr const char* kConfigSection = "speed_dial";
// ADJUST WIDTH HERE (DIP px). Fixed popup width; was 360, now 1.5x. Height is not set here -
// the popup auto-resizes to the page content (see resize_to_content + the list max-height in style.css).
constexpr int kPopupWidth = 540;
constexpr int kPopupMinHeight = 60; // just above the bare search-bar height, so the popup hugs content
constexpr int kPopupMaxHeight = 282;
// Tolerant parse of a config-stored JSON value; anything unreadable degrades to `fallback`
// (the section is brand new, so damaged/absent values just mean empty defaults).
nlohmann::json parse_config_json(const std::string& value, nlohmann::json fallback)
{
nlohmann::json parsed = nlohmann::json::parse(value, nullptr, false);
return parsed.is_discarded() ? std::move(fallback) : parsed;
}
// Stable action identity for the page: type-prefixed (plugin_key, capability) pair.
std::string action_id(const std::string& key, const std::string& cap) { return "script:" + key + "::" + cap; }
// frecency = frequency + recency
double frecency_score(int count, long long last, long long now)
{
if (count <= 0)
return 0.0;
constexpr double HALF_LIFE_DAYS = 30.0; // tunable knob: score halves every 30 idle days
double age = std::max(0.0, double(now - last) / 86400.0);
return count * std::pow(2.0, -age / HALF_LIFE_DAYS);
}
// why: STATIC free function, not a member - run_action Dismisses (destroys) the popup before
// the deferred run, so there is no `this` left to bump on. Reads/writes config directly.
void bump_frecency(const std::string& id)
{
auto stats = parse_config_json(wxGetApp().app_config->get(kConfigSection, "stats"), nlohmann::json::object());
// why: stats[id] on a new id yields a null node; value("count",...) throws type_error.306 on a
// non-object, so seed it to {} first (the first run of every action hit this before the toast).
nlohmann::json& e = stats[id];
if (!e.is_object())
e = nlohmann::json::object();
e["count"] = e.value("count", 0) + 1;
e["last"] = (long long) std::time(nullptr);
wxGetApp().app_config->set(kConfigSection, "stats", stats.dump());
}
wxString dialog_url()
{
boost::filesystem::path path = boost::filesystem::path(resources_dir()) / "web/dialog/SpeedDial/index.html";
return wxString("file://") + from_u8(path.make_preferred().string());
}
wxPoint clamped_position(wxPoint pos, const wxSize& size)
{
int display_index = wxDisplay::GetFromPoint(pos);
if (display_index == wxNOT_FOUND)
display_index = 0;
wxRect area = wxDisplay(display_index).GetClientArea();
pos.x = std::max(area.GetLeft(), std::min(pos.x, area.GetRight() - size.GetWidth()));
pos.y = std::max(area.GetTop(), std::min(pos.y, area.GetBottom() - size.GetHeight()));
return pos;
}
// ADJUST POSITION HERE. Horizontal: centred over the plater. Vertical: the window TOP sits at
// 1/3 of the screen height (upper-third placement). Tune `/ 3` to move the anchor up/down, or
// change the `x` line to re-anchor horizontally. clamped_position() at the call site then nudges
// the popup back on-screen if it would spill off an edge.
wxPoint popup_position(const wxSize& size)
{
wxWindow* ref = wxGetApp().plater();
if (!ref)
ref = wxGetApp().mainframe;
const wxRect plater = ref ? ref->GetScreenRect() : wxDisplay(0u).GetClientArea();
int disp = wxDisplay::GetFromPoint(wxPoint(plater.GetLeft() + plater.GetWidth() / 2,
plater.GetTop() + plater.GetHeight() / 2));
if (disp == wxNOT_FOUND)
disp = 0;
const wxRect screen = wxDisplay(disp).GetClientArea();
const int x = plater.GetLeft() + (plater.GetWidth() - size.GetWidth()) / 2; // centred over plater
const int y = screen.GetTop() + screen.GetHeight() / 3; // top at 1/3 screen height
return wxPoint(x, y);
}
int json_int_or(const nlohmann::json& j, const char* key, int fallback)
{
auto it = j.find(key);
return it != j.end() && it->is_number() ? it->get<int>() : fallback;
}
wxColour bg_color() { return wxGetApp().get_window_default_clr(); }
std::string css_color(const wxColour& c) { return c.GetAsString(wxC2S_HTML_SYNTAX).ToStdString(); }
std::string host_theme_name() { return wxGetApp().dark_mode() ? "dark" : "light"; }
std::string host_theme_vars_css()
{
GUI_App& app = wxGetApp();
const wxColour bg = app.get_window_default_clr();
const wxColour fg = app.get_label_clr_default();
const wxColour muted = app.get_label_clr_sys();
const wxColour border = app.get_highlight_default_clr();
const wxColour accent = StateColor::darkModeColorFor(wxColour("#009688"));
std::string font = app.normal_font().GetFaceName().ToStdString();
font.erase(std::remove_if(font.begin(), font.end(), [](char c) {
return c == '\'' || c == '"' || c == '<' || c == '>' || c == '{' || c == '}' || c == ';';
}), font.end());
std::string s;
s += ":root{";
s += "--orca-bg:" + css_color(bg) + ";";
s += "--orca-fg:" + css_color(fg) + ";";
s += "--orca-muted:" + css_color(muted) + ";";
s += "--orca-border:" + css_color(border) + ";";
s += "--orca-accent:" + css_color(accent) + ";";
s += "--orca-accent-fg:#ffffff;";
s += "--orca-font:" + (font.empty() ? std::string() : "'" + font + "',") +
"system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;";
s += "color-scheme:" + host_theme_name() + ";";
s += "}";
return s;
}
std::string host_theme_user_script()
{
const std::string style = "<style id=\"orca-host-theme-vars\">" + host_theme_vars_css() + "</style>";
return WebViewHostDialog::document_start_injector(
style, "orca-host-theme-vars", "afterbegin", "window.__orcaHostThemed=true;var theme=\"" + host_theme_name() + "\";",
"if(document.documentElement)document.documentElement.setAttribute('data-orca-theme',theme);");
}
std::string host_theme_apply_js()
{
const std::string vars_literal = nlohmann::json(host_theme_vars_css()).dump();
const std::string theme = host_theme_name();
return "(function(){var css=" + vars_literal + ";var theme=\"" + theme + "\";" + R"JS(
var el=document.getElementById('orca-host-theme-vars');
if(el){el.textContent=css;}
else if(document.head){document.head.insertAdjacentHTML('afterbegin','<style id="orca-host-theme-vars"></style>');var e2=document.getElementById('orca-host-theme-vars');if(e2)e2.textContent=css;}
if(document.documentElement)
document.documentElement.setAttribute('data-orca-theme',theme);
})();)JS";
}
class SpeedDialWebPopup : public ::PopupWindow
{
public:
explicit SpeedDialWebPopup(wxWindow* parent)
: ::PopupWindow(parent, wxBORDER_NONE | wxPU_CONTAINS_CONTROLS)
{
SetBackgroundColour(bg_color());
auto* sizer = new wxBoxSizer(wxVERTICAL);
m_browser = WebView::CreateWebView(this, wxEmptyString);
if (m_browser) {
m_browser->AddUserScript(wxString::FromUTF8(host_theme_user_script()));
m_browser->Bind(EVT_WEBVIEW_RECREATED, &SpeedDialWebPopup::on_webview_recreated, this);
sizer->Add(m_browser, wxSizerFlags().Expand().Proportion(1));
Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, &SpeedDialWebPopup::on_script_message, this, m_browser->GetId());
} else {
auto* fallback = new wxStaticText(this, wxID_ANY, wxS("wxWebView unavailable"));
sizer->Add(fallback, wxSizerFlags().Border(wxALL, 20));
}
SetSizer(sizer);
SetClientSize(FromDIP(wxSize(kPopupWidth, kPopupMaxHeight))); // DIP -> physical (see resize_to_content)
if (m_browser)
WebView::LoadUrl(m_browser, dialog_url());
}
void request_show()
{
if (IsShown()) {
focus_browser();
return;
}
m_pending_show = true;
if (m_browser && m_page_ready)
send_actions();
else if (!m_browser)
show_ready();
}
void focus_browser()
{
if (m_browser)
m_browser->SetFocus();
}
private:
void OnDismiss() override {}
void on_script_message(wxWebViewEvent& event)
{
nlohmann::json p = nlohmann::json::parse(event.GetString().utf8_string(), nullptr, false);
if (!p.is_object())
return;
const std::string cmd = p.value("command", "");
if (cmd == "request_actions") {
m_page_ready = true;
send_actions();
}
else if (cmd == "toggle_favourite") toggle_favourite(p.value("id", ""), p.value("fav", false));
else if (cmd == "run_action") run_action(p.value("id", ""), p.value("title", ""));
else if (cmd == "resize") resize_to_content(json_int_or(p, "height", 0));
else if (cmd == "close_page") Dismiss();
}
void on_webview_recreated(wxCommandEvent&)
{
apply_host_theme();
}
void apply_host_theme()
{
if (m_browser)
WebView::RunScript(m_browser, wxString::FromUTF8(host_theme_apply_js()));
}
void resize_to_content(int height)
{
if (height <= 0)
return;
// why: HTML is the source of truth - size the popup to the measured content height so
// the window always contains the WebView and never clips. `height` arrives in CSS px
// (DIP); wx SetClientSize wants physical px, so on a scaled display (e.g. 125%) applying
// DIP as physical shrinks the popup below the content and clips it. Clamp in DIP, then
// FromDIP -> physical. The only ceiling is a screen fraction; normal growth is already
// bounded by the list's own max-height.
int disp = wxDisplay::GetFromPoint(IsShown() ? GetPosition() : wxGetMousePosition());
if (disp == wxNOT_FOUND)
disp = 0;
const int screen_dip = ToDIP(wxDisplay(disp).GetClientArea().GetHeight());
const int max_dip = std::max(kPopupMinHeight, screen_dip * 85 / 100);
const int height_dip = std::max(kPopupMinHeight, std::min(height, max_dip));
SetClientSize(FromDIP(wxSize(kPopupWidth, height_dip)));
Layout();
if (m_pending_show)
show_ready();
else if (IsShown())
SetPosition(clamped_position(GetPosition(), GetSize()));
}
void show_ready()
{
if (!m_pending_show)
return;
SetPosition(clamped_position(popup_position(GetSize()), GetSize()));
Popup();
focus_browser();
m_pending_show = false;
}
// Config readers/writer for the persisted dial state (favourites, run stats, ask suppression).
std::vector<std::string> read_favourites() const
{
auto j = parse_config_json(wxGetApp().app_config->get(kConfigSection, "favourites"), nlohmann::json::array());
std::vector<std::string> v;
for (auto& e : j)
if (e.is_string())
v.push_back(e.get<std::string>());
return v;
}
nlohmann::json read_stats() const
{
return parse_config_json(wxGetApp().app_config->get(kConfigSection, "stats"), nlohmann::json::object());
}
std::vector<std::string> read_ask_suppressed() const
{
auto j = parse_config_json(wxGetApp().app_config->get(kConfigSection, "ask_suppressed"), nlohmann::json::array());
std::vector<std::string> v;
for (auto& e : j)
if (e.is_string())
v.push_back(e.get<std::string>());
return v;
}
static void write_json(const char* key, const nlohmann::json& j) { wxGetApp().app_config->set(kConfigSection, key, j.dump()); }
void toggle_favourite(const std::string& id, bool fav)
{
auto favs = read_favourites();
auto it = std::find(favs.begin(), favs.end(), id);
if (fav && it == favs.end())
favs.push_back(id);
if (!fav && it != favs.end())
favs.erase(it);
write_json("favourites", nlohmann::json(favs));
}
void suppress_ask_for(const std::string& key)
{
auto arr = read_ask_suppressed();
if (std::find(arr.begin(), arr.end(), key) == arr.end())
arr.push_back(key);
write_json("ask_suppressed", nlohmann::json(arr));
}
// id == "script:<key>::<cap>". Hide FIRST (scripts may open their own modals that must not stack
// under this launcher), gate on a native run-confirm unless suppressed, then run on the next tick.
void run_action(const std::string& id, const std::string& title)
{
const std::string prefix = "script:";
if (id.rfind(prefix, 0) != 0)
return;
auto body = id.substr(prefix.size());
auto sep = body.find("::");
if (sep == std::string::npos)
return;
std::string key = body.substr(0, sep), cap = body.substr(sep + 2);
auto suppressed = read_ask_suppressed();
const bool ask = std::find(suppressed.begin(), suppressed.end(), key) == suppressed.end();
Dismiss(); // launcher gone before the modal / the script's own UI
if (ask) {
const wxString label = title.empty() ? from_u8(cap) : from_u8(title);
RichMessageDialog dlg(wxGetApp().mainframe, wxString::Format(_L("Run \"%s\"?"), label),
_L("Run plugin"), wxOK | wxCANCEL);
dlg.ShowCheckBox(_L("Don't ask again for this plugin"));
if (dlg.ShowModal() != wxID_OK)
return;
if (dlg.IsCheckBoxChecked())
suppress_ask_for(key);
}
// why: value-capture only. Script execution may outlive this popup if the app is closing.
wxGetApp().CallAfter([id, key, cap] {
ScriptRunOutcome o = run_script_plugin_capability(key, cap);
if (o.level == ScriptRunOutcome::Level::Busy)
return;
bump_frecency(id);
if (!o.message.IsEmpty())
wxGetApp().plater()->get_notification_manager()->push_notification(
NotificationType::CustomNotification,
o.level == ScriptRunOutcome::Level::Error ? NotificationManager::NotificationLevel::ErrorNotificationLevel :
NotificationManager::NotificationLevel::RegularNotificationLevel,
into_u8(o.message));
});
}
// Frecency ordering so the page shows most-reached-for first; ties broken alphabetically by title.
void sort_by_frecency(nlohmann::json& actions) const
{
nlohmann::json stats = read_stats();
long long now = (long long) std::time(nullptr);
std::stable_sort(actions.begin(), actions.end(), [&](const nlohmann::json& a, const nlohmann::json& b) {
auto sc = [&](const nlohmann::json& x) {
auto it = stats.find(x["id"].get<std::string>());
// why: guard is_object() too - value() throws on a malformed (non-object) stats entry.
if (it == stats.end() || !it->is_object())
return 0.0;
return frecency_score(it->value("count", 0), it->value("last", 0LL), now);
};
double sa = sc(a), sb = sc(b);
if (sa != sb)
return sa > sb;
return a["title"].get<std::string>() < b["title"].get<std::string>();
});
}
// C++ -> JS push. Same escaping as WebViewHostDialog::call_web_handler: the ignore error
// handler keeps a stray non-UTF-8 byte in a plugin name from throwing and aborting the send;
// concatenation (not wxString::Format) avoids a '%' in a title being read as a format token.
void push(const nlohmann::json& j)
{
if (!m_browser)
return;
const wxString payload = wxString::FromUTF8(j.dump(-1, ' ', false, nlohmann::json::error_handler_t::ignore));
WebView::RunScript(m_browser, wxT("window.HandleStudio(") + payload + wxT(")"));
}
// Only runnable actions are offered: enabled AND loaded SCRIPT capabilities. Unloaded or
// invalid ones stay hidden (this transient launcher has no greyed/edit state yet).
nlohmann::json build_actions() const
{
PluginLoader& loader = PluginManager::instance().get_loader();
// plugin_key -> package display name (the row eyebrow); falls back to the key.
std::map<std::string, std::string> pkg_name;
for (const PluginDescriptor& desc : loader.get_all_loaded_plugin_descriptors())
pkg_name[desc.plugin_key] = desc.name;
nlohmann::json arr = nlohmann::json::array();
for (const auto& cap : loader.get_plugin_capabilities_by_type(PluginCapabilityType::Script)) {
if (!cap || !cap->enabled)
continue;
if (!loader.is_plugin_loaded(cap->plugin_key))
continue;
const auto it = pkg_name.find(cap->plugin_key);
arr.push_back({{"id", action_id(cap->plugin_key, cap->name)},
{"title", cap->name.empty() ? cap->plugin_key : cap->name},
{"pkg", (it != pkg_name.end() && !it->second.empty()) ? it->second : cap->plugin_key},
{"runnable", true},
{"shortcut", ""}});
}
return arr;
}
void send_actions()
{
nlohmann::json actions = build_actions();
sort_by_frecency(actions);
push({{"command", "list_actions"},
{"actions", std::move(actions)},
{"favourites", read_favourites()}});
}
wxWebView* m_browser{nullptr};
bool m_page_ready{false};
bool m_pending_show{false};
};
}
void open_speed_dial_popup()
{
wxWindow* parent = wxGetApp().mainframe;
if (!parent)
return;
static SpeedDialWebPopup* popup = nullptr;
if (!popup) {
popup = new SpeedDialWebPopup(parent);
popup->Bind(wxEVT_DESTROY, [](wxWindowDestroyEvent&) { popup = nullptr; });
}
popup->request_show();
}
}}

View File

@@ -0,0 +1,12 @@
#ifndef slic3r_GUI_SpeedDialDialog_hpp_
#define slic3r_GUI_SpeedDialDialog_hpp_
#include <wx/string.h>
namespace Slic3r { namespace GUI {
void open_speed_dial_popup();
}}
#endif

View File

@@ -222,4 +222,4 @@ void CheckList::ShowMenu(wxMouseEvent &evt)
wxPoint screen_pos = src->ClientToScreen(evt.GetPosition());
wxPoint local_pos = ScreenToClient(screen_pos);
PopupMenu(&m, local_pos);
}
}