mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-12 11:37:42 +00:00
Add actions speed dial
This commit is contained in:
30
resources/web/dialog/SpeedDial/index.html
Normal file
30
resources/web/dialog/SpeedDial/index.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Speed Dial</title>
|
||||||
|
<link rel="stylesheet" href="./style.css" />
|
||||||
|
<link rel="stylesheet" type="text/css" href="../css/theme.css" />
|
||||||
|
<script type="text/javascript" src="../../include/globalapi.js"></script>
|
||||||
|
<script src="./speeddial.js"></script>
|
||||||
|
</head>
|
||||||
|
<body onload="OnInit()">
|
||||||
|
<div class="launcher">
|
||||||
|
<div class="row-eyebrow fav-eyebrow" id="favEyebrow" hidden></div>
|
||||||
|
<div class="fav-bar" id="favBar"></div>
|
||||||
|
<div class="dial-head">
|
||||||
|
<div class="plugin-search">
|
||||||
|
<span class="plugin-search-icon" aria-hidden="true">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.5" y2="16.5"/></svg>
|
||||||
|
</span>
|
||||||
|
<input class="plugin-search-input" id="q" type="text" placeholder="Search actions" spellcheck="false" autocomplete="off" aria-label="Search actions" />
|
||||||
|
<button class="plugin-search-clear" id="clear" type="button" title="Clear" aria-label="Clear search" hidden>
|
||||||
|
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><line x1="5" y1="5" x2="11" y2="11"/><line x1="11" y1="5" x2="5" y2="11"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dial-list" id="list"></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
395
resources/web/dialog/SpeedDial/speeddial.js
Normal file
395
resources/web/dialog/SpeedDial/speeddial.js
Normal file
@@ -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:<key>::<cap>"; C++ keys off <key> 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 <div class=className> with the search-match ranges wrapped in <mark>. 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 '<svg width="15" height="15" viewBox="0 0 24 24" fill="' + (on ? "currentColor" : "none") +
|
||||||
|
'" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">' +
|
||||||
|
'<path d="M12 3.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 17.9 6.8 20.6l1-5.8L3.5 9.7l5.9-.9z"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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" });
|
||||||
|
}
|
||||||
185
resources/web/dialog/SpeedDial/style.css
Normal file
185
resources/web/dialog/SpeedDial/style.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -51,6 +51,12 @@
|
|||||||
--ctx-bg: #ffffff;
|
--ctx-bg: #ffffff;
|
||||||
--ctx-border: #cccccc;
|
--ctx-border: #cccccc;
|
||||||
--ctx-hover: #efefef;
|
--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"] {
|
:root[data-orca-theme="dark"] {
|
||||||
@@ -84,6 +90,12 @@
|
|||||||
--ctx-bg: #2a313a;
|
--ctx-bg: #2a313a;
|
||||||
--ctx-border: #4b5664;
|
--ctx-border: #4b5664;
|
||||||
--ctx-hover: #3a4451;
|
--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
|
/* Re-theme the shared common.css chrome through variables (replaces dark.css's
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ set(SLIC3R_GUI_SOURCES
|
|||||||
GUI/PluginPickerDialog.hpp
|
GUI/PluginPickerDialog.hpp
|
||||||
GUI/PluginsDialog.cpp
|
GUI/PluginsDialog.cpp
|
||||||
GUI/PluginsDialog.hpp
|
GUI/PluginsDialog.hpp
|
||||||
|
GUI/PluginScriptRunner.cpp
|
||||||
|
GUI/PluginScriptRunner.hpp
|
||||||
|
GUI/SpeedDialDialog.cpp
|
||||||
|
GUI/SpeedDialDialog.hpp
|
||||||
GUI/ProcessRunner.cpp
|
GUI/ProcessRunner.cpp
|
||||||
GUI/ProcessRunner.hpp
|
GUI/ProcessRunner.hpp
|
||||||
GUI/TerminalDialog.cpp
|
GUI/TerminalDialog.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_CURR_PLATE_ALL, SimpleEvent);
|
||||||
wxDEFINE_EVENT(EVT_GLCANVAS_SELECT_ALL, SimpleEvent);
|
wxDEFINE_EVENT(EVT_GLCANVAS_SELECT_ALL, SimpleEvent);
|
||||||
wxDEFINE_EVENT(EVT_GLCANVAS_QUESTION_MARK, SimpleEvent);
|
wxDEFINE_EVENT(EVT_GLCANVAS_QUESTION_MARK, SimpleEvent);
|
||||||
|
wxDEFINE_EVENT(EVT_GLCANVAS_OPEN_SPEED_DIAL, SimpleEvent);
|
||||||
wxDEFINE_EVENT(EVT_GLCANVAS_INCREASE_INSTANCES, Event<int>);
|
wxDEFINE_EVENT(EVT_GLCANVAS_INCREASE_INSTANCES, Event<int>);
|
||||||
wxDEFINE_EVENT(EVT_GLCANVAS_INSTANCE_MOVED, SimpleEvent);
|
wxDEFINE_EVENT(EVT_GLCANVAS_INSTANCE_MOVED, SimpleEvent);
|
||||||
wxDEFINE_EVENT(EVT_GLCANVAS_INSTANCE_ROTATED, SimpleEvent);
|
wxDEFINE_EVENT(EVT_GLCANVAS_INSTANCE_ROTATED, SimpleEvent);
|
||||||
@@ -3502,6 +3503,11 @@ void GLCanvas3D::on_char(wxKeyEvent& evt)
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case '?': { post_event(SimpleEvent(EVT_GLCANVAS_QUESTION_MARK)); 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':
|
||||||
case 'a':
|
case 'a':
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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_CURR_PLATE_ALL, SimpleEvent);
|
||||||
wxDECLARE_EVENT(EVT_GLCANVAS_SELECT_ALL, SimpleEvent);
|
wxDECLARE_EVENT(EVT_GLCANVAS_SELECT_ALL, SimpleEvent);
|
||||||
wxDECLARE_EVENT(EVT_GLCANVAS_QUESTION_MARK, SimpleEvent);
|
wxDECLARE_EVENT(EVT_GLCANVAS_QUESTION_MARK, SimpleEvent);
|
||||||
|
wxDECLARE_EVENT(EVT_GLCANVAS_OPEN_SPEED_DIAL, SimpleEvent);
|
||||||
wxDECLARE_EVENT(EVT_GLCANVAS_INCREASE_INSTANCES, Event<int>); // data: +1 => increase, -1 => decrease
|
wxDECLARE_EVENT(EVT_GLCANVAS_INCREASE_INSTANCES, Event<int>); // data: +1 => increase, -1 => decrease
|
||||||
wxDECLARE_EVENT(EVT_GLCANVAS_INSTANCE_MOVED, SimpleEvent);
|
wxDECLARE_EVENT(EVT_GLCANVAS_INSTANCE_MOVED, SimpleEvent);
|
||||||
wxDECLARE_EVENT(EVT_GLCANVAS_FORCE_UPDATE, SimpleEvent);
|
wxDECLARE_EVENT(EVT_GLCANVAS_FORCE_UPDATE, SimpleEvent);
|
||||||
|
|||||||
@@ -141,6 +141,7 @@
|
|||||||
#include "slic3r/Utils/bambu_networking.hpp"
|
#include "slic3r/Utils/bambu_networking.hpp"
|
||||||
|
|
||||||
#include "PluginsDialog.hpp"
|
#include "PluginsDialog.hpp"
|
||||||
|
#include "SpeedDialDialog.hpp"
|
||||||
#include "TerminalDialog.hpp"
|
#include "TerminalDialog.hpp"
|
||||||
|
|
||||||
//#ifdef WIN32
|
//#ifdef WIN32
|
||||||
@@ -8246,6 +8247,8 @@ void GUI_App::open_terminal_dialog()
|
|||||||
m_terminal_dlg->Raise();
|
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)
|
void GUI_App::open_exportpresetbundledialog(size_t open_on_tab, const std::string& highlight_option)
|
||||||
{
|
{
|
||||||
bool app_layout_changed = false;
|
bool app_layout_changed = false;
|
||||||
|
|||||||
@@ -624,6 +624,7 @@ public:
|
|||||||
void open_presetbundledialog(size_t open_on_tab = 0, const std::string& highlight_option = std::string());
|
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_plugins_dialog(size_t open_on_tab = 0, const std::string& highlight_option = std::string());
|
||||||
void open_terminal_dialog();
|
void open_terminal_dialog();
|
||||||
|
void open_speed_dial();
|
||||||
void open_exportpresetbundledialog(size_t open_on_tab = 0, const std::string& highlight_option = std::string());
|
void open_exportpresetbundledialog(size_t open_on_tab = 0, const std::string& highlight_option = std::string());
|
||||||
virtual bool OnExceptionInMainLoop() override;
|
virtual bool OnExceptionInMainLoop() override;
|
||||||
// Calls wxLaunchDefaultBrowser if user confirms in dialog.
|
// Calls wxLaunchDefaultBrowser if user confirms in dialog.
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ void KBShortcutsDialog::fill_shortcuts()
|
|||||||
{ "O", L("Zoom out") },
|
{ "O", L("Zoom out") },
|
||||||
{ "V", L("Toggle printable for object/part") },
|
{ "V", L("Toggle printable for object/part") },
|
||||||
{ L("Tab"), L("Switch between Prepare/Preview") },
|
{ L("Tab"), L("Switch between Prepare/Preview") },
|
||||||
|
{ L("Space"), L("Open actions speed dial") },
|
||||||
|
|
||||||
};
|
};
|
||||||
m_full_shortcuts.push_back({ { _L("Plater"), "" }, plater_shortcuts });
|
m_full_shortcuts.push_back({ { _L("Plater"), "" }, plater_shortcuts });
|
||||||
|
|||||||
@@ -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_SELECT_ALL, [this](SimpleEvent&) { this->q->select_all(); });
|
||||||
view3D_canvas->Bind(EVT_GLCANVAS_QUESTION_MARK, [](SimpleEvent&) { wxGetApp().keyboard_shortcuts(); });
|
view3D_canvas->Bind(EVT_GLCANVAS_QUESTION_MARK, [](SimpleEvent&) { wxGetApp().keyboard_shortcuts(); });
|
||||||
|
view3D_canvas->Bind(EVT_GLCANVAS_OPEN_SPEED_DIAL, [this](SimpleEvent&) {
|
||||||
|
if (this->q->is_view3D_shown())
|
||||||
|
wxGetApp().open_speed_dial();
|
||||||
|
});
|
||||||
view3D_canvas->Bind(EVT_GLCANVAS_INCREASE_INSTANCES, [this](Event<int>& evt)
|
view3D_canvas->Bind(EVT_GLCANVAS_INCREASE_INSTANCES, [this](Event<int>& evt)
|
||||||
{ if (evt.data == 1) this->q->increase_instances(); else if (this->can_decrease_instances()) this->q->decrease_instances(); });
|
{ 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(); });
|
view3D_canvas->Bind(EVT_GLCANVAS_INSTANCE_MOVED, [this](SimpleEvent&) { update(); });
|
||||||
|
|||||||
148
src/slic3r/GUI/PluginScriptRunner.cpp
Normal file
148
src/slic3r/GUI/PluginScriptRunner.cpp
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
#include "PluginScriptRunner.hpp"
|
||||||
|
|
||||||
|
#include "GUI.hpp"
|
||||||
|
#include "I18N.hpp"
|
||||||
|
#include "slic3r/plugin/PluginManager.hpp"
|
||||||
|
#include "slic3r/plugin/PythonInterpreter.hpp"
|
||||||
|
#include "slic3r/plugin/pluginTypes/script/ScriptPluginCapability.hpp"
|
||||||
|
|
||||||
|
#include <libslic3r/Utils.hpp>
|
||||||
|
|
||||||
|
#include <boost/log/trivial.hpp>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include <wx/utils.h>
|
||||||
|
|
||||||
|
namespace Slic3r { namespace GUI {
|
||||||
|
|
||||||
|
ScriptRunOutcome run_script_plugin_capability(const std::string& plugin_key, const std::string& capability_name)
|
||||||
|
{
|
||||||
|
if (plugin_key.empty() || capability_name.empty()) {
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << "Ignoring script run request with an empty plugin key or capability name";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
PluginManager& manager = PluginManager::instance();
|
||||||
|
auto cap = manager.get_loader().get_plugin_capability_by_name(plugin_key, Slic3r::PluginCapabilityType::Script, capability_name);
|
||||||
|
if (!cap) {
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << "Ignoring stale run request for missing script capability. plugin_key=" << plugin_key
|
||||||
|
<< " capability_name=" << capability_name;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (!cap->enabled) {
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << "Ignoring stale run request for disabled script capability. plugin_key=" << plugin_key
|
||||||
|
<< " capability_name=" << capability_name;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plugin's modal orca.host.ui dialog or the result message box pumps a nested event
|
||||||
|
// loop; a launcher surface could re-dispatch a run command mid-run. Refuse the
|
||||||
|
// overlapping run. UI-thread only, so a plain static is a sufficient latch.
|
||||||
|
static bool s_script_running = false;
|
||||||
|
if (s_script_running) {
|
||||||
|
BOOST_LOG_TRIVIAL(info) << "Ignoring script run request; a plugin is already running. plugin_key=" << plugin_key;
|
||||||
|
return {ScriptRunOutcome::Level::Busy, wxString()};
|
||||||
|
}
|
||||||
|
s_script_running = true;
|
||||||
|
ScopeGuard running_guard([]() { s_script_running = false; });
|
||||||
|
|
||||||
|
BOOST_LOG_TRIVIAL(info) << "Run script plugin requested. plugin_key=" << plugin_key << " capability_name=" << capability_name;
|
||||||
|
|
||||||
|
auto complete_with_error = [&manager, &plugin_key](const std::string& plugin_error, const wxString& status_message) {
|
||||||
|
const std::string normalized_error = plugin_error.empty() ? "Script plugin failed." : plugin_error;
|
||||||
|
if (!manager.get_catalog().set_plugin_error(plugin_key, normalized_error))
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << "Failed to record plugin error. plugin_key=" << plugin_key;
|
||||||
|
|
||||||
|
if (!manager.get_loader().unload_plugin(plugin_key))
|
||||||
|
BOOST_LOG_TRIVIAL(error) << "Failed to unload plugin after script error. plugin_key=" << plugin_key;
|
||||||
|
|
||||||
|
return ScriptRunOutcome{ScriptRunOutcome::Level::Error,
|
||||||
|
status_message.empty() ? from_u8(normalized_error) : status_message};
|
||||||
|
};
|
||||||
|
|
||||||
|
PluginDescriptor descriptor;
|
||||||
|
if (!manager.get_catalog().try_get_plugin_descriptor(plugin_key, descriptor)) {
|
||||||
|
BOOST_LOG_TRIVIAL(error) << "Cannot run script plugin because manifest was not found. plugin_key=" << plugin_key;
|
||||||
|
return complete_with_error("Plugin manifest was not found.", _L("Plugin manifest was not found."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (descriptor.has_error())
|
||||||
|
return complete_with_error(descriptor.normalized_error(), wxString());
|
||||||
|
|
||||||
|
// Should not reach here, handle for extra safety
|
||||||
|
if (!descriptor.is_metadata_valid()) {
|
||||||
|
std::string plugin_type_str = plugin_capability_type_to_string(descriptor.primary_capability_type());
|
||||||
|
std::string metadata_valid = descriptor.is_metadata_valid() ? "true" : "false";
|
||||||
|
const std::string plugin_error = "Cannot run plugin because its metadata is invalid:\n\tplugin type: " + plugin_type_str +
|
||||||
|
"\n\tmetadata_valid: " + metadata_valid;
|
||||||
|
BOOST_LOG_TRIVIAL(error) << "Cannot run plugin because its metadata is invalid. plugin_key=" << plugin_key
|
||||||
|
<< " is_metadata_valid=" << descriptor.is_metadata_valid()
|
||||||
|
<< " type=" << plugin_capability_type_to_string(descriptor.primary_capability_type());
|
||||||
|
return complete_with_error(plugin_error, _L("Only plugins with valid metadata can be run from this dialog."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not reach here as non-loaded plugins have disabled run buttons, handle for extra safety
|
||||||
|
if (!manager.get_loader().is_plugin_loaded(plugin_key)) {
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << "Cannot run script plugin because it is not loaded. plugin_key=" << plugin_key;
|
||||||
|
return complete_with_error("Load the script plugin before running it: Cannot run script plugin because it is not loaded.",
|
||||||
|
_L("Load the script plugin before running it."));
|
||||||
|
}
|
||||||
|
|
||||||
|
auto plugin = std::dynamic_pointer_cast<Slic3r::ScriptPluginCapability>(cap->instance);
|
||||||
|
if (!plugin) {
|
||||||
|
BOOST_LOG_TRIVIAL(error) << "Loaded plugin does not implement ScriptPluginCapability. plugin_key=" << plugin_key;
|
||||||
|
return complete_with_error("The selected plugin is not a runnable script plugin: Loaded plugin does not implement ScriptPluginCapability.",
|
||||||
|
_L("The selected plugin is not a runnable script plugin."));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string error;
|
||||||
|
ExecutionResult result;
|
||||||
|
|
||||||
|
// Script plugins run on the main/UI thread (not a worker). They hold live, non-owning
|
||||||
|
// ModelObject*/ModelVolume*/ModelInstance* aliases into host data and can mint ObjectIDs,
|
||||||
|
// which libslic3r requires on the main thread (ObjectID.hpp's non-atomic s_last_id). Running
|
||||||
|
// here makes those reads/instantiations legal and means nothing mutates the model underneath
|
||||||
|
// a run. The trade-off is that a slow execute() freezes the UI: the contract (see
|
||||||
|
// plugin_development.md) is to keep execute() quick and offload heavy work to the plugin's own
|
||||||
|
// threading.Thread. orca.host.ui calls already no-op their main-thread marshaling here.
|
||||||
|
{
|
||||||
|
wxBusyCursor busy;
|
||||||
|
try {
|
||||||
|
PythonGILState gil;
|
||||||
|
result = plugin->execute();
|
||||||
|
} catch (const std::exception& ex) {
|
||||||
|
error = ex.what();
|
||||||
|
BOOST_LOG_TRIVIAL(error) << "Script plugin execution threw exception. plugin_key=" << plugin_key << " error=" << error;
|
||||||
|
} catch (...) {
|
||||||
|
error = "Unknown error";
|
||||||
|
BOOST_LOG_TRIVIAL(error) << "Script plugin execution threw unknown exception. plugin_key=" << plugin_key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!error.empty()) {
|
||||||
|
plugin.reset();
|
||||||
|
cap.reset();
|
||||||
|
return complete_with_error(error, wxString());
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_LOG_TRIVIAL(info) << "Script plugin execution completed. plugin_key=" << plugin_key
|
||||||
|
<< " status=" << static_cast<int>(result.status) << " message=" << result.message << " data=" << result.data;
|
||||||
|
|
||||||
|
const bool failed = result.status == PluginResult::RecoverableError || result.status == PluginResult::FatalError;
|
||||||
|
if (failed) {
|
||||||
|
plugin.reset();
|
||||||
|
cap.reset();
|
||||||
|
// complete_with_error normalizes an empty message to "Script plugin failed.".
|
||||||
|
return complete_with_error(result.message, wxString());
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.clear_plugin_error(plugin_key);
|
||||||
|
|
||||||
|
const bool skipped = result.status == PluginResult::Skipped;
|
||||||
|
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
|
||||||
|
return {skipped ? ScriptRunOutcome::Level::Info : ScriptRunOutcome::Level::Success,
|
||||||
|
result.message.empty() ? fallback : from_u8(result.message)};
|
||||||
|
}
|
||||||
|
|
||||||
|
}} // namespace Slic3r::GUI
|
||||||
31
src/slic3r/GUI/PluginScriptRunner.hpp
Normal file
31
src/slic3r/GUI/PluginScriptRunner.hpp
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
#ifndef slic3r_PluginScriptRunner_hpp_
|
||||||
|
#define slic3r_PluginScriptRunner_hpp_
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <wx/string.h>
|
||||||
|
|
||||||
|
namespace Slic3r { namespace GUI {
|
||||||
|
|
||||||
|
// Outcome of running one SCRIPT capability, for the caller's own status surface
|
||||||
|
// (PluginsDialog footer, speed-dial notification). An empty message means "nothing
|
||||||
|
// worth showing" (stale/disabled requests that were silently ignored).
|
||||||
|
struct ScriptRunOutcome
|
||||||
|
{
|
||||||
|
enum class Level { Success, Info, Error, Busy };
|
||||||
|
|
||||||
|
Level level = Level::Info;
|
||||||
|
wxString message;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Runs a SCRIPT plugin capability on the calling (UI) thread: validates the request,
|
||||||
|
// executes under the Python GIL, and on failure records the plugin error in the catalog
|
||||||
|
// and unloads the package. Shared by every surface that can launch a script so the
|
||||||
|
// subtle parts (UI-thread requirement, GIL, re-entrancy latch, error bookkeeping) live
|
||||||
|
// in one place. Returns Level::Busy when a script is already running (callers ignore).
|
||||||
|
// Callers refresh their plugin views after any non-Busy outcome.
|
||||||
|
ScriptRunOutcome run_script_plugin_capability(const std::string& plugin_key, const std::string& capability_name);
|
||||||
|
|
||||||
|
}} // namespace Slic3r::GUI
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -4,10 +4,9 @@
|
|||||||
#include "GUI_App.hpp"
|
#include "GUI_App.hpp"
|
||||||
#include "I18N.hpp"
|
#include "I18N.hpp"
|
||||||
#include "OrcaCloudServiceAgent.hpp"
|
#include "OrcaCloudServiceAgent.hpp"
|
||||||
|
#include "PluginScriptRunner.hpp"
|
||||||
#include "slic3r/plugin/PluginFsUtils.hpp"
|
#include "slic3r/plugin/PluginFsUtils.hpp"
|
||||||
#include "slic3r/plugin/PluginManager.hpp"
|
#include "slic3r/plugin/PluginManager.hpp"
|
||||||
#include "slic3r/plugin/PythonInterpreter.hpp"
|
|
||||||
#include "slic3r/plugin/pluginTypes/script/ScriptPluginCapability.hpp"
|
|
||||||
|
|
||||||
#include <libslic3r/Utils.hpp>
|
#include <libslic3r/Utils.hpp>
|
||||||
|
|
||||||
@@ -19,8 +18,6 @@
|
|||||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||||
#include <slic3r/plugin/PluginLoader.hpp>
|
#include <slic3r/plugin/PluginLoader.hpp>
|
||||||
|
|
||||||
#include <pybind11/embed.h>
|
|
||||||
|
|
||||||
#include <boost/filesystem.hpp>
|
#include <boost/filesystem.hpp>
|
||||||
#include <boost/log/trivial.hpp>
|
#include <boost/log/trivial.hpp>
|
||||||
|
|
||||||
@@ -898,147 +895,22 @@ wxString PluginsDialog::plugin_display_name(const std::string& plugin_key) const
|
|||||||
|
|
||||||
void PluginsDialog::run_script_plugin(const std::string& plugin_key, const std::string& capability_name)
|
void PluginsDialog::run_script_plugin(const std::string& plugin_key, const std::string& capability_name)
|
||||||
{
|
{
|
||||||
if (plugin_key.empty() || capability_name.empty()) {
|
// Shared with the speed dial; validation, GIL/UI-thread execution, re-entrancy latch
|
||||||
BOOST_LOG_TRIVIAL(warning) << "Ignoring run_script_plugin with an empty plugin key or capability name";
|
// and error bookkeeping all live in run_script_plugin_capability.
|
||||||
send_plugins();
|
const ScriptRunOutcome outcome = run_script_plugin_capability(plugin_key, capability_name);
|
||||||
|
if (outcome.level == ScriptRunOutcome::Level::Busy)
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
PluginManager& manager = PluginManager::instance();
|
|
||||||
auto cap = manager.get_loader().get_plugin_capability_by_name(
|
|
||||||
plugin_key, Slic3r::PluginCapabilityType::Script, capability_name);
|
|
||||||
if (!cap) {
|
|
||||||
BOOST_LOG_TRIVIAL(warning) << "Ignoring stale run request for missing script capability. plugin_key=" << plugin_key
|
|
||||||
<< " capability_name=" << capability_name;
|
|
||||||
send_plugins();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!cap->enabled) {
|
|
||||||
BOOST_LOG_TRIVIAL(warning) << "Ignoring stale run request for disabled script capability. plugin_key=" << plugin_key
|
|
||||||
<< " capability_name=" << capability_name;
|
|
||||||
send_plugins();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A plugin's modal orca.host.ui dialog or the result message box pumps a nested event
|
|
||||||
// loop; the WebView could re-dispatch this command mid-run. Refuse the overlapping run.
|
|
||||||
if (m_script_running) {
|
|
||||||
BOOST_LOG_TRIVIAL(info) << "Ignoring run_script_plugin; a plugin is already running. plugin_key=" << plugin_key;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
m_script_running = true;
|
|
||||||
ScopeGuard running_guard([this]() { m_script_running = false; });
|
|
||||||
|
|
||||||
BOOST_LOG_TRIVIAL(info) << "Run script plugin requested from Plugins dialog. plugin_key=" << plugin_key
|
|
||||||
<< " capability_name=" << capability_name;
|
|
||||||
|
|
||||||
auto complete_with_error = [this, &manager, &plugin_key](const std::string& plugin_error, const wxString& status_message) {
|
|
||||||
const std::string normalized_error = plugin_error.empty() ? "Script plugin failed." : plugin_error;
|
|
||||||
if (!manager.get_catalog().set_plugin_error(plugin_key, normalized_error))
|
|
||||||
BOOST_LOG_TRIVIAL(warning) << "Failed to record plugin error. plugin_key=" << plugin_key;
|
|
||||||
|
|
||||||
if (!manager.get_loader().unload_plugin(plugin_key))
|
|
||||||
BOOST_LOG_TRIVIAL(error) << "Failed to unload plugin after script error. plugin_key=" << plugin_key;
|
|
||||||
|
|
||||||
send_plugins();
|
|
||||||
|
|
||||||
// The row now shows an "Error" status and the Diagnostics tab holds the full text, so surface
|
|
||||||
// the outcome in the footer status bar instead of a modal box (prefer the friendlier override).
|
|
||||||
const wxString message = status_message.empty() ? from_u8(normalized_error) : status_message;
|
|
||||||
show_status(message, "error");
|
|
||||||
};
|
|
||||||
|
|
||||||
PluginDescriptor descriptor;
|
|
||||||
if (!get_descriptor(plugin_key, descriptor)) {
|
|
||||||
BOOST_LOG_TRIVIAL(error) << "Cannot run script plugin because manifest was not found. plugin_key=" << plugin_key;
|
|
||||||
complete_with_error("Plugin manifest was not found.", _L("Plugin manifest was not found."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (descriptor.has_error()) {
|
|
||||||
complete_with_error(descriptor.normalized_error(), wxString());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should not reach here, handle for extra safety
|
|
||||||
if (!descriptor.is_metadata_valid()) {
|
|
||||||
std::string plugin_type_str = plugin_capability_type_to_string(descriptor.primary_capability_type());
|
|
||||||
std::string metadata_valid = descriptor.is_metadata_valid() ? "true" : "false";
|
|
||||||
const std::string plugin_error = "Cannot run plugin because its metadata is invalid:\n\tplugin type: " + plugin_type_str +
|
|
||||||
"\n\tmetadata_valid: " + metadata_valid;
|
|
||||||
BOOST_LOG_TRIVIAL(error) << "Cannot run plugin because its metadata is invalid. plugin_key=" << plugin_key
|
|
||||||
<< " is_metadata_valid=" << descriptor.is_metadata_valid()
|
|
||||||
<< " type=" << plugin_capability_type_to_string(descriptor.primary_capability_type());
|
|
||||||
complete_with_error(plugin_error, _L("Only plugins with valid metadata can be run from this dialog."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should not reach here as non-loaded plugins have disabled run buttons, handle for extra safety
|
|
||||||
if (!manager.get_loader().is_plugin_loaded(plugin_key)) {
|
|
||||||
BOOST_LOG_TRIVIAL(warning) << "Cannot run script plugin because it is not loaded. plugin_key=" << plugin_key;
|
|
||||||
complete_with_error("Load the script plugin before running it: Cannot run script plugin because it is not loaded.",
|
|
||||||
_L("Load the script plugin before running it."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto plugin = std::dynamic_pointer_cast<Slic3r::ScriptPluginCapability>(cap->instance);
|
|
||||||
if (!plugin) {
|
|
||||||
BOOST_LOG_TRIVIAL(error) << "Loaded plugin does not implement ScriptPluginCapability. plugin_key=" << plugin_key;
|
|
||||||
complete_with_error("The selected plugin is not a runnable script plugin: Loaded plugin does not implement ScriptPluginCapability.",
|
|
||||||
_L("The selected plugin is not a runnable script plugin."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string error;
|
|
||||||
ExecutionResult result;
|
|
||||||
|
|
||||||
// Script plugins run on the main/UI thread (not a worker). They hold live, non-owning
|
|
||||||
// ModelObject*/ModelVolume*/ModelInstance* aliases into host data and can mint ObjectIDs,
|
|
||||||
// which libslic3r requires on the main thread (ObjectID.hpp's non-atomic s_last_id). Running
|
|
||||||
// here makes those reads/instantiations legal and means nothing mutates the model underneath
|
|
||||||
// a run. The trade-off is that a slow execute() freezes the UI: the contract (see
|
|
||||||
// plugin_development.md) is to keep execute() quick and offload heavy work to the plugin's own
|
|
||||||
// threading.Thread. orca.host.ui calls already no-op their main-thread marshaling here.
|
|
||||||
{
|
|
||||||
wxBusyCursor busy;
|
|
||||||
try {
|
|
||||||
PythonGILState gil;
|
|
||||||
result = plugin->execute();
|
|
||||||
} catch (const std::exception& ex) {
|
|
||||||
error = ex.what();
|
|
||||||
BOOST_LOG_TRIVIAL(error) << "Script plugin execution threw exception. plugin_key=" << plugin_key << " error=" << error;
|
|
||||||
} catch (...) {
|
|
||||||
error = "Unknown error";
|
|
||||||
BOOST_LOG_TRIVIAL(error) << "Script plugin execution threw unknown exception. plugin_key=" << plugin_key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!error.empty()) {
|
|
||||||
plugin.reset();
|
|
||||||
cap.reset();
|
|
||||||
complete_with_error(error, wxString());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
BOOST_LOG_TRIVIAL(info) << "Script plugin execution completed. plugin_key=" << plugin_key
|
|
||||||
<< " status=" << static_cast<int>(result.status) << " message=" << result.message << " data=" << result.data;
|
|
||||||
|
|
||||||
const bool failed = result.status == PluginResult::RecoverableError || result.status == PluginResult::FatalError;
|
|
||||||
if (failed) {
|
|
||||||
plugin.reset();
|
|
||||||
cap.reset();
|
|
||||||
// complete_with_error normalizes an empty message to "Script plugin failed." and reports via the status bar.
|
|
||||||
complete_with_error(result.message, wxString());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.clear_plugin_error(plugin_key);
|
|
||||||
send_plugins();
|
send_plugins();
|
||||||
|
|
||||||
const bool skipped = result.status == PluginResult::Skipped;
|
// The row shows any "Error" status and the Diagnostics tab holds the full text, so surface
|
||||||
const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished.");
|
// the outcome in the footer status bar instead of a modal box (prefer the friendlier override).
|
||||||
const wxString message = result.message.empty() ? fallback : from_u8(result.message);
|
if (outcome.message.empty())
|
||||||
show_status(message, skipped ? "info" : "success");
|
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)
|
void PluginsDialog::update_plugin(const std::string& plugin_key)
|
||||||
|
|||||||
@@ -213,11 +213,6 @@ private:
|
|||||||
PluginSortKey m_plugin_sort_key = PluginSortKey::None;
|
PluginSortKey m_plugin_sort_key = PluginSortKey::None;
|
||||||
PluginSortOrder m_plugin_sort_order = PluginSortOrder::Asc;
|
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().
|
// Plugin whose asynchronous activation is in flight, awaited by resolve_pending_activation().
|
||||||
// Empty when no activation is pending.
|
// Empty when no activation is pending.
|
||||||
std::string m_activating_plugin_key;
|
std::string m_activating_plugin_key;
|
||||||
|
|||||||
462
src/slic3r/GUI/SpeedDialDialog.cpp
Normal file
462
src/slic3r/GUI/SpeedDialDialog.cpp
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
#include "SpeedDialDialog.hpp"
|
||||||
|
|
||||||
|
#include "GUI.hpp"
|
||||||
|
#include "GUI_App.hpp"
|
||||||
|
#include "MainFrame.hpp"
|
||||||
|
#include "MsgDialog.hpp"
|
||||||
|
#include "NotificationManager.hpp"
|
||||||
|
#include "Plater.hpp"
|
||||||
|
#include "PluginScriptRunner.hpp"
|
||||||
|
#include "Widgets/PopupWindow.hpp"
|
||||||
|
#include "Widgets/WebView.hpp"
|
||||||
|
#include "Widgets/WebViewHostDialog.hpp"
|
||||||
|
#include "Widgets/StateColor.hpp"
|
||||||
|
#include "slic3r/plugin/PluginManager.hpp"
|
||||||
|
|
||||||
|
#include <libslic3r/AppConfig.hpp>
|
||||||
|
|
||||||
|
#include <slic3r/plugin/PluginLoader.hpp>
|
||||||
|
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||||
|
|
||||||
|
#include <boost/filesystem.hpp>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <ctime>
|
||||||
|
#include <map>
|
||||||
|
|
||||||
|
#include <wx/display.h>
|
||||||
|
#include <wx/sizer.h>
|
||||||
|
#include <wx/stattext.h>
|
||||||
|
#include <wx/webview.h>
|
||||||
|
|
||||||
|
namespace Slic3r { namespace GUI {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// AppConfig section for the speed dial (favourites, run stats, ask suppression).
|
||||||
|
constexpr const char* kConfigSection = "speed_dial";
|
||||||
|
// ADJUST WIDTH HERE (DIP px). Fixed popup width; was 360, now 1.5x. Height is not set here -
|
||||||
|
// the popup auto-resizes to the page content (see resize_to_content + the list max-height in style.css).
|
||||||
|
constexpr int kPopupWidth = 540;
|
||||||
|
constexpr int kPopupMinHeight = 60; // just above the bare search-bar height, so the popup hugs content
|
||||||
|
constexpr int kPopupMaxHeight = 282;
|
||||||
|
|
||||||
|
// Tolerant parse of a config-stored JSON value; anything unreadable degrades to `fallback`
|
||||||
|
// (the section is brand new, so damaged/absent values just mean empty defaults).
|
||||||
|
nlohmann::json parse_config_json(const std::string& value, nlohmann::json fallback)
|
||||||
|
{
|
||||||
|
nlohmann::json parsed = nlohmann::json::parse(value, nullptr, false);
|
||||||
|
return parsed.is_discarded() ? std::move(fallback) : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable action identity for the page: type-prefixed (plugin_key, capability) pair.
|
||||||
|
std::string action_id(const std::string& key, const std::string& cap) { return "script:" + key + "::" + cap; }
|
||||||
|
|
||||||
|
// frecency = frequency + recency
|
||||||
|
double frecency_score(int count, long long last, long long now)
|
||||||
|
{
|
||||||
|
if (count <= 0)
|
||||||
|
return 0.0;
|
||||||
|
constexpr double HALF_LIFE_DAYS = 30.0; // tunable knob: score halves every 30 idle days
|
||||||
|
double age = std::max(0.0, double(now - last) / 86400.0);
|
||||||
|
return count * std::pow(2.0, -age / HALF_LIFE_DAYS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// why: STATIC free function, not a member - run_action Dismisses (destroys) the popup before
|
||||||
|
// the deferred run, so there is no `this` left to bump on. Reads/writes config directly.
|
||||||
|
void bump_frecency(const std::string& id)
|
||||||
|
{
|
||||||
|
auto stats = parse_config_json(wxGetApp().app_config->get(kConfigSection, "stats"), nlohmann::json::object());
|
||||||
|
// why: stats[id] on a new id yields a null node; value("count",...) throws type_error.306 on a
|
||||||
|
// non-object, so seed it to {} first (the first run of every action hit this before the toast).
|
||||||
|
nlohmann::json& e = stats[id];
|
||||||
|
if (!e.is_object())
|
||||||
|
e = nlohmann::json::object();
|
||||||
|
e["count"] = e.value("count", 0) + 1;
|
||||||
|
e["last"] = (long long) std::time(nullptr);
|
||||||
|
wxGetApp().app_config->set(kConfigSection, "stats", stats.dump());
|
||||||
|
}
|
||||||
|
|
||||||
|
wxString dialog_url()
|
||||||
|
{
|
||||||
|
boost::filesystem::path path = boost::filesystem::path(resources_dir()) / "web/dialog/SpeedDial/index.html";
|
||||||
|
return wxString("file://") + from_u8(path.make_preferred().string());
|
||||||
|
}
|
||||||
|
|
||||||
|
wxPoint clamped_position(wxPoint pos, const wxSize& size)
|
||||||
|
{
|
||||||
|
int display_index = wxDisplay::GetFromPoint(pos);
|
||||||
|
if (display_index == wxNOT_FOUND)
|
||||||
|
display_index = 0;
|
||||||
|
wxRect area = wxDisplay(display_index).GetClientArea();
|
||||||
|
pos.x = std::max(area.GetLeft(), std::min(pos.x, area.GetRight() - size.GetWidth()));
|
||||||
|
pos.y = std::max(area.GetTop(), std::min(pos.y, area.GetBottom() - size.GetHeight()));
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADJUST POSITION HERE. Horizontal: centred over the plater. Vertical: the window TOP sits at
|
||||||
|
// 1/3 of the screen height (upper-third placement). Tune `/ 3` to move the anchor up/down, or
|
||||||
|
// change the `x` line to re-anchor horizontally. clamped_position() at the call site then nudges
|
||||||
|
// the popup back on-screen if it would spill off an edge.
|
||||||
|
wxPoint popup_position(const wxSize& size)
|
||||||
|
{
|
||||||
|
wxWindow* ref = wxGetApp().plater();
|
||||||
|
if (!ref)
|
||||||
|
ref = wxGetApp().mainframe;
|
||||||
|
const wxRect plater = ref ? ref->GetScreenRect() : wxDisplay(0u).GetClientArea();
|
||||||
|
int disp = wxDisplay::GetFromPoint(wxPoint(plater.GetLeft() + plater.GetWidth() / 2,
|
||||||
|
plater.GetTop() + plater.GetHeight() / 2));
|
||||||
|
if (disp == wxNOT_FOUND)
|
||||||
|
disp = 0;
|
||||||
|
const wxRect screen = wxDisplay(disp).GetClientArea();
|
||||||
|
const int x = plater.GetLeft() + (plater.GetWidth() - size.GetWidth()) / 2; // centred over plater
|
||||||
|
const int y = screen.GetTop() + screen.GetHeight() / 3; // top at 1/3 screen height
|
||||||
|
return wxPoint(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
int json_int_or(const nlohmann::json& j, const char* key, int fallback)
|
||||||
|
{
|
||||||
|
auto it = j.find(key);
|
||||||
|
return it != j.end() && it->is_number() ? it->get<int>() : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
wxColour bg_color() { return wxGetApp().get_window_default_clr(); }
|
||||||
|
|
||||||
|
std::string css_color(const wxColour& c) { return c.GetAsString(wxC2S_HTML_SYNTAX).ToStdString(); }
|
||||||
|
|
||||||
|
std::string host_theme_name() { return wxGetApp().dark_mode() ? "dark" : "light"; }
|
||||||
|
|
||||||
|
std::string host_theme_vars_css()
|
||||||
|
{
|
||||||
|
GUI_App& app = wxGetApp();
|
||||||
|
const wxColour bg = app.get_window_default_clr();
|
||||||
|
const wxColour fg = app.get_label_clr_default();
|
||||||
|
const wxColour muted = app.get_label_clr_sys();
|
||||||
|
const wxColour border = app.get_highlight_default_clr();
|
||||||
|
const wxColour accent = StateColor::darkModeColorFor(wxColour("#009688"));
|
||||||
|
std::string font = app.normal_font().GetFaceName().ToStdString();
|
||||||
|
|
||||||
|
font.erase(std::remove_if(font.begin(), font.end(), [](char c) {
|
||||||
|
return c == '\'' || c == '"' || c == '<' || c == '>' || c == '{' || c == '}' || c == ';';
|
||||||
|
}), font.end());
|
||||||
|
|
||||||
|
std::string s;
|
||||||
|
s += ":root{";
|
||||||
|
s += "--orca-bg:" + css_color(bg) + ";";
|
||||||
|
s += "--orca-fg:" + css_color(fg) + ";";
|
||||||
|
s += "--orca-muted:" + css_color(muted) + ";";
|
||||||
|
s += "--orca-border:" + css_color(border) + ";";
|
||||||
|
s += "--orca-accent:" + css_color(accent) + ";";
|
||||||
|
s += "--orca-accent-fg:#ffffff;";
|
||||||
|
s += "--orca-font:" + (font.empty() ? std::string() : "'" + font + "',") +
|
||||||
|
"system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;";
|
||||||
|
s += "color-scheme:" + host_theme_name() + ";";
|
||||||
|
s += "}";
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string host_theme_user_script()
|
||||||
|
{
|
||||||
|
const std::string style = "<style id=\"orca-host-theme-vars\">" + host_theme_vars_css() + "</style>";
|
||||||
|
return WebViewHostDialog::document_start_injector(
|
||||||
|
style, "orca-host-theme-vars", "afterbegin", "window.__orcaHostThemed=true;var theme=\"" + host_theme_name() + "\";",
|
||||||
|
"if(document.documentElement)document.documentElement.setAttribute('data-orca-theme',theme);");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string host_theme_apply_js()
|
||||||
|
{
|
||||||
|
const std::string vars_literal = nlohmann::json(host_theme_vars_css()).dump();
|
||||||
|
const std::string theme = host_theme_name();
|
||||||
|
return "(function(){var css=" + vars_literal + ";var theme=\"" + theme + "\";" + R"JS(
|
||||||
|
var el=document.getElementById('orca-host-theme-vars');
|
||||||
|
if(el){el.textContent=css;}
|
||||||
|
else if(document.head){document.head.insertAdjacentHTML('afterbegin','<style id="orca-host-theme-vars"></style>');var e2=document.getElementById('orca-host-theme-vars');if(e2)e2.textContent=css;}
|
||||||
|
if(document.documentElement)
|
||||||
|
document.documentElement.setAttribute('data-orca-theme',theme);
|
||||||
|
})();)JS";
|
||||||
|
}
|
||||||
|
|
||||||
|
class SpeedDialWebPopup : public ::PopupWindow
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit SpeedDialWebPopup(wxWindow* parent)
|
||||||
|
: ::PopupWindow(parent, wxBORDER_NONE | wxPU_CONTAINS_CONTROLS)
|
||||||
|
{
|
||||||
|
SetBackgroundColour(bg_color());
|
||||||
|
|
||||||
|
auto* sizer = new wxBoxSizer(wxVERTICAL);
|
||||||
|
m_browser = WebView::CreateWebView(this, wxEmptyString);
|
||||||
|
if (m_browser) {
|
||||||
|
m_browser->AddUserScript(wxString::FromUTF8(host_theme_user_script()));
|
||||||
|
m_browser->Bind(EVT_WEBVIEW_RECREATED, &SpeedDialWebPopup::on_webview_recreated, this);
|
||||||
|
sizer->Add(m_browser, wxSizerFlags().Expand().Proportion(1));
|
||||||
|
Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, &SpeedDialWebPopup::on_script_message, this, m_browser->GetId());
|
||||||
|
} else {
|
||||||
|
auto* fallback = new wxStaticText(this, wxID_ANY, wxS("wxWebView unavailable"));
|
||||||
|
sizer->Add(fallback, wxSizerFlags().Border(wxALL, 20));
|
||||||
|
}
|
||||||
|
SetSizer(sizer);
|
||||||
|
SetClientSize(FromDIP(wxSize(kPopupWidth, kPopupMaxHeight))); // DIP -> physical (see resize_to_content)
|
||||||
|
if (m_browser)
|
||||||
|
WebView::LoadUrl(m_browser, dialog_url());
|
||||||
|
}
|
||||||
|
|
||||||
|
void request_show()
|
||||||
|
{
|
||||||
|
if (IsShown()) {
|
||||||
|
focus_browser();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_pending_show = true;
|
||||||
|
if (m_browser && m_page_ready)
|
||||||
|
send_actions();
|
||||||
|
else if (!m_browser)
|
||||||
|
show_ready();
|
||||||
|
}
|
||||||
|
|
||||||
|
void focus_browser()
|
||||||
|
{
|
||||||
|
if (m_browser)
|
||||||
|
m_browser->SetFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
void OnDismiss() override {}
|
||||||
|
|
||||||
|
void on_script_message(wxWebViewEvent& event)
|
||||||
|
{
|
||||||
|
nlohmann::json p = nlohmann::json::parse(event.GetString().utf8_string(), nullptr, false);
|
||||||
|
if (!p.is_object())
|
||||||
|
return;
|
||||||
|
const std::string cmd = p.value("command", "");
|
||||||
|
if (cmd == "request_actions") {
|
||||||
|
m_page_ready = true;
|
||||||
|
send_actions();
|
||||||
|
}
|
||||||
|
else if (cmd == "toggle_favourite") toggle_favourite(p.value("id", ""), p.value("fav", false));
|
||||||
|
else if (cmd == "run_action") run_action(p.value("id", ""), p.value("title", ""));
|
||||||
|
else if (cmd == "resize") resize_to_content(json_int_or(p, "height", 0));
|
||||||
|
else if (cmd == "close_page") Dismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_webview_recreated(wxCommandEvent&)
|
||||||
|
{
|
||||||
|
apply_host_theme();
|
||||||
|
}
|
||||||
|
|
||||||
|
void apply_host_theme()
|
||||||
|
{
|
||||||
|
if (m_browser)
|
||||||
|
WebView::RunScript(m_browser, wxString::FromUTF8(host_theme_apply_js()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void resize_to_content(int height)
|
||||||
|
{
|
||||||
|
if (height <= 0)
|
||||||
|
return;
|
||||||
|
// why: HTML is the source of truth - size the popup to the measured content height so
|
||||||
|
// the window always contains the WebView and never clips. `height` arrives in CSS px
|
||||||
|
// (DIP); wx SetClientSize wants physical px, so on a scaled display (e.g. 125%) applying
|
||||||
|
// DIP as physical shrinks the popup below the content and clips it. Clamp in DIP, then
|
||||||
|
// FromDIP -> physical. The only ceiling is a screen fraction; normal growth is already
|
||||||
|
// bounded by the list's own max-height.
|
||||||
|
int disp = wxDisplay::GetFromPoint(IsShown() ? GetPosition() : wxGetMousePosition());
|
||||||
|
if (disp == wxNOT_FOUND)
|
||||||
|
disp = 0;
|
||||||
|
const int screen_dip = ToDIP(wxDisplay(disp).GetClientArea().GetHeight());
|
||||||
|
const int max_dip = std::max(kPopupMinHeight, screen_dip * 85 / 100);
|
||||||
|
const int height_dip = std::max(kPopupMinHeight, std::min(height, max_dip));
|
||||||
|
SetClientSize(FromDIP(wxSize(kPopupWidth, height_dip)));
|
||||||
|
Layout();
|
||||||
|
if (m_pending_show)
|
||||||
|
show_ready();
|
||||||
|
else if (IsShown())
|
||||||
|
SetPosition(clamped_position(GetPosition(), GetSize()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void show_ready()
|
||||||
|
{
|
||||||
|
if (!m_pending_show)
|
||||||
|
return;
|
||||||
|
SetPosition(clamped_position(popup_position(GetSize()), GetSize()));
|
||||||
|
Popup();
|
||||||
|
focus_browser();
|
||||||
|
m_pending_show = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config readers/writer for the persisted dial state (favourites, run stats, ask suppression).
|
||||||
|
std::vector<std::string> read_favourites() const
|
||||||
|
{
|
||||||
|
auto j = parse_config_json(wxGetApp().app_config->get(kConfigSection, "favourites"), nlohmann::json::array());
|
||||||
|
std::vector<std::string> v;
|
||||||
|
for (auto& e : j)
|
||||||
|
if (e.is_string())
|
||||||
|
v.push_back(e.get<std::string>());
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
nlohmann::json read_stats() const
|
||||||
|
{
|
||||||
|
return parse_config_json(wxGetApp().app_config->get(kConfigSection, "stats"), nlohmann::json::object());
|
||||||
|
}
|
||||||
|
std::vector<std::string> read_ask_suppressed() const
|
||||||
|
{
|
||||||
|
auto j = parse_config_json(wxGetApp().app_config->get(kConfigSection, "ask_suppressed"), nlohmann::json::array());
|
||||||
|
std::vector<std::string> v;
|
||||||
|
for (auto& e : j)
|
||||||
|
if (e.is_string())
|
||||||
|
v.push_back(e.get<std::string>());
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
static void write_json(const char* key, const nlohmann::json& j) { wxGetApp().app_config->set(kConfigSection, key, j.dump()); }
|
||||||
|
|
||||||
|
void toggle_favourite(const std::string& id, bool fav)
|
||||||
|
{
|
||||||
|
auto favs = read_favourites();
|
||||||
|
auto it = std::find(favs.begin(), favs.end(), id);
|
||||||
|
if (fav && it == favs.end())
|
||||||
|
favs.push_back(id);
|
||||||
|
if (!fav && it != favs.end())
|
||||||
|
favs.erase(it);
|
||||||
|
write_json("favourites", nlohmann::json(favs));
|
||||||
|
}
|
||||||
|
|
||||||
|
void suppress_ask_for(const std::string& key)
|
||||||
|
{
|
||||||
|
auto arr = read_ask_suppressed();
|
||||||
|
if (std::find(arr.begin(), arr.end(), key) == arr.end())
|
||||||
|
arr.push_back(key);
|
||||||
|
write_json("ask_suppressed", nlohmann::json(arr));
|
||||||
|
}
|
||||||
|
|
||||||
|
// id == "script:<key>::<cap>". Hide FIRST (scripts may open their own modals that must not stack
|
||||||
|
// under this launcher), gate on a native run-confirm unless suppressed, then run on the next tick.
|
||||||
|
void run_action(const std::string& id, const std::string& title)
|
||||||
|
{
|
||||||
|
const std::string prefix = "script:";
|
||||||
|
if (id.rfind(prefix, 0) != 0)
|
||||||
|
return;
|
||||||
|
auto body = id.substr(prefix.size());
|
||||||
|
auto sep = body.find("::");
|
||||||
|
if (sep == std::string::npos)
|
||||||
|
return;
|
||||||
|
std::string key = body.substr(0, sep), cap = body.substr(sep + 2);
|
||||||
|
|
||||||
|
auto suppressed = read_ask_suppressed();
|
||||||
|
const bool ask = std::find(suppressed.begin(), suppressed.end(), key) == suppressed.end();
|
||||||
|
Dismiss(); // launcher gone before the modal / the script's own UI
|
||||||
|
|
||||||
|
if (ask) {
|
||||||
|
const wxString label = title.empty() ? from_u8(cap) : from_u8(title);
|
||||||
|
RichMessageDialog dlg(wxGetApp().mainframe, wxString::Format(_L("Run \"%s\"?"), label),
|
||||||
|
_L("Run plugin"), wxOK | wxCANCEL);
|
||||||
|
dlg.ShowCheckBox(_L("Don't ask again for this plugin"));
|
||||||
|
if (dlg.ShowModal() != wxID_OK)
|
||||||
|
return;
|
||||||
|
if (dlg.IsCheckBoxChecked())
|
||||||
|
suppress_ask_for(key);
|
||||||
|
}
|
||||||
|
// why: value-capture only. Script execution may outlive this popup if the app is closing.
|
||||||
|
wxGetApp().CallAfter([id, key, cap] {
|
||||||
|
ScriptRunOutcome o = run_script_plugin_capability(key, cap);
|
||||||
|
if (o.level == ScriptRunOutcome::Level::Busy)
|
||||||
|
return;
|
||||||
|
bump_frecency(id);
|
||||||
|
if (!o.message.IsEmpty())
|
||||||
|
wxGetApp().plater()->get_notification_manager()->push_notification(
|
||||||
|
NotificationType::CustomNotification,
|
||||||
|
o.level == ScriptRunOutcome::Level::Error ? NotificationManager::NotificationLevel::ErrorNotificationLevel :
|
||||||
|
NotificationManager::NotificationLevel::RegularNotificationLevel,
|
||||||
|
into_u8(o.message));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frecency ordering so the page shows most-reached-for first; ties broken alphabetically by title.
|
||||||
|
void sort_by_frecency(nlohmann::json& actions) const
|
||||||
|
{
|
||||||
|
nlohmann::json stats = read_stats();
|
||||||
|
long long now = (long long) std::time(nullptr);
|
||||||
|
std::stable_sort(actions.begin(), actions.end(), [&](const nlohmann::json& a, const nlohmann::json& b) {
|
||||||
|
auto sc = [&](const nlohmann::json& x) {
|
||||||
|
auto it = stats.find(x["id"].get<std::string>());
|
||||||
|
// why: guard is_object() too - value() throws on a malformed (non-object) stats entry.
|
||||||
|
if (it == stats.end() || !it->is_object())
|
||||||
|
return 0.0;
|
||||||
|
return frecency_score(it->value("count", 0), it->value("last", 0LL), now);
|
||||||
|
};
|
||||||
|
double sa = sc(a), sb = sc(b);
|
||||||
|
if (sa != sb)
|
||||||
|
return sa > sb;
|
||||||
|
return a["title"].get<std::string>() < b["title"].get<std::string>();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// C++ -> JS push. Same escaping as WebViewHostDialog::call_web_handler: the ignore error
|
||||||
|
// handler keeps a stray non-UTF-8 byte in a plugin name from throwing and aborting the send;
|
||||||
|
// concatenation (not wxString::Format) avoids a '%' in a title being read as a format token.
|
||||||
|
void push(const nlohmann::json& j)
|
||||||
|
{
|
||||||
|
if (!m_browser)
|
||||||
|
return;
|
||||||
|
const wxString payload = wxString::FromUTF8(j.dump(-1, ' ', false, nlohmann::json::error_handler_t::ignore));
|
||||||
|
WebView::RunScript(m_browser, wxT("window.HandleStudio(") + payload + wxT(")"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only runnable actions are offered: enabled AND loaded SCRIPT capabilities. Unloaded or
|
||||||
|
// invalid ones stay hidden (this transient launcher has no greyed/edit state yet).
|
||||||
|
nlohmann::json build_actions() const
|
||||||
|
{
|
||||||
|
PluginLoader& loader = PluginManager::instance().get_loader();
|
||||||
|
|
||||||
|
// plugin_key -> package display name (the row eyebrow); falls back to the key.
|
||||||
|
std::map<std::string, std::string> pkg_name;
|
||||||
|
for (const PluginDescriptor& desc : loader.get_all_loaded_plugin_descriptors())
|
||||||
|
pkg_name[desc.plugin_key] = desc.name;
|
||||||
|
|
||||||
|
nlohmann::json arr = nlohmann::json::array();
|
||||||
|
for (const auto& cap : loader.get_plugin_capabilities_by_type(PluginCapabilityType::Script)) {
|
||||||
|
if (!cap || !cap->enabled)
|
||||||
|
continue;
|
||||||
|
if (!loader.is_plugin_loaded(cap->plugin_key))
|
||||||
|
continue;
|
||||||
|
const auto it = pkg_name.find(cap->plugin_key);
|
||||||
|
arr.push_back({{"id", action_id(cap->plugin_key, cap->name)},
|
||||||
|
{"title", cap->name.empty() ? cap->plugin_key : cap->name},
|
||||||
|
{"pkg", (it != pkg_name.end() && !it->second.empty()) ? it->second : cap->plugin_key},
|
||||||
|
{"runnable", true},
|
||||||
|
{"shortcut", ""}});
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void send_actions()
|
||||||
|
{
|
||||||
|
nlohmann::json actions = build_actions();
|
||||||
|
sort_by_frecency(actions);
|
||||||
|
push({{"command", "list_actions"},
|
||||||
|
{"actions", std::move(actions)},
|
||||||
|
{"favourites", read_favourites()}});
|
||||||
|
}
|
||||||
|
|
||||||
|
wxWebView* m_browser{nullptr};
|
||||||
|
bool m_page_ready{false};
|
||||||
|
bool m_pending_show{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
void open_speed_dial_popup()
|
||||||
|
{
|
||||||
|
wxWindow* parent = wxGetApp().mainframe;
|
||||||
|
if (!parent)
|
||||||
|
return;
|
||||||
|
|
||||||
|
static SpeedDialWebPopup* popup = nullptr;
|
||||||
|
if (!popup) {
|
||||||
|
popup = new SpeedDialWebPopup(parent);
|
||||||
|
popup->Bind(wxEVT_DESTROY, [](wxWindowDestroyEvent&) { popup = nullptr; });
|
||||||
|
}
|
||||||
|
popup->request_show();
|
||||||
|
}
|
||||||
|
|
||||||
|
}}
|
||||||
12
src/slic3r/GUI/SpeedDialDialog.hpp
Normal file
12
src/slic3r/GUI/SpeedDialDialog.hpp
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#ifndef slic3r_GUI_SpeedDialDialog_hpp_
|
||||||
|
#define slic3r_GUI_SpeedDialDialog_hpp_
|
||||||
|
|
||||||
|
#include <wx/string.h>
|
||||||
|
|
||||||
|
namespace Slic3r { namespace GUI {
|
||||||
|
|
||||||
|
void open_speed_dial_popup();
|
||||||
|
|
||||||
|
}}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -222,4 +222,4 @@ void CheckList::ShowMenu(wxMouseEvent &evt)
|
|||||||
wxPoint screen_pos = src->ClientToScreen(evt.GetPosition());
|
wxPoint screen_pos = src->ClientToScreen(evt.GetPosition());
|
||||||
wxPoint local_pos = ScreenToClient(screen_pos);
|
wxPoint local_pos = ScreenToClient(screen_pos);
|
||||||
PopupMenu(&m, local_pos);
|
PopupMenu(&m, local_pos);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user