diff --git a/resources/web/dialog/SpeedDial/speeddial.js b/resources/web/dialog/SpeedDial/speeddial.js index f63e831a9e..c5caf0d423 100644 --- a/resources/web/dialog/SpeedDial/speeddial.js +++ b/resources/web/dialog/SpeedDial/speeddial.js @@ -9,6 +9,7 @@ // - action.input "percent"/"tab" -> NativeCommands catalog (phases handled in activateEntry) // - action.icon SVG base name -> AppAction::icon / resources/images/.svg // - action.desc/wiki -> AppAction::tooltip / help_url (footer detail strip) +// - payload.tooltip_expanded -> ActionRegistry::tooltip_expanded (persisted footer state) // - action list is frecency-sorted -> ActionRegistry::snapshot() // ---- state (populated by the C++ bridge via window.HandleStudio) ---- @@ -17,9 +18,12 @@ var FAVS = []; // [id...] var RECENTS = []; // [{id,title,source,group,kind,input,icon,mode}] - last-N launched var query = ""; var sel = { zone: "list", i: 0 }; // zone: 'list' | 'fav' -var lastResizeHeight = 0; var matchIndex = {}; +// Global tooltip expansion, seeded from C++ (persisted in the speed_dial config section). Collapsing +// hides the footer description + wiki link for every action; the arrow remains to expand again. +var TOOLTIP_EXPANDED = true; + // The user's current settings mode (from the C++ payload) plus the rank order of the modes. Each // action carries the mode it requires, so "would this need a switch?" is a rank comparison. var USER_MODE = "simple"; @@ -548,6 +552,10 @@ function actionHasWiki(a) { return !!(a && a.wiki); } // Whether the action has anything for the footer strip to show (a description or a wiki link). function actionHasDetail(a) { return !!(a && ((a.desc && a.desc.length) || a.wiki)); } +// The expand/collapse arrow is offered for actions that carry a description. Collapsing is a global +// (persisted) preference, so even a short tooltip gets the control. +function detailToggleVisible(a) { return !!(a && a.desc && a.desc.length); } + function foldLabel(s) { return String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, ""); } // Title-case a source for display: "GCODE OPTIMIZER"/"iRoNiNg pRo" -> "Gcode Optimizer"/"Ironing Pro". @@ -634,7 +642,8 @@ function stateFromPayload(payload) { actions: payload.actions || [], favourites: payload.favourites || [], recent: payload.recent || [], - userMode: payload.user_mode || "simple" + userMode: payload.user_mode || "simple", + tooltipExpanded: payload.tooltip_expanded !== false }; } @@ -690,12 +699,12 @@ window.HandleStudio = function (payload) { FAVS = next.favourites; RECENTS = next.recent; USER_MODE = next.userMode; + TOOLTIP_EXPANDED = next.tooltipExpanded; // A fresh payload re-opens the main phase; C++ never rehydrates the transient phase/query state. phase = "commands"; tabOptions = []; query = ""; sel = { zone: "list", i: 0 }; - lastResizeHeight = 0; // why: builtKey caches phase|query so renderCommandsList can skip a rebuild on arrow-nav. // It survives a re-open (which never goes through exitPhase), so without a reset the cached // empty-query key would skip the rebuild and leave stale list content. @@ -1153,23 +1162,26 @@ function currentDetailAction() { return id ? byId(id) : null; } -// Footer detail strip: the selected action's description plus, when it has a wiki page, a link that -// opens it (same path as F1). Shown only when the highlighted action has something to say, so -// selecting a command with no description hides the strip. +// Footer detail strip: the selected action's description, its wiki link and the expand/collapse +// arrow. Shown whenever the highlighted action has a description or a wiki page; expanding is a +// persisted global preference, so the arrow stays available to collapse/expand every tooltip. function renderDetail() { if (!detailEl) return; var a = currentDetailAction(); + var hasDesc = detailToggleVisible(a); var show = phase === "commands" && actionHasDetail(a); detailEl.hidden = !show; detailEl.innerHTML = ""; if (!show) return; - if (a && a.desc) { + if (hasDesc && TOOLTIP_EXPANDED) { var desc = document.createElement("div"); desc.className = "detail-desc"; desc.textContent = a.desc; detailEl.appendChild(desc); } - if (a && a.wiki) { + // The wiki link is part of the expanded detail, so collapsing hides it too. An action with only a + // wiki (no description) has nothing to collapse, so its link always shows. + if (a.wiki && (!hasDesc || TOOLTIP_EXPANDED)) { var link = document.createElement("button"); link.type = "button"; link.className = "detail-wiki"; @@ -1177,6 +1189,25 @@ function renderDetail() { link.onclick = function (ev) { ev.stopPropagation(); SendMessage({ command: "open_wiki", id: a.id }); }; detailEl.appendChild(link); } + if (hasDesc) { + var toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "detail-toggle"; + toggle.setAttribute("aria-expanded", TOOLTIP_EXPANDED ? "true" : "false"); + var label = TOOLTIP_EXPANDED ? T("sd_hide_details", "Hide details") : T("sd_show_details", "Show details"); + toggle.title = label; + toggle.setAttribute("aria-label", label); + toggle.innerHTML = ''; + toggle.onclick = function (ev) { + ev.stopPropagation(); + TOOLTIP_EXPANDED = !TOOLTIP_EXPANDED; + SendMessage({ command: "set_tooltip_expanded", expanded: TOOLTIP_EXPANDED }); + render({ resize: true }); + }; + detailEl.appendChild(toggle); + } } // Refresh the muted inline completion shown at the end of the search field. Only offered in the @@ -1230,10 +1261,14 @@ function requestResize() { var launcher = document.querySelector(".launcher"); if (!launcher) return; - var height = Math.ceil(launcher.getBoundingClientRect().height); - if (!height || height === lastResizeHeight) + // Include any overflow (WebKit can report a -webkit-box border box a fraction short of its + // content), so the window never clips the footer's last line. + var height = Math.ceil(Math.max(launcher.getBoundingClientRect().height, launcher.scrollHeight)); + if (!height) return; - lastResizeHeight = height; + // Always (re)send rather than caching: a resize can be measured but dropped (e.g. while the + // window is being shown) and an unchanged-size cache would then suppress every retry. C++ + // SetClientSize is a no-op on an unchanged size, so this is cheap. SendMessage({ command: "resize", height: height }); }, 0); } @@ -1448,7 +1483,7 @@ function OnInit() { e.preventDefault(); sel = nextSel(sel, e.key, list.length, favs.length); // why: entering/leaving the fav zone toggles the eyebrow line, changing launcher height; - // resize so the popup grows/shrinks instead of clipping. requestResize no-ops when unchanged. + // resize so the popup grows/shrinks instead of clipping. render({ resize: true }); } else if (e.key === "Enter") { e.preventDefault(); @@ -1462,5 +1497,15 @@ function OnInit() { } }); + // Keep the dialog sized to the content: any reflow that lands after a render (tooltip + // expand/collapse or clamped-box settling, font metrics, list reveal) re-measures. Without this + // a later reflow left the window a few pixels short and clipped the footer's last line. + if (typeof ResizeObserver !== "undefined") { + new ResizeObserver(function () { requestResize(); }).observe(document.querySelector(".launcher")); + } + // Font metrics can swap after first layout; re-measure once they settle. + if (document.fonts && document.fonts.ready && document.fonts.ready.then) + document.fonts.ready.then(function () { requestResize(); }); + SendMessage({ command: "request_actions" }); } diff --git a/resources/web/dialog/SpeedDial/speeddial.test.js b/resources/web/dialog/SpeedDial/speeddial.test.js index 33d7d0e17f..03099df91b 100644 --- a/resources/web/dialog/SpeedDial/speeddial.test.js +++ b/resources/web/dialog/SpeedDial/speeddial.test.js @@ -422,4 +422,18 @@ assert.equal(ctx.actionHasDetail({ id: "a", desc: "", wiki: false }), false, "em assert.equal(ctx.actionHasDetail({ id: "a" }), false, "an action with neither hides the footer"); assert.equal(ctx.actionHasDetail(null), false, "no selected action hides the footer"); +// detailToggleVisible: the expand/collapse arrow is offered only when there is a description to +// toggle. Collapse is a global preference, so the control stays for short tooltips too. +assert.equal(ctx.detailToggleVisible({ id: "a", desc: "Layer height" }), true, "a description offers the toggle"); +assert.equal(ctx.detailToggleVisible({ id: "a", desc: "" }), false, "an empty description offers no toggle"); +assert.equal(ctx.detailToggleVisible({ id: "a", wiki: true }), false, "a wiki-only action has nothing to collapse"); +assert.equal(ctx.detailToggleVisible({ id: "a" }), false, "an action with no description offers no toggle"); +assert.equal(ctx.detailToggleVisible(null), false, "no selected action offers no toggle"); + +// stateFromPayload: the footer expansion is a persisted global and defaults to expanded when the +// C++ payload omits it (first run / older config). +assert.equal(ctx.stateFromPayload({}).tooltipExpanded, true, "expansion defaults to true when absent"); +assert.equal(ctx.stateFromPayload({ tooltip_expanded: false }).tooltipExpanded, false, "a collapsed payload is honored"); +assert.equal(ctx.stateFromPayload({ tooltip_expanded: true }).tooltipExpanded, true, "an expanded payload is honored"); + console.log("ok"); diff --git a/resources/web/dialog/SpeedDial/style.css b/resources/web/dialog/SpeedDial/style.css index 0beae39931..3aaeddb460 100644 --- a/resources/web/dialog/SpeedDial/style.css +++ b/resources/web/dialog/SpeedDial/style.css @@ -476,8 +476,8 @@ body { text-align: center; } -/* Footer detail strip: the selected action's description plus its wiki link. Auto-sizes to the - content; the description is clamped below. */ +/* Footer detail strip: the selected action's description, its wiki link and the expand/collapse + arrow. Auto-sizes to the content; the description is clamped to 6 lines. */ .dial-detail { flex: 0 0 auto; display: flex; @@ -500,7 +500,7 @@ body { color: var(--muted, var(--orca-muted, #6b7280)); display: -webkit-box; -webkit-box-orient: vertical; - -webkit-line-clamp: 3; + -webkit-line-clamp: 6; overflow: hidden; overflow-wrap: anywhere; } @@ -520,3 +520,30 @@ body { .detail-wiki:hover { text-decoration: underline; } + +/* Expand/collapse arrow for the tooltip. Pinned right so it stays put whether or not the + description/wiki are visible; the chevron points up (collapse) when expanded. */ +.detail-toggle { + flex: 0 0 auto; + margin-left: auto; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + padding: 2px; + background: transparent; + color: var(--muted, var(--orca-muted, #6b7280)); + cursor: pointer; +} + +.detail-toggle:hover { + color: var(--text, var(--orca-fg, #1b1c1e)); +} + +.detail-toggle svg { + transition: transform .12s ease; +} + +.detail-toggle[aria-expanded="true"] svg { + transform: rotate(180deg); +} diff --git a/src/slic3r/GUI/ActionRegistry.cpp b/src/slic3r/GUI/ActionRegistry.cpp index 880c8e1fba..6dd9b0b542 100644 --- a/src/slic3r/GUI/ActionRegistry.cpp +++ b/src/slic3r/GUI/ActionRegistry.cpp @@ -726,6 +726,19 @@ void ActionRegistry::suppress_ask(const std::string& id) write_section("ask_suppressed", nlohmann::json(arr)); } +bool ActionRegistry::tooltip_expanded() const +{ + assert(wxThread::IsMain()); + const nlohmann::json j = read_section("tooltip_expanded", nlohmann::json(true)); + return j.is_boolean() ? j.get() : true; +} + +void ActionRegistry::set_tooltip_expanded(bool expanded) +{ + assert(wxThread::IsMain()); + write_section("tooltip_expanded", nlohmann::json(expanded)); +} + // ---- snapshot --------------------------------------------------------------- nlohmann::json ActionRegistry::snapshot() @@ -810,7 +823,8 @@ nlohmann::json ActionRegistry::snapshot() return {{"actions", std::move(actions)}, {"favourites", std::move(favourites)}, {"recent", std::move(recent_json)}, - {"user_mode", mode_key(wxGetApp().get_mode())}}; + {"user_mode", mode_key(wxGetApp().get_mode())}, + {"tooltip_expanded", tooltip_expanded()}}; } // ---- tab options (enumerate the MainFrame notebook's current pages) ---------- diff --git a/src/slic3r/GUI/ActionRegistry.hpp b/src/slic3r/GUI/ActionRegistry.hpp index b192a8ccb2..61cdbffd25 100644 --- a/src/slic3r/GUI/ActionRegistry.hpp +++ b/src/slic3r/GUI/ActionRegistry.hpp @@ -196,6 +196,11 @@ public: bool should_ask(const std::string& id) const; void suppress_ask(const std::string& id); + // Footer expand/collapse preference. Global (applies to every action) and persisted; absent + // means expanded, so a fresh config picks the richer default with no migration. + bool tooltip_expanded() const; + void set_tooltip_expanded(bool expanded); + // Flat, frecency-sorted snapshot for the webview: // {actions:[...], favourites:[...], recent:[...]} (recent = last-N launched by recency). nlohmann::json snapshot(); diff --git a/src/slic3r/GUI/GUI_Utils.hpp b/src/slic3r/GUI/GUI_Utils.hpp index 85790516ee..dae0305f1d 100644 --- a/src/slic3r/GUI/GUI_Utils.hpp +++ b/src/slic3r/GUI/GUI_Utils.hpp @@ -478,6 +478,8 @@ int get_dpi_for_window(const wxWindow *window); #ifdef __WXOSX__ void dataview_remove_insets(wxDataViewCtrl* dv); void staticbox_remove_margin(wxStaticBox* sb); +// Clip a top-level window (and its webview) to a rounded rect with a native layer. +void set_window_corner_radius(wxWindow* win, int radius); #endif #ifdef __WXGTK__ diff --git a/src/slic3r/GUI/GUI_UtilsMac.mm b/src/slic3r/GUI/GUI_UtilsMac.mm index 01501f6265..453229e429 100644 --- a/src/slic3r/GUI/GUI_UtilsMac.mm +++ b/src/slic3r/GUI/GUI_UtilsMac.mm @@ -1,5 +1,6 @@ #include #include +#import #import #import "GUI_Utils.hpp" @@ -21,6 +22,22 @@ void staticbox_remove_margin(wxStaticBox* sb) { [nativeBox setBorderWidth:0]; } +// wxOSX SetShape only clears the window background; it cannot clip to a region. Clipping the +// window's view layer to a rounded rect is what actually rounds the opaque webview inside. +void set_window_corner_radius(wxWindow* win, int radius) { + if (!win) + return; + NSView* view = (NSView*)win->GetHandle(); + if (!view) + return; + NSWindow* window = [view window]; + [window setOpaque:NO]; + [window setBackgroundColor:[NSColor clearColor]]; + [view setWantsLayer:YES]; + [[view layer] setCornerRadius:radius]; + [[view layer] setMasksToBounds:YES]; +} + bool is_debugger_present() // Returns true if the current process is being debugged (either // running under the debugger or has a debugger attached post facto). diff --git a/src/slic3r/GUI/SpeedDialDialog.cpp b/src/slic3r/GUI/SpeedDialDialog.cpp index 3de0c137bb..7be8bee123 100644 --- a/src/slic3r/GUI/SpeedDialDialog.cpp +++ b/src/slic3r/GUI/SpeedDialDialog.cpp @@ -113,6 +113,8 @@ nlohmann::json speed_dial_ui_strings() {"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")}, }; } @@ -148,8 +150,8 @@ SpeedDialWebDialog::SpeedDialWebDialog(wxWindow* parent) // 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 region whenever layout changes the client size; SetShape itself - // does not generate size events, so this cannot recurse. + // 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(); @@ -224,7 +226,9 @@ void SpeedDialWebDialog::handle_web_command(const nlohmann::json& payload) if (id.is_string()) ids.push_back(id.get()); wxGetApp().action_registry().reorder_favourites(ids); - } else if (command == "run_action") + } 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", "")); @@ -259,34 +263,58 @@ void SpeedDialWebDialog::resize_to_content(int height) 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 -// with a shape region (same mask trick as FilamentPickerDialog). Binary edges, no anti-aliasing. +// 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()) - return; + 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); - 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 - wxRegion region(m_shape_bmp, wxColour(0, 0, 0)); - if (region.IsOk()) - SetShape(region); + m_applying_shape = false; } void SpeedDialWebDialog::on_dpi_changed(const wxRect&) @@ -379,7 +407,8 @@ void SpeedDialWebDialog::send_actions() {"actions", std::move(snap["actions"])}, {"favourites", std::move(snap["favourites"])}, {"recent", std::move(snap["recent"])}, - {"user_mode", std::move(snap["user_mode"])}}); + {"user_mode", std::move(snap["user_mode"])}, + {"tooltip_expanded", std::move(snap["tooltip_expanded"])}}); } }} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/SpeedDialDialog.hpp b/src/slic3r/GUI/SpeedDialDialog.hpp index e7a4060e16..8ae745b446 100644 --- a/src/slic3r/GUI/SpeedDialDialog.hpp +++ b/src/slic3r/GUI/SpeedDialDialog.hpp @@ -31,9 +31,11 @@ private: void on_dpi_changed(const wxRect& suggested_rect) override; bool m_page_ready{false}; - // Rounded corners via a window shape region, since the webview itself is opaque. + // Rounded corners (shape region on GTK/MSW, native layer on macOS), since the webview is opaque. int m_corner_radius{7}; wxBitmap m_shape_bmp; + // wxOSX SetShape resizes the window, which re-enters apply_rounded_shape() through wxEVT_SIZE. + bool m_applying_shape{false}; // Guards the CallAfter in on_script_message across dialog destruction, same as // PluginsDialog::m_alive (PluginsDialog.hpp:249). std::shared_ptr> m_alive = std::make_shared>(true);