diff --git a/resources/web/dialog/SpeedDial/index.html b/resources/web/dialog/SpeedDial/index.html new file mode 100644 index 0000000000..0a3b9f6947 --- /dev/null +++ b/resources/web/dialog/SpeedDial/index.html @@ -0,0 +1,30 @@ + + + + + + Speed Dial + + + + + + +
+ +
+
+ +
+
+
+ + diff --git a/resources/web/dialog/SpeedDial/speeddial.js b/resources/web/dialog/SpeedDial/speeddial.js new file mode 100644 index 0000000000..60652463a5 --- /dev/null +++ b/resources/web/dialog/SpeedDial/speeddial.js @@ -0,0 +1,395 @@ +// Speed Dial launcher page. Static-safe module: no DOM access at load time so a +// node vm can exercise the pure helpers (filterActions / parseId / nextSel). + +// ---- state (populated by the C++ bridge via window.HandleStudio) ---- +var ACTIONS = []; // [{id,title,pkg,runnable,shortcut}], already frecency-sorted by C++ +var FAVS = []; // [id...] +var query = ""; +var sel = { zone: "list", i: 0 }; // zone: 'list' | 'fav' +var lastResizeHeight = 0; +var matchIndex = {}; + +function FoldChar(ch) { + return ch.normalize("NFD").replace(/\p{Diacritic}/gu, ""); +} + +function NormChar(ch) { + return FoldChar(ch).toLowerCase(); +} + +// element handles, assigned in OnInit (kept null so load-time touches no DOM) +var qEl = null, listEl = null, favEl = null, clearEl = null, eyeEl = null; + +// ---- pure helpers (no DOM; unit-tested) ------------------------------------- +function fuzzyRanges(text, query) { + var t = text || ""; + var q = query || ""; + if (!q) + return null; + var needle = Array.from(q).map(function (c) { return NormChar(c); }).join(""); + var ranges = []; + var qi = 0; + for (var i = 0; i < t.length && qi < needle.length; i++) { + if (NormChar(t[i]) === needle[qi]) { + if (ranges.length && ranges[ranges.length - 1][1] === i) + ranges[ranges.length - 1][1] = i + 1; + else + ranges.push([i, i + 1]); + qi++; + } + } + return qi === needle.length ? ranges : null; +} + +function filterActions(actions, query) { + var q = (query || "").trim(); + var list = actions || []; + matchIndex = {}; + if (!q) + return list.slice(0); + + var out = []; + for (var i = 0; i < list.length; i++) { + var a = list[i]; + var titleMatch = fuzzyRanges(a.title, q); + var pkgMatch = fuzzyRanges(a.pkg, q); + if (!titleMatch && !pkgMatch) + continue; + matchIndex[a.id] = { title: titleMatch, pkg: pkgMatch, useTitle: !!titleMatch }; + out.push(a); + } + return out; +} + +// why: ids are "script:::"; C++ keys off for dont-ask scoping. +function parseId(id) { + var body = id.slice(id.indexOf(":") + 1); // strip "script:" + var sep = body.indexOf("::"); + return { key: body.slice(0, sep), cap: body.slice(sep + 2) }; +} + +function visibleFavourites(favourites, actions) { + var seen = {}; + (actions || []).forEach(function (a) { seen[a.id] = true; }); + return (favourites || []).filter(function (id, i, arr) { + return seen[id] && arr.indexOf(id) === i; + }); +} + +function syncClearButton() { + if (clearEl) + clearEl.hidden = !query; +} + +function stateFromPayload(payload) { + return { + actions: payload.actions || [], + favourites: payload.favourites || [], + query: "", + sel: { zone: "list", i: 0 }, + lastResizeHeight: 0 + }; +} + +function resetScrollPositions(list, doc) { + if (list) + list.scrollTop = 0; + if (doc && doc.scrollingElement) + doc.scrollingElement.scrollTop = 0; + if (doc && doc.documentElement) + doc.documentElement.scrollTop = 0; + if (doc && doc.body) + doc.body.scrollTop = 0; +} + +// nextSel: pure arrow-nav transition. Down fav->list0; Down list->clamp; Up list@0->fav0; +// Up list->i-1; Left/Right clamp within fav. Returns a fresh {zone,i}. +function nextSel(sel, key, listLen, favLen) { + var zone = sel.zone, i = sel.i; + if (key === "ArrowDown") { + if (zone === "fav") return { zone: "list", i: 0 }; + return { zone: "list", i: Math.min(i + 1, Math.max(0, listLen - 1)) }; + } + if (key === "ArrowUp") { + if (zone === "list") { + if (i <= 0) return favLen ? { zone: "fav", i: 0 } : { zone: "list", i: 0 }; + return { zone: "list", i: i - 1 }; + } + return { zone: zone, i: i }; + } + if (key === "ArrowLeft" && zone === "fav") return { zone: "fav", i: Math.max(0, i - 1) }; + if (key === "ArrowRight" && zone === "fav") return { zone: "fav", i: Math.min(favLen - 1, i + 1) }; + return { zone: zone, i: i }; +} + +// ---- bridge ------------------------------------------------------------------ +function SendMessage(msg) { + if (typeof SendWXMessage !== "function") + return; + if (typeof msg === "string") msg = { command: msg }; + if (msg.sequence_id === undefined) msg.sequence_id = Date.now(); + SendWXMessage(JSON.stringify(msg)); +} + +// C++ pushes payloads here. Only list_actions is handled; it (re)seeds all state. +window.HandleStudio = function (payload) { + if (!payload) return; + if (typeof payload === "string") { try { payload = JSON.parse(payload); } catch (e) { return; } } + if (payload.command === "list_actions") { + var next = stateFromPayload(payload); + ACTIONS = next.actions; + FAVS = next.favourites; + query = next.query; + sel = next.sel; + lastResizeHeight = next.lastResizeHeight; + if (qEl) { + qEl.value = ""; + syncClearButton(); + } + render({ resize: true, resetScroll: true }); + focusInput(); + } +}; + +// ---- DOM helpers ------------------------------------------------------------- +function $(id) { return document.getElementById(id); } + +function byId(id) { + for (var i = 0; i < ACTIONS.length; i++) if (ACTIONS[i].id === id) return ACTIONS[i]; + return null; +} + +function currentVisibleFavs() { return visibleFavourites(FAVS, ACTIONS); } + +function hue(id) { + var h = 0; + for (var i = 0; i < id.length; i++) + h = (h * 31 + id.charCodeAt(i)) >>> 0; + return h % 360; +} + +// Build a
with the search-match ranges wrapped in . Used for both the +// title and the pkg eyebrow. Pure (only touches the document factory), so the node-vm test never +// calls it and load-time stays DOM-free. +function markedText(className, text, match) { + var node = document.createElement("div"); + node.className = className; node.title = text; + if (!match || !match.length) { + node.textContent = text; + return node; + } + var last = 0; + for (var i = 0; i < match.length; i++) { + var range = match[i]; + if (range[0] > last) + node.appendChild(document.createTextNode(text.slice(last, range[0]))); + var m = document.createElement("mark"); + m.textContent = text.slice(range[0], range[1]); + node.appendChild(m); + last = range[1]; + } + if (last < text.length) + node.appendChild(document.createTextNode(text.slice(last))); + return node; +} + +function starSvg(on) { + return '' + + ''; +} + +// ---- render ------------------------------------------------------------------ +function renderFav() { + favEl.innerHTML = ""; + var favs = currentVisibleFavs(); + favEl.hidden = favs.length === 0; + if (!favs.length && sel.zone === "fav") + sel = { zone: "list", i: 0 }; + else if (sel.zone === "fav") + sel.i = Math.max(0, Math.min(sel.i, favs.length - 1)); + updateFavEyebrow(favs); + favs.forEach(function (id, i) { + var a = byId(id); + var tile = document.createElement("button"); + tile.className = "fav-tile" + (sel.zone === "fav" && sel.i === i ? " sel" : ""); + tile.style.setProperty("--h", hue(id)); + tile.textContent = a.title.charAt(0).toUpperCase(); + tile.title = a.title; + tile.onclick = function () { sel = { zone: "fav", i: i }; run(a); }; + favEl.appendChild(tile); + }); +} + +// Name of the selected favourite, shown above the bar; hidden unless a fav is selected. +function updateFavEyebrow(favs) { + if (!eyeEl) return; + var a = sel.zone === "fav" && favs.length ? byId(favs[sel.i]) : null; + eyeEl.textContent = a ? a.title : ""; + eyeEl.hidden = !a; +} + +function renderList() { + var q = (query || "").trim(); + var arr = filterActions(ACTIONS, query); + if (sel.zone === "list") + sel.i = Math.max(0, Math.min(sel.i, arr.length - 1)); + listEl.innerHTML = ""; + // why: search-first - the list stays blank until the user types; only a + // non-empty query with zero hits earns the "No actions match" message. + if (!q) { + listEl.className = "dial-list empty"; + return; + } + listEl.className = "dial-list" + (!arr.length ? " empty" : ""); + if (!arr.length) { + var empty = document.createElement("div"); + empty.className = "dial-empty"; + empty.textContent = "No actions match"; + listEl.appendChild(empty); + return; + } + arr.forEach(function (a, i) { + var on = FAVS.indexOf(a.id) !== -1; + var row = document.createElement("div"); + row.className = "row" + (sel.zone === "list" && sel.i === i ? " sel" : "") + (a.runnable === false ? " disabled" : ""); + + var tile = document.createElement("div"); + tile.className = "tile"; + tile.style.setProperty("--h", hue(a.id)); + tile.textContent = a.title.charAt(0).toUpperCase(); + + var left = document.createElement("div"); + left.className = "row-left"; + var mi = matchIndex[a.id]; + var pkg = markedText("row-eyebrow", a.pkg, mi ? mi.pkg : null); + var line = document.createElement("div"); + line.className = "row-line"; + var name = markedText("row-name", a.title, mi ? mi.title : null); + line.appendChild(name); + if (a.shortcut) { + var sc = document.createElement("div"); + sc.className = "row-sc"; + a.shortcut.split("+").forEach(function (k) { + var key = document.createElement("kbd"); + key.textContent = k; + sc.appendChild(key); + }); + line.appendChild(sc); + } + left.appendChild(pkg); + left.appendChild(line); + row.appendChild(tile); + row.appendChild(left); + + if (a.runnable !== false) { + var star = document.createElement("button"); + star.className = "star" + (on ? " on" : ""); + star.innerHTML = starSvg(on); + star.title = on ? "Unpin from favourites" : "Pin to favourites"; + star.onclick = function (ev) { ev.stopPropagation(); toggleFav(a.id); }; + // why: two quick fav/unfav clicks must not dblclick-run the row + star.ondblclick = function (ev) { ev.stopPropagation(); }; + row.appendChild(star); + } + + row.onclick = function () { sel = { zone: "list", i: i }; render(); }; + row.ondblclick = function () { sel = { zone: "list", i: i }; run(a); }; + listEl.appendChild(row); + }); +} + +function render(opts) { + renderFav(); + renderList(); + scrollSelectedIntoView(); + if (opts && opts.resetScroll) + resetScrollPositions(listEl, document); + if (opts && opts.resize) + requestResize(); +} + +// Keep the selected item in view as arrows move it: the list scrolls vertically, the fav bar +// horizontally (arrow nav "pushes" the scrollable fav row to follow the selection). +function scrollSelectedIntoView() { + var el = null; + if (sel.zone === "list" && listEl) + el = listEl.querySelector(".row.sel"); + else if (sel.zone === "fav" && favEl) + el = favEl.querySelector(".fav-tile.sel"); + if (el && el.scrollIntoView) + el.scrollIntoView({ block: "nearest", inline: "nearest" }); +} + +function requestResize() { + if (!document.body) + return; + setTimeout(function () { + var launcher = document.querySelector(".launcher"); + if (!launcher) + return; + var height = Math.ceil(launcher.getBoundingClientRect().height); + if (!height || height === lastResizeHeight) + return; + lastResizeHeight = height; + SendMessage({ command: "resize", height: height }); + }, 0); +} + +// ---- actions ----------------------------------------------------------------- +function toggleFav(id) { + var k = FAVS.indexOf(id); + var newState = k === -1; + if (newState) FAVS.push(id); else FAVS.splice(k, 1); + SendMessage({ command: "toggle_favourite", id: id, fav: newState }); + render({ resize: true }); +} + +// Fire the action; C++ owns the run-confirm (native dialog) + suppression, then closes the popup + toasts. +function run(a) { + if (!a || a.runnable === false) return; + SendMessage({ command: "run_action", id: a.id, title: a.title }); +} + +function runSelected() { + if (sel.zone === "fav") { var id = currentVisibleFavs()[sel.i]; if (id) run(byId(id)); return; } + var a = filterActions(ACTIONS, query)[sel.i]; + if (a) run(a); +} + +function focusInput() { setTimeout(function () { qEl.focus(); }, 0); } + +// ---- init -------------------------------------------------------------------- +function OnInit() { + qEl = $("q"); listEl = $("list"); favEl = $("favBar"); clearEl = $("clear"); eyeEl = $("favEyebrow"); + syncClearButton(); + + $("clear").onclick = function () { + query = ""; qEl.value = ""; sel = { zone: "list", i: 0 }; render({ resize: true, resetScroll: true }); qEl.focus(); + syncClearButton(); + }; + qEl.addEventListener("input", function () { + query = qEl.value; sel = { zone: "list", i: 0 }; syncClearButton(); render({ resize: true, resetScroll: true }); + }); + + document.addEventListener("keydown", function (e) { + var arr = filterActions(ACTIONS, query); + var favs = currentVisibleFavs(); + if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "ArrowLeft" || e.key === "ArrowRight") { + e.preventDefault(); + sel = nextSel(sel, e.key, arr.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. + render({ resize: true }); + } else if (e.key === "Enter") { + e.preventDefault(); + runSelected(); + } else if (e.key === "Escape") { + e.preventDefault(); + if (query) { query = ""; qEl.value = ""; sel = { zone: "list", i: 0 }; syncClearButton(); render({ resize: true, resetScroll: true }); } + else SendMessage({ command: "close_page" }); + } + }); + + SendMessage({ command: "request_actions" }); +} diff --git a/resources/web/dialog/SpeedDial/style.css b/resources/web/dialog/SpeedDial/style.css new file mode 100644 index 0000000000..5a0ba0f023 --- /dev/null +++ b/resources/web/dialog/SpeedDial/style.css @@ -0,0 +1,185 @@ +* { box-sizing: border-box; } +html, body { margin: 0; } +body { + font-family: var(--orca-font, "Segoe UI", sans-serif); + font-size: 13px; + color: var(--text, var(--orca-fg, #1b1c1e)); + background: var(--bg, var(--orca-bg, #fff)); + overflow: hidden; + user-select: none; +} +.launcher { + display: flex; + flex-direction: column; + background: var(--panel, var(--orca-bg, #fff)); + border: 1px solid var(--border, var(--orca-border, #ddd)); + overflow: hidden; + /* why: no height cap here - the launcher reports its true natural height to C++, which + sizes the popup to match (HTML is source of truth). The list's own max-height is what + bounds growth; capping the launcher would make the measurement circular. */ +} +.fav-bar { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; + padding: 9px 10px; + border-bottom: 1px solid var(--border, var(--orca-border, #ddd)); + overflow-x: auto; /* scroll horizontally once favourites overflow the row */ + scrollbar-width: thin; + /* why: keep scrollIntoView (arrow-nav) from scrolling the first/last tile flush to the edge, + which would clip its selected outline. Matches the 10px horizontal padding. */ + scroll-padding-inline: 10px; +} +.fav-bar[hidden] { display: none; } +/* Name of the selected favourite, above the bar; left-aligned to the tiles' 10px inset. */ +.fav-eyebrow { flex: 0 0 auto; padding: 8px 10px 0; } +.fav-tile { + flex: 0 0 auto; /* keep tiles full-size; don't shrink to fit - scroll instead */ + width: 30px; + height: 30px; + border: 0; + border-radius: 8px; + cursor: pointer; + color: hsl(var(--h) var(--speed-tile-text-s, 72%) var(--speed-tile-text-l, 38%)); + font-weight: 700; + background: var(--speed-tile-bg, #f0f0f0); + border: 1px solid var(--speed-tile-border, #d8d8d8); +} +.fav-tile.sel { outline: 2px solid var(--main-color, var(--orca-accent, #009688)); outline-offset: 2px; } +.dial-head { flex: 0 0 auto; padding: 8px; } +.plugin-search { + display: flex; + align-items: center; + gap: 4px; + height: 30px; + padding: 0 8px; + background: var(--panel, var(--orca-bg, #fff)); + border: 1px solid var(--border, var(--orca-border, #ddd)); + border-radius: 7px; +} +.plugin-search:focus-within { + border-color: var(--main-color, var(--orca-accent, #009688)); + box-shadow: 0 0 0 2px rgba(0,150,136,.25); +} +.plugin-search-icon { display: inline-flex; color: var(--muted, var(--orca-muted, #6b7280)); } +.plugin-search-input { + flex: 1; + min-width: 0; + background: transparent; + border: 0; + outline: 0; + color: var(--text, var(--orca-fg, #1b1c1e)); + font: inherit; +} +.plugin-search-clear { + display: inline-flex; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; + padding: 0; + border: 0; + border-radius: 50%; + color: var(--muted, var(--orca-muted, #6b7280)); + cursor: pointer; + background: rgba(127,127,127,.20); +} +.plugin-search-clear[hidden] { display: none; } +.dial-list { + flex: 0 1 auto; + min-height: 0; + /* ADJUST HEIGHT HERE. The popup auto-resizes to the content (HTML is the source of truth), so + this list cap drives the whole window height: --rows full rows + a ~30% peek of the next row + as a "scroll for more" affordance. Bump --rows to show more rows; change 0.3 for a bigger/ + smaller peek. --row-h MUST match .row min-height (44px). */ + --row-h: 44px; + --rows: 5; + max-height: calc(var(--rows) * var(--row-h) + 0.3 * var(--row-h) + 4px); + overflow-y: auto; + padding: 4px 4px 6px; + scrollbar-width: thin; +} +.dial-list.empty { overflow-y: hidden; } +.row { + display: flex; + align-items: center; + gap: 10px; + min-height: 44px; + padding: 6px 8px; + border-radius: 7px; + cursor: pointer; +} +.row:hover { background: var(--row-hover, rgba(127,127,127,.16)); } +.row.sel { background: var(--row-selected, rgba(0,150,136,.18)); } +.tile { + flex: 0 0 auto; + width: 26px; + height: 26px; + border-radius: 6px; + color: hsl(var(--h) var(--speed-tile-text-s, 72%) var(--speed-tile-text-l, 38%)); + font-weight: 700; + font-size: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--speed-tile-bg, #f0f0f0); + border: 1px solid var(--speed-tile-border, #d8d8d8); +} +.row-left { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; } +.row-eyebrow { + font-size: 10px; + line-height: 1.3; + color: var(--muted, var(--orca-muted, #6b7280)); + opacity: .85; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.row-line { display: flex; align-items: center; gap: 7px; min-width: 0; } +.row-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.row-name mark, .row-eyebrow mark { background: var(--plugin-status-warn-bg); color: var(--plugin-status-warn); border-radius: 2px; } +.row-sc { flex: 0 0 auto; display: inline-flex; gap: 3px; } +kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; + font-size: 11px; + color: var(--muted, var(--orca-muted, #6b7280)); + background: rgba(127,127,127,.12); + border: 1px solid var(--border, var(--orca-border, #ddd)); + border-radius: 4px; +} +.row.disabled { opacity: .5; cursor: not-allowed; } +.star { + flex: 0 0 auto; + width: 24px; + height: 24px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--muted, var(--orca-muted, #6b7280)); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + opacity: 0; +} +.star svg { display: block; } +.row:hover .star:not(.on), +.row.sel .star:not(.on) { opacity: .8; } +.star.on { opacity: 1; color: var(--main-color, var(--orca-accent, #009688)); } +.star:hover { background: rgba(127,127,127,.18); } +.dial-empty { + min-height: 64px; + padding: 18px 10px; + display: flex; + align-items: center; + justify-content: center; + color: var(--muted, var(--orca-muted, #6b7280)); + text-align: center; +} diff --git a/resources/web/dialog/css/theme.css b/resources/web/dialog/css/theme.css index 665f0c941a..d2917edfae 100644 --- a/resources/web/dialog/css/theme.css +++ b/resources/web/dialog/css/theme.css @@ -51,6 +51,12 @@ --ctx-bg: #ffffff; --ctx-border: #cccccc; --ctx-hover: #efefef; + + /* Speed dial icon tile treatment */ + --speed-tile-bg: #efefef; + --speed-tile-border: #d8d8d8; + --speed-tile-text-s: 74%; + --speed-tile-text-l: 34%; } :root[data-orca-theme="dark"] { @@ -84,6 +90,12 @@ --ctx-bg: #2a313a; --ctx-border: #4b5664; --ctx-hover: #3a4451; + + /* Speed dial icon tile treatment */ + --speed-tile-bg: #34343b; + --speed-tile-border: #50505a; + --speed-tile-text-s: 82%; + --speed-tile-text-l: 84%; } /* Re-theme the shared common.css chrome through variables (replaces dark.css's diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 647af33174..cb54d39d12 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -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 diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 09335d154c..64da34b10a 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -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); 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': { diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index 72e7f1f8e2..d47193aaec 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -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); // data: +1 => increase, -1 => decrease wxDECLARE_EVENT(EVT_GLCANVAS_INSTANCE_MOVED, SimpleEvent); wxDECLARE_EVENT(EVT_GLCANVAS_FORCE_UPDATE, SimpleEvent); diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index f6e15f8d08..2411d6ee69 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -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; diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 19aeef17b4..48789f3247 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -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. diff --git a/src/slic3r/GUI/KBShortcutsDialog.cpp b/src/slic3r/GUI/KBShortcutsDialog.cpp index 3ee3a93e7d..956ef98933 100644 --- a/src/slic3r/GUI/KBShortcutsDialog.cpp +++ b/src/slic3r/GUI/KBShortcutsDialog.cpp @@ -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 }); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 822ae7bd4c..12f0df30c8 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -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& 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(); }); diff --git a/src/slic3r/GUI/PluginScriptRunner.cpp b/src/slic3r/GUI/PluginScriptRunner.cpp new file mode 100644 index 0000000000..7ef27fd2ff --- /dev/null +++ b/src/slic3r/GUI/PluginScriptRunner.cpp @@ -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 + +#include + +#include + +#include + +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(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(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 diff --git a/src/slic3r/GUI/PluginScriptRunner.hpp b/src/slic3r/GUI/PluginScriptRunner.hpp new file mode 100644 index 0000000000..69c723d8eb --- /dev/null +++ b/src/slic3r/GUI/PluginScriptRunner.hpp @@ -0,0 +1,31 @@ +#ifndef slic3r_PluginScriptRunner_hpp_ +#define slic3r_PluginScriptRunner_hpp_ + +#include + +#include + +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 diff --git a/src/slic3r/GUI/PluginsDialog.cpp b/src/slic3r/GUI/PluginsDialog.cpp index 070bc36a14..1422ffdc49 100644 --- a/src/slic3r/GUI/PluginsDialog.cpp +++ b/src/slic3r/GUI/PluginsDialog.cpp @@ -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 @@ -19,8 +18,6 @@ #include #include -#include - #include #include @@ -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(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(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) diff --git a/src/slic3r/GUI/PluginsDialog.hpp b/src/slic3r/GUI/PluginsDialog.hpp index 55885262a5..fefb09946f 100644 --- a/src/slic3r/GUI/PluginsDialog.hpp +++ b/src/slic3r/GUI/PluginsDialog.hpp @@ -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; diff --git a/src/slic3r/GUI/SpeedDialDialog.cpp b/src/slic3r/GUI/SpeedDialDialog.cpp new file mode 100644 index 0000000000..a9189851ea --- /dev/null +++ b/src/slic3r/GUI/SpeedDialDialog.cpp @@ -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 + +#include +#include + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include + +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() : 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 = ""; + 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','');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 read_favourites() const + { + auto j = parse_config_json(wxGetApp().app_config->get(kConfigSection, "favourites"), nlohmann::json::array()); + std::vector v; + for (auto& e : j) + if (e.is_string()) + v.push_back(e.get()); + return v; + } + nlohmann::json read_stats() const + { + return parse_config_json(wxGetApp().app_config->get(kConfigSection, "stats"), nlohmann::json::object()); + } + std::vector read_ask_suppressed() const + { + auto j = parse_config_json(wxGetApp().app_config->get(kConfigSection, "ask_suppressed"), nlohmann::json::array()); + std::vector v; + for (auto& e : j) + if (e.is_string()) + v.push_back(e.get()); + 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:::". 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()); + // 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() < b["title"].get(); + }); + } + + // 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 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(); +} + +}} diff --git a/src/slic3r/GUI/SpeedDialDialog.hpp b/src/slic3r/GUI/SpeedDialDialog.hpp new file mode 100644 index 0000000000..1f75d8a10f --- /dev/null +++ b/src/slic3r/GUI/SpeedDialDialog.hpp @@ -0,0 +1,12 @@ +#ifndef slic3r_GUI_SpeedDialDialog_hpp_ +#define slic3r_GUI_SpeedDialDialog_hpp_ + +#include + +namespace Slic3r { namespace GUI { + +void open_speed_dial_popup(); + +}} + +#endif diff --git a/src/slic3r/GUI/Widgets/CheckList.cpp b/src/slic3r/GUI/Widgets/CheckList.cpp index cd0dcebff3..b2ea41c99f 100644 --- a/src/slic3r/GUI/Widgets/CheckList.cpp +++ b/src/slic3r/GUI/Widgets/CheckList.cpp @@ -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); -} \ No newline at end of file +}