Initial Commit of speed dial improvement

This commit is contained in:
Lam Wei Lun
2026-09-07 14:00:28 +08:00
parent 8d44b680bd
commit b32f28e712
15 changed files with 1441 additions and 566 deletions
+421 -68
View File
@@ -1,14 +1,22 @@
// Speed Dial launcher page. Static-safe module: no DOM access at load time so a // Speed Dial launcher page. Static-safe module: no DOM access at load time so a
// node vm can exercise the pure helpers (filterActions / actionLabel / nextSel). // node vm can exercise the pure helpers (filterActions / actionLabel / nextSel / commandList).
// ---- state (populated by the C++ bridge via window.HandleStudio) ---- // ---- state (populated by the C++ bridge via window.HandleStudio) ----
var ACTIONS = []; // [{id,title,source,shortcut}], already frecency-sorted by C++ var ACTIONS = []; // [{id,title,source,group,input,shortcut}], already frecency-sorted by C++
var FAVS = []; // [id...] var FAVS = []; // [id...]
var RECENTS = []; // [{id,title,source,group,input,shortcut}] - last-N launched
var query = ""; var query = "";
var sel = { zone: "list", i: 0 }; // zone: 'list' | 'fav' var sel = { zone: "list", i: 0 }; // zone: 'list' | 'fav'
var lastResizeHeight = 0; var lastResizeHeight = 0;
var matchIndex = {}; var matchIndex = {};
// Palette phase: 'commands' (search actions/commands, show recents), 'settings' ("Go to
// setting..." second phase: search config options), 'percent' ("Go to layer" second phase:
// enter a 0-100 percentage), 'tab' ("Go to tab..." second phase: pick a notebook tab).
var phase = "commands";
var settingsResults = []; // [{opt_key,type,label,category,group}]
var tabOptions = []; // [{id,title}] - notebook pages, fetched on entering the tab phase
// why: fuzzy matcher (FoldChar/Norm/FuzzyRanges) lives in shared ../../js/fuzzy-search.js, loaded before // why: fuzzy matcher (FoldChar/Norm/FuzzyRanges) lives in shared ../../js/fuzzy-search.js, loaded before
// this script - it is shared with the Plugins dialog. Speed dial search is always case-insensitive. // this script - it is shared with the Plugins dialog. Speed dial search is always case-insensitive.
@@ -27,7 +35,7 @@ function filterActions(actions, query) {
for (var i = 0; i < list.length; i++) { for (var i = 0; i < list.length; i++) {
var a = list[i]; var a = list[i];
var titleMatch = FuzzyRanges(a.title, q, false); var titleMatch = FuzzyRanges(a.title, q, false);
var sourceMatch = FuzzyRanges(a.source, q, false); var sourceMatch = FuzzyRanges(a.source || "", q, false);
if (!titleMatch && !sourceMatch) if (!titleMatch && !sourceMatch)
continue; continue;
matchIndex[a.id] = { title: titleMatch, source: sourceMatch, useTitle: !!titleMatch }; matchIndex[a.id] = { title: titleMatch, source: sourceMatch, useTitle: !!titleMatch };
@@ -50,18 +58,44 @@ function resultCountText(total, shown, query) {
return (query || "").trim() ? "Showing " + shown + " of " + total + " actions" : total + " actions"; return (query || "").trim() ? "Showing " + shown + " of " + total + " actions" : total + " actions";
} }
// Display label for a notebook tab. Notebook's ButtonsListCtrl labels every non-empty page as
// " <text>" (a leading space), so trim it; pages added with an empty title (Home, MainFrame adds
// TAB_ID_HOME with "") fall back to the title-cased id ("home" -> "Home").
function tabTitle(t) {
var title = (t && t.title) ? String(t.title).trim() : "";
return title || prettySource((t && t.id) || "");
}
// Filter the tab list by a fuzzy title/id match. Pure so the node-vm test can exercise it.
function filterTabs(tabs, query) {
var q = (query || "").trim();
if (!q)
return (tabs || []).slice(0);
return (tabs || []).filter(function (t) {
return FuzzyRanges(tabTitle(t), q, false) || FuzzyRanges(t.id, q, false);
});
}
function shouldRenderActionList(query) { function shouldRenderActionList(query) {
return !!(query || "").trim(); return !!((query || "").trim());
}
// The active list for the commands phase. A typed query filters every action (plugins +
// commands); an empty query shows the recent list instead (recents live below the search bar).
function commandList(actions, recents, query) {
if (shouldRenderActionList(query))
return filterActions(actions || [], query);
return (recents || []).slice(0);
} }
// Resolve the selection cursor {zone,i} to the action id it points at: fav zone indexes the // Resolve the selection cursor {zone,i} to the action id it points at: fav zone indexes the
// visible favourites, list zone the filtered actions. Pure so runSelected() shares one lookup. // visible favourites, list zone the active commands list. `actions` must be the already-resolved
// list (recents for an empty query, the filtered list otherwise) - pure so runSelected() shares
// one lookup and the node-vm test can call it directly.
function selectedActionId(sel, actions, favIds, query) { function selectedActionId(sel, actions, favIds, query) {
if (sel.zone === "fav") if (sel.zone === "fav")
return favIds[sel.i]; return favIds[sel.i];
// why: search-first keeps the list blank until the user types; an empty query still if (!actions || !actions.length)
// filters to ALL actions, so without this gate Enter fires an action never shown.
if (!shouldRenderActionList(query))
return null; return null;
var a = actions[sel.i]; var a = actions[sel.i];
return a && a.id; return a && a.id;
@@ -77,11 +111,11 @@ function prettySource(source) {
// Accessible label "Title from Pretty Source", disambiguated with the opaque action id when another // Accessible label "Title from Pretty Source", disambiguated with the opaque action id when another
// action shares the same title+source (case/separator-insensitive) - so two rows never read out identically. // action shares the same title+source (case/separator-insensitive) - so two rows never read out identically.
function actionLabel(action, actions) { function actionLabel(action, actions) {
var label = action.title + " from " + prettySource(action.source); var label = action.title + " from " + prettySource(action.source || action.group || "");
if (actions && actions.length) { if (actions && actions.length) {
var mine = foldLabel(action.title) + "|" + foldLabel(action.source); var mine = foldLabel(action.title) + "|" + foldLabel(action.source || action.group || "");
var clash = actions.some(function (o) { var clash = actions.some(function (o) {
return o.id !== action.id && foldLabel(o.title) + "|" + foldLabel(o.source) === mine; return o.id !== action.id && foldLabel(o.title) + "|" + foldLabel(o.source || o.group || "") === mine;
}); });
if (clash) if (clash)
label += " (" + action.id + ")"; label += " (" + action.id + ")";
@@ -90,26 +124,55 @@ function actionLabel(action, actions) {
} }
// Monogram code for a tile: title initial, escalated on collision by PREPENDING the source // Monogram code for a tile: title initial, escalated on collision by PREPENDING the source
// initial (pi+ti, e.g. "GC"), then a 1-based ordinal - so same-titled actions stay distinct. // initial (pi+ti, e.g. "GC"), then a 1-based ordinal - so same-titled items stay distinct.
// why: ordinal is assigned by id, not by ACTIONS order - ACTIONS is frecency-sorted and // why: ordinal is assigned by id, not by list order - list order is frecency-sorted and
// reshuffles as usage changes, which would otherwise flip who's "1" and who's "2" across runs. // reshuffles as usage changes, which would otherwise flip who's "1" and who's "2" across runs.
function tileCode(action, actions) { function monogramFor(item, list, titleOf, sourceOf, idOf) {
var list = actions || []; var items = list || [];
var ti = (action.title || " ").charAt(0).toUpperCase(); var title = titleOf(item) || " ";
var sameTitle = list.filter(function (o) { return (o.title || " ").charAt(0).toUpperCase() === ti; }); var ti = title.charAt(0).toUpperCase();
var sameTitle = items.filter(function (o) { return (titleOf(o) || " ").charAt(0).toUpperCase() === ti; });
if (sameTitle.length <= 1) if (sameTitle.length <= 1)
return ti; return ti;
var pi = (action.source || " ").charAt(0).toUpperCase(); var source = sourceOf(item) || " ";
var sameSource = sameTitle.filter(function (o) { return (o.source || " ").charAt(0).toUpperCase() === pi; }); var pi = source.charAt(0).toUpperCase();
var sameSource = sameTitle.filter(function (o) { return (sourceOf(o) || " ").charAt(0).toUpperCase() === pi; });
if (sameSource.length <= 1) if (sameSource.length <= 1)
return pi + ti; return pi + ti;
sameSource.sort(function (a, b) { return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; }); sameSource.sort(function (a, b) { return idOf(a) < idOf(b) ? -1 : idOf(a) > idOf(b) ? 1 : 0; });
for (var i = 0; i < sameSource.length; i++) for (var i = 0; i < sameSource.length; i++)
if (sameSource[i].id === action.id) if (sameSource[i] === item || idOf(sameSource[i]) === idOf(item))
return pi + ti + (i + 1); return pi + ti + (i + 1);
return pi + ti; return pi + ti;
} }
// Action tile code - see monogramFor for the escalation ladder. Accessed via accessors so the
// same helper serves settings rows (leaf label + category) without duplicating the logic.
function tileCode(action, actions) {
return monogramFor(action, actions,
function (o) { return o.title; },
function (o) { return o.source; },
function (o) { return o.id; });
}
// Last " : "-separated segment of a "category : group : label" setting label = the option name
// (e.g. "Quality : Layer Height" -> "Layer Height"); empty labels fall back to the opt_key.
function leafLabel(s) {
var label = String(s && (s.label || s.opt_key || "") || "");
var parts = label.split(" : ");
var leaf = parts[parts.length - 1];
return (leaf || "").trim() || label.trim();
}
// Setting tile code - the option name initial, then the section (category) on collision, then a
// stable ordinal, so settings no longer all collapse to a generic "S".
function settingCode(s, list) {
return monogramFor(s, list,
function (o) { return leafLabel(o); },
function (o) { return o.category; },
function (o) { return o.opt_key; });
}
function syncClearButton() { function syncClearButton() {
if (clearEl) if (clearEl)
clearEl.hidden = !query; clearEl.hidden = !query;
@@ -119,9 +182,13 @@ function stateFromPayload(payload) {
return { return {
actions: payload.actions || [], actions: payload.actions || [],
favourites: payload.favourites || [], favourites: payload.favourites || [],
recent: payload.recent || [],
query: "", query: "",
sel: { zone: "list", i: 0 }, sel: { zone: "list", i: 0 },
lastResizeHeight: 0 lastResizeHeight: 0,
phase: "commands",
settingsResults: [],
tabOptions: []
}; };
} }
@@ -165,7 +232,7 @@ function SendMessage(msg) {
SendWXMessage(JSON.stringify(msg)); SendWXMessage(JSON.stringify(msg));
} }
// C++ pushes payloads here. Only list_actions is handled; it (re)seeds all state. // C++ pushes payloads here.
window.HandleStudio = function (payload) { window.HandleStudio = function (payload) {
if (!payload) return; if (!payload) return;
if (typeof payload === "string") { try { payload = JSON.parse(payload); } catch (e) { return; } } if (typeof payload === "string") { try { payload = JSON.parse(payload); } catch (e) { return; } }
@@ -173,9 +240,13 @@ window.HandleStudio = function (payload) {
var next = stateFromPayload(payload); var next = stateFromPayload(payload);
ACTIONS = next.actions; ACTIONS = next.actions;
FAVS = next.favourites; FAVS = next.favourites;
RECENTS = next.recent;
query = next.query; query = next.query;
sel = next.sel; sel = next.sel;
lastResizeHeight = next.lastResizeHeight; lastResizeHeight = next.lastResizeHeight;
phase = next.phase;
settingsResults = next.settingsResults;
tabOptions = next.tabOptions;
if (qEl) { if (qEl) {
qEl.value = ""; qEl.value = "";
qEl.placeholder = "Search " + ACTIONS.length + " actions"; qEl.placeholder = "Search " + ACTIONS.length + " actions";
@@ -183,6 +254,16 @@ window.HandleStudio = function (payload) {
} }
render({ resize: true, resetScroll: true }); render({ resize: true, resetScroll: true });
focusInput(); focusInput();
} else if (payload.command === "settings_results") {
settingsResults = payload.results || [];
if (sel.zone === "list")
sel.i = Math.max(0, Math.min(sel.i, settingsResults.length - 1));
render({ resize: true });
} else if (payload.command === "tab_results") {
tabOptions = payload.tabs || [];
if (sel.zone === "list")
sel.i = Math.max(0, Math.min(sel.i, tabOptions.length - 1));
render({ resize: true });
} }
}; };
@@ -194,8 +275,21 @@ function byId(id) {
return null; return null;
} }
function findActionByInput(input) {
for (var i = 0; i < ACTIONS.length; i++) if (ACTIONS[i].input === input) return ACTIONS[i];
return null;
}
function currentVisibleFavs() { return visibleFavourites(FAVS, ACTIONS); } function currentVisibleFavs() { return visibleFavourites(FAVS, ACTIONS); }
// Active list for the current phase (drives list rendering + arrow nav).
function currentList() {
if (phase === "settings") return settingsResults;
if (phase === "tab") return filterTabs(tabOptions, query);
if (phase === "commands") return commandList(ACTIONS, RECENTS, query);
return []; // percent - the input itself is the only field
}
function hue(id) { function hue(id) {
var h = 0; var h = 0;
for (var i = 0; i < id.length; i++) for (var i = 0; i < id.length; i++)
@@ -236,6 +330,12 @@ function starSvg(on) {
// ---- render ------------------------------------------------------------------ // ---- render ------------------------------------------------------------------
function renderFav() { function renderFav() {
// Only the commands phase shows the pinned quick-bar.
if (phase !== "commands") {
if (favEl) { favEl.innerHTML = ""; favEl.hidden = true; }
if (eyeEl) eyeEl.hidden = true;
return;
}
favEl.innerHTML = ""; favEl.innerHTML = "";
var favs = currentVisibleFavs(); var favs = currentVisibleFavs();
favEl.hidden = favs.length === 0; favEl.hidden = favs.length === 0;
@@ -252,7 +352,16 @@ function renderFav() {
tile.textContent = tileCode(a, ACTIONS); tile.textContent = tileCode(a, ACTIONS);
tile.title = a.title; tile.title = a.title;
tile.setAttribute("aria-label", actionLabel(a, ACTIONS)); tile.setAttribute("aria-label", actionLabel(a, ACTIONS));
tile.onclick = function () { sel = { zone: "fav", i: i }; run(a); }; tile.onclick = function () { sel = { zone: "fav", i: i }; activateEntry(a); };
// Direct removal: a hover-revealed ✕ in the tile's corner. click() stops propagation so it
// unpins without activating the action.
var unpin = document.createElement("button");
unpin.className = "fav-unpin";
unpin.title = "Remove from favourites";
unpin.setAttribute("aria-label", "Remove from favourites");
unpin.innerHTML = '<svg width="9" height="9" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/></svg>';
unpin.onclick = function (ev) { ev.stopPropagation(); toggleFav(id); };
tile.appendChild(unpin);
tile.oncontextmenu = function (ev) { tile.oncontextmenu = function (ev) {
ev.preventDefault(); ev.preventDefault();
// why: selecting shows the eyebrow, which grows the launcher - resize so the popup // why: selecting shows the eyebrow, which grows the launcher - resize so the popup
@@ -320,33 +429,37 @@ function updateFavEyebrow(favs) {
eyeEl.hidden = !a; eyeEl.hidden = !a;
} }
function renderList() { // One settings row; has no star/tile because settings aren't pinnable.
var q = (query || "").trim(); function renderSettingRow(s, i) {
var arr = filterActions(ACTIONS, query); var row = document.createElement("div");
if (sel.zone === "list") row.className = "row" + (sel.zone === "list" && sel.i === i ? " sel" : "");
sel.i = Math.max(0, Math.min(sel.i, arr.length - 1)); row.setAttribute("aria-label", s.label);
listEl.innerHTML = "";
// why: search-first - the list stays blank until the user types; only a var tile = document.createElement("div");
// non-empty query with zero hits earns the "No actions match" message. tile.className = "tile";
if (!shouldRenderActionList(query)) { tile.style.setProperty("--h", hue(s.opt_key));
listEl.className = "dial-list empty"; tile.textContent = settingCode(s, settingsResults);
if (countEl) countEl.hidden = true;
return; var left = document.createElement("div");
} left.className = "row-left";
listEl.className = "dial-list" + (!arr.length ? " empty" : ""); // why: the label already packs "Category : Group : Label", so no separate eyebrow.
if (!arr.length) { var line = document.createElement("div");
if (countEl) countEl.hidden = true; line.className = "row-line";
var empty = document.createElement("div"); var name = document.createElement("div");
empty.className = "dial-empty"; name.className = "row-name";
empty.textContent = "No actions match (Total: " + ACTIONS.length + ")"; name.textContent = s.label;
listEl.appendChild(empty); line.appendChild(name);
return; left.appendChild(line);
}
if (countEl) { row.appendChild(tile);
countEl.hidden = false; row.appendChild(left);
countEl.textContent = resultCountText(ACTIONS.length, arr.length, query); row.onclick = function () { sel = { zone: "list", i: i }; render({ resize: true }); };
} row.ondblclick = function () { sel = { zone: "list", i: i }; jumpToSetting(s); };
arr.forEach(function (a, i) { return row;
}
// A command/action row (used for both recents and filtered results).
function renderActionRow(a, i) {
var on = FAVS.indexOf(a.id) !== -1; var on = FAVS.indexOf(a.id) !== -1;
var row = document.createElement("div"); var row = document.createElement("div");
row.className = "row" + (sel.zone === "list" && sel.i === i ? " sel" : ""); row.className = "row" + (sel.zone === "list" && sel.i === i ? " sel" : "");
@@ -360,7 +473,7 @@ function renderList() {
var left = document.createElement("div"); var left = document.createElement("div");
left.className = "row-left"; left.className = "row-left";
var mi = matchIndex[a.id]; var mi = matchIndex[a.id];
var sourceEl = markedText("row-eyebrow", a.source, mi ? mi.source : null); var sourceEl = markedText("row-eyebrow", a.group || a.source, mi ? mi.source : null);
var line = document.createElement("div"); var line = document.createElement("div");
line.className = "row-line"; line.className = "row-line";
var name = markedText("row-name", a.title, mi ? mi.title : null); var name = markedText("row-name", a.title, mi ? mi.title : null);
@@ -389,10 +502,159 @@ function renderList() {
star.ondblclick = function (ev) { ev.stopPropagation(); }; star.ondblclick = function (ev) { ev.stopPropagation(); };
row.appendChild(star); row.appendChild(star);
row.onclick = function () { sel = { zone: "list", i: i }; render(); }; row.onclick = function () { sel = { zone: "list", i: i }; render({ resize: true }); };
row.ondblclick = function () { sel = { zone: "list", i: i }; run(a); }; row.ondblclick = function () { sel = { zone: "list", i: i }; activateEntry(a); };
listEl.appendChild(row); return row;
}); }
function renderCommandsList() {
var q = (query || "").trim();
var list = currentList();
if (sel.zone === "list")
sel.i = Math.max(0, Math.min(sel.i, list.length - 1));
// Recents are not filtered, so clear any stale match marks from a previous typed query.
if (!shouldRenderActionList(query))
matchIndex = {};
listEl.innerHTML = "";
if (!shouldRenderActionList(query) && list.length) {
var head = document.createElement("div");
head.className = "dial-group";
head.textContent = "Recent";
listEl.appendChild(head);
}
if (!list.length) {
listEl.className = "dial-list empty";
if (countEl) countEl.hidden = true;
var empty = document.createElement("div");
empty.className = "dial-empty";
empty.textContent = shouldRenderActionList(query) ? ("No actions match (Total: " + ACTIONS.length + ")") : "No actions yet";
listEl.appendChild(empty);
return;
}
listEl.className = "dial-list";
if (countEl) {
countEl.hidden = false;
countEl.textContent = shouldRenderActionList(query) ? resultCountText(ACTIONS.length, list.length, query) : list.length + " recent";
}
list.forEach(function (a, i) { listEl.appendChild(renderActionRow(a, i)); });
}
function renderSettingsList() {
var q = (query || "").trim();
listEl.innerHTML = "";
// Empty query + recents -> show the recent settings under a "Recent" group header.
var showingRecents = !q && settingsResults.length > 0;
if (!q && !showingRecents) {
listEl.className = "dial-list empty";
if (countEl) countEl.hidden = true;
var hint = document.createElement("div");
hint.className = "dial-empty";
hint.textContent = "Type to search print, filament and printer settings";
listEl.appendChild(hint);
return;
}
if (q && !settingsResults.length) {
listEl.className = "dial-list empty";
if (countEl) countEl.hidden = true;
var empty = document.createElement("div");
empty.className = "dial-empty";
empty.textContent = "No settings match";
listEl.appendChild(empty);
return;
}
if (sel.zone === "list")
sel.i = Math.max(0, Math.min(sel.i, settingsResults.length - 1));
listEl.className = "dial-list";
if (countEl) {
countEl.hidden = false;
countEl.textContent = showingRecents ? settingsResults.length + " recent" : settingsResults.length + " matches";
}
if (showingRecents) {
var head = document.createElement("div");
head.className = "dial-group";
head.textContent = "Recent";
listEl.appendChild(head);
}
settingsResults.forEach(function (s, i) { listEl.appendChild(renderSettingRow(s, i)); });
}
// A tab row: no star/unpin (tabs aren't pinnable), tile monogram from the title. Uses tabTitle so
// pages added with an empty text (e.g. Home) still show a label and an icon letter.
function renderTabRow(t, i) {
var label = tabTitle(t);
var row = document.createElement("div");
row.className = "row" + (sel.zone === "list" && sel.i === i ? " sel" : "");
row.setAttribute("aria-label", label);
var tile = document.createElement("div");
tile.className = "tile";
tile.style.setProperty("--h", hue(t.id));
tile.textContent = label.charAt(0).toUpperCase();
var left = document.createElement("div");
left.className = "row-left";
var line = document.createElement("div");
line.className = "row-line";
var name = document.createElement("div");
name.className = "row-name";
name.textContent = label;
line.appendChild(name);
left.appendChild(line);
row.appendChild(tile);
row.appendChild(left);
row.onclick = function () { sel = { zone: "list", i: i }; render({ resize: true }); };
row.ondblclick = function () { sel = { zone: "list", i: i }; jumpToTab(t); };
return row;
}
function renderTabList() {
var q = (query || "").trim();
var list = currentList();
listEl.innerHTML = "";
if (!list.length) {
listEl.className = "dial-list empty";
if (countEl) countEl.hidden = true;
var empty = document.createElement("div");
empty.className = "dial-empty";
empty.textContent = q ? "No tabs match" : "No tabs";
listEl.appendChild(empty);
return;
}
if (sel.zone === "list")
sel.i = Math.max(0, Math.min(sel.i, list.length - 1));
listEl.className = "dial-list";
if (countEl) {
countEl.hidden = false;
countEl.textContent = q ? list.length + " matches" : list.length + " tabs";
}
list.forEach(function (t, i) { listEl.appendChild(renderTabRow(t, i)); });
}
function renderPercentList() {
var q = (query || "").trim();
listEl.innerHTML = "";
listEl.className = "dial-list empty";
if (countEl) countEl.hidden = true;
var ph = document.createElement("div");
ph.className = "dial-empty";
ph.textContent = q ? ("Go to " + q + "% of the layer range") : "Enter a layer percentage (0-100)";
listEl.appendChild(ph);
}
function renderList() {
if (phase === "settings")
renderSettingsList();
else if (phase === "tab")
renderTabList();
else if (phase === "percent")
renderPercentList();
else
renderCommandsList();
} }
function render(opts) { function render(opts) {
@@ -441,18 +703,99 @@ function toggleFav(id) {
render({ resize: true }); render({ resize: true });
} }
// Fire the action; C++ owns the run-confirm (native dialog) + suppression, then closes the popup + toasts. // Fire a command/plugin action; C++ owns the run-confirm (native dialog) + suppression, then
// closes the popup + toasts.
function run(a) { function run(a) {
if (!a) return; if (!a) return;
SendMessage({ command: "run_action", id: a.id, title: a.title }); SendMessage({ command: "run_action", id: a.id, title: a.title, param: "" });
}
// Activate an entry in the commands phase. Two-phase commands switch the palette to their input
// phase instead of running; everything else runs immediately.
function activateEntry(a) {
if (!a) return;
if (a.input === "settings") { enterSettingsPhase(); return; }
if (a.input === "percent") { enterPercentPhase(); return; }
if (a.input === "tab") { enterTabsPhase(); return; }
run(a);
} }
function runSelected() { function runSelected() {
var id = selectedActionId(sel, filterActions(ACTIONS, query), currentVisibleFavs(), query); if (phase === "percent") {
if (id) run(byId(id)); runJumpToLayer(query.trim());
return;
}
if (phase === "settings") {
var s = settingsResults[sel.i];
if (s) jumpToSetting(s);
return;
}
if (phase === "tab") {
var t = currentList()[sel.i];
if (t) jumpToTab(t);
return;
}
var list = currentList();
var id = selectedActionId(sel, list, currentVisibleFavs(), query);
if (id) activateEntry(byId(id));
} }
function focusInput() { setTimeout(function () { qEl.focus(); }, 0); } function jumpToSetting(s) {
SendMessage({ command: "go_to_setting", opt_key: s.opt_key, type: s.type, category: s.category || "", label: s.label || "", group: s.group || "" });
}
function runJumpToLayer(pct) {
if (pct === "") return;
var n = parseFloat(pct);
if (!isFinite(n) || n < 0 || n > 100) return;
var a = findActionByInput("percent");
if (!a) return;
SendMessage({ command: "run_action", id: a.id, title: a.title, param: String(n) });
}
function jumpToTab(t) {
SendMessage({ command: "go_to_tab", id: t.id, title: tabTitle(t) });
}
function enterSettingsPhase() {
phase = "settings"; settingsResults = []; query = ""; qEl.value = "";
sel = { zone: "list", i: 0 };
qEl.placeholder = "Search settings...";
syncClearButton();
render({ resize: true, resetScroll: true });
qEl.focus();
SendMessage({ command: "search_settings", q: "" });
}
function enterPercentPhase() {
phase = "percent"; query = ""; qEl.value = "";
sel = { zone: "list", i: 0 };
qEl.placeholder = "Go to layer % (0-100)";
syncClearButton();
render({ resize: true, resetScroll: true });
qEl.focus();
}
function enterTabsPhase() {
phase = "tab"; tabOptions = []; query = ""; qEl.value = "";
sel = { zone: "list", i: 0 };
qEl.placeholder = "Go to tab";
syncClearButton();
render({ resize: true, resetScroll: true });
qEl.focus();
SendMessage({ command: "search_tabs" });
}
function exitPhase() {
phase = "commands"; settingsResults = []; tabOptions = []; query = ""; qEl.value = "";
sel = { zone: "list", i: 0 };
qEl.placeholder = "Search " + ACTIONS.length + " actions";
syncClearButton();
render({ resize: true, resetScroll: true });
qEl.focus();
}
function focusInput() { setTimeout(function () { if (qEl) qEl.focus(); }, 0); }
// ---- init -------------------------------------------------------------------- // ---- init --------------------------------------------------------------------
function OnInit() { function OnInit() {
@@ -460,11 +803,15 @@ function OnInit() {
syncClearButton(); syncClearButton();
$("clear").onclick = function () { $("clear").onclick = function () {
query = ""; qEl.value = ""; sel = { zone: "list", i: 0 }; render({ resize: true, resetScroll: true }); qEl.focus(); query = ""; qEl.value = ""; sel = { zone: "list", i: 0 };
if (phase === "settings") SendMessage({ command: "search_settings", q: "" });
render({ resize: true, resetScroll: true }); qEl.focus();
syncClearButton(); syncClearButton();
}; };
qEl.addEventListener("input", function () { qEl.addEventListener("input", function () {
query = qEl.value; sel = { zone: "list", i: 0 }; syncClearButton(); render({ resize: true, resetScroll: true }); query = qEl.value; sel = { zone: "list", i: 0 }; syncClearButton();
if (phase === "settings") SendMessage({ command: "search_settings", q: query });
render({ resize: true, resetScroll: true });
}); });
// why: dismiss the fav context menu on any click/scroll away from it (capture scroll to catch nested scrollers). // why: dismiss the fav context menu on any click/scroll away from it (capture scroll to catch nested scrollers).
@@ -473,23 +820,29 @@ function OnInit() {
document.addEventListener("keydown", function (e) { document.addEventListener("keydown", function (e) {
if (favMenuEl && !favMenuEl.hidden && e.key === "Escape") { e.preventDefault(); hideFavMenu(); return; } if (favMenuEl && !favMenuEl.hidden && e.key === "Escape") { e.preventDefault(); hideFavMenu(); return; }
var arr = filterActions(ACTIONS, query); var list = currentList();
var favs = currentVisibleFavs(); // why: fav bar only exists in the commands phase; other phases are list-only, so an
// ArrowUp at the top must not jump into a hidden fav zone.
var favs = (phase === "commands") ? currentVisibleFavs() : [];
// why: Up/Down always navigate; Left/Right only navigate the fav bar. In the list zone, // why: Up/Down always navigate; Left/Right only navigate the fav bar. In the list zone,
// let Left/Right fall through so they move the caret in the focused search field. // let Left/Right fall through so they move the caret in the focused search field.
var lr = e.key === "ArrowLeft" || e.key === "ArrowRight"; var lr = e.key === "ArrowLeft" || e.key === "ArrowRight";
if (e.key === "ArrowDown" || e.key === "ArrowUp" || (lr && sel.zone === "fav")) { if (e.key === "ArrowDown" || e.key === "ArrowUp" || (lr && sel.zone === "fav")) {
// In the percent phase the input is the whole UI - arrows move the caret, not rows.
if (phase === "percent") return;
e.preventDefault(); e.preventDefault();
sel = nextSel(sel, e.key, arr.length, favs.length); sel = nextSel(sel, e.key, list.length, favs.length);
// why: entering/leaving the fav zone toggles the eyebrow line, changing launcher height; // why: entering/leaving the fav zone toggles the eyebrow line, changing launcher height;
// resize so the popup grows/shrinks instead of clipping. requestResize no-ops when unchanged. // resize so the popup grows/shrinks instead of clipping. requestResize no-ops when unchanged.
render({ resize: true }); render({ resize: true });
} else if (e.key === "Enter") { } else if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
runSelected(); if (phase === "percent") runJumpToLayer(query.trim());
else runSelected();
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
if (query) { query = ""; qEl.value = ""; sel = { zone: "list", i: 0 }; syncClearButton(); render({ resize: true, resetScroll: true }); } if (phase !== "commands") { exitPhase(); }
else if (query) { query = ""; qEl.value = ""; sel = { zone: "list", i: 0 }; syncClearButton(); render({ resize: true, resetScroll: true }); qEl.focus(); }
else SendMessage({ command: "close_page" }); else SendMessage({ command: "close_page" });
} }
}); });
@@ -22,20 +22,92 @@ assert.equal(
"duplicate labels should use the opaque id without interpreting its contents" "duplicate labels should use the opaque id without interpreting its contents"
); );
assert.equal(ctx.shouldRenderActionList(""), false, "an empty search keeps the action list hidden"); assert.equal(ctx.shouldRenderActionList(""), false, "an empty search keeps recent/empty list");
assert.equal(ctx.shouldRenderActionList(" "), false, "whitespace-only search keeps the action list hidden"); assert.equal(ctx.shouldRenderActionList(" "), false, "whitespace-only search keeps recent/empty list");
assert.equal(ctx.shouldRenderActionList("r"), true, "typing starts rendering matching actions"); assert.equal(ctx.shouldRenderActionList("r"), true, "typing starts rendering matching actions");
// commandList: an empty query shows recents; a typed query filters all actions.
assert.deepEqual(ctx.commandList(duplicateActions, [], ""), [],
"empty query + no recents shows nothing");
assert.deepEqual(ctx.commandList(duplicateActions, [duplicateActions[0]], ""),
[duplicateActions[0]],
"empty query shows the recent list");
assert.deepEqual(ctx.commandList(duplicateActions, [], "rep"), duplicateActions,
"a typed query filters actions (both identical titles match) instead of showing recents");
// filterTabs (tab phase): an empty query keeps the whole list; a typed query filters by title/id.
const tabOptions = [
{ id: "home", title: "Home" },
{ id: "prepare", title: "Prepare" },
{ id: "monitor", title: "Device" },
{ id: "project", title: "Project" }
];
assert.deepEqual(ctx.filterTabs(tabOptions, ""), tabOptions,
"empty query keeps the whole tab list");
assert.equal(ctx.filterTabs(tabOptions, "prep").length, 1,
"a typed query filters tabs by title");
assert.equal(ctx.filterTabs(tabOptions, "Device").length, 1,
"a typed query matches a tab title");
assert.deepEqual(ctx.filterTabs(tabOptions, "zzz"), [],
"a typed query with no match returns an empty list");
// tabTitle: pages added with an empty title (e.g. MainFrame's Home tab) fall back to the id.
assert.equal(ctx.tabTitle({ id: "home", title: "" }), "Home",
"an empty title falls back to the title-cased id");
assert.equal(ctx.tabTitle({ id: "home" }), "Home",
"a missing title falls back to the title-cased id");
assert.equal(ctx.tabTitle({ id: "prepare", title: "Prepare" }), "Prepare",
"a populated title is kept as-is");
assert.equal(ctx.tabTitle({ id: "prepare", title: " Prepare" }), "Prepare",
"a leading space from the Notebook button label is trimmed so the icon letter shows");
assert.equal(ctx.filterTabs([{ id: "home", title: "" }], "home").length, 1,
"an untitled tab still matches a typed query via the id/title fallback");
assert.equal(ctx.filterTabs([{ id: "prepare", title: " Prepare" }], "prepare").length, 1,
"a leading-space tab title still matches a typed query");
// settingCode: a setting tile uses the leaf option-name initial (label's last " : "-segment),
// escalated by category, then a stable ordinal - so settings aren't all a generic "S".
const settings = [
{ opt_key: "layer_height", label: "Quality : Layer Height", category: "Quality", type: 0 },
{ opt_key: "smooth", label: "Quality : Smooth", category: "Quality", type: 0 }
];
assert.equal(ctx.leafLabel({ label: "Quality : Layer Height", opt_key: "x" }), "Layer Height",
"leafLabel takes the last segment of the label");
assert.equal(ctx.settingCode(settings[0], settings), "L",
"a unique leaf initial yields a single letter");
// renderSettingRow iterates settingsResults, so the coded item is always a member of the list.
const infillQ = { opt_key: "a", label: "Quality : Infill", category: "Quality", type: 0 };
const infillS = { opt_key: "b", label: "Supports : Infill", category: "Supports", type: 0 };
const sameLeaf = [infillQ, infillS];
assert.equal(ctx.settingCode(infillQ, sameLeaf), "QI",
"a colliding leaf initial escalates to category + initial");
assert.equal(ctx.settingCode(infillS, sameLeaf), "SI",
"a different category disambiguates the same leaf initial");
const dupQ = [
{ opt_key: "a", label: "Quality : Infill", category: "Quality", type: 0 },
{ opt_key: "b", label: "Quality : Infill", category: "Quality", type: 0 }
];
assert.equal(ctx.settingCode(dupQ[0], dupQ), "QI1",
"both title and category collide -> stable ordinal by opt_key");
assert.equal(ctx.settingCode(dupQ[1], dupQ), "QI2",
"the ordinal advances by opt_key order");
// selectedActionId: resolves the active list (recents for an empty query, filtered list otherwise).
assert.equal( assert.equal(
ctx.selectedActionId({ zone: "list", i: 0 }, duplicateActions, [], ""), ctx.selectedActionId({ zone: "list", i: 0 }, ctx.commandList(duplicateActions, [], ""), [], ""),
null, null,
"Enter with an empty query must not resolve to an action the blank list never showed" "Enter with an empty query and no recents must not resolve to an action the list never showed"
); );
assert.equal( assert.equal(
ctx.selectedActionId({ zone: "list", i: 0 }, duplicateActions, [], "rep"), ctx.selectedActionId({ zone: "list", i: 0 }, ctx.commandList(duplicateActions, [], "rep"), [], "rep"),
"0123456789abcdef", "0123456789abcdef",
"a typed query resolves the list selection" "a typed query resolves the list selection"
); );
assert.equal(
ctx.selectedActionId({ zone: "list", i: 0 }, ctx.commandList(duplicateActions, [duplicateActions[0]], ""), [], ""),
"0123456789abcdef",
"Enter with an empty query resolves the recent entry"
);
assert.equal( assert.equal(
ctx.selectedActionId({ zone: "fav", i: 0 }, duplicateActions, ["fedcba9876543210"], ""), ctx.selectedActionId({ zone: "fav", i: 0 }, duplicateActions, ["fedcba9876543210"], ""),
"fedcba9876543210", "fedcba9876543210",
+31
View File
@@ -55,6 +55,28 @@ body {
border: 1px solid var(--speed-tile-border, #d8d8d8); border: 1px solid var(--speed-tile-border, #d8d8d8);
} }
.fav-tile.sel { outline: 2px solid var(--main-color, var(--orca-accent, #009688)); outline-offset: 2px; } .fav-tile.sel { outline: 2px solid var(--main-color, var(--orca-accent, #009688)); outline-offset: 2px; }
/* Hover-revealed remove button in the tile's corner; sits inside the tile bounds so overflow:hidden clips it. */
.fav-tile { position: relative; }
.fav-unpin {
position: absolute;
top: 1px;
right: 1px;
width: 13px;
height: 13px;
padding: 0;
border: 0;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--muted, var(--orca-muted, #6b7280));
background: rgba(127,127,127,.35);
cursor: pointer;
opacity: 0;
}
.fav-tile:hover .fav-unpin,
.fav-tile.sel .fav-unpin { opacity: 1; }
.fav-unpin:hover { background: rgba(127,127,127,.6); }
.ctx-menu { .ctx-menu {
position: fixed; position: fixed;
z-index: 10; z-index: 10;
@@ -127,6 +149,15 @@ body {
color: var(--muted, var(--orca-muted, #6b7280)); color: var(--muted, var(--orca-muted, #6b7280));
} }
.dial-count[hidden] { display: none; } .dial-count[hidden] { display: none; }
/* Section header inside the list (e.g. the "Recent" row above the recent entries). */
.dial-group {
padding: 8px 8px 2px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--muted, var(--orca-muted, #6b7280));
}
.dial-list { .dial-list {
flex: 0 1 auto; flex: 0 1 auto;
min-height: 0; min-height: 0;
+326 -40
View File
@@ -1,15 +1,29 @@
#include "ActionRegistry.hpp" #include "ActionRegistry.hpp"
#include "GCodeViewer.hpp"
#include "GLCanvas3D.hpp"
#include "GUI.hpp" #include "GUI.hpp"
#include "GUI_App.hpp" #include "GUI_App.hpp"
#include "I18N.hpp" #include "I18N.hpp"
#include "IMSlider.hpp"
#include "MainFrame.hpp"
#include "Notebook.hpp"
#include "Plater.hpp"
#include "Search.hpp"
#include "Tab.hpp"
#include "slic3r/plugin/PluginManager.hpp" #include "slic3r/plugin/PluginManager.hpp"
#include <libslic3r/AppConfig.hpp> #include <libslic3r/AppConfig.hpp>
#include <libslic3r/Config.hpp>
#include <libslic3r/PresetBundle.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp> #include <slic3r/plugin/PythonPluginInterface.hpp>
#include <wx/thread.h> #include <wx/thread.h>
#include <boost/algorithm/string/predicate.hpp>
#include <boost/algorithm/string/trim.hpp>
#include <boost/nowide/convert.hpp>
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <ctime> #include <ctime>
@@ -28,14 +42,9 @@ nlohmann::json parse_config_json(const std::string& value, nlohmann::json fallba
} }
nlohmann::json read_section(const char* key, nlohmann::json fallback) nlohmann::json read_section(const char* key, nlohmann::json fallback)
{ { return parse_config_json(wxGetApp().app_config->get(kConfigSection, key), std::move(fallback)); }
return parse_config_json(wxGetApp().app_config->get(kConfigSection, key), std::move(fallback));
}
void write_section(const char* key, const nlohmann::json& j) void write_section(const char* key, const nlohmann::json& j) { wxGetApp().app_config->set(kConfigSection, key, j.dump()); }
{
wxGetApp().app_config->set(kConfigSection, key, j.dump());
}
std::vector<std::string> read_string_array(const char* key) std::vector<std::string> read_string_array(const char* key)
{ {
@@ -79,19 +88,18 @@ struct PluginScriptAction : AppAction
// The id an action for (plugin_key, capability) would have - lets refresh_capability // The id an action for (plugin_key, capability) would have - lets refresh_capability
// remove a gone capability without materialising the action. // remove a gone capability without materialising the action.
static std::string id_for(const std::string& plugin_key, const std::string& capability) static std::string id_for(const std::string& plugin_key, const std::string& capability)
{ { return AppAction::compose_id(kIdPrefix, capability.empty() ? plugin_key : capability, plugin_key); }
return AppAction::compose_id(kIdPrefix, capability.empty() ? plugin_key : capability, plugin_key);
}
PluginScriptAction(std::string plugin_key_in, std::string capability_in, std::string source_name) PluginScriptAction(std::string plugin_key_in, std::string capability_in, std::string source_name)
: AppAction(kIdPrefix, : AppAction(kIdPrefix,
capability_in.empty() ? plugin_key_in : capability_in, // title capability_in.empty() ? plugin_key_in : capability_in, // title
plugin_key_in, // source_key plugin_key_in, // source_key
std::move(source_name)), std::move(source_name))
plugin_key(std::move(plugin_key_in)), capability(std::move(capability_in)) , plugin_key(std::move(plugin_key_in))
, capability(std::move(capability_in))
{} {}
AppActionRunResult run() const override AppActionRunResult run(const std::string& /*param*/) const override
{ {
std::string error; std::string error;
const ExecutionResult result = PluginManager::instance().run_script_capability(plugin_key, capability, error); const ExecutionResult result = PluginManager::instance().run_script_capability(plugin_key, capability, error);
@@ -107,8 +115,7 @@ struct PluginScriptAction : AppAction
// Builds an action for a capability, or nullptr if it is not a currently-loaded, // Builds an action for a capability, or nullptr if it is not a currently-loaded,
// enabled script capability. // enabled script capability.
std::unique_ptr<AppAction> make_action(const std::string& plugin_key, const std::string& capability, std::unique_ptr<AppAction> make_action(const std::string& plugin_key, const std::string& capability, const std::string& source_name)
const std::string& source_name)
{ {
PluginManager& manager = PluginManager::instance(); PluginManager& manager = PluginManager::instance();
if (!manager.is_plugin_loaded(plugin_key)) if (!manager.is_plugin_loaded(plugin_key))
@@ -119,8 +126,167 @@ std::unique_ptr<AppAction> make_action(const std::string& plugin_key, const std:
return std::make_unique<PluginScriptAction>(plugin_key, capability, source_name); return std::make_unique<PluginScriptAction>(plugin_key, capability, source_name);
} }
// ---- built-in command actions (the speed dial "commands" section) ------
constexpr const char* kCommandPrefix = "orca_command";
constexpr const char* kOrcaSourceKey = "orca";
constexpr const char* kOrcaSourceName = "OrcaSlicer";
// Jump the preview to a layer selected by a 0-100 percent of the layer range. Best-effort:
// switches to the preview tab and requests a slice (select_view_3D("Preview", false)); if the
// slicer result is already present the slider is repositioned immediately, otherwise the user
// can re-run after slicing.
void go_to_layer(Plater* plater, const std::string& param)
{
if (!plater)
return;
double pct = 50.0;
try {
pct = std::stod(param);
} catch (const std::exception&) {}
pct = std::clamp(pct, 0.0, 100.0);
GLCanvas3D* canvas = plater->get_current_canvas3D();
if (!canvas)
return;
GCodeViewer& viewer = canvas->get_gcode_viewer();
IMSlider* layers = viewer.get_layers_slider();
IMSlider* moves = viewer.get_moves_slider();
if (!layers || layers->GetMaxValue() <= 0)
return; // no slice result yet - the slice request above will populate it
const double max = double(layers->GetMaxValue());
const int target = int(std::lround(pct / 100.0 * max));
layers->SetHigherValue(target);
// In "one layer" mode the lower handle follows the higher one (mirrors arrow-key nav).
if (layers->is_one_layer())
layers->SetLowerValue(target);
layers->set_as_dirty();
if (moves) {
moves->SetHigherValue(moves->GetMaxValue());
moves->set_as_dirty();
}
}
// Dispatch a built-in command. The CommandAction stays a thin value; the actual GUI work
// lives here so it can touch the live app state.
AppActionRunResult run_native_command(const std::string& command_key, const std::string& param)
{
GUI_App& app = wxGetApp();
if (app.is_closing())
return {};
Plater* plater = app.plater();
if (command_key == "save_project") {
if (plater)
plater->save_project(false);
return {AppActionRunResult::Level::Success};
}
if (command_key == "save_project_as") {
if (plater)
plater->save_project(true);
return {AppActionRunResult::Level::Success};
}
if (command_key == "load_project") {
if (plater)
plater->load_project();
return {AppActionRunResult::Level::Success};
}
if (command_key == "open_preferences") {
app.open_preferences();
return {AppActionRunResult::Level::Success};
}
if (command_key == "mode_simple" || command_key == "mode_advanced" || command_key == "mode_expert") {
const int mode = command_key == "mode_simple" ? comSimple : command_key == "mode_advanced" ? comAdvanced : comExpert;
app.save_mode(mode);
return {AppActionRunResult::Level::Success};
}
if (command_key == "slice_and_preview") {
if (plater) {
plater->select_view_3D("Preview", false);
if (app.mainframe)
app.mainframe->select_tab(TAB_ID_PREVIEW);
}
return {AppActionRunResult::Level::Success};
}
if (command_key == "go_to_layer") {
if (plater) {
plater->select_view_3D("Preview", false);
if (app.mainframe)
app.mainframe->select_tab(TAB_ID_PREVIEW);
go_to_layer(plater, param);
}
return {AppActionRunResult::Level::Success};
}
// "go_to_setting"/"go_to_tab" are two-phase: the palette collects the option after
// activating it, so dispatch here is a no-op (the actual jump goes through the web command).
if (command_key == "go_to_setting" || command_key == "go_to_tab")
return {AppActionRunResult::Level::Success};
return {AppActionRunResult::Level::Info, _L("Unknown command.")};
}
// A built-in command action. source_key is the constant "orca" so a renamed title never
// re-keys the action (matches the plugin source-key contract).
struct CommandAction : AppAction
{
std::string command_key;
CommandAction(std::string command_key, std::string title, std::string group, std::string input = "")
: AppAction(kCommandPrefix, std::move(title), kOrcaSourceKey, kOrcaSourceName), command_key(std::move(command_key))
{
this->kind = AppActionKind::Command;
this->group = std::move(group);
this->input = std::move(input);
}
AppActionRunResult run(const std::string& param) const override { return run_native_command(command_key, param); }
};
std::unique_ptr<AppAction> make_command(std::string key, std::string title, std::string group, std::string input = "")
{ return std::make_unique<CommandAction>(std::move(key), std::move(title), std::move(group), std::move(input)); }
// The built-in palette commands, registered once at init().
std::vector<std::unique_ptr<AppAction>> native_commands()
{
std::vector<std::unique_ptr<AppAction>> out;
// why: _u8L (std::string) for titles/groups - make_command takes std::string; _L would
// return a wxString and silently fail to convert here.
out.push_back(make_command("slice_and_preview", _u8L("Slice and Preview"), _u8L("Commands")));
// Two-phase commands: activating them collects input in the palette, then runs.
out.push_back(make_command("go_to_layer", _u8L("Go to layer (percent)"), _u8L("Commands"), "percent"));
// The "…" is avoided in the msgid: use ASCII "..." to keep the .pot extraction simple.
out.push_back(make_command("go_to_setting", _u8L("Go to setting..."), _u8L("Commands"), "settings"));
out.push_back(make_command("go_to_tab", _u8L("Go to tab..."), _u8L("Commands"), "tab"));
out.push_back(make_command("load_project", _u8L("Load Project"), _u8L("Commands")));
out.push_back(make_command("save_project", _u8L("Save Project"), _u8L("Commands")));
out.push_back(make_command("save_project_as", _u8L("Save Project As"), _u8L("Commands")));
out.push_back(make_command("open_preferences", _u8L("Preferences"), _u8L("Commands")));
out.push_back(make_command("mode_simple", _u8L("Mode: Simple"), _u8L("Mode")));
out.push_back(make_command("mode_advanced", _u8L("Mode: Advanced"), _u8L("Mode")));
out.push_back(make_command("mode_expert", _u8L("Mode: Expert"), _u8L("Mode")));
return out;
}
// Replicates Sidebar's get_search_inputs(): the configs of every tab supporting the current
// printer technology, in the current UI mode.
std::vector<Search::InputInfo> settings_inputs()
{
std::vector<Search::InputInfo> ret;
GUI_App& app = wxGetApp();
if (!app.preset_bundle)
return ret;
auto print_tech = app.preset_bundle->printers.get_selected_preset().printer_technology();
for (Tab* tab : app.tabs_list)
if (tab && tab->supports_printer_technology(print_tech))
ret.emplace_back(Search::InputInfo{tab->get_config(), tab->type(), app.get_mode()});
return ret;
}
} // namespace } // namespace
ActionRegistry::~ActionRegistry() = default;
void ActionRegistry::init() void ActionRegistry::init()
{ {
assert(wxThread::IsMain()); assert(wxThread::IsMain());
@@ -151,18 +317,12 @@ void ActionRegistry::init()
// Subscribe before enumerating so a concurrent load cannot land between the initial // Subscribe before enumerating so a concurrent load cannot land between the initial
// snapshot and callback registration. Duplicate notifications are safe: upsert is by // snapshot and callback registration. Duplicate notifications are safe: upsert is by
// id and the m_actions scan in refresh_source is idempotent. // id and the m_actions scan in refresh_source is idempotent.
manager.subscribe_on_load_callback( manager.subscribe_on_load_callback([on_source](const std::string& key) { on_source(key, ActionChange::Added); });
[on_source](const std::string& key) { on_source(key, ActionChange::Added); }); manager.subscribe_on_unload_callback([on_source](const std::string& key) { on_source(key, ActionChange::Removed); });
manager.subscribe_on_unload_callback(
[on_source](const std::string& key) { on_source(key, ActionChange::Removed); });
manager.subscribe_on_capability_load_callback( manager.subscribe_on_capability_load_callback(
[on_capability](const PluginCapabilityId& capability) { [on_capability](const PluginCapabilityId& capability) { on_capability(capability, ActionChange::Added); });
on_capability(capability, ActionChange::Added);
});
manager.subscribe_on_capability_unload_callback( manager.subscribe_on_capability_unload_callback(
[on_capability](const PluginCapabilityId& capability) { [on_capability](const PluginCapabilityId& capability) { on_capability(capability, ActionChange::Removed); });
on_capability(capability, ActionChange::Removed);
});
// enumerate current script capabilities // enumerate current script capabilities
std::unordered_map<std::string, std::string> source_names; std::unordered_map<std::string, std::string> source_names;
@@ -179,6 +339,12 @@ void ActionRegistry::init()
if (auto action = make_action(key, capability->name(), source_name)) if (auto action = make_action(key, capability->name(), source_name))
upsert(std::move(action)); upsert(std::move(action));
} }
// Built-in palette commands (Save/Load, Preferences, Mode switch, Slice/Preview, Go to layer).
// Register after plugins so the plugin ids win on any (unlikely) id collision - ids are distinct
// by prefix, so this is order-independent.
for (auto& action : native_commands())
upsert(std::move(action));
} }
void ActionRegistry::refresh_source(const std::string& plugin_key, ActionChange change) void ActionRegistry::refresh_source(const std::string& plugin_key, ActionChange change)
@@ -208,8 +374,7 @@ void ActionRegistry::refresh_source(const std::string& plugin_key, ActionChange
} }
} }
void ActionRegistry::refresh_capability(const std::string& plugin_key, const std::string& capability, void ActionRegistry::refresh_capability(const std::string& plugin_key, const std::string& capability, ActionChange change)
ActionChange change)
{ {
assert(wxThread::IsMain()); assert(wxThread::IsMain());
@@ -269,14 +434,11 @@ const AppAction* ActionRegistry::by_id(const std::string& id) const
return it == m_actions.end() ? nullptr : it->second.get(); return it == m_actions.end() ? nullptr : it->second.get();
} }
AppAction* ActionRegistry::find(const std::string& id) AppAction* ActionRegistry::find(const std::string& id) { return const_cast<AppAction*>(by_id(id)); }
{
return const_cast<AppAction*>(by_id(id));
}
// ---- dispatch + write-through ---------------------------------------------- // ---- dispatch + write-through ----------------------------------------------
AppActionRunResult ActionRegistry::run(const std::string& id) AppActionRunResult ActionRegistry::run(const std::string& id, const std::string& param)
{ {
assert(wxThread::IsMain()); assert(wxThread::IsMain());
auto it = m_actions.find(id); auto it = m_actions.find(id);
@@ -286,7 +448,7 @@ AppActionRunResult ActionRegistry::run(const std::string& id)
// nested event loop; a queued source refresh can erase the entry while the // nested event loop; a queued source refresh can erase the entry while the
// keep-alive preserves the action until run returns. // keep-alive preserves the action until run returns.
std::shared_ptr<AppAction> keep = it->second; std::shared_ptr<AppAction> keep = it->second;
AppActionRunResult o = keep->run(); AppActionRunResult o = keep->run(param);
if (o.level == AppActionRunResult::Level::Busy) if (o.level == AppActionRunResult::Level::Busy)
return o; return o;
@@ -300,7 +462,10 @@ AppActionRunResult ActionRegistry::run(const std::string& id)
e["count"] = e.value("count", 0) + 1; e["count"] = e.value("count", 0) + 1;
e["last"] = (long long) std::time(nullptr); e["last"] = (long long) std::time(nullptr);
write_section("stats", stats); write_section("stats", stats);
if (AppAction* live = find(id)) { live->count = e["count"]; live->last = e["last"]; } if (AppAction* live = find(id)) {
live->count = e["count"];
live->last = e["last"];
}
return o; return o;
} }
@@ -325,8 +490,7 @@ void ActionRegistry::reorder_favourites(const std::vector<std::string>& ids)
std::vector<std::string> next; std::vector<std::string> next;
// keep the requested order, but only ids that are actually favourites (guard a bad payload) // keep the requested order, but only ids that are actually favourites (guard a bad payload)
for (const auto& id : ids) for (const auto& id : ids)
if (std::find(cur.begin(), cur.end(), id) != cur.end() && if (std::find(cur.begin(), cur.end(), id) != cur.end() && std::find(next.begin(), next.end(), id) == next.end())
std::find(next.begin(), next.end(), id) == next.end())
next.push_back(id); next.push_back(id);
// why: don't drop favourites the page omitted (e.g. pins with no live action hidden from the bar) // why: don't drop favourites the page omitted (e.g. pins with no live action hidden from the bar)
for (const auto& id : cur) for (const auto& id : cur)
@@ -374,17 +538,139 @@ nlohmann::json ActionRegistry::snapshot() const
return a->id() < b->id(); return a->id() < b->id();
}); });
nlohmann::json actions = nlohmann::json::array(); auto action_to_json = [](const AppAction* a) {
for (const AppAction* a : sorted) return nlohmann::json({{"id", a->id()},
actions.push_back({{"id", a->id()},
{"title", a->title()}, {"title", a->title()},
{"source", a->source_name()}, {"source", a->source_name()},
{"group", a->group},
{"input", a->input},
{"shortcut", ""}}); {"shortcut", ""}});
};
nlohmann::json actions = nlohmann::json::array();
for (const AppAction* a : sorted)
actions.push_back(action_to_json(a));
// why: favourites is the ORDERED pin list - it must come from favourite_actions // why: favourites is the ORDERED pin list - it must come from favourite_actions
// as stored, not be re-derived from the frecency-sorted actions (that would // as stored, not be re-derived from the frecency-sorted actions (that would
// reorder the favourites bar). The page (js) filters out ids with no live action itself. // reorder the favourites bar). The page (js) filters out ids with no live action itself.
nlohmann::json favourites(read_string_array("favourite_actions")); nlohmann::json favourites(read_string_array("favourite_actions"));
return {{"actions", std::move(actions)}, {"favourites", std::move(favourites)}};
// Recent = the last-N launched actions by recency (only actions with a run history).
constexpr size_t kRecentLimit = 5;
std::vector<const AppAction*> recent;
for (const auto& entry : m_actions)
if (entry.second->last > 0)
recent.push_back(entry.second.get());
std::sort(recent.begin(), recent.end(), [](const AppAction* a, const AppAction* b) {
if (a->last != b->last)
return a->last > b->last;
return a->id() < b->id();
});
if (recent.size() > kRecentLimit)
recent.resize(kRecentLimit);
nlohmann::json recent_json = nlohmann::json::array();
for (const AppAction* a : recent)
recent_json.push_back(action_to_json(a));
return {{"actions", std::move(actions)}, {"favourites", std::move(favourites)}, {"recent", std::move(recent_json)}};
}
nlohmann::json ActionRegistry::settings_search(const std::string& query)
{
assert(wxThread::IsMain());
std::string q = boost::trim_copy(query);
// Empty query: show the recently-jumped-to settings instead of a blank list.
if (q.empty())
return settings_recent();
// Use the sidebar's live searcher. It is the instance Tab registration (add_key) populates
// with each option's group/category, and it carries the current printer technology. A fresh
// OptionsSearcher has an empty groups_and_categories, so init()/append_options() drops every
// option and the search returns nothing.
Search::OptionsSearcher& searcher = wxGetApp().sidebar().get_searcher();
searcher.init(settings_inputs());
searcher.search(q, true);
auto& found = searcher.found_options();
constexpr size_t kLimit = 20;
const size_t n = std::min<size_t>(kLimit, found.size());
nlohmann::json out = nlohmann::json::array();
for (size_t i = 0; i < n; ++i) {
const auto& opt = searcher.get_option(i);
// Clean plain label "category : group : label" - OptionsSearcher's own label string
// carries ImGui icon control chars + <b>/</b> markup (SUPPORTS_MARKUP), which render
// as garbage in the webview. Build it from the Option's localized strings instead.
std::wstring plain;
const std::wstring* prev = nullptr;
for (const std::wstring* const s : {&opt.category_local, &opt.group_local, &opt.label_local})
if (s != nullptr && !s->empty() && (prev == nullptr || *prev != *s)) {
if (!plain.empty())
plain += L" : ";
plain += *s;
prev = s;
}
out.push_back({{"opt_key", opt.opt_key()},
{"type", int(opt.type)},
{"label", boost::nowide::narrow(plain)},
{"category", boost::nowide::narrow(opt.category)},
{"group", boost::nowide::narrow(opt.group)}});
}
return out;
}
// ---- tab options (enumerate the MainFrame notebook's current pages) ----------
nlohmann::json ActionRegistry::tab_options() const
{
assert(wxThread::IsMain());
nlohmann::json out = nlohmann::json::array();
if (!wxTheApp || wxGetApp().is_closing())
return out;
MainFrame* mf = wxGetApp().mainframe;
if (!mf || !mf->m_tabpanel)
return out;
Notebook* notebook = mf->m_tabpanel;
for (size_t i = 0; i < notebook->GetPageCount(); ++i) {
const wxString id = notebook->GetPageName(i);
if (id.empty())
continue;
out.push_back({{"id", id.ToStdString()}, {"title", notebook->GetPageText(i).ToStdString()}});
}
return out;
}
// ---- settings recents (persisted, most-recent-first, capped at 8) -----------
nlohmann::json ActionRegistry::settings_recent() const
{
assert(wxThread::IsMain());
return read_section("recent_settings", nlohmann::json::array());
}
void ActionRegistry::record_setting_recent(
const std::string& opt_key, int type, const std::string& label, const std::string& category, const std::string& group)
{
assert(wxThread::IsMain());
if (opt_key.empty())
return;
constexpr size_t kLimit = 8;
auto arr = read_section("recent_settings", nlohmann::json::array());
if (!arr.is_array())
arr = nlohmann::json::array();
auto same = [&](const nlohmann::json& e) {
return e.is_object() && e.value("opt_key", std::string()) == opt_key && e.value("type", int(-1)) == type;
};
nlohmann::json next = nlohmann::json::array();
next.push_back({{"opt_key", opt_key}, {"type", type}, {"label", label}, {"category", category}, {"group", group}});
for (const auto& e : arr)
if (!same(e))
next.push_back(e);
if (next.size() > kLimit)
next.erase(next.begin() + long(kLimit), next.end());
write_section("recent_settings", next);
} }
}} // namespace Slic3r::GUI }} // namespace Slic3r::GUI
+42 -3
View File
@@ -18,6 +18,9 @@ namespace Slic3r { namespace GUI {
// How a source's action set changed. Drives the registry's refresh handlers. // How a source's action set changed. Drives the registry's refresh handlers.
enum class ActionChange { Added, Removed }; enum class ActionChange { Added, Removed };
// What kind of runnable thing an action is. Drives the palette section + dispatch.
enum class AppActionKind { Plugin, Command };
// Result of running an AppAction, in the action layer's own vocabulary. Concrete // Result of running an AppAction, in the action layer's own vocabulary. Concrete
// actions translate their runner-specific result into this generic shape. // actions translate their runner-specific result into this generic shape.
struct AppActionRunResult struct AppActionRunResult
@@ -56,8 +59,18 @@ struct AppAction
int count = 0; int count = 0;
long long last = 0; // epoch seconds long long last = 0; // epoch seconds
// Speed Dial presentation: Plugin keeps group empty (the UI falls back to the
// source name); Command sets a section label (e.g. "Commands", "Mode").
AppActionKind kind = AppActionKind::Plugin;
std::string group;
// Second-phase input descriptor for the palette: "settings" (jump to a config option)
// or "percent" (jump to layer by a 0-100 value). Empty = run immediately on activation.
std::string input;
virtual ~AppAction() = default; virtual ~AppAction() = default;
virtual AppActionRunResult run() const = 0; // re-resolves + runs (UI thread) // Re-resolves + runs (UI thread). `param` carries an optional per-run argument for
// commands (e.g. a layer percentage); plugins ignore it.
virtual AppActionRunResult run(const std::string& param = {}) const = 0;
protected: protected:
// The definition is constructor-set and immutable. Refreshes replace an action // The definition is constructor-set and immutable. Refreshes replace an action
@@ -92,6 +105,8 @@ private:
class ActionRegistry class ActionRegistry
{ {
public: public:
~ActionRegistry();
// Subscribes to the plugin loader and enumerates its current actions. Call once // Subscribes to the plugin loader and enumerates its current actions. Call once
// on the UI thread after the plugin system is up; wires the initial list and live // on the UI thread after the plugin system is up; wires the initial list and live
// updates together. // updates together.
@@ -108,7 +123,7 @@ public:
const AppAction* by_id(const std::string& id) const; const AppAction* by_id(const std::string& id) const;
// Dispatch + write-through (registry is the only thing that touches AppConfig). // Dispatch + write-through (registry is the only thing that touches AppConfig).
AppActionRunResult run(const std::string& id); // runs + bumps stats AppActionRunResult run(const std::string& id, const std::string& param = {}); // runs + bumps stats
void set_favourite(const std::string& id, bool on); void set_favourite(const std::string& id, bool on);
void reorder_favourites(const std::vector<std::string>& ids); // persist a new bar order void reorder_favourites(const std::vector<std::string>& ids); // persist a new bar order
@@ -116,9 +131,33 @@ public:
bool should_ask(const std::string& id) const; bool should_ask(const std::string& id) const;
void suppress_ask(const std::string& id); void suppress_ask(const std::string& id);
// Flat, frecency-sorted snapshot for the webview: {actions:[...], favourites:[...]}. // Flat, frecency-sorted snapshot for the webview:
// {actions:[...], favourites:[...], recent:[...]} (recent = last-N launched by recency).
nlohmann::json snapshot() const; nlohmann::json snapshot() const;
// "Go to setting..." Speed Dial helper: query the current print/filament/printer
// config options via the sidebar's live OptionsSearcher (the instance Tab registration
// populates with group/category, and which carries the current printer technology) and
// return the top matches as JSON. The searcher is re-seeded from the current configs +
// user mode on every call so the result always reflects what the sidebar's own search
// would show. An empty/whitespace query returns the recent settings list (below), and the
// page shows a "type to search" hint when there are no recents.
nlohmann::json settings_search(const std::string& query);
// Recently-jumped-to settings, persisted (most-recent-first, capped at 8). Returns the
// stored JSON array [{opt_key,type,label,category,group},...]; record_setting_recent()
// prepends an entry (deduped by opt_key+type) and re-persists.
nlohmann::json settings_recent() const;
void record_setting_recent(const std::string& opt_key, int type, const std::string& label,
const std::string& category, const std::string& group);
// "Go to tab..." Speed Dial helper: enumerate the MainFrame notebook's current pages
// as [{id,title},...]. Live by construction - built-in tabs (Home/Prepare/Preview/Device/
// Project/Calibration) and plugin tabs (plugin.<key>.<name>) are all Notebook pages, so a
// page appears/disappears with the notebook. Plugin tabs hidden in the overflow menu (many
// plugins) aren't separate pages and are not listed. Call on the UI thread; null-safe.
nlohmann::json tab_options() const;
private: private:
void seed_state(AppAction& a) const; // favourite/stats from config void seed_state(AppAction& a) const; // favourite/stats from config
AppAction* find(const std::string& id); AppAction* find(const std::string& id);
-6
View File
@@ -1039,7 +1039,6 @@ 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,11 +3501,6 @@ 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':
{ {
-1
View File
@@ -166,7 +166,6 @@ 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);
+2 -2
View File
@@ -197,6 +197,8 @@ void KBShortcutsDialog::fill_shortcuts()
// Switch table page // Switch table page
{ ctrl + L("Tab"), L("Switch table page")}, { ctrl + L("Tab"), L("Switch table page")},
// Open speed dial
{ ctrl + "K", L("Open speed dial") },
//DEL //DEL
#ifdef __APPLE__ #ifdef __APPLE__
{"fn+⌫", L("Delete Selected")}, {"fn+⌫", L("Delete Selected")},
@@ -267,8 +269,6 @@ 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_CONTEXT("Tab", "Keyboard Shortcut"), L("Switch between Prepare/Preview") }, { L_CONTEXT("Tab", "Keyboard Shortcut"), L("Switch between Prepare/Preview") },
{ L_CONTEXT("Space", "Keyboard Shortcut"), L("Open actions speed dial") },
}; };
m_full_shortcuts.push_back({ { _L("Plater"), "" }, plater_shortcuts }); m_full_shortcuts.push_back({ { _L("Plater"), "" }, plater_shortcuts });
+17
View File
@@ -701,6 +701,8 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_
} }
return;} return;}
#endif #endif
// Orca: open the speed dial from any page. CmdDown() = Ctrl on Win/Linux, Cmd on macOS.
if (evt.CmdDown() && evt.GetKeyCode() == 'K') { wxGetApp().open_speed_dial(); return; }
if (evt.CmdDown() && evt.GetKeyCode() == 'R') { if (m_slice_enable) { wxGetApp().plater()->update(true, true); wxPostEvent(m_plater, SimpleEvent(EVT_GLTOOLBAR_SLICE_PLATE)); this->m_tabpanel->SelectPageByName(TAB_ID_PREVIEW); } return; } if (evt.CmdDown() && evt.GetKeyCode() == 'R') { if (m_slice_enable) { wxGetApp().plater()->update(true, true); wxPostEvent(m_plater, SimpleEvent(EVT_GLTOOLBAR_SLICE_PLATE)); this->m_tabpanel->SelectPageByName(TAB_ID_PREVIEW); } return; }
if (evt.CmdDown() && evt.ShiftDown() && evt.GetKeyCode() == 'G') { if (evt.CmdDown() && evt.ShiftDown() && evt.GetKeyCode() == 'G') {
m_plater->apply_background_progress(); m_plater->apply_background_progress();
@@ -3346,6 +3348,11 @@ void MainFrame::init_menubar_as_editor()
wxGetApp().open_preferences(); wxGetApp().open_preferences();
}, },
"", nullptr, []() { return true; }, this, 1); "", nullptr, []() { return true; }, this, 1);
parent_menu->AppendSeparator();
append_menu_item(
parent_menu, wxID_ANY, _L("Open speed dial...") + sep + ctrl_t + "K", "",
[](wxCommandEvent &) { wxGetApp().open_speed_dial(); },
"", nullptr, []() { return true; }, this);
//parent_menu->Insert(1, preference_item); //parent_menu->Insert(1, preference_item);
#endif #endif
// Help menu // Help menu
@@ -3370,6 +3377,12 @@ void MainFrame::init_menubar_as_editor()
auto top_menu = m_topbar->GetTopMenu(); auto top_menu = m_topbar->GetTopMenu();
top_menu->AppendSeparator(); top_menu->AppendSeparator();
append_menu_item(
top_menu, wxID_ANY, _L("Open speed dial...") + "\t" + ctrl + "K", "",
[](wxCommandEvent &) { wxGetApp().open_speed_dial(); },
"", nullptr, []() { return true; }, this);
top_menu->AppendSeparator();
append_menu_item( append_menu_item(
top_menu, wxID_ANY, _L("Preset Bundle") + "\t", "", top_menu, wxID_ANY, _L("Preset Bundle") + "\t", "",
[this](wxCommandEvent &) { [this](wxCommandEvent &) {
@@ -3508,6 +3521,10 @@ void MainFrame::init_menubar_as_editor()
#else #else
// On Mac, the Apple menu ignores non-standard custom items, so add Preset Bundle to the File menu // On Mac, the Apple menu ignores non-standard custom items, so add Preset Bundle to the File menu
fileMenu->AppendSeparator(); fileMenu->AppendSeparator();
append_menu_item(
fileMenu, wxID_ANY, _L("Open speed dial...") + sep + ctrl_t + "K", "",
[](wxCommandEvent&) { wxGetApp().open_speed_dial(); },
"", nullptr, []() { return true; }, this);
append_menu_item( append_menu_item(
fileMenu, wxID_ANY, _L("Preset Bundle"), "", fileMenu, wxID_ANY, _L("Preset Bundle"), "",
[this](wxCommandEvent&) { [this](wxCommandEvent&) {
+13 -2
View File
@@ -10,6 +10,7 @@
#include "Widgets/Label.hpp" #include "Widgets/Label.hpp"
#include <wx/button.h> #include <wx/button.h>
#include <wx/dcclient.h>
#include <wx/sizer.h> #include <wx/sizer.h>
wxDEFINE_EVENT(wxCUSTOMEVT_NOTEBOOK_SEL_CHANGED, wxCommandEvent); wxDEFINE_EVENT(wxCUSTOMEVT_NOTEBOOK_SEL_CHANGED, wxCommandEvent);
@@ -158,12 +159,22 @@ void ButtonsListCtrl::SetSelection(int sel)
bool ButtonsListCtrl::InsertPage(size_t n, const wxString &text, bool bSelect /* = false*/, const std::string &bmp_name /* = ""*/, const wxBitmap &bmp /* = wxNullBitmap */) bool ButtonsListCtrl::InsertPage(size_t n, const wxString &text, bool bSelect /* = false*/, const std::string &bmp_name /* = ""*/, const wxBitmap &bmp /* = wxNullBitmap */)
{ {
Button * btn = new Button(this, text.empty() ? text : " " + text, bmp_name, wxNO_BORDER); Button * btn = new Button(this, text, bmp_name, wxNO_BORDER);
btn->SetCornerRadius(0); btn->SetCornerRadius(0);
if (bmp_name.empty() && bmp.IsOk()) if (bmp_name.empty() && bmp.IsOk())
btn->SetIcon(bmp); btn->SetIcon(bmp);
// The label no longer carries a leading space, so widen the icon<->text gap to keep the
// original spacing between a tab's icon and its caption.
{
wxClientDC dc(btn);
dc.SetFont(btn->GetFont());
int space_w = 0;
dc.GetTextExtent(" ", &space_w, nullptr);
btn->SetIconSpacing(5 + space_w);
}
int em = em_unit(this); int em = em_unit(this);
//BBS set size for button //BBS set size for button
btn->SetMinSize({(text.empty() ? 40 : 136) * em / 10, 36 * em / 10}); btn->SetMinSize({(text.empty() ? 40 : 136) * em / 10, 36 * em / 10});
@@ -245,7 +256,7 @@ void ButtonsListCtrl::SetCompact(size_t n, bool compact)
int em = em_unit(this); int em = em_unit(this);
Button* btn = m_pageButtons[n]; Button* btn = m_pageButtons[n];
btn->SetMinSize({(compact ? 40 : 136) * em / 10, 36 * em / 10}); btn->SetMinSize({(compact ? 40 : 136) * em / 10, 36 * em / 10});
btn->SetLabel(compact ? "" : (" " + m_pageLabels[n])); btn->SetLabel(compact ? "" : m_pageLabels[n]);
} }
wxString ButtonsListCtrl::GetPageText(size_t n) const wxString ButtonsListCtrl::GetPageText(size_t n) const
-4
View File
@@ -7567,10 +7567,6 @@ 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(); });
+110 -27
View File
@@ -9,12 +9,18 @@
#include "Plater.hpp" #include "Plater.hpp"
#include "Widgets/WebViewHostDialog.hpp" #include "Widgets/WebViewHostDialog.hpp"
#include <libslic3r/Preset.hpp>
#include <algorithm> #include <algorithm>
#include <wx/display.h> #include <wx/display.h>
#include <wx/sizer.h> #include <wx/sizer.h>
#include <wx/stattext.h> #include <wx/stattext.h>
#ifdef __linux__
#include <gtk/gtk.h>
#endif
namespace Slic3r { namespace GUI { namespace Slic3r { namespace GUI {
namespace { namespace {
@@ -33,23 +39,49 @@ int json_int_or(const nlohmann::json& j, const char* key, int fallback)
wxColour bg_color() { return wxGetApp().get_window_default_clr(); } wxColour bg_color() { return wxGetApp().get_window_default_clr(); }
// Give the WebKitGTK widget itself input focus, not its GtkScrolledWindow container.
// (browser()->SetFocus() grabs focus on the container and doesn't reach the web content,
// so typing only works after the user clicks.) On Linux the native backend is the
// WebKitWebView widget; grab focus there directly. Elsewhere SetFocus() is correct.
void focus_webview(wxWebView* browser, bool page_ready)
{
if (!browser)
return;
#ifdef __linux__
if (void* nb = browser->GetNativeBackend())
gtk_widget_grab_focus((GtkWidget*) nb);
#else
browser->SetFocus();
#endif
if (page_ready)
browser->RunScript("focusInput();");
} }
} // namespace
SpeedDialWebDialog::SpeedDialWebDialog(wxWindow* parent) SpeedDialWebDialog::SpeedDialWebDialog(wxWindow* parent)
: WebViewHostDialog(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, : WebViewHostDialog(parent,
wxBORDER_NONE | wxFRAME_NO_TASKBAR) wxID_ANY,
wxEmptyString,
wxDefaultPosition,
wxDefaultSize,
wxBORDER_NONE | wxFRAME_NO_TASKBAR | wxFRAME_FLOAT_ON_PARENT)
{ {
SetBackgroundColour(bg_color()); SetBackgroundColour(bg_color());
Bind(wxEVT_ACTIVATE, [this](wxActivateEvent& event) { Bind(wxEVT_ACTIVATE, [this](wxActivateEvent& event) {
if (!event.GetActive() && IsShown()) // Focus the WebKit widget exactly when the WM makes the popup the active window
// (modeless focus is granted asynchronously, so a focus request made right after
// Show() is dropped). Also re-corrects focus on every re-open.
if (event.GetActive() && IsShown())
focus_webview(browser(), m_page_ready);
else if (!event.GetActive() && IsShown())
Hide(); Hide();
event.Skip(); event.Skip();
}); });
if (!create_webview("web/dialog/SpeedDial/index.html", wxEmptyString, if (!create_webview("web/dialog/SpeedDial/index.html", wxEmptyString, wxSize(kPopupWidth, kPopupMaxHeight),
wxSize(kPopupWidth, kPopupMaxHeight), wxSize(kPopupWidth, kPopupMinHeight))) { wxSize(kPopupWidth, kPopupMinHeight))) {
auto* sizer = new wxBoxSizer(wxVERTICAL); auto* sizer = new wxBoxSizer(wxVERTICAL);
sizer->Add(new wxStaticText(this, wxID_ANY, wxS("wxWebView unavailable")), sizer->Add(new wxStaticText(this, wxID_ANY, wxS("wxWebView unavailable")), wxSizerFlags().Border(wxALL, 20));
wxSizerFlags().Border(wxALL, 20));
SetSizer(sizer); SetSizer(sizer);
SetClientSize(FromDIP(wxSize(kPopupWidth, kPopupMinHeight))); SetClientSize(FromDIP(wxSize(kPopupWidth, kPopupMinHeight)));
} }
@@ -61,8 +93,7 @@ void SpeedDialWebDialog::request_show()
{ {
if (IsShown()) { if (IsShown()) {
Raise(); Raise();
if (browser()) focus_webview(browser(), m_page_ready);
browser()->SetFocus();
return; return;
} }
@@ -70,8 +101,9 @@ void SpeedDialWebDialog::request_show()
Raise(); Raise();
if (m_page_ready) if (m_page_ready)
send_actions(); send_actions();
if (browser()) // Grab focus now and again on wxEVT_ACTIVATE; grabbing directly on the WebKit widget is
browser()->SetFocus(); // what makes typing reach the search field immediately on open.
focus_webview(browser(), m_page_ready);
} }
void SpeedDialWebDialog::on_script_message(const nlohmann::json& payload) void SpeedDialWebDialog::on_script_message(const nlohmann::json& payload)
@@ -95,8 +127,7 @@ void SpeedDialWebDialog::handle_web_command(const nlohmann::json& payload)
if (command == "request_actions") { if (command == "request_actions") {
m_page_ready = true; m_page_ready = true;
send_actions(); send_actions();
} } else if (command == "toggle_favourite")
else if (command == "toggle_favourite")
wxGetApp().action_registry().set_favourite(payload.value("id", ""), payload.value("fav", false)); wxGetApp().action_registry().set_favourite(payload.value("id", ""), payload.value("fav", false));
else if (command == "reorder_favourites") { else if (command == "reorder_favourites") {
std::vector<std::string> ids; std::vector<std::string> ids;
@@ -105,13 +136,61 @@ void SpeedDialWebDialog::handle_web_command(const nlohmann::json& payload)
if (id.is_string()) if (id.is_string())
ids.push_back(id.get<std::string>()); ids.push_back(id.get<std::string>());
wxGetApp().action_registry().reorder_favourites(ids); wxGetApp().action_registry().reorder_favourites(ids);
} else if (command == "run_action")
run_action(payload.value("id", ""), payload.value("title", ""), payload.value("param", ""));
else if (command == "go_to_setting") {
// "Go to setting..." second phase: the page hands back the option it matched.
const std::string opt_key = payload.value("opt_key", "");
if (!opt_key.empty()) {
const int type = json_int_or(payload, "type", int(Preset::TYPE_INVALID));
const std::string label = payload.value("label", "");
const std::string group = payload.value("group", "");
const std::string cat = payload.value("category", "");
// Track it in the palette's recent-settings list before jumping (persisted).
wxGetApp().action_registry().record_setting_recent(opt_key, type, label, cat, group);
Hide();
wxGetApp().sidebar().jump_to_option(opt_key, Preset::Type(type), from_u8(cat).ToStdWstring());
} }
else if (command == "run_action") } else if (command == "search_settings")
run_action(payload.value("id", ""), payload.value("title", "")); search_settings(payload.value("q", ""));
else if (command == "resize") else if (command == "search_tabs")
search_tabs();
else if (command == "go_to_tab") {
// "Go to tab..." second phase: the page hands back the tab id it matched.
const std::string tab_id = payload.value("id", "");
if (!tab_id.empty()) {
Hide();
if (wxGetApp().mainframe)
wxGetApp().mainframe->select_tab(from_u8(tab_id));
}
} else if (command == "resize")
resize_to_content(json_int_or(payload, "height", 0)); resize_to_content(json_int_or(payload, "height", 0));
} }
void SpeedDialWebDialog::search_tabs()
{
// Round-trip is async because the webview delivers script messages synchronously on the
// GTK/macOS stack; defer the (cheap) enumeration and push the result back to the page.
wxGetApp().CallAfter([this, alive = m_alive]() {
if (!alive->load(std::memory_order_acquire))
return;
auto tabs = wxGetApp().action_registry().tab_options();
call_web_handler({{"command", "tab_results"}, {"tabs", std::move(tabs)}});
});
}
void SpeedDialWebDialog::search_settings(const std::string& query)
{
// Round-trip is async because the webview delivers script messages synchronously on the
// GTK/macOS stack; defer the (cheap) search and push the result back to the page.
wxGetApp().CallAfter([this, alive = m_alive, query]() {
if (!alive->load(std::memory_order_acquire))
return;
auto results = wxGetApp().action_registry().settings_search(query);
call_web_handler({{"command", "settings_results"}, {"results", std::move(results)}});
});
}
void SpeedDialWebDialog::resize_to_content(int height) void SpeedDialWebDialog::resize_to_content(int height)
{ {
if (height <= 0) if (height <= 0)
@@ -127,14 +206,15 @@ void SpeedDialWebDialog::resize_to_content(int height)
Layout(); Layout();
} }
void SpeedDialWebDialog::run_action(const std::string& id, const std::string& title) void SpeedDialWebDialog::run_action(const std::string& id, const std::string& title, const std::string& param)
{ {
ActionRegistry& reg = wxGetApp().action_registry(); ActionRegistry& reg = wxGetApp().action_registry();
const AppAction* a = reg.by_id(id); const AppAction* a = reg.by_id(id);
if (!a) if (!a)
return; return;
const bool ask = reg.should_ask(id); // Only plugin actions get the "Run plugin?" confirm. Built-in commands act immediately.
const bool ask = a->kind == AppActionKind::Plugin && reg.should_ask(id);
const std::string atitle = a->title(); const std::string atitle = a->title();
if (IsModal()) if (IsModal())
EndModal(wxID_CANCEL); EndModal(wxID_CANCEL);
@@ -143,8 +223,7 @@ void SpeedDialWebDialog::run_action(const std::string& id, const std::string& ti
if (ask) { if (ask) {
const wxString label = title.empty() ? from_u8(atitle) : from_u8(title); const wxString label = title.empty() ? from_u8(atitle) : from_u8(title);
RichMessageDialog dlg(wxGetApp().mainframe, wxString::Format(_L("Run \"%s\"?"), label), RichMessageDialog dlg(wxGetApp().mainframe, wxString::Format(_L("Run \"%s\"?"), label), _L("Run plugin"), wxOK | wxCANCEL);
_L("Run plugin"), wxOK | wxCANCEL);
dlg.ShowCheckBox(_L("Don't ask again for this action")); dlg.ShowCheckBox(_L("Don't ask again for this action"));
if (dlg.ShowModal() != wxID_OK) if (dlg.ShowModal() != wxID_OK)
return; return;
@@ -152,16 +231,19 @@ void SpeedDialWebDialog::run_action(const std::string& id, const std::string& ti
wxGetApp().action_registry().suppress_ask(id); wxGetApp().action_registry().suppress_ask(id);
} }
wxGetApp().CallAfter([id] { wxGetApp().CallAfter([id, param] {
if (wxGetApp().is_closing()) if (wxGetApp().is_closing())
return; return;
AppActionRunResult result = wxGetApp().action_registry().run(id); AppActionRunResult result = wxGetApp().action_registry().run(id, param);
if (result.level == AppActionRunResult::Level::Busy) if (result.level == AppActionRunResult::Level::Busy)
return; return;
if (!result.message.IsEmpty() && wxGetApp().plater()) if (!result.message.IsEmpty() && wxGetApp().plater())
wxGetApp().plater()->get_notification_manager()->push_notification( wxGetApp()
NotificationType::CustomNotification, .plater()
result.level == AppActionRunResult::Level::Error ? NotificationManager::NotificationLevel::ErrorNotificationLevel : ->get_notification_manager()
->push_notification(NotificationType::CustomNotification,
result.level == AppActionRunResult::Level::Error ?
NotificationManager::NotificationLevel::ErrorNotificationLevel :
NotificationManager::NotificationLevel::RegularNotificationLevel, NotificationManager::NotificationLevel::RegularNotificationLevel,
into_u8(result.message)); into_u8(result.message));
}); });
@@ -172,7 +254,8 @@ void SpeedDialWebDialog::send_actions()
nlohmann::json snap = wxGetApp().action_registry().snapshot(); nlohmann::json snap = wxGetApp().action_registry().snapshot();
call_web_handler({{"command", "list_actions"}, call_web_handler({{"command", "list_actions"},
{"actions", std::move(snap["actions"])}, {"actions", std::move(snap["actions"])},
{"favourites", std::move(snap["favourites"])}}); {"favourites", std::move(snap["favourites"])},
{"recent", std::move(snap["recent"])}});
} }
}} }} // namespace Slic3r::GUI
+3 -1
View File
@@ -20,8 +20,10 @@ private:
void on_script_message(const nlohmann::json& payload) override; void on_script_message(const nlohmann::json& payload) override;
void handle_web_command(const nlohmann::json& payload); void handle_web_command(const nlohmann::json& payload);
void resize_to_content(int height); void resize_to_content(int height);
void run_action(const std::string& id, const std::string& title); void run_action(const std::string& id, const std::string& title, const std::string& param = "");
void send_actions(); void send_actions();
void search_settings(const std::string& query);
void search_tabs();
bool m_page_ready{false}; bool m_page_ready{false};
// Guards the CallAfter in on_script_message across dialog destruction, same as // Guards the CallAfter in on_script_message across dialog destruction, same as
+91 -103
View File
@@ -25,36 +25,29 @@ END_EVENT_TABLE()
* calling Refresh()/Update(). * calling Refresh()/Update().
*/ */
Button::Button() Button::Button() : paddingSize(10, 8)
: paddingSize(10, 8)
{ {
background_color = StateColor( background_color = StateColor(std::make_pair(0xF0F0F1, (int) StateColor::Disabled),
std::make_pair(0xF0F0F1, (int) StateColor::Disabled),
std::make_pair(0x52c7b8, (int) StateColor::Hovered | StateColor::Checked), std::make_pair(0x52c7b8, (int) StateColor::Hovered | StateColor::Checked),
std::make_pair(0x009688, (int) StateColor::Checked), std::make_pair(0x009688, (int) StateColor::Checked),
std::make_pair(*wxLIGHT_GREY, (int) StateColor::Hovered), std::make_pair(*wxLIGHT_GREY, (int) StateColor::Hovered),
std::make_pair(*wxWHITE, (int) StateColor::Normal)); std::make_pair(*wxWHITE, (int) StateColor::Normal));
text_color = StateColor( text_color = StateColor(std::make_pair(*wxLIGHT_GREY, (int) StateColor::Disabled), std::make_pair(*wxBLACK, (int) StateColor::Normal));
std::make_pair(*wxLIGHT_GREY, (int) StateColor::Disabled),
std::make_pair(*wxBLACK, (int) StateColor::Normal));
} }
Button::Button(wxWindow* parent, wxString text, wxString icon, long style, int iconSize, wxWindowID btn_id) Button::Button(wxWindow* parent, wxString text, wxString icon, long style, int iconSize, wxWindowID btn_id) : Button()
: Button() { Create(parent, text, icon, style, iconSize, btn_id); }
{
Create(parent, text, icon, style, iconSize, btn_id);
}
bool Button::Create(wxWindow* parent, wxString text, wxString icon, long style, int iconSize, wxWindowID btn_id) bool Button::Create(wxWindow* parent, wxString text, wxString icon, long style, int iconSize, wxWindowID btn_id)
{ {
StaticBox::Create(parent, btn_id, wxDefaultPosition, wxDefaultSize, style); StaticBox::Create(parent, btn_id, wxDefaultPosition, wxDefaultSize, style);
state_handler.attach(std::vector<StateColor const*>{&text_color}); state_handler.attach(std::vector<StateColor const*>{&text_color});
state_handler.update_binds(); state_handler.update_binds();
//BBS set default font // BBS set default font
SetFont(Label::Body_14); SetFont(Label::Body_14);
wxWindow::SetLabel(text); wxWindow::SetLabel(text);
if (!icon.IsEmpty()) { if (!icon.IsEmpty()) {
//BBS set button icon default size to 20 // BBS set button icon default size to 20
this->active_icon = ScalableBitmap(this, icon.ToStdString(), iconSize > 0 ? iconSize : 20); this->active_icon = ScalableBitmap(this, icon.ToStdString(), iconSize > 0 ? iconSize : 20);
} }
messureSize(); messureSize();
@@ -82,14 +75,12 @@ void Button::SetIcon(const wxString& icon)
{ {
auto tmpBitmap = ScalableBitmap(this, icon.ToStdString(), this->active_icon.px_cnt()); auto tmpBitmap = ScalableBitmap(this, icon.ToStdString(), this->active_icon.px_cnt());
if (!icon.IsEmpty()) { if (!icon.IsEmpty()) {
//BBS set button icon default size to 20 // BBS set button icon default size to 20
if (!tmpBitmap.bmp().IsSameAs(this->active_icon.bmp())) { if (!tmpBitmap.bmp().IsSameAs(this->active_icon.bmp())) {
this->active_icon = tmpBitmap; this->active_icon = tmpBitmap;
Refresh(); Refresh();
} }
} } else {
else
{
this->active_icon = ScalableBitmap(); this->active_icon = ScalableBitmap();
Refresh(); Refresh();
} }
@@ -121,6 +112,13 @@ void Button::SetPaddingSize(const wxSize& size)
messureSize(); messureSize();
} }
void Button::SetIconSpacing(int spacing)
{
m_icon_spacing = spacing;
messureSize();
Refresh();
}
void Button::SetTextColor(StateColor const& color) void Button::SetTextColor(StateColor const& color)
{ {
text_color = color; text_color = color;
@@ -128,7 +126,7 @@ void Button::SetTextColor(StateColor const& color)
Refresh(); Refresh();
} }
void Button::SetTextColorNormal(wxColor const &color) void Button::SetTextColorNormal(wxColor const& color)
{ {
text_color.setColorForStates(color, 0); text_color.setColorForStates(color, 0);
Refresh(); Refresh();
@@ -145,22 +143,22 @@ bool Button::Enable(bool enable)
return result; return result;
} }
void Button::SetCanFocus(bool canFocus) { void Button::SetCanFocus(bool canFocus)
{
StaticBox::SetCanFocus(canFocus); StaticBox::SetCanFocus(canFocus);
this->canFocus = canFocus; this->canFocus = canFocus;
} }
void Button::SetValue(bool state) void Button::SetValue(bool state)
{ {
if (GetValue() == state) return; if (GetValue() == state)
return;
state_handler.set_state(state ? StateHandler::Checked : 0, StateHandler::Checked); state_handler.set_state(state ? StateHandler::Checked : 0, StateHandler::Checked);
} }
bool Button::GetValue() const { return state_handler.states() & StateHandler::Checked; } bool Button::GetValue() const { return state_handler.states() & StateHandler::Checked; }
void Button::SetCenter(bool isCenter) void Button::SetCenter(bool isCenter) { this->isCenter = isCenter; }
{
this->isCenter = isCenter; }
void Button::SetVertical(bool vertical) void Button::SetVertical(bool vertical)
{ {
@@ -178,37 +176,32 @@ wxString btn_disabled[10] = {"#DFDFDF", "#DFDFDF", "#DFDFDF", "#DFDFDF", "#DFDFD
void Button::SetStyle(const ButtonStyle style, const ButtonType type) void Button::SetStyle(const ButtonStyle style, const ButtonType type)
{ {
if (type == ButtonType::Compact) { if (type == ButtonType::Compact) {
this->SetPaddingSize(FromDIP(wxSize(8,3))); this->SetPaddingSize(FromDIP(wxSize(8, 3)));
this->SetCornerRadius(this->FromDIP(8)); this->SetCornerRadius(this->FromDIP(8));
this->SetFont(Label::Body_10); this->SetFont(Label::Body_10);
} } else if (type == ButtonType::Window) {
else if (type == ButtonType::Window) { this->SetSize(FromDIP(wxSize(58, 24)));
this->SetSize(FromDIP(wxSize(58,24))); this->SetMinSize(FromDIP(wxSize(58, 24)));
this->SetMinSize(FromDIP(wxSize(58,24)));
this->SetCornerRadius(this->FromDIP(12)); this->SetCornerRadius(this->FromDIP(12));
this->SetFont(Label::Body_12); this->SetFont(Label::Body_12);
} } else if (type == ButtonType::Choice) {
else if (type == ButtonType::Choice) { this->SetMinSize(FromDIP(wxSize(100, 32)));
this->SetMinSize(FromDIP(wxSize(100,32))); this->SetPaddingSize(FromDIP(wxSize(12, 8)));
this->SetPaddingSize(FromDIP(wxSize(12,8)));
this->SetCornerRadius(this->FromDIP(4)); this->SetCornerRadius(this->FromDIP(4));
this->SetFont(Label::Body_14); this->SetFont(Label::Body_14);
} } else if (type == ButtonType::Parameter) {
else if (type == ButtonType::Parameter) { this->SetMinSize(FromDIP(wxSize(120, 26)));
this->SetMinSize(FromDIP(wxSize(120,26))); this->SetSize(FromDIP(wxSize(120, 26)));
this->SetSize(FromDIP(wxSize(120,26)));
this->SetCornerRadius(this->FromDIP(4)); this->SetCornerRadius(this->FromDIP(4));
this->SetFont(Label::Body_14); this->SetFont(Label::Body_14);
} } else if (type == ButtonType::Icon) {
else if (type == ButtonType::Icon) { this->SetPaddingSize(FromDIP(wxSize(5, 5)));
this->SetPaddingSize(FromDIP(wxSize(5,5))); this->SetMinSize(FromDIP(wxSize(26, 26)));
this->SetMinSize(FromDIP(wxSize(26,26))); this->SetSize(FromDIP(wxSize(26, 26)));
this->SetSize(FromDIP(wxSize(26,26)));
this->SetCornerRadius(this->FromDIP(4)); this->SetCornerRadius(this->FromDIP(4));
} } else if (type == ButtonType::Expanded) {
else if (type == ButtonType::Expanded) { this->SetMinSize(FromDIP(wxSize(-1, 32)));
this->SetMinSize(FromDIP(wxSize(-1,32))); this->SetPaddingSize(FromDIP(wxSize(12, 8)));
this->SetPaddingSize(FromDIP(wxSize(12,8)));
this->SetCornerRadius(this->FromDIP(4)); this->SetCornerRadius(this->FromDIP(4));
this->SetFont(Label::Body_14); this->SetFont(Label::Body_14);
} }
@@ -221,31 +214,25 @@ void Button::SetStyle(const ButtonStyle style, const ButtonType type)
style == ButtonStyle::Confirm ? btn_confirm : style == ButtonStyle::Confirm ? btn_confirm :
style == ButtonStyle::Alert ? btn_alert : style == ButtonStyle::Alert ? btn_alert :
style == ButtonStyle::Disabled ? btn_disabled : style == ButtonStyle::Disabled ? btn_disabled :
btn_regular ; btn_regular;
auto bg_color = StateColor( auto bg_color = StateColor(std::pair(wxColour(clr_arr[0]), (int) StateColor::Disabled),
std::pair(wxColour(clr_arr[0]), (int)StateColor::Disabled), std::pair(wxColour(clr_arr[1]), (int) StateColor::Pressed),
std::pair(wxColour(clr_arr[1]), (int)StateColor::Pressed), std::pair(wxColour(clr_arr[2]), (int) StateColor::Hovered),
std::pair(wxColour(clr_arr[2]), (int)StateColor::Hovered), std::pair(wxColour(clr_arr[3]), (int) StateColor::Normal),
std::pair(wxColour(clr_arr[3]), (int)StateColor::Normal), std::pair(wxColour(clr_arr[4]), (int) StateColor::Enabled));
std::pair(wxColour(clr_arr[4]), (int)StateColor::Enabled)
);
bg_color.setTakeFocusedAsHovered(false); bg_color.setTakeFocusedAsHovered(false);
this->SetBackgroundColor(bg_color); this->SetBackgroundColor(bg_color);
wxColour focus_clr = clr_arr[is_dark ? 8 : 9]; wxColour focus_clr = clr_arr[is_dark ? 8 : 9];
auto border_color = StateColor( auto border_color = StateColor(std::pair(wxColour(clr_arr[0]), (int) StateColor::Disabled),
std::pair(wxColour(clr_arr[0]), (int)StateColor::Disabled), std::pair(wxColour(clr_arr[2]), (int) (StateColor::Hovered | ~StateColor::Focused)),
std::pair(wxColour(clr_arr[2]), (int)(StateColor::Hovered | ~StateColor::Focused)), std::pair(wxColour(focus_clr), (int) StateColor::Focused),
std::pair(wxColour(focus_clr ), (int)StateColor::Focused), std::pair(wxColour(clr_arr[3]), (int) StateColor::Normal));
std::pair(wxColour(clr_arr[3]), (int)StateColor::Normal)
);
border_color.setTakeFocusedAsHovered(false); border_color.setTakeFocusedAsHovered(false);
this->SetBorderColor(border_color); this->SetBorderColor(border_color);
this->SetTextColor(StateColor( this->SetTextColor(StateColor(std::pair(wxColour(clr_arr[5]), (int) StateColor::Disabled),
std::pair(wxColour(clr_arr[5]), (int)StateColor::Disabled), std::pair(wxColour(clr_arr[7]), (int) StateColor::Hovered),
std::pair(wxColour(clr_arr[7]), (int)StateColor::Hovered), std::pair(wxColour(clr_arr[6]), (int) StateColor::Normal)));
std::pair(wxColour(clr_arr[6]), (int)StateColor::Normal)
));
m_has_style = true; m_has_style = true;
m_style = style; m_style = style;
@@ -260,7 +247,7 @@ void Button::Rescale()
messureSize(); messureSize();
if(m_has_style) if (m_has_style)
SetStyle(m_style, m_type); SetStyle(m_style, m_type);
Refresh(); Refresh();
@@ -290,7 +277,7 @@ void Button::render(wxDC& dc)
const ScalableBitmap& icon = active_icon; const ScalableBitmap& icon = active_icon;
wxSize padding = this->paddingSize; wxSize padding = this->paddingSize;
int spacing = 5; int spacing = m_icon_spacing;
// Wrap text // Wrap text
auto text = GetLabel(); auto text = GetLabel();
if (vertical && textSize.x + padding.x * 2 > size.x) { if (vertical && textSize.x + padding.x * 2 > size.x) {
@@ -304,7 +291,7 @@ void Button::render(wxDC& dc)
auto szContent = textSize; auto szContent = textSize;
if (icon.bmp().IsOk()) { if (icon.bmp().IsOk()) {
if (szContent.y > 0) { if (szContent.y > 0) {
//BBS norrow size between text and icon // BBS norrow size between text and icon
if (vertical) if (vertical)
szContent.y += spacing; szContent.y += spacing;
else else
@@ -313,10 +300,12 @@ void Button::render(wxDC& dc)
szIcon = icon.GetBmpSize(); szIcon = icon.GetBmpSize();
if (vertical) { if (vertical) {
szContent.y += szIcon.y; szContent.y += szIcon.y;
if (szIcon.x > szContent.x) szContent.x = szIcon.x; if (szIcon.x > szContent.x)
szContent.x = szIcon.x;
} else { } else {
szContent.x += szIcon.x; szContent.x += szIcon.x;
if (szIcon.y > szContent.y) szContent.y = szIcon.y; if (szIcon.y > szContent.y)
szContent.y = szIcon.y;
} }
if (szContent.x > size.x) { if (szContent.x > size.x) {
int d = std::min(padding.x, (szContent.x - size.x) / 2); int d = std::min(padding.x, (szContent.x - size.x) / 2);
@@ -325,10 +314,11 @@ void Button::render(wxDC& dc)
} }
} }
// move to center // move to center
wxRect rcContent = { {0, 0}, size }; wxRect rcContent = {{0, 0}, size};
if (isCenter) { if (isCenter) {
wxSize offset = (size - szContent) / 2; wxSize offset = (size - szContent) / 2;
if (offset.x < 0) offset.x = 0; if (offset.x < 0)
offset.x = 0;
rcContent.Deflate(offset.x, offset.y); rcContent.Deflate(offset.x, offset.y);
} }
// start draw // start draw
@@ -339,7 +329,7 @@ void Button::render(wxDC& dc)
else else
pt.y += (rcContent.height - szIcon.y) / 2; pt.y += (rcContent.height - szIcon.y) / 2;
dc.DrawBitmap(icon.bmp(), pt); dc.DrawBitmap(icon.bmp(), pt);
//BBS norrow size between text and icon // BBS norrow size between text and icon
if (vertical) { if (vertical) {
pt.y += szIcon.y + spacing; pt.y += szIcon.y + spacing;
pt.x = rcContent.x; pt.x = rcContent.x;
@@ -373,19 +363,21 @@ void Button::messureSize()
wxSize szContent = textSize.GetSize(); wxSize szContent = textSize.GetSize();
if (this->active_icon.bmp().IsOk()) { if (this->active_icon.bmp().IsOk()) {
if (szContent.y > 0) { if (szContent.y > 0) {
//BBS norrow size between text and icon // BBS narrow size between text and icon
if (vertical) if (vertical)
szContent.y += 5; szContent.y += m_icon_spacing;
else else
szContent.x += 5; szContent.x += m_icon_spacing;
} }
wxSize szIcon = this->active_icon.GetBmpSize(); wxSize szIcon = this->active_icon.GetBmpSize();
if (vertical) { if (vertical) {
szContent.y += szIcon.y; szContent.y += szIcon.y;
if (szIcon.x > szContent.x) szContent.x = szIcon.x; if (szIcon.x > szContent.x)
szContent.x = szIcon.x;
} else { } else {
szContent.x += szIcon.x; szContent.x += szIcon.x;
if (szIcon.y > szContent.y) szContent.y = szIcon.y; if (szIcon.y > szContent.y)
szContent.y = szIcon.y;
} }
} }
wxSize size = szContent + paddingSize * 2; wxSize size = szContent + paddingSize * 2;
@@ -429,13 +421,13 @@ void Button::mouseReleased(wxMouseEvent& event)
} }
} }
void Button::mouseCaptureLost(wxMouseCaptureLostEvent &event) void Button::mouseCaptureLost(wxMouseCaptureLostEvent& event)
{ {
wxMouseEvent evt; wxMouseEvent evt;
mouseReleased(evt); mouseReleased(evt);
} }
void Button::keyDownUp(wxKeyEvent &event) void Button::keyDownUp(wxKeyEvent& event)
{ {
if (event.GetKeyCode() == WXK_SPACE || event.GetKeyCode() == WXK_RETURN) { if (event.GetKeyCode() == WXK_SPACE || event.GetKeyCode() == WXK_RETURN) {
wxMouseEvent evt(event.GetEventType() == wxEVT_KEY_UP ? wxEVT_LEFT_UP : wxEVT_LEFT_DOWN); wxMouseEvent evt(event.GetEventType() == wxEVT_KEY_UP ? wxEVT_LEFT_UP : wxEVT_LEFT_DOWN);
@@ -444,8 +436,8 @@ void Button::keyDownUp(wxKeyEvent &event)
return; return;
} }
if (event.GetEventType() == wxEVT_KEY_DOWN && if (event.GetEventType() == wxEVT_KEY_DOWN &&
(event.GetKeyCode() == WXK_TAB || event.GetKeyCode() == WXK_LEFT || event.GetKeyCode() == WXK_RIGHT (event.GetKeyCode() == WXK_TAB || event.GetKeyCode() == WXK_LEFT || event.GetKeyCode() == WXK_RIGHT ||
|| event.GetKeyCode() == WXK_UP || event.GetKeyCode() == WXK_DOWN)) event.GetKeyCode() == WXK_UP || event.GetKeyCode() == WXK_DOWN))
HandleAsNavigationKey(event); HandleAsNavigationKey(event);
else else
event.Skip(); event.Skip();
@@ -462,7 +454,9 @@ void Button::sendButtonEvent()
WXLRESULT Button::MSWWindowProc(WXUINT nMsg, WXWPARAM wParam, WXLPARAM lParam) WXLRESULT Button::MSWWindowProc(WXUINT nMsg, WXWPARAM wParam, WXLPARAM lParam)
{ {
if (nMsg == WM_GETDLGCODE) { return DLGC_WANTMESSAGE; } if (nMsg == WM_GETDLGCODE) {
return DLGC_WANTMESSAGE;
}
if (nMsg == WM_KEYDOWN) { if (nMsg == WM_KEYDOWN) {
wxKeyEvent event(CreateKeyEvent(wxEVT_KEY_DOWN, wParam, lParam)); wxKeyEvent event(CreateKeyEvent(wxEVT_KEY_DOWN, wParam, lParam));
switch (wParam) { switch (wParam) {
@@ -483,8 +477,7 @@ void Button::EnableTooltipEvenDisabled()
{ {
#if defined(_MSC_VER) || defined(_WIN32) #if defined(_MSC_VER) || defined(_WIN32)
auto parent = this->GetParent(); auto parent = this->GetParent();
if (parent) if (parent) {
{
parent->Bind(wxEVT_MOTION, &Button::OnParentMotion, this); parent->Bind(wxEVT_MOTION, &Button::OnParentMotion, this);
parent->Bind(wxEVT_LEAVE_WINDOW, &Button::OnParentLeave, this); parent->Bind(wxEVT_LEAVE_WINDOW, &Button::OnParentLeave, this);
}; };
@@ -494,22 +487,21 @@ void Button::EnableTooltipEvenDisabled()
void Button::OnParentMotion(wxMouseEvent& event) void Button::OnParentMotion(wxMouseEvent& event)
{ {
auto parent = this->GetParent(); auto parent = this->GetParent();
if (!parent) return event.Skip(); if (!parent)
return event.Skip();
wxPoint pos = parent->ClientToScreen(event.GetPosition()); wxPoint pos = parent->ClientToScreen(event.GetPosition());
wxRect screen_rect = this->GetScreenRect(); wxRect screen_rect = this->GetScreenRect();
wxString tip = this->GetToolTipText(); wxString tip = this->GetToolTipText();
if (!tip.IsEmpty() && !this->IsEnabled() && screen_rect.Contains(pos)) if (!tip.IsEmpty() && !this->IsEnabled() && screen_rect.Contains(pos)) {
{ if (!tipWindow) {
if (!tipWindow)
{
tipWindow = wxTipWindow::New(this, tip); tipWindow = wxTipWindow::New(this, tip);
if (!tipWindow) return event.Skip(); if (!tipWindow)
return event.Skip();
tipWindow->Enable(false); tipWindow->Enable(false);
} }
if (tipWindow->GetLabel() != tip) if (tipWindow->GetLabel() != tip) {
{
tipWindow->SetLabel(tip); tipWindow->SetLabel(tip);
} }
@@ -517,11 +509,8 @@ void Button::OnParentMotion(wxMouseEvent& event)
// using wxGetMousePosition() which returns (0,0) on Wayland. // using wxGetMousePosition() which returns (0,0) on Wayland.
tipWindow->Position(this->ClientToScreen(wxPoint(0, 0)), this->GetSize()); tipWindow->Position(this->ClientToScreen(wxPoint(0, 0)), this->GetSize());
tipWindow->Popup(); tipWindow->Popup();
} } else {
else if (tipWindow) {
{
if (tipWindow)
{
tipWindow->Dismiss(); tipWindow->Dismiss();
tipWindow->Destroy(); tipWindow->Destroy();
tipWindow = nullptr; tipWindow = nullptr;
@@ -534,15 +523,14 @@ void Button::OnParentMotion(wxMouseEvent& event)
void Button::OnParentLeave(wxMouseEvent& event) void Button::OnParentLeave(wxMouseEvent& event)
{ {
auto parent = this->GetParent(); auto parent = this->GetParent();
if (!parent) return event.Skip(); if (!parent)
return event.Skip();
if (tipWindow) if (tipWindow) {
{
wxPoint pos = parent->ClientToScreen(event.GetPosition()); wxPoint pos = parent->ClientToScreen(event.GetPosition());
wxRect screen_rect = this->GetScreenRect(); wxRect screen_rect = this->GetScreenRect();
wxString tip = this->GetToolTipText(); wxString tip = this->GetToolTipText();
if (!screen_rect.Contains(pos)) if (!screen_rect.Contains(pos)) {
{
tipWindow->Dismiss(); tipWindow->Dismiss();
tipWindow->Destroy(); tipWindow->Destroy();
tipWindow = nullptr; tipWindow = nullptr;
+18 -14
View File
@@ -8,24 +8,25 @@
class ButtonProps class ButtonProps
{ {
public: public:
static int ChoiceButtonGap(){return 10;}; static int ChoiceButtonGap() { return 10; };
static int WindowButtonGap(){return 10;}; static int WindowButtonGap() { return 10; };
}; };
enum class ButtonStyle{ enum class ButtonStyle {
Regular, Regular,
Confirm, Confirm,
Alert, Alert,
Disabled, Disabled,
}; };
enum class ButtonType{ enum class ButtonType {
Compact , // Font10 FullyRounded For spaces with less areas Compact, // Font10 FullyRounded For spaces with less areas
Window , // Font12 FullyRounded For regular buttons in windows and not related with parameter boxes Window, // Font12 FullyRounded For regular buttons in windows and not related with parameter boxes
Choice , // Font14 Semi-Rounded For dialog/window choice buttons Choice, // Font14 Semi-Rounded For dialog/window choice buttons
Parameter, // Font14 Semi-Rounded For buttons that near parameter boxes Parameter, // Font14 Semi-Rounded For buttons that near parameter boxes
Icon , // ------ Semi-Rounded For buttons that only has icons. icons should be 16x16 and iconSize has to be defined as 16 while creation of button Icon, // ------ Semi-Rounded For buttons that only has icons. icons should be 16x16 and iconSize has to be defined as 16 while
Expanded , // Font14 Semi-Rounded For full length buttons. ex. buttons in static box // creation of button
Expanded, // Font14 Semi-Rounded For full length buttons. ex. buttons in static box
}; };
class Button : public StaticBox class Button : public StaticBox
@@ -34,6 +35,7 @@ class Button : public StaticBox
wxRect textSize; wxRect textSize;
wxSize minSize; // set by outer wxSize minSize; // set by outer
wxSize paddingSize; wxSize paddingSize;
int m_icon_spacing = 5;
ScalableBitmap active_icon; ScalableBitmap active_icon;
StateColor text_color; StateColor text_color;
@@ -66,11 +68,13 @@ public:
void SetPaddingSize(const wxSize& size); void SetPaddingSize(const wxSize& size);
void SetIconSpacing(int spacing);
void SetStyle(const ButtonStyle style /*= ButtonStyle::Regular*/, const ButtonType type /*= ButtonType::None*/); void SetStyle(const ButtonStyle style /*= ButtonStyle::Regular*/, const ButtonType type /*= ButtonType::None*/);
void SetTextColor(StateColor const &color); void SetTextColor(StateColor const& color);
void SetTextColorNormal(wxColor const &color); void SetTextColorNormal(wxColor const& color);
void SetSelected(bool selected = true) { m_selected = selected; } void SetSelected(bool selected = true) { m_selected = selected; }
@@ -80,7 +84,7 @@ public:
bool IsSelected() const { return m_selected; } bool IsSelected() const { return m_selected; }
bool Enable(bool enable = true) override; bool Enable(bool enable = true) override;
void EnableTooltipEvenDisabled();// The tip will be shown even if the button is disabled void EnableTooltipEvenDisabled(); // The tip will be shown even if the button is disabled
void SetCanFocus(bool canFocus) override; void SetCanFocus(bool canFocus) override;
@@ -115,8 +119,8 @@ private:
// some useful events // some useful events
void mouseDown(wxMouseEvent& event); void mouseDown(wxMouseEvent& event);
void mouseReleased(wxMouseEvent& event); void mouseReleased(wxMouseEvent& event);
void mouseCaptureLost(wxMouseCaptureLostEvent &event); void mouseCaptureLost(wxMouseCaptureLostEvent& event);
void keyDownUp(wxKeyEvent &event); void keyDownUp(wxKeyEvent& event);
// //
void sendButtonEvent(); void sendButtonEvent();