#include "SpeedDialDialog.hpp" #include "ActionRegistry.hpp" #include "GUI.hpp" #include "GUI_App.hpp" #include "MainFrame.hpp" #include "MsgDialog.hpp" #include "NotificationManager.hpp" #include "Plater.hpp" #include "Widgets/WebViewHostDialog.hpp" #include #include #include #include #include #include #include #ifdef __linux__ #include #endif namespace Slic3r { namespace GUI { namespace { // ADJUST WIDTH HERE (DIP px). Fixed dialog width; was 360, now 1.5x. Height is not set here - // the dialog 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 dialog hugs content constexpr int kPopupMaxHeight = 282; 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() : fallback; } // Display name of a settings mode, for the mode-switch confirmation. wxString mode_label(ConfigOptionMode mode) { switch (mode) { case comAdvanced: return _L("Advanced"); case comExpert: return _L("Expert"); case comDevelop: return _L("Developer"); default: return _L("Simple"); } } wxColour bg_color() { return wxGetApp().get_window_default_clr(); } // Give the WebKitGTK widget itself input focus, not its GtkScrolledWindow container. // (browser()->SetFocus() grabs focus on the container and doesn't reach the web content, // so typing only works after the user clicks.) On Linux the native backend is the // WebKitWebView widget; grab focus there directly. Elsewhere SetFocus() is correct. void focus_webview(wxWebView* browser, bool page_ready) { if (!browser) return; #ifdef __linux__ if (void* nb = browser->GetNativeBackend()) gtk_widget_grab_focus((GtkWidget*) nb); #else browser->SetFocus(); #endif if (page_ready) browser->RunScript("focusInput();"); } // Localized strings for the Speed Dial page, injected as a document-start user script. The page's // T() reads window.ORCA_UI_STRINGS, so these flow through the same .po pipeline as the rest of the // UI (the JS literals are only a fallback before the script runs / in the node vm test). // %% is a literal '%': T() collapses it after substituting %s. Keep the shortcut tokens out of the // translated text so the platform prefix (Alt+/⌥+, Ctrl+/⌘+) stays correct. nlohmann::json speed_dial_ui_strings() { const std::string alt = GUI::shortkey_alt_prefix(); const std::string ctrl = GUI::shortkey_ctrl_prefix(); return { {"shortcut_alt", alt}, {"shortcut_ctrl", ctrl}, {"sd_search", _u8L("Search actions")}, {"sd_clear", _u8L("Clear")}, {"sd_search_n", _u8L("Search %s actions")}, {"sd_recent", _u8L("Recent")}, {"sd_plugins", _u8L("Plugins")}, {"sd_other", _u8L("Other")}, {"sd_no_match_total", _u8L("No actions match (Total: %s)")}, {"sd_no_actions", _u8L("No actions yet")}, {"sd_no_tabs_match", _u8L("No tabs match")}, {"sd_no_tabs", _u8L("No tabs")}, {"sd_result_count", _u8L("Showing %s of %s actions")}, {"sd_result_count_all", _u8L("%s actions")}, {"sd_tab_count", _u8L("%s tabs")}, {"sd_tab_match_count", _u8L("%s matches")}, {"sd_favs_full", _u8L("Favourites are full (%s max)")}, {"sd_go_to_pct", _u8L("Go to %s%% of the layer range")}, {"sd_enter_pct", _u8L("Enter a layer percentage (0-100)")}, {"sd_go_layer_ph", _u8L("Go to layer %% (0-100)")}, {"sd_go_tab_ph", _u8L("Go to tab")}, {"sd_fav_slot", _u8L("Favourite %s (%s)")}, {"sd_pin_fav", _u8L("Pin to favourites (%s)")}, {"sd_unpin_fav", _u8L("Unpin from favourites (%s)")}, {"sd_remove_fav", _u8L("Remove from favourites")}, {"sd_move_left", _u8L("Move left")}, {"sd_move_right", _u8L("Move right")}, {"sd_unpin", _u8L("Unpin")}, {"sd_mode_advanced", _u8L("Advanced")}, {"sd_mode_expert", _u8L("Expert")}, {"sd_mode_develop", _u8L("Developer")}, {"sd_wiki_f1", _u8L("Wiki (F1)")}, {"sd_no_wiki", _u8L("No wiki page for this action")}, {"sd_show_details", _u8L("Show details")}, {"sd_hide_details", _u8L("Hide details")}, }; } } // namespace SpeedDialWebDialog::SpeedDialWebDialog(wxWindow* parent) : WebViewHostDialog(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE | wxFRAME_NO_TASKBAR | wxFRAME_FLOAT_ON_PARENT | wxFRAME_SHAPED) { SetBackgroundColour(bg_color()); Bind(wxEVT_ACTIVATE, [this](wxActivateEvent& event) { // Focus the WebKit widget exactly when the WM makes the popup the active window // (modeless focus is granted asynchronously, so a focus request made right after // Show() is dropped). Also re-corrects focus on every re-open. if (event.GetActive() && IsShown()) focus_webview(browser(), m_page_ready); else if (!event.GetActive() && IsShown()) Hide(); event.Skip(); }); if (!create_webview("web/dialog/SpeedDial/index.html", wxEmptyString, wxSize(kPopupWidth, kPopupMaxHeight), wxSize(kPopupWidth, kPopupMinHeight))) { auto* sizer = new wxBoxSizer(wxVERTICAL); sizer->Add(new wxStaticText(this, wxID_ANY, wxS("wxWebView unavailable")), wxSizerFlags().Border(wxALL, 20)); SetSizer(sizer); SetClientSize(FromDIP(wxSize(kPopupWidth, kPopupMinHeight))); } // WebView2's browser accelerator keys include Ctrl +/-/0 and Ctrl+wheel zoom, which would resize // the page inside the fixed-size popup. No-op on the other backends (wxWidgets 3.3 base virtual). if (wxWebView* wv = browser()) wv->EnableBrowserAcceleratorKeys(false); // Re-cut the shape whenever layout changes the client size. wxOSX SetShape resizes the // NSWindow, which fires this synchronously; apply_rounded_shape() guards re-entry. Bind(wxEVT_SIZE, [this](wxSizeEvent& event) { event.Skip(); apply_rounded_shape(); }); apply_rounded_shape(); } SpeedDialWebDialog::~SpeedDialWebDialog() { m_alive->store(false, std::memory_order_release); } // Document-start hook: hand the page its translated strings before speeddial.js runs, so the first // paint is already localized. The table is built when the dialog is created; a live language switch // rebuilds the GUI (and with it this dialog), so the next open re-injects the new locale. void SpeedDialWebDialog::add_user_scripts() { if (wxWebView* wv = browser()) { const std::string js = "window.ORCA_UI_STRINGS = " + speed_dial_ui_strings().dump(-1, ' ', false, nlohmann::json::error_handler_t::ignore) + ";"; wv->AddUserScript(wxString::FromUTF8(js)); } } void SpeedDialWebDialog::request_show() { if (IsShown()) { Raise(); focus_webview(browser(), m_page_ready); return; } Show(); Raise(); apply_rounded_shape(); if (m_page_ready) send_actions(); // Grab focus now and again on wxEVT_ACTIVATE; grabbing directly on the WebKit widget is // what makes typing reach the search field immediately on open. focus_webview(browser(), m_page_ready); } void SpeedDialWebDialog::on_script_message(const nlohmann::json& payload) { if (handle_common_script_command(payload)) return; // Defer command handling out of the webview script-message callback: GTK and macOS deliver // it synchronously inside the native webview callback, and window work on that stack is the // crash class fixed in b779a7bfed/f2ccbfc8b5 (see PluginsDialog::on_script_message). // run_action puts a modal confirm on that stack, which is the same bug. wxGetApp().CallAfter([this, alive = m_alive, payload]() { if (alive->load(std::memory_order_acquire)) handle_web_command(payload); }); } void SpeedDialWebDialog::handle_web_command(const nlohmann::json& payload) { const std::string command = payload.value("command", ""); if (command == "request_actions") { m_page_ready = true; send_actions(); } else if (command == "toggle_favourite") { // set_favourite() refuses once the bar hits kFavLimit; tell the page so it can undo the // pin and show a "favourites are full" hint instead of silently losing the favourite. const std::string fav_id = payload.value("id", ""); const bool ok = wxGetApp().action_registry().set_favourite(fav_id, payload.value("fav", false)); if (!ok) call_web_handler({{"command", "favourite_full"}, {"limit", (int) ActionRegistry::kFavLimit}, {"id", fav_id}}); } else if (command == "reorder_favourites") { std::vector ids; if (payload.contains("ids") && payload["ids"].is_array()) for (const auto& id : payload["ids"]) if (id.is_string()) ids.push_back(id.get()); wxGetApp().action_registry().reorder_favourites(ids); } else if (command == "set_tooltip_expanded") wxGetApp().action_registry().set_tooltip_expanded(payload.value("expanded", true)); else if (command == "run_action") run_action(payload.value("id", ""), payload.value("title", ""), payload.value("param", "")); else if (command == "open_wiki") open_wiki(payload.value("id", "")); else if (command == "search_tabs") search_tabs(); else if (command == "resize") resize_to_content(json_int_or(payload, "height", 0)); } void SpeedDialWebDialog::search_tabs() { // Round-trip is async because the webview delivers script messages synchronously on the // GTK/macOS stack; defer the (cheap) enumeration and push the result back to the page. wxGetApp().CallAfter([this, alive = m_alive]() { if (!alive->load(std::memory_order_acquire)) return; auto tabs = wxGetApp().action_registry().tab_options(); call_web_handler({{"command", "tab_results"}, {"tabs", std::move(tabs)}}); }); } void SpeedDialWebDialog::resize_to_content(int height) { if (height <= 0) return; int display_index = wxDisplay::GetFromWindow(this); if (display_index == wxNOT_FOUND) display_index = 0; const int screen_dip = ToDIP(wxDisplay(display_index).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(); #ifdef __WXOSX__ // WKWebView can lag the dialog's new client size; force the viewport to match so the page is // never painted (and clipped by the rounded layer) below the footer. if (wxWebView* wv = browser()) { const wxSize client = GetClientSize(); if (wv->GetSize() != client) wv->SetSize(client); } #endif apply_rounded_shape(); } // Rounded corners: the webview paints an opaque rectangle, so round the whole top-level window. // GTK/MSW use a shape region (same mask trick as FilamentPickerDialog, binary edges, no // anti-aliasing); macOS clips the native view layer instead, since SetShape cannot shape there. void SpeedDialWebDialog::apply_rounded_shape() { // wxOSX SetShape resizes the NSWindow (setContentSize 10x10 then back), which synchronously // fires wxEVT_SIZE -> apply_rounded_shape() -> SetShape() and recurses until the stack // overflows. GTK/MSW set a region without resizing, so they are unaffected. if (m_applying_shape) return; // BORDER_NONE means the window is all client area, so the client size is the shape size. const wxSize size = GetClientSize(); if (size.GetWidth() <= 0 || size.GetHeight() <= 0) return; m_applying_shape = true; #ifdef __WXOSX__ // wxOSX ignores the region (it only clears the window background), so round the native view. set_window_corner_radius(this, FromDIP(m_corner_radius)); #else m_shape_bmp.Create(size.GetWidth(), size.GetHeight(), 32); if (m_shape_bmp.IsOk()) { wxMemoryDC dc; dc.SelectObject(m_shape_bmp); dc.SetBackground(wxBrush(wxColour(0, 0, 0))); dc.Clear(); dc.SetBrush(wxBrush(wxColour(255, 255, 255, 255))); dc.SetPen(*wxTRANSPARENT_PEN); dc.DrawRoundedRectangle(0, 0, size.GetWidth(), size.GetHeight(), FromDIP(m_corner_radius)); dc.SelectObject(wxNullBitmap); wxRegion region(m_shape_bmp, wxColour(0, 0, 0)); if (region.IsOk()) SetShape(region); } #endif m_applying_shape = false; } void SpeedDialWebDialog::on_dpi_changed(const wxRect&) { apply_rounded_shape(); Refresh(); } void SpeedDialWebDialog::run_action(const std::string& id, const std::string& title, const std::string& param) { ActionRegistry& reg = wxGetApp().action_registry(); const AppAction* a = reg.by_id(id); if (!a) return; // Only plugin actions get the "Run plugin?" confirm. Built-in commands act immediately. const bool ask = a->kind == AppActionKind::Plugin && reg.should_ask(id); const std::string atitle = a->title(); const ConfigOptionMode required = a->required_mode; // Settings the current mode hides require a switch first. Ask while the dial is still up; a // cancel dismisses both (the dial also auto-hides when the modal takes activation). if (requires_mode_switch(required, wxGetApp().get_mode())) { const wxString setting = title.empty() ? from_u8(atitle) : from_u8(title); if (required == comDevelop) { RichMessageDialog dlg(wxGetApp().mainframe, wxString::Format(_L("\"%s\" is a Developer setting. Enable Developer mode to edit it?"), setting), _L("Developer setting"), wxOK | wxCANCEL); if (dlg.ShowModal() != wxID_OK) return; wxGetApp().enable_developer_mode(); } else { RichMessageDialog dlg(wxGetApp().mainframe, wxString::Format(_L("\"%s\" is a %s setting. Switch from %s mode to %s mode to edit it?"), setting, mode_label(required), mode_label(wxGetApp().get_mode()), mode_label(required)), _L("Switch settings mode"), wxOK | wxCANCEL); if (dlg.ShowModal() != wxID_OK) return; wxGetApp().save_mode(required); } } if (IsModal()) EndModal(wxID_CANCEL); else Hide(); if (ask) { const wxString label = title.empty() ? from_u8(atitle) : 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 action")); if (dlg.ShowModal() != wxID_OK) return; if (dlg.IsCheckBoxChecked()) wxGetApp().action_registry().suppress_ask(id); } wxGetApp().CallAfter([id, param] { if (wxGetApp().is_closing()) return; AppActionRunResult result = wxGetApp().action_registry().run(id, param); if (result.level == AppActionRunResult::Level::Busy) return; if (!result.message.IsEmpty() && wxGetApp().plater()) wxGetApp() .plater() ->get_notification_manager() ->push_notification(NotificationType::CustomNotification, result.level == AppActionRunResult::Level::Error ? NotificationManager::NotificationLevel::ErrorNotificationLevel : NotificationManager::NotificationLevel::RegularNotificationLevel, into_u8(result.message)); }); } void SpeedDialWebDialog::open_wiki(const std::string& id) { const AppAction* a = wxGetApp().action_registry().by_id(id); if (!a || a->help_url.empty()) return; Hide(); wxLaunchDefaultBrowser(from_u8(a->help_url)); } void SpeedDialWebDialog::send_actions() { nlohmann::json snap = wxGetApp().action_registry().snapshot(); call_web_handler({{"command", "list_actions"}, {"actions", std::move(snap["actions"])}, {"favourites", std::move(snap["favourites"])}, {"recent", std::move(snap["recent"])}, {"user_mode", std::move(snap["user_mode"])}, {"tooltip_expanded", std::move(snap["tooltip_expanded"])}}); } }} // namespace Slic3r::GUI