From b32f28e71252fd90c70602671de257acbfcfad62 Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Mon, 7 Sep 2026 14:00:28 +0800 Subject: [PATCH] Initial Commit of speed dial improvement --- resources/web/dialog/SpeedDial/speeddial.js | 981 ++++++++++++------ .../web/dialog/SpeedDial/speeddial.test.js | 82 +- resources/web/dialog/SpeedDial/style.css | 31 + src/slic3r/GUI/ActionRegistry.cpp | 390 ++++++- src/slic3r/GUI/ActionRegistry.hpp | 45 +- src/slic3r/GUI/GLCanvas3D.cpp | 6 - src/slic3r/GUI/GLCanvas3D.hpp | 1 - src/slic3r/GUI/KBShortcutsDialog.cpp | 4 +- src/slic3r/GUI/MainFrame.cpp | 19 +- src/slic3r/GUI/Notebook.cpp | 15 +- src/slic3r/GUI/Plater.cpp | 4 - src/slic3r/GUI/SpeedDialDialog.cpp | 149 ++- src/slic3r/GUI/SpeedDialDialog.hpp | 4 +- src/slic3r/GUI/Widgets/Button.cpp | 230 ++-- src/slic3r/GUI/Widgets/Button.hpp | 46 +- 15 files changed, 1441 insertions(+), 566 deletions(-) diff --git a/resources/web/dialog/SpeedDial/speeddial.js b/resources/web/dialog/SpeedDial/speeddial.js index 65f64b4399..b25df97723 100644 --- a/resources/web/dialog/SpeedDial/speeddial.js +++ b/resources/web/dialog/SpeedDial/speeddial.js @@ -1,14 +1,22 @@ // 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) ---- -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 RECENTS = []; // [{id,title,source,group,input,shortcut}] - last-N launched var query = ""; var sel = { zone: "list", i: 0 }; // zone: 'list' | 'fav' var lastResizeHeight = 0; 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 // this script - it is shared with the Plugins dialog. Speed dial search is always case-insensitive. @@ -17,251 +25,352 @@ var qEl = null, listEl = null, favEl = null, clearEl = null, eyeEl = null, count // ---- pure helpers (no DOM; unit-tested) ------------------------------------- function filterActions(actions, query) { - var q = (query || "").trim(); - var list = actions || []; - matchIndex = {}; - if (!q) - return list.slice(0); + var q = (query || "").trim(); + var list = actions || []; + matchIndex = {}; + if (!q) + return list.slice(0); - var out = []; - for (var i = 0; i < list.length; i++) { - var a = list[i]; - var titleMatch = FuzzyRanges(a.title, q, false); - var sourceMatch = FuzzyRanges(a.source, q, false); - if (!titleMatch && !sourceMatch) - continue; - matchIndex[a.id] = { title: titleMatch, source: sourceMatch, useTitle: !!titleMatch }; - out.push(a); - } - return out; + var out = []; + for (var i = 0; i < list.length; i++) { + var a = list[i]; + var titleMatch = FuzzyRanges(a.title, q, false); + var sourceMatch = FuzzyRanges(a.source || "", q, false); + if (!titleMatch && !sourceMatch) + continue; + matchIndex[a.id] = { title: titleMatch, source: sourceMatch, useTitle: !!titleMatch }; + out.push(a); + } + return out; } function visibleFavourites(favourites, actions) { - // why: a fav whose id has no live action (plugin unloaded/disabled) renders a dead - // monogram tile whose click run()s to a silent no-op; drop it from the quick-bar. - var seen = {}; - (actions || []).forEach(function (a) { seen[a.id] = true; }); - return (favourites || []).filter(function (id, i, arr) { - return seen[id] && arr.indexOf(id) === i; - }); + // why: a fav whose id has no live action (plugin unloaded/disabled) renders a dead + // monogram tile whose click run()s to a silent no-op; drop it from the quick-bar. + var seen = {}; + (actions || []).forEach(function (a) { seen[a.id] = true; }); + return (favourites || []).filter(function (id, i, arr) { + return seen[id] && arr.indexOf(id) === i; + }); } function 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 +// " " (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) { - 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 -// 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) { - if (sel.zone === "fav") - return favIds[sel.i]; - // why: search-first keeps the list blank until the user types; an empty query still - // filters to ALL actions, so without this gate Enter fires an action never shown. - if (!shouldRenderActionList(query)) - return null; - var a = actions[sel.i]; - return a && a.id; + if (sel.zone === "fav") + return favIds[sel.i]; + if (!actions || !actions.length) + return null; + var a = actions[sel.i]; + return a && a.id; } function foldLabel(s) { return String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, ""); } // Title-case a source for display: "GCODE OPTIMIZER"/"iRoNiNg pRo" -> "Gcode Optimizer"/"Ironing Pro". function prettySource(source) { - return String(source || "").toLowerCase().replace(/\b\w/g, function (c) { return c.toUpperCase(); }); + return String(source || "").toLowerCase().replace(/\b\w/g, function (c) { return c.toUpperCase(); }); } // 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. function actionLabel(action, actions) { - var label = action.title + " from " + prettySource(action.source); - if (actions && actions.length) { - var mine = foldLabel(action.title) + "|" + foldLabel(action.source); - var clash = actions.some(function (o) { - return o.id !== action.id && foldLabel(o.title) + "|" + foldLabel(o.source) === mine; - }); - if (clash) - label += " (" + action.id + ")"; - } - return label; + var label = action.title + " from " + prettySource(action.source || action.group || ""); + if (actions && actions.length) { + var mine = foldLabel(action.title) + "|" + foldLabel(action.source || action.group || ""); + var clash = actions.some(function (o) { + return o.id !== action.id && foldLabel(o.title) + "|" + foldLabel(o.source || o.group || "") === mine; + }); + if (clash) + label += " (" + action.id + ")"; + } + return label; } // 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. -// why: ordinal is assigned by id, not by ACTIONS order - ACTIONS is frecency-sorted and +// 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 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. -function tileCode(action, actions) { - var list = actions || []; - var ti = (action.title || " ").charAt(0).toUpperCase(); - var sameTitle = list.filter(function (o) { return (o.title || " ").charAt(0).toUpperCase() === ti; }); - if (sameTitle.length <= 1) - return ti; - var pi = (action.source || " ").charAt(0).toUpperCase(); - var sameSource = sameTitle.filter(function (o) { return (o.source || " ").charAt(0).toUpperCase() === pi; }); - if (sameSource.length <= 1) +function monogramFor(item, list, titleOf, sourceOf, idOf) { + var items = list || []; + var title = titleOf(item) || " "; + var ti = title.charAt(0).toUpperCase(); + var sameTitle = items.filter(function (o) { return (titleOf(o) || " ").charAt(0).toUpperCase() === ti; }); + if (sameTitle.length <= 1) + return ti; + var source = sourceOf(item) || " "; + var pi = source.charAt(0).toUpperCase(); + var sameSource = sameTitle.filter(function (o) { return (sourceOf(o) || " ").charAt(0).toUpperCase() === pi; }); + if (sameSource.length <= 1) + return pi + ti; + 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++) + if (sameSource[i] === item || idOf(sameSource[i]) === idOf(item)) + return pi + ti + (i + 1); return pi + ti; - sameSource.sort(function (a, b) { return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; }); - for (var i = 0; i < sameSource.length; i++) - if (sameSource[i].id === action.id) - return pi + ti + (i + 1); - 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() { - if (clearEl) - clearEl.hidden = !query; + if (clearEl) + clearEl.hidden = !query; } function stateFromPayload(payload) { - return { - actions: payload.actions || [], - favourites: payload.favourites || [], - query: "", - sel: { zone: "list", i: 0 }, - lastResizeHeight: 0 - }; + return { + actions: payload.actions || [], + favourites: payload.favourites || [], + recent: payload.recent || [], + query: "", + sel: { zone: "list", i: 0 }, + lastResizeHeight: 0, + phase: "commands", + settingsResults: [], + tabOptions: [] + }; } function resetScrollPositions(list, doc) { - if (list) - list.scrollTop = 0; - if (doc && doc.scrollingElement) - doc.scrollingElement.scrollTop = 0; - if (doc && doc.documentElement) - doc.documentElement.scrollTop = 0; - if (doc && doc.body) - doc.body.scrollTop = 0; + if (list) + list.scrollTop = 0; + if (doc && doc.scrollingElement) + doc.scrollingElement.scrollTop = 0; + if (doc && doc.documentElement) + doc.documentElement.scrollTop = 0; + if (doc && doc.body) + doc.body.scrollTop = 0; } // nextSel: pure arrow-nav transition. Down fav->list0; Down list->clamp; Up list@0->fav0; // Up list->i-1; Left/Right clamp within fav. Returns a fresh {zone,i}. function nextSel(sel, key, listLen, favLen) { - var zone = sel.zone, i = sel.i; - if (key === "ArrowDown") { - if (zone === "fav") return { zone: "list", i: 0 }; - return { zone: "list", i: Math.min(i + 1, Math.max(0, listLen - 1)) }; - } - if (key === "ArrowUp") { - if (zone === "list") { - if (i <= 0) return favLen ? { zone: "fav", i: 0 } : { zone: "list", i: 0 }; - return { zone: "list", i: i - 1 }; + var zone = sel.zone, i = sel.i; + if (key === "ArrowDown") { + if (zone === "fav") return { zone: "list", i: 0 }; + return { zone: "list", i: Math.min(i + 1, Math.max(0, listLen - 1)) }; } + if (key === "ArrowUp") { + if (zone === "list") { + if (i <= 0) return favLen ? { zone: "fav", i: 0 } : { zone: "list", i: 0 }; + return { zone: "list", i: i - 1 }; + } + return { zone: zone, i: i }; + } + if (key === "ArrowLeft" && zone === "fav") return { zone: "fav", i: Math.max(0, i - 1) }; + if (key === "ArrowRight" && zone === "fav") return { zone: "fav", i: Math.min(favLen - 1, i + 1) }; return { zone: zone, i: i }; - } - if (key === "ArrowLeft" && zone === "fav") return { zone: "fav", i: Math.max(0, i - 1) }; - if (key === "ArrowRight" && zone === "fav") return { zone: "fav", i: Math.min(favLen - 1, i + 1) }; - return { zone: zone, i: i }; } // ---- bridge ------------------------------------------------------------------ function SendMessage(msg) { - if (typeof SendWXMessage !== "function") - return; - if (typeof msg === "string") msg = { command: msg }; - if (msg.sequence_id === undefined) msg.sequence_id = Date.now(); - SendWXMessage(JSON.stringify(msg)); + if (typeof SendWXMessage !== "function") + return; + if (typeof msg === "string") msg = { command: msg }; + if (msg.sequence_id === undefined) msg.sequence_id = Date.now(); + SendWXMessage(JSON.stringify(msg)); } -// C++ pushes payloads here. Only list_actions is handled; it (re)seeds all state. +// C++ pushes payloads here. window.HandleStudio = function (payload) { - if (!payload) return; - if (typeof payload === "string") { try { payload = JSON.parse(payload); } catch (e) { return; } } - if (payload.command === "list_actions") { - var next = stateFromPayload(payload); - ACTIONS = next.actions; - FAVS = next.favourites; - query = next.query; - sel = next.sel; - lastResizeHeight = next.lastResizeHeight; - if (qEl) { - qEl.value = ""; - qEl.placeholder = "Search " + ACTIONS.length + " actions"; - syncClearButton(); + if (!payload) return; + if (typeof payload === "string") { try { payload = JSON.parse(payload); } catch (e) { return; } } + if (payload.command === "list_actions") { + var next = stateFromPayload(payload); + ACTIONS = next.actions; + FAVS = next.favourites; + RECENTS = next.recent; + query = next.query; + sel = next.sel; + lastResizeHeight = next.lastResizeHeight; + phase = next.phase; + settingsResults = next.settingsResults; + tabOptions = next.tabOptions; + if (qEl) { + qEl.value = ""; + qEl.placeholder = "Search " + ACTIONS.length + " actions"; + syncClearButton(); + } + render({ resize: true, resetScroll: true }); + 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 }); } - render({ resize: true, resetScroll: true }); - focusInput(); - } }; // ---- DOM helpers ------------------------------------------------------------- function $(id) { return document.getElementById(id); } function byId(id) { - for (var i = 0; i < ACTIONS.length; i++) if (ACTIONS[i].id === id) return ACTIONS[i]; - return null; + for (var i = 0; i < ACTIONS.length; i++) if (ACTIONS[i].id === id) return ACTIONS[i]; + 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); } +// 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) { - var h = 0; - for (var i = 0; i < id.length; i++) - h = (h * 31 + id.charCodeAt(i)) >>> 0; - return h % 360; + var h = 0; + for (var i = 0; i < id.length; i++) + h = (h * 31 + id.charCodeAt(i)) >>> 0; + return h % 360; } // Build a
with the search-match ranges wrapped in . Used for both the // title and the source eyebrow. Pure (only touches the document factory), so the node-vm test never // calls it and load-time stays DOM-free. function markedText(className, text, match) { - var node = document.createElement("div"); - node.className = className; node.title = text; - if (!match || !match.length) { - node.textContent = text; + var node = document.createElement("div"); + node.className = className; node.title = text; + if (!match || !match.length) { + node.textContent = text; + return node; + } + var last = 0; + for (var i = 0; i < match.length; i++) { + var range = match[i]; + if (range[0] > last) + node.appendChild(document.createTextNode(text.slice(last, range[0]))); + var m = document.createElement("mark"); + m.textContent = text.slice(range[0], range[1]); + node.appendChild(m); + last = range[1]; + } + if (last < text.length) + node.appendChild(document.createTextNode(text.slice(last))); return node; - } - var last = 0; - for (var i = 0; i < match.length; i++) { - var range = match[i]; - if (range[0] > last) - node.appendChild(document.createTextNode(text.slice(last, range[0]))); - var m = document.createElement("mark"); - m.textContent = text.slice(range[0], range[1]); - node.appendChild(m); - last = range[1]; - } - if (last < text.length) - node.appendChild(document.createTextNode(text.slice(last))); - return node; } function starSvg(on) { - return '' + - ''; + return '' + + ''; } // ---- render ------------------------------------------------------------------ function renderFav() { - favEl.innerHTML = ""; - var favs = currentVisibleFavs(); - favEl.hidden = favs.length === 0; - if (!favs.length && sel.zone === "fav") - sel = { zone: "list", i: 0 }; - else if (sel.zone === "fav") - sel.i = Math.max(0, Math.min(sel.i, favs.length - 1)); - updateFavEyebrow(favs); - favs.forEach(function (id, i) { - var a = byId(id); - var tile = document.createElement("button"); - tile.className = "fav-tile" + (sel.zone === "fav" && sel.i === i ? " sel" : ""); - tile.style.setProperty("--h", hue(id)); - tile.textContent = tileCode(a, ACTIONS); - tile.title = a.title; - tile.setAttribute("aria-label", actionLabel(a, ACTIONS)); - tile.onclick = function () { sel = { zone: "fav", i: i }; run(a); }; - tile.oncontextmenu = function (ev) { - ev.preventDefault(); - // why: selecting shows the eyebrow, which grows the launcher - resize so the popup - // isn't clipped (mirrors arrow-nav). requestResize no-ops when height is unchanged. - sel = { zone: "fav", i: i }; render({ resize: true }); - showFavMenu(ev.clientX, ev.clientY, id); - }; - favEl.appendChild(tile); - }); + // 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 = ""; + var favs = currentVisibleFavs(); + favEl.hidden = favs.length === 0; + if (!favs.length && sel.zone === "fav") + sel = { zone: "list", i: 0 }; + else if (sel.zone === "fav") + sel.i = Math.max(0, Math.min(sel.i, favs.length - 1)); + updateFavEyebrow(favs); + favs.forEach(function (id, i) { + var a = byId(id); + var tile = document.createElement("button"); + tile.className = "fav-tile" + (sel.zone === "fav" && sel.i === i ? " sel" : ""); + tile.style.setProperty("--h", hue(id)); + tile.textContent = tileCode(a, ACTIONS); + tile.title = a.title; + tile.setAttribute("aria-label", actionLabel(a, ACTIONS)); + 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 = ''; + unpin.onclick = function (ev) { ev.stopPropagation(); toggleFav(id); }; + tile.appendChild(unpin); + tile.oncontextmenu = function (ev) { + ev.preventDefault(); + // why: selecting shows the eyebrow, which grows the launcher - resize so the popup + // isn't clipped (mirrors arrow-nav). requestResize no-ops when height is unchanged. + sel = { zone: "fav", i: i }; render({ resize: true }); + showFavMenu(ev.clientX, ev.clientY, id); + }; + favEl.appendChild(tile); + }); } // ---- favourite context menu (right-click a tile) ----------------------------- @@ -270,83 +379,87 @@ var favMenuEl = null; function hideFavMenu() { if (favMenuEl) favMenuEl.hidden = true; } function addFavMenuItem(label, enabled, fn) { - var item = document.createElement("button"); - item.className = "ctx-item"; - item.textContent = label; - item.disabled = !enabled; - item.onclick = function () { hideFavMenu(); fn(); }; - favMenuEl.appendChild(item); + var item = document.createElement("button"); + item.className = "ctx-item"; + item.textContent = label; + item.disabled = !enabled; + item.onclick = function () { hideFavMenu(); fn(); }; + favMenuEl.appendChild(item); } // One reused menu node (Move left/right + Unpin), positioned at the cursor and clamped // to the viewport. Native browser context menus can't add items, so we roll our own tiny one. function showFavMenu(x, y, id) { - if (!favMenuEl) { - favMenuEl = document.createElement("div"); - favMenuEl.className = "ctx-menu"; - document.body.appendChild(favMenuEl); - } - favMenuEl.innerHTML = ""; - var favs = currentVisibleFavs(); - var vi = favs.indexOf(id); - addFavMenuItem("Move left", vi > 0, function () { moveFav(id, -1); }); - addFavMenuItem("Move right", vi >= 0 && vi < favs.length - 1, function () { moveFav(id, 1); }); - addFavMenuItem("Unpin", true, function () { toggleFav(id); }); - favMenuEl.hidden = false; - favMenuEl.style.left = Math.max(0, Math.min(x, window.innerWidth - favMenuEl.offsetWidth - 4)) + "px"; - favMenuEl.style.top = Math.max(0, Math.min(y, window.innerHeight - favMenuEl.offsetHeight - 4)) + "px"; + if (!favMenuEl) { + favMenuEl = document.createElement("div"); + favMenuEl.className = "ctx-menu"; + document.body.appendChild(favMenuEl); + } + favMenuEl.innerHTML = ""; + var favs = currentVisibleFavs(); + var vi = favs.indexOf(id); + addFavMenuItem("Move left", vi > 0, function () { moveFav(id, -1); }); + addFavMenuItem("Move right", vi >= 0 && vi < favs.length - 1, function () { moveFav(id, 1); }); + addFavMenuItem("Unpin", true, function () { toggleFav(id); }); + favMenuEl.hidden = false; + favMenuEl.style.left = Math.max(0, Math.min(x, window.innerWidth - favMenuEl.offsetWidth - 4)) + "px"; + favMenuEl.style.top = Math.max(0, Math.min(y, window.innerHeight - favMenuEl.offsetHeight - 4)) + "px"; } // Swap a favourite with its visible neighbour (dir -1/+1) and persist the new order. Swapping // by id inside FAVS (not the visible slice) keeps any hidden pins (no live action) in place. function moveFav(id, dir) { - var favs = currentVisibleFavs(); - var vi = favs.indexOf(id); - var ni = vi + dir; - if (vi === -1 || ni < 0 || ni >= favs.length) return; - var a = FAVS.indexOf(id), b = FAVS.indexOf(favs[ni]); - if (a === -1 || b === -1) return; - FAVS[a] = favs[ni]; FAVS[b] = id; - SendMessage({ command: "reorder_favourites", ids: FAVS.slice() }); - sel = { zone: "fav", i: ni }; - render({ resize: true }); + var favs = currentVisibleFavs(); + var vi = favs.indexOf(id); + var ni = vi + dir; + if (vi === -1 || ni < 0 || ni >= favs.length) return; + var a = FAVS.indexOf(id), b = FAVS.indexOf(favs[ni]); + if (a === -1 || b === -1) return; + FAVS[a] = favs[ni]; FAVS[b] = id; + SendMessage({ command: "reorder_favourites", ids: FAVS.slice() }); + sel = { zone: "fav", i: ni }; + render({ resize: true }); } // Name of the selected favourite, shown above the bar; hidden unless a fav is selected. function updateFavEyebrow(favs) { - if (!eyeEl) return; - var a = sel.zone === "fav" && favs.length ? byId(favs[sel.i]) : null; - eyeEl.textContent = a ? a.title : ""; - eyeEl.hidden = !a; + if (!eyeEl) return; + var a = sel.zone === "fav" && favs.length ? byId(favs[sel.i]) : null; + eyeEl.textContent = a ? a.title : ""; + eyeEl.hidden = !a; } -function renderList() { - var q = (query || "").trim(); - var arr = filterActions(ACTIONS, query); - if (sel.zone === "list") - sel.i = Math.max(0, Math.min(sel.i, arr.length - 1)); - listEl.innerHTML = ""; - // why: search-first - the list stays blank until the user types; only a - // non-empty query with zero hits earns the "No actions match" message. - if (!shouldRenderActionList(query)) { - listEl.className = "dial-list empty"; - if (countEl) countEl.hidden = true; - return; - } - listEl.className = "dial-list" + (!arr.length ? " empty" : ""); - if (!arr.length) { - if (countEl) countEl.hidden = true; - var empty = document.createElement("div"); - empty.className = "dial-empty"; - empty.textContent = "No actions match (Total: " + ACTIONS.length + ")"; - listEl.appendChild(empty); - return; - } - if (countEl) { - countEl.hidden = false; - countEl.textContent = resultCountText(ACTIONS.length, arr.length, query); - } - arr.forEach(function (a, i) { +// One settings row; has no star/tile because settings aren't pinnable. +function renderSettingRow(s, i) { + var row = document.createElement("div"); + row.className = "row" + (sel.zone === "list" && sel.i === i ? " sel" : ""); + row.setAttribute("aria-label", s.label); + + var tile = document.createElement("div"); + tile.className = "tile"; + tile.style.setProperty("--h", hue(s.opt_key)); + tile.textContent = settingCode(s, settingsResults); + + var left = document.createElement("div"); + left.className = "row-left"; + // why: the label already packs "Category : Group : Label", so no separate eyebrow. + var line = document.createElement("div"); + line.className = "row-line"; + var name = document.createElement("div"); + name.className = "row-name"; + name.textContent = s.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 }; jumpToSetting(s); }; + 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 row = document.createElement("div"); row.className = "row" + (sel.zone === "list" && sel.i === i ? " sel" : ""); @@ -360,20 +473,20 @@ function renderList() { var left = document.createElement("div"); left.className = "row-left"; 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"); line.className = "row-line"; var name = markedText("row-name", a.title, mi ? mi.title : null); line.appendChild(name); if (a.shortcut) { - var sc = document.createElement("div"); - sc.className = "row-sc"; - a.shortcut.split("+").forEach(function (k) { - var key = document.createElement("kbd"); - key.textContent = k; - sc.appendChild(key); - }); - line.appendChild(sc); + var sc = document.createElement("div"); + sc.className = "row-sc"; + a.shortcut.split("+").forEach(function (k) { + var key = document.createElement("kbd"); + key.textContent = k; + sc.appendChild(key); + }); + line.appendChild(sc); } left.appendChild(sourceEl); left.appendChild(line); @@ -389,110 +502,350 @@ function renderList() { star.ondblclick = function (ev) { ev.stopPropagation(); }; row.appendChild(star); - row.onclick = function () { sel = { zone: "list", i: i }; render(); }; - row.ondblclick = function () { sel = { zone: "list", i: i }; run(a); }; - listEl.appendChild(row); - }); + row.onclick = function () { sel = { zone: "list", i: i }; render({ resize: true }); }; + row.ondblclick = function () { sel = { zone: "list", i: i }; activateEntry(a); }; + 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) { - renderFav(); - renderList(); - scrollSelectedIntoView(); - if (opts && opts.resetScroll) - resetScrollPositions(listEl, document); - if (opts && opts.resize) - requestResize(); + renderFav(); + renderList(); + scrollSelectedIntoView(); + if (opts && opts.resetScroll) + resetScrollPositions(listEl, document); + if (opts && opts.resize) + requestResize(); } // Keep the selected item in view as arrows move it: the list scrolls vertically, the fav bar // horizontally (arrow nav "pushes" the scrollable fav row to follow the selection). function scrollSelectedIntoView() { - var el = null; - if (sel.zone === "list" && listEl) - el = listEl.querySelector(".row.sel"); - else if (sel.zone === "fav" && favEl) - el = favEl.querySelector(".fav-tile.sel"); - if (el && el.scrollIntoView) - el.scrollIntoView({ block: "nearest", inline: "nearest" }); + var el = null; + if (sel.zone === "list" && listEl) + el = listEl.querySelector(".row.sel"); + else if (sel.zone === "fav" && favEl) + el = favEl.querySelector(".fav-tile.sel"); + if (el && el.scrollIntoView) + el.scrollIntoView({ block: "nearest", inline: "nearest" }); } function requestResize() { - if (!document.body) - return; - setTimeout(function () { - var launcher = document.querySelector(".launcher"); - if (!launcher) - return; - var height = Math.ceil(launcher.getBoundingClientRect().height); - if (!height || height === lastResizeHeight) - return; - lastResizeHeight = height; - SendMessage({ command: "resize", height: height }); - }, 0); + if (!document.body) + return; + setTimeout(function () { + var launcher = document.querySelector(".launcher"); + if (!launcher) + return; + var height = Math.ceil(launcher.getBoundingClientRect().height); + if (!height || height === lastResizeHeight) + return; + lastResizeHeight = height; + SendMessage({ command: "resize", height: height }); + }, 0); } // ---- actions ----------------------------------------------------------------- function toggleFav(id) { - var k = FAVS.indexOf(id); - var newState = k === -1; - if (newState) FAVS.push(id); else FAVS.splice(k, 1); - SendMessage({ command: "toggle_favourite", id: id, fav: newState }); - render({ resize: true }); + var k = FAVS.indexOf(id); + var newState = k === -1; + if (newState) FAVS.push(id); else FAVS.splice(k, 1); + SendMessage({ command: "toggle_favourite", id: id, fav: newState }); + render({ resize: true }); } -// Fire the action; C++ owns the run-confirm (native dialog) + suppression, then closes the popup + toasts. +// Fire a command/plugin action; C++ owns the run-confirm (native dialog) + suppression, then +// closes the popup + toasts. function run(a) { - if (!a) return; - SendMessage({ command: "run_action", id: a.id, title: a.title }); + if (!a) return; + 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() { - var id = selectedActionId(sel, filterActions(ACTIONS, query), currentVisibleFavs(), query); - if (id) run(byId(id)); + if (phase === "percent") { + 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 -------------------------------------------------------------------- function OnInit() { - qEl = $("q"); listEl = $("list"); favEl = $("favBar"); clearEl = $("clear"); eyeEl = $("favEyebrow"); countEl = $("count"); - syncClearButton(); - - $("clear").onclick = function () { - query = ""; qEl.value = ""; sel = { zone: "list", i: 0 }; render({ resize: true, resetScroll: true }); qEl.focus(); + qEl = $("q"); listEl = $("list"); favEl = $("favBar"); clearEl = $("clear"); eyeEl = $("favEyebrow"); countEl = $("count"); syncClearButton(); - }; - qEl.addEventListener("input", function () { - query = qEl.value; sel = { zone: "list", i: 0 }; syncClearButton(); render({ resize: true, resetScroll: true }); - }); - // why: dismiss the fav context menu on any click/scroll away from it (capture scroll to catch nested scrollers). - document.addEventListener("click", hideFavMenu); - document.addEventListener("scroll", hideFavMenu, true); + $("clear").onclick = function () { + query = ""; qEl.value = ""; sel = { zone: "list", i: 0 }; + if (phase === "settings") SendMessage({ command: "search_settings", q: "" }); + render({ resize: true, resetScroll: true }); qEl.focus(); + syncClearButton(); + }; + qEl.addEventListener("input", function () { + query = qEl.value; sel = { zone: "list", i: 0 }; syncClearButton(); + if (phase === "settings") SendMessage({ command: "search_settings", q: query }); + render({ resize: true, resetScroll: true }); + }); - document.addEventListener("keydown", function (e) { - if (favMenuEl && !favMenuEl.hidden && e.key === "Escape") { e.preventDefault(); hideFavMenu(); return; } - var arr = filterActions(ACTIONS, query); - var favs = currentVisibleFavs(); - // 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. - var lr = e.key === "ArrowLeft" || e.key === "ArrowRight"; - if (e.key === "ArrowDown" || e.key === "ArrowUp" || (lr && sel.zone === "fav")) { - e.preventDefault(); - sel = nextSel(sel, e.key, arr.length, favs.length); - // why: entering/leaving the fav zone toggles the eyebrow line, changing launcher height; - // resize so the popup grows/shrinks instead of clipping. requestResize no-ops when unchanged. - render({ resize: true }); - } else if (e.key === "Enter") { - e.preventDefault(); - runSelected(); - } else if (e.key === "Escape") { - e.preventDefault(); - if (query) { query = ""; qEl.value = ""; sel = { zone: "list", i: 0 }; syncClearButton(); render({ resize: true, resetScroll: true }); } - else SendMessage({ command: "close_page" }); - } - }); + // why: dismiss the fav context menu on any click/scroll away from it (capture scroll to catch nested scrollers). + document.addEventListener("click", hideFavMenu); + document.addEventListener("scroll", hideFavMenu, true); - SendMessage({ command: "request_actions" }); + document.addEventListener("keydown", function (e) { + if (favMenuEl && !favMenuEl.hidden && e.key === "Escape") { e.preventDefault(); hideFavMenu(); return; } + var list = currentList(); + // 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, + // let Left/Right fall through so they move the caret in the focused search field. + var lr = e.key === "ArrowLeft" || e.key === "ArrowRight"; + 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(); + sel = nextSel(sel, e.key, list.length, favs.length); + // why: entering/leaving the fav zone toggles the eyebrow line, changing launcher height; + // resize so the popup grows/shrinks instead of clipping. requestResize no-ops when unchanged. + render({ resize: true }); + } else if (e.key === "Enter") { + e.preventDefault(); + if (phase === "percent") runJumpToLayer(query.trim()); + else runSelected(); + } else if (e.key === "Escape") { + e.preventDefault(); + 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" }); + } + }); + + SendMessage({ command: "request_actions" }); } diff --git a/resources/web/dialog/SpeedDial/speeddial.test.js b/resources/web/dialog/SpeedDial/speeddial.test.js index 05c1e69c23..2ce625072b 100644 --- a/resources/web/dialog/SpeedDial/speeddial.test.js +++ b/resources/web/dialog/SpeedDial/speeddial.test.js @@ -22,20 +22,92 @@ assert.equal( "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, "whitespace-only 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 recent/empty list"); 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( - ctx.selectedActionId({ zone: "list", i: 0 }, duplicateActions, [], ""), + ctx.selectedActionId({ zone: "list", i: 0 }, ctx.commandList(duplicateActions, [], ""), [], ""), 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( - ctx.selectedActionId({ zone: "list", i: 0 }, duplicateActions, [], "rep"), + ctx.selectedActionId({ zone: "list", i: 0 }, ctx.commandList(duplicateActions, [], "rep"), [], "rep"), "0123456789abcdef", "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( ctx.selectedActionId({ zone: "fav", i: 0 }, duplicateActions, ["fedcba9876543210"], ""), "fedcba9876543210", diff --git a/resources/web/dialog/SpeedDial/style.css b/resources/web/dialog/SpeedDial/style.css index 72de412f66..cdbeca2c69 100644 --- a/resources/web/dialog/SpeedDial/style.css +++ b/resources/web/dialog/SpeedDial/style.css @@ -55,6 +55,28 @@ body { border: 1px solid var(--speed-tile-border, #d8d8d8); } .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 { position: fixed; z-index: 10; @@ -127,6 +149,15 @@ body { color: var(--muted, var(--orca-muted, #6b7280)); } .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 { flex: 0 1 auto; min-height: 0; diff --git a/src/slic3r/GUI/ActionRegistry.cpp b/src/slic3r/GUI/ActionRegistry.cpp index f3df867ab7..3b9e568a96 100644 --- a/src/slic3r/GUI/ActionRegistry.cpp +++ b/src/slic3r/GUI/ActionRegistry.cpp @@ -1,15 +1,29 @@ #include "ActionRegistry.hpp" +#include "GCodeViewer.hpp" +#include "GLCanvas3D.hpp" #include "GUI.hpp" #include "GUI_App.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 +#include +#include #include #include +#include +#include +#include + #include #include #include @@ -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) -{ - 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) -{ - wxGetApp().app_config->set(kConfigSection, key, j.dump()); -} +void write_section(const char* key, const nlohmann::json& j) { wxGetApp().app_config->set(kConfigSection, key, j.dump()); } std::vector read_string_array(const char* key) { @@ -53,7 +62,7 @@ double frecency_score(int count, long long last, long long now) if (count <= 0) return 0.0; constexpr double HALF_LIFE_DAYS = 30.0; - double age = std::max(0.0, double(now - last) / 86400.0); + double age = std::max(0.0, double(now - last) / 86400.0); return count * std::pow(2.0, -age / HALF_LIFE_DAYS); } @@ -79,26 +88,25 @@ struct PluginScriptAction : AppAction // The id an action for (plugin_key, capability) would have - lets refresh_capability // remove a gone capability without materialising the action. 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) : AppAction(kIdPrefix, - capability_in.empty() ? plugin_key_in : capability_in, // title - plugin_key_in, // source_key - std::move(source_name)), - plugin_key(std::move(plugin_key_in)), capability(std::move(capability_in)) + capability_in.empty() ? plugin_key_in : capability_in, // title + plugin_key_in, // source_key + std::move(source_name)) + , 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; const ExecutionResult result = PluginManager::instance().run_script_capability(plugin_key, capability, error); if (!error.empty()) return {AppActionRunResult::Level::Error, from_u8(error)}; - const bool skipped = result.status == PluginResult::Skipped; + const bool skipped = result.status == PluginResult::Skipped; const wxString fallback = skipped ? _L("Script plugin skipped.") : _L("Script plugin finished."); return {skipped ? AppActionRunResult::Level::Info : AppActionRunResult::Level::Success, result.message.empty() ? fallback : from_u8(result.message)}; @@ -107,8 +115,7 @@ struct PluginScriptAction : AppAction // Builds an action for a capability, or nullptr if it is not a currently-loaded, // enabled script capability. -std::unique_ptr make_action(const std::string& plugin_key, const std::string& capability, - const std::string& source_name) +std::unique_ptr make_action(const std::string& plugin_key, const std::string& capability, const std::string& source_name) { PluginManager& manager = PluginManager::instance(); if (!manager.is_plugin_loaded(plugin_key)) @@ -119,8 +126,167 @@ std::unique_ptr make_action(const std::string& plugin_key, const std: return std::make_unique(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 make_command(std::string key, std::string title, std::string group, std::string input = "") +{ return std::make_unique(std::move(key), std::move(title), std::move(group), std::move(input)); } + +// The built-in palette commands, registered once at init(). +std::vector> native_commands() +{ + std::vector> 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 settings_inputs() +{ + std::vector 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 +ActionRegistry::~ActionRegistry() = default; + void ActionRegistry::init() { assert(wxThread::IsMain()); @@ -151,18 +317,12 @@ void ActionRegistry::init() // Subscribe before enumerating so a concurrent load cannot land between the initial // snapshot and callback registration. Duplicate notifications are safe: upsert is by // id and the m_actions scan in refresh_source is idempotent. - manager.subscribe_on_load_callback( - [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_load_callback([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_capability_load_callback( - [on_capability](const PluginCapabilityId& capability) { - on_capability(capability, ActionChange::Added); - }); + [on_capability](const PluginCapabilityId& capability) { on_capability(capability, ActionChange::Added); }); manager.subscribe_on_capability_unload_callback( - [on_capability](const PluginCapabilityId& capability) { - on_capability(capability, ActionChange::Removed); - }); + [on_capability](const PluginCapabilityId& capability) { on_capability(capability, ActionChange::Removed); }); // enumerate current script capabilities std::unordered_map source_names; @@ -173,12 +333,18 @@ void ActionRegistry::init() for (const auto& capability : manager.get_plugin_capabilities("", PluginCapabilityType::Script)) { if (!capability) continue; - const std::string& key = capability->audit_plugin_key(); - auto it = source_names.find(key); + const std::string& key = capability->audit_plugin_key(); + auto it = source_names.find(key); const std::string& source_name = it == source_names.end() ? key : it->second; if (auto action = make_action(key, capability->name(), source_name)) 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) @@ -198,7 +364,7 @@ void ActionRegistry::refresh_source(const std::string& plugin_key, ActionChange if (change == ActionChange::Removed) return; - PluginManager& manager = PluginManager::instance(); + PluginManager& manager = PluginManager::instance(); const std::string source_name = find_loaded_source_name(manager, plugin_key); for (const auto& capability : manager.get_plugin_capabilities(plugin_key, PluginCapabilityType::Script)) { if (!capability) @@ -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, - ActionChange change) +void ActionRegistry::refresh_capability(const std::string& plugin_key, const std::string& capability, ActionChange change) { assert(wxThread::IsMain()); @@ -233,7 +398,7 @@ void ActionRegistry::upsert(std::unique_ptr action) return; seed_state(*action); - std::string id = action->id(); + std::string id = action->id(); std::shared_ptr stored = std::move(action); m_actions.insert_or_assign(std::move(id), std::move(stored)); } @@ -246,11 +411,11 @@ void ActionRegistry::remove(const std::string& id) void ActionRegistry::seed_state(AppAction& a) const { - auto favs = read_string_array("favourite_actions"); + auto favs = read_string_array("favourite_actions"); a.favourite = std::find(favs.begin(), favs.end(), a.id()) != favs.end(); nlohmann::json stats = read_section("stats", nlohmann::json::object()); - auto it = stats.find(a.id()); + auto it = stats.find(a.id()); if (it != stats.end() && it->is_object()) { a.count = it->value("count", 0); a.last = it->value("last", 0LL); @@ -269,14 +434,11 @@ const AppAction* ActionRegistry::by_id(const std::string& id) const return it == m_actions.end() ? nullptr : it->second.get(); } -AppAction* ActionRegistry::find(const std::string& id) -{ - return const_cast(by_id(id)); -} +AppAction* ActionRegistry::find(const std::string& id) { return const_cast(by_id(id)); } // ---- dispatch + write-through ---------------------------------------------- -AppActionRunResult ActionRegistry::run(const std::string& id) +AppActionRunResult ActionRegistry::run(const std::string& id, const std::string& param) { assert(wxThread::IsMain()); auto it = m_actions.find(id); @@ -286,13 +448,13 @@ AppActionRunResult ActionRegistry::run(const std::string& id) // nested event loop; a queued source refresh can erase the entry while the // keep-alive preserves the action until run returns. std::shared_ptr keep = it->second; - AppActionRunResult o = keep->run(); + AppActionRunResult o = keep->run(param); if (o.level == AppActionRunResult::Level::Busy) return o; // Bump stats (write-through). Re-read to avoid clobbering a concurrent field. nlohmann::json stats = read_section("stats", nlohmann::json::object()); - if (!stats.is_object()) // corrupt (valid-JSON, non-object) value degrades to empty + if (!stats.is_object()) // corrupt (valid-JSON, non-object) value degrades to empty stats = nlohmann::json::object(); nlohmann::json& e = stats[id]; if (!e.is_object()) @@ -300,7 +462,10 @@ AppActionRunResult ActionRegistry::run(const std::string& id) e["count"] = e.value("count", 0) + 1; e["last"] = (long long) std::time(nullptr); 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; } @@ -325,8 +490,7 @@ void ActionRegistry::reorder_favourites(const std::vector& ids) std::vector next; // keep the requested order, but only ids that are actually favourites (guard a bad payload) for (const auto& id : ids) - if (std::find(cur.begin(), cur.end(), id) != cur.end() && - std::find(next.begin(), next.end(), id) == next.end()) + if (std::find(cur.begin(), cur.end(), id) != cur.end() && std::find(next.begin(), next.end(), id) == next.end()) next.push_back(id); // why: don't drop favourites the page omitted (e.g. pins with no live action hidden from the bar) for (const auto& id : cur) @@ -374,17 +538,139 @@ nlohmann::json ActionRegistry::snapshot() const return a->id() < b->id(); }); + auto action_to_json = [](const AppAction* a) { + return nlohmann::json({{"id", a->id()}, + {"title", a->title()}, + {"source", a->source_name()}, + {"group", a->group}, + {"input", a->input}, + {"shortcut", ""}}); + }; + nlohmann::json actions = nlohmann::json::array(); for (const AppAction* a : sorted) - actions.push_back({{"id", a->id()}, - {"title", a->title()}, - {"source", a->source_name()}, - {"shortcut", ""}}); + actions.push_back(action_to_json(a)); + // 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 // reorder the favourites bar). The page (js) filters out ids with no live action itself. 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 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(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 + / 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 diff --git a/src/slic3r/GUI/ActionRegistry.hpp b/src/slic3r/GUI/ActionRegistry.hpp index c421297217..7b718b4c09 100644 --- a/src/slic3r/GUI/ActionRegistry.hpp +++ b/src/slic3r/GUI/ActionRegistry.hpp @@ -18,6 +18,9 @@ namespace Slic3r { namespace GUI { // How a source's action set changed. Drives the registry's refresh handlers. 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 // actions translate their runner-specific result into this generic shape. struct AppActionRunResult @@ -56,8 +59,18 @@ struct AppAction int count = 0; 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 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: // The definition is constructor-set and immutable. Refreshes replace an action @@ -92,6 +105,8 @@ private: class ActionRegistry { public: + ~ActionRegistry(); + // 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 // updates together. @@ -108,7 +123,7 @@ public: const AppAction* by_id(const std::string& id) const; // 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 reorder_favourites(const std::vector& ids); // persist a new bar order @@ -116,9 +131,33 @@ public: bool should_ask(const std::string& id) const; 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; + // "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..) 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: void seed_state(AppAction& a) const; // favourite/stats from config AppAction* find(const std::string& id); diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 7db7e01e9c..f218a3f564 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -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_ALL, SimpleEvent); wxDEFINE_EVENT(EVT_GLCANVAS_QUESTION_MARK, SimpleEvent); -wxDEFINE_EVENT(EVT_GLCANVAS_OPEN_SPEED_DIAL, SimpleEvent); wxDEFINE_EVENT(EVT_GLCANVAS_INCREASE_INSTANCES, Event); wxDEFINE_EVENT(EVT_GLCANVAS_INSTANCE_MOVED, SimpleEvent); wxDEFINE_EVENT(EVT_GLCANVAS_INSTANCE_ROTATED, SimpleEvent); @@ -3502,11 +3501,6 @@ void GLCanvas3D::on_char(wxKeyEvent& evt) break; } case '?': { post_event(SimpleEvent(EVT_GLCANVAS_QUESTION_MARK)); break; } - case ' ': { - if (m_canvas_type == ECanvasType::CanvasView3D) - post_event(SimpleEvent(EVT_GLCANVAS_OPEN_SPEED_DIAL)); - break; - } case 'A': case 'a': { diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index 84dbd5d652..4899a94e51 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -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_ALL, SimpleEvent); wxDECLARE_EVENT(EVT_GLCANVAS_QUESTION_MARK, SimpleEvent); -wxDECLARE_EVENT(EVT_GLCANVAS_OPEN_SPEED_DIAL, SimpleEvent); wxDECLARE_EVENT(EVT_GLCANVAS_INCREASE_INSTANCES, Event); // data: +1 => increase, -1 => decrease wxDECLARE_EVENT(EVT_GLCANVAS_INSTANCE_MOVED, SimpleEvent); wxDECLARE_EVENT(EVT_GLCANVAS_FORCE_UPDATE, SimpleEvent); diff --git a/src/slic3r/GUI/KBShortcutsDialog.cpp b/src/slic3r/GUI/KBShortcutsDialog.cpp index ed513c425f..82e499ac55 100644 --- a/src/slic3r/GUI/KBShortcutsDialog.cpp +++ b/src/slic3r/GUI/KBShortcutsDialog.cpp @@ -197,6 +197,8 @@ void KBShortcutsDialog::fill_shortcuts() // Switch table page { ctrl + L("Tab"), L("Switch table page")}, + // Open speed dial + { ctrl + "K", L("Open speed dial") }, //DEL #ifdef __APPLE__ {"fn+⌫", L("Delete Selected")}, @@ -267,8 +269,6 @@ void KBShortcutsDialog::fill_shortcuts() { "O", L("Zoom out") }, { "V", L("Toggle printable for object/part") }, { 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 }); diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index 675bc8da27..2b4c8754c8 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -701,6 +701,8 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_ } return;} #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.ShiftDown() && evt.GetKeyCode() == 'G') { m_plater->apply_background_progress(); @@ -3346,6 +3348,11 @@ void MainFrame::init_menubar_as_editor() wxGetApp().open_preferences(); }, "", 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); #endif // Help menu @@ -3370,7 +3377,13 @@ void MainFrame::init_menubar_as_editor() auto top_menu = m_topbar->GetTopMenu(); top_menu->AppendSeparator(); - append_menu_item( + 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( top_menu, wxID_ANY, _L("Preset Bundle") + "\t", "", [this](wxCommandEvent &) { // Orca: Use GUI_App::open_preferences instead of direct call so windows associations are updated on exit @@ -3508,6 +3521,10 @@ void MainFrame::init_menubar_as_editor() #else // On Mac, the Apple menu ignores non-standard custom items, so add Preset Bundle to the File menu 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( fileMenu, wxID_ANY, _L("Preset Bundle"), "", [this](wxCommandEvent&) { diff --git a/src/slic3r/GUI/Notebook.cpp b/src/slic3r/GUI/Notebook.cpp index 67c1831e4d..5d10e230e9 100644 --- a/src/slic3r/GUI/Notebook.cpp +++ b/src/slic3r/GUI/Notebook.cpp @@ -10,6 +10,7 @@ #include "Widgets/Label.hpp" #include +#include #include 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 */) { - 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); if (bmp_name.empty() && bmp.IsOk()) 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); //BBS set size for button 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); Button* btn = m_pageButtons[n]; 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 diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 90206a438c..0537edbc6f 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -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_QUESTION_MARK, [](SimpleEvent&) { wxGetApp().keyboard_shortcuts(); }); - view3D_canvas->Bind(EVT_GLCANVAS_OPEN_SPEED_DIAL, [this](SimpleEvent&) { - if (this->q->is_view3D_shown()) - wxGetApp().open_speed_dial(); - }); view3D_canvas->Bind(EVT_GLCANVAS_INCREASE_INSTANCES, [this](Event& evt) { if (evt.data == 1) this->q->increase_instances(); else if (this->can_decrease_instances()) this->q->decrease_instances(); }); view3D_canvas->Bind(EVT_GLCANVAS_INSTANCE_MOVED, [this](SimpleEvent&) { update(); }); diff --git a/src/slic3r/GUI/SpeedDialDialog.cpp b/src/slic3r/GUI/SpeedDialDialog.cpp index 09140b9868..8a0aca5445 100644 --- a/src/slic3r/GUI/SpeedDialDialog.cpp +++ b/src/slic3r/GUI/SpeedDialDialog.cpp @@ -9,20 +9,26 @@ #include "Plater.hpp" #include "Widgets/WebViewHostDialog.hpp" +#include + #include #include #include #include +#ifdef __linux__ +#include +#endif + namespace Slic3r { namespace GUI { namespace { // ADJUST WIDTH HERE (DIP px). Fixed dialog width; was 360, now 1.5x. Height is not set here - // the dialog auto-resizes to the page content (see resize_to_content + the list max-height in style.css). -constexpr int kPopupWidth = 540; -constexpr int kPopupMinHeight = 60; // just above the bare search-bar height, so the dialog hugs content +constexpr int kPopupWidth = 540; +constexpr int kPopupMinHeight = 60; // just above the bare search-bar height, so the dialog hugs content constexpr int kPopupMaxHeight = 282; int json_int_or(const nlohmann::json& j, const char* key, int fallback) @@ -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(); } +// 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) - : WebViewHostDialog(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, - wxBORDER_NONE | wxFRAME_NO_TASKBAR) + : WebViewHostDialog(parent, + wxID_ANY, + wxEmptyString, + wxDefaultPosition, + wxDefaultSize, + wxBORDER_NONE | wxFRAME_NO_TASKBAR | wxFRAME_FLOAT_ON_PARENT) { SetBackgroundColour(bg_color()); 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(); event.Skip(); }); - if (!create_webview("web/dialog/SpeedDial/index.html", wxEmptyString, - wxSize(kPopupWidth, kPopupMaxHeight), wxSize(kPopupWidth, kPopupMinHeight))) { + if (!create_webview("web/dialog/SpeedDial/index.html", wxEmptyString, wxSize(kPopupWidth, kPopupMaxHeight), + wxSize(kPopupWidth, kPopupMinHeight))) { auto* sizer = new wxBoxSizer(wxVERTICAL); - sizer->Add(new wxStaticText(this, wxID_ANY, wxS("wxWebView unavailable")), - wxSizerFlags().Border(wxALL, 20)); + sizer->Add(new wxStaticText(this, wxID_ANY, wxS("wxWebView unavailable")), wxSizerFlags().Border(wxALL, 20)); SetSizer(sizer); SetClientSize(FromDIP(wxSize(kPopupWidth, kPopupMinHeight))); } @@ -61,8 +93,7 @@ void SpeedDialWebDialog::request_show() { if (IsShown()) { Raise(); - if (browser()) - browser()->SetFocus(); + focus_webview(browser(), m_page_ready); return; } @@ -70,8 +101,9 @@ void SpeedDialWebDialog::request_show() Raise(); if (m_page_ready) send_actions(); - if (browser()) - browser()->SetFocus(); + // Grab focus now and again on wxEVT_ACTIVATE; grabbing directly on the WebKit widget is + // what makes typing reach the search field immediately on open. + focus_webview(browser(), m_page_ready); } void SpeedDialWebDialog::on_script_message(const nlohmann::json& payload) @@ -95,8 +127,7 @@ void SpeedDialWebDialog::handle_web_command(const nlohmann::json& payload) if (command == "request_actions") { m_page_ready = true; send_actions(); - } - else if (command == "toggle_favourite") + } else if (command == "toggle_favourite") wxGetApp().action_registry().set_favourite(payload.value("id", ""), payload.value("fav", false)); else if (command == "reorder_favourites") { std::vector ids; @@ -105,13 +136,61 @@ void SpeedDialWebDialog::handle_web_command(const nlohmann::json& payload) if (id.is_string()) ids.push_back(id.get()); wxGetApp().action_registry().reorder_favourites(ids); - } - else if (command == "run_action") - run_action(payload.value("id", ""), payload.value("title", "")); - else if (command == "resize") + } 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 == "search_settings") + search_settings(payload.value("q", "")); + 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)); } +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) { if (height <= 0) @@ -127,14 +206,15 @@ void SpeedDialWebDialog::resize_to_content(int height) 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(); - const AppAction* a = reg.by_id(id); + const AppAction* a = reg.by_id(id); if (!a) 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(); if (IsModal()) EndModal(wxID_CANCEL); @@ -143,8 +223,7 @@ void SpeedDialWebDialog::run_action(const std::string& id, const std::string& ti if (ask) { const wxString label = title.empty() ? from_u8(atitle) : from_u8(title); - RichMessageDialog dlg(wxGetApp().mainframe, wxString::Format(_L("Run \"%s\"?"), label), - _L("Run plugin"), wxOK | wxCANCEL); + RichMessageDialog dlg(wxGetApp().mainframe, wxString::Format(_L("Run \"%s\"?"), label), _L("Run plugin"), wxOK | wxCANCEL); dlg.ShowCheckBox(_L("Don't ask again for this action")); if (dlg.ShowModal() != wxID_OK) return; @@ -152,18 +231,21 @@ void SpeedDialWebDialog::run_action(const std::string& id, const std::string& ti wxGetApp().action_registry().suppress_ask(id); } - wxGetApp().CallAfter([id] { + wxGetApp().CallAfter([id, param] { if (wxGetApp().is_closing()) return; - AppActionRunResult result = wxGetApp().action_registry().run(id); + AppActionRunResult result = wxGetApp().action_registry().run(id, param); if (result.level == AppActionRunResult::Level::Busy) return; if (!result.message.IsEmpty() && wxGetApp().plater()) - wxGetApp().plater()->get_notification_manager()->push_notification( - NotificationType::CustomNotification, - result.level == AppActionRunResult::Level::Error ? NotificationManager::NotificationLevel::ErrorNotificationLevel : - NotificationManager::NotificationLevel::RegularNotificationLevel, - into_u8(result.message)); + wxGetApp() + .plater() + ->get_notification_manager() + ->push_notification(NotificationType::CustomNotification, + result.level == AppActionRunResult::Level::Error ? + NotificationManager::NotificationLevel::ErrorNotificationLevel : + NotificationManager::NotificationLevel::RegularNotificationLevel, + into_u8(result.message)); }); } @@ -172,7 +254,8 @@ void SpeedDialWebDialog::send_actions() nlohmann::json snap = wxGetApp().action_registry().snapshot(); call_web_handler({{"command", "list_actions"}, {"actions", std::move(snap["actions"])}, - {"favourites", std::move(snap["favourites"])}}); + {"favourites", std::move(snap["favourites"])}, + {"recent", std::move(snap["recent"])}}); } -}} +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/SpeedDialDialog.hpp b/src/slic3r/GUI/SpeedDialDialog.hpp index 52ee05e502..74e806dd85 100644 --- a/src/slic3r/GUI/SpeedDialDialog.hpp +++ b/src/slic3r/GUI/SpeedDialDialog.hpp @@ -20,8 +20,10 @@ private: void on_script_message(const nlohmann::json& payload) override; void handle_web_command(const nlohmann::json& payload); 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 search_settings(const std::string& query); + void search_tabs(); bool m_page_ready{false}; // Guards the CallAfter in on_script_message across dialog destruction, same as diff --git a/src/slic3r/GUI/Widgets/Button.cpp b/src/slic3r/GUI/Widgets/Button.cpp index 5a5bd89403..e25956e3d5 100644 --- a/src/slic3r/GUI/Widgets/Button.cpp +++ b/src/slic3r/GUI/Widgets/Button.cpp @@ -25,36 +25,29 @@ END_EVENT_TABLE() * calling Refresh()/Update(). */ -Button::Button() - : paddingSize(10, 8) +Button::Button() : paddingSize(10, 8) { - background_color = StateColor( - std::make_pair(0xF0F0F1, (int) StateColor::Disabled), - std::make_pair(0x52c7b8, (int) StateColor::Hovered | StateColor::Checked), - std::make_pair(0x009688, (int) StateColor::Checked), - std::make_pair(*wxLIGHT_GREY, (int) StateColor::Hovered), - std::make_pair(*wxWHITE, (int) StateColor::Normal)); - text_color = StateColor( - std::make_pair(*wxLIGHT_GREY, (int) StateColor::Disabled), - std::make_pair(*wxBLACK, (int) StateColor::Normal)); + background_color = StateColor(std::make_pair(0xF0F0F1, (int) StateColor::Disabled), + std::make_pair(0x52c7b8, (int) StateColor::Hovered | StateColor::Checked), + std::make_pair(0x009688, (int) StateColor::Checked), + std::make_pair(*wxLIGHT_GREY, (int) StateColor::Hovered), + std::make_pair(*wxWHITE, (int) StateColor::Normal)); + text_color = StateColor(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() -{ - Create(parent, text, icon, style, iconSize, btn_id); -} +Button::Button(wxWindow* parent, wxString text, wxString icon, long style, int iconSize, wxWindowID btn_id) : Button() +{ Create(parent, text, icon, style, iconSize, 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); state_handler.attach(std::vector{&text_color}); state_handler.update_binds(); - //BBS set default font + // BBS set default font SetFont(Label::Body_14); wxWindow::SetLabel(text); 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); } messureSize(); @@ -82,14 +75,12 @@ void Button::SetIcon(const wxString& icon) { auto tmpBitmap = ScalableBitmap(this, icon.ToStdString(), this->active_icon.px_cnt()); 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())) { this->active_icon = tmpBitmap; Refresh(); } - } - else - { + } else { this->active_icon = ScalableBitmap(); Refresh(); } @@ -97,7 +88,7 @@ void Button::SetIcon(const wxString& icon) void Button::SetIcon(const wxBitmap& icon) { - this->active_icon = ScalableBitmap(); + this->active_icon = ScalableBitmap(); this->active_icon.bmp() = icon; messureSize(); Refresh(); @@ -121,6 +112,13 @@ void Button::SetPaddingSize(const wxSize& size) messureSize(); } +void Button::SetIconSpacing(int spacing) +{ + m_icon_spacing = spacing; + messureSize(); + Refresh(); +} + void Button::SetTextColor(StateColor const& color) { text_color = color; @@ -128,7 +126,7 @@ void Button::SetTextColor(StateColor const& color) Refresh(); } -void Button::SetTextColorNormal(wxColor const &color) +void Button::SetTextColorNormal(wxColor const& color) { text_color.setColorForStates(color, 0); Refresh(); @@ -145,22 +143,22 @@ bool Button::Enable(bool enable) return result; } -void Button::SetCanFocus(bool canFocus) { +void Button::SetCanFocus(bool canFocus) +{ StaticBox::SetCanFocus(canFocus); this->canFocus = canFocus; } void Button::SetValue(bool state) { - if (GetValue() == state) return; + if (GetValue() == state) + return; state_handler.set_state(state ? StateHandler::Checked : 0, StateHandler::Checked); } bool Button::GetValue() const { return state_handler.states() & StateHandler::Checked; } -void Button::SetCenter(bool isCenter) -{ - this->isCenter = isCenter; } +void Button::SetCenter(bool isCenter) { this->isCenter = isCenter; } void Button::SetVertical(bool vertical) { @@ -177,38 +175,33 @@ wxString btn_disabled[10] = {"#DFDFDF", "#DFDFDF", "#DFDFDF", "#DFDFDF", "#DFDFD void Button::SetStyle(const ButtonStyle style, const ButtonType type) { - if (type == ButtonType::Compact) { - this->SetPaddingSize(FromDIP(wxSize(8,3))); + if (type == ButtonType::Compact) { + this->SetPaddingSize(FromDIP(wxSize(8, 3))); this->SetCornerRadius(this->FromDIP(8)); this->SetFont(Label::Body_10); - } - else if (type == ButtonType::Window) { - this->SetSize(FromDIP(wxSize(58,24))); - this->SetMinSize(FromDIP(wxSize(58,24))); + } else if (type == ButtonType::Window) { + this->SetSize(FromDIP(wxSize(58, 24))); + this->SetMinSize(FromDIP(wxSize(58, 24))); this->SetCornerRadius(this->FromDIP(12)); this->SetFont(Label::Body_12); - } - else if (type == ButtonType::Choice) { - this->SetMinSize(FromDIP(wxSize(100,32))); - this->SetPaddingSize(FromDIP(wxSize(12,8))); + } else if (type == ButtonType::Choice) { + this->SetMinSize(FromDIP(wxSize(100, 32))); + this->SetPaddingSize(FromDIP(wxSize(12, 8))); this->SetCornerRadius(this->FromDIP(4)); this->SetFont(Label::Body_14); - } - else if (type == ButtonType::Parameter) { - this->SetMinSize(FromDIP(wxSize(120,26))); - this->SetSize(FromDIP(wxSize(120,26))); + } else if (type == ButtonType::Parameter) { + this->SetMinSize(FromDIP(wxSize(120, 26))); + this->SetSize(FromDIP(wxSize(120, 26))); this->SetCornerRadius(this->FromDIP(4)); this->SetFont(Label::Body_14); - } - else if (type == ButtonType::Icon) { - this->SetPaddingSize(FromDIP(wxSize(5,5))); - this->SetMinSize(FromDIP(wxSize(26,26))); - this->SetSize(FromDIP(wxSize(26,26))); + } else if (type == ButtonType::Icon) { + this->SetPaddingSize(FromDIP(wxSize(5, 5))); + this->SetMinSize(FromDIP(wxSize(26, 26))); + this->SetSize(FromDIP(wxSize(26, 26))); this->SetCornerRadius(this->FromDIP(4)); - } - else if (type == ButtonType::Expanded) { - this->SetMinSize(FromDIP(wxSize(-1,32))); - this->SetPaddingSize(FromDIP(wxSize(12,8))); + } else if (type == ButtonType::Expanded) { + this->SetMinSize(FromDIP(wxSize(-1, 32))); + this->SetPaddingSize(FromDIP(wxSize(12, 8))); this->SetCornerRadius(this->FromDIP(4)); this->SetFont(Label::Body_14); } @@ -217,39 +210,33 @@ void Button::SetStyle(const ButtonStyle style, const ButtonType type) bool is_dark = StateColor::darkModeColorFor("#FFFFFF") != wxColour("#FFFFFF"); - auto clr_arr = style == ButtonStyle::Regular ? btn_regular : - style == ButtonStyle::Confirm ? btn_confirm : - style == ButtonStyle::Alert ? btn_alert : + auto clr_arr = style == ButtonStyle::Regular ? btn_regular : + style == ButtonStyle::Confirm ? btn_confirm : + style == ButtonStyle::Alert ? btn_alert : style == ButtonStyle::Disabled ? btn_disabled : - btn_regular ; + btn_regular; - auto bg_color = StateColor( - std::pair(wxColour(clr_arr[0]), (int)StateColor::Disabled), - std::pair(wxColour(clr_arr[1]), (int)StateColor::Pressed), - std::pair(wxColour(clr_arr[2]), (int)StateColor::Hovered), - std::pair(wxColour(clr_arr[3]), (int)StateColor::Normal), - std::pair(wxColour(clr_arr[4]), (int)StateColor::Enabled) - ); + auto bg_color = StateColor(std::pair(wxColour(clr_arr[0]), (int) StateColor::Disabled), + std::pair(wxColour(clr_arr[1]), (int) StateColor::Pressed), + std::pair(wxColour(clr_arr[2]), (int) StateColor::Hovered), + std::pair(wxColour(clr_arr[3]), (int) StateColor::Normal), + std::pair(wxColour(clr_arr[4]), (int) StateColor::Enabled)); bg_color.setTakeFocusedAsHovered(false); this->SetBackgroundColor(bg_color); wxColour focus_clr = clr_arr[is_dark ? 8 : 9]; - auto border_color = StateColor( - std::pair(wxColour(clr_arr[0]), (int)StateColor::Disabled), - std::pair(wxColour(clr_arr[2]), (int)(StateColor::Hovered | ~StateColor::Focused)), - std::pair(wxColour(focus_clr ), (int)StateColor::Focused), - std::pair(wxColour(clr_arr[3]), (int)StateColor::Normal) - ); + auto border_color = StateColor(std::pair(wxColour(clr_arr[0]), (int) StateColor::Disabled), + std::pair(wxColour(clr_arr[2]), (int) (StateColor::Hovered | ~StateColor::Focused)), + std::pair(wxColour(focus_clr), (int) StateColor::Focused), + std::pair(wxColour(clr_arr[3]), (int) StateColor::Normal)); border_color.setTakeFocusedAsHovered(false); this->SetBorderColor(border_color); - this->SetTextColor(StateColor( - std::pair(wxColour(clr_arr[5]), (int)StateColor::Disabled), - std::pair(wxColour(clr_arr[7]), (int)StateColor::Hovered), - std::pair(wxColour(clr_arr[6]), (int)StateColor::Normal) - )); + this->SetTextColor(StateColor(std::pair(wxColour(clr_arr[5]), (int) StateColor::Disabled), + std::pair(wxColour(clr_arr[7]), (int) StateColor::Hovered), + std::pair(wxColour(clr_arr[6]), (int) StateColor::Normal))); m_has_style = true; - m_style = style; - m_type = type; + m_style = style; + m_type = type; } void Button::Rescale() @@ -260,7 +247,7 @@ void Button::Rescale() messureSize(); - if(m_has_style) + if (m_has_style) SetStyle(m_style, m_type); Refresh(); @@ -281,7 +268,7 @@ void Button::paintEvent(wxPaintEvent& evt) void Button::render(wxDC& dc) { StaticBox::render(dc); - int states = state_handler.states(); + int states = state_handler.states(); wxSize size = GetSize(); dc.SetBrush(*wxTRANSPARENT_BRUSH); // calc content size @@ -289,22 +276,22 @@ void Button::render(wxDC& dc) wxSize textSize = this->textSize.GetSize(); const ScalableBitmap& icon = active_icon; - wxSize padding = this->paddingSize; - int spacing = 5; + wxSize padding = this->paddingSize; + int spacing = m_icon_spacing; // Wrap text auto text = GetLabel(); if (vertical && textSize.x + padding.x * 2 > size.x) { Label::split_lines(dc, size.x - padding.x * 2, text, text, 2); textSize = dc.GetMultiLineTextExtent(text); if (padding.x * 2 + textSize.x > size.x) { - text = wxControl::Ellipsize(text, dc, wxELLIPSIZE_END, size.x - padding.x * 2); + text = wxControl::Ellipsize(text, dc, wxELLIPSIZE_END, size.x - padding.x * 2); textSize = dc.GetMultiLineTextExtent(text); } } auto szContent = textSize; if (icon.bmp().IsOk()) { if (szContent.y > 0) { - //BBS norrow size between text and icon + // BBS norrow size between text and icon if (vertical) szContent.y += spacing; else @@ -313,10 +300,12 @@ void Button::render(wxDC& dc) szIcon = icon.GetBmpSize(); if (vertical) { szContent.y += szIcon.y; - if (szIcon.x > szContent.x) szContent.x = szIcon.x; + if (szIcon.x > szContent.x) + szContent.x = szIcon.x; } else { 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) { int d = std::min(padding.x, (szContent.x - size.x) / 2); @@ -325,10 +314,11 @@ void Button::render(wxDC& dc) } } // move to center - wxRect rcContent = { {0, 0}, size }; + wxRect rcContent = {{0, 0}, size}; if (isCenter) { 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); } // start draw @@ -339,7 +329,7 @@ void Button::render(wxDC& dc) else pt.y += (rcContent.height - szIcon.y) / 2; dc.DrawBitmap(icon.bmp(), pt); - //BBS norrow size between text and icon + // BBS norrow size between text and icon if (vertical) { pt.y += szIcon.y + spacing; pt.x = rcContent.x; @@ -373,19 +363,21 @@ void Button::messureSize() wxSize szContent = textSize.GetSize(); if (this->active_icon.bmp().IsOk()) { if (szContent.y > 0) { - //BBS norrow size between text and icon + // BBS narrow size between text and icon if (vertical) - szContent.y += 5; + szContent.y += m_icon_spacing; else - szContent.x += 5; + szContent.x += m_icon_spacing; } wxSize szIcon = this->active_icon.GetBmpSize(); if (vertical) { szContent.y += szIcon.y; - if (szIcon.x > szContent.x) szContent.x = szIcon.x; + if (szIcon.x > szContent.x) + szContent.x = szIcon.x; } else { 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; @@ -429,13 +421,13 @@ void Button::mouseReleased(wxMouseEvent& event) } } -void Button::mouseCaptureLost(wxMouseCaptureLostEvent &event) +void Button::mouseCaptureLost(wxMouseCaptureLostEvent& event) { wxMouseEvent evt; mouseReleased(evt); } -void Button::keyDownUp(wxKeyEvent &event) +void Button::keyDownUp(wxKeyEvent& event) { if (event.GetKeyCode() == WXK_SPACE || event.GetKeyCode() == WXK_RETURN) { wxMouseEvent evt(event.GetEventType() == wxEVT_KEY_UP ? wxEVT_LEFT_UP : wxEVT_LEFT_DOWN); @@ -444,8 +436,8 @@ void Button::keyDownUp(wxKeyEvent &event) return; } if (event.GetEventType() == wxEVT_KEY_DOWN && - (event.GetKeyCode() == WXK_TAB || event.GetKeyCode() == WXK_LEFT || event.GetKeyCode() == WXK_RIGHT - || event.GetKeyCode() == WXK_UP || event.GetKeyCode() == WXK_DOWN)) + (event.GetKeyCode() == WXK_TAB || event.GetKeyCode() == WXK_LEFT || event.GetKeyCode() == WXK_RIGHT || + event.GetKeyCode() == WXK_UP || event.GetKeyCode() == WXK_DOWN)) HandleAsNavigationKey(event); else event.Skip(); @@ -462,7 +454,9 @@ void Button::sendButtonEvent() 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) { wxKeyEvent event(CreateKeyEvent(wxEVT_KEY_DOWN, wParam, lParam)); switch (wParam) { @@ -483,8 +477,7 @@ void Button::EnableTooltipEvenDisabled() { #if defined(_MSC_VER) || defined(_WIN32) auto parent = this->GetParent(); - if (parent) - { + if (parent) { parent->Bind(wxEVT_MOTION, &Button::OnParentMotion, this); parent->Bind(wxEVT_LEAVE_WINDOW, &Button::OnParentLeave, this); }; @@ -494,22 +487,21 @@ void Button::EnableTooltipEvenDisabled() void Button::OnParentMotion(wxMouseEvent& event) { 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(); - wxString tip = this->GetToolTipText(); - if (!tip.IsEmpty() && !this->IsEnabled() && screen_rect.Contains(pos)) - { - if (!tipWindow) - { + wxString tip = this->GetToolTipText(); + if (!tip.IsEmpty() && !this->IsEnabled() && screen_rect.Contains(pos)) { + if (!tipWindow) { tipWindow = wxTipWindow::New(this, tip); - if (!tipWindow) return event.Skip(); + if (!tipWindow) + return event.Skip(); tipWindow->Enable(false); } - if (tipWindow->GetLabel() != tip) - { + if (tipWindow->GetLabel() != tip) { tipWindow->SetLabel(tip); } @@ -517,11 +509,8 @@ void Button::OnParentMotion(wxMouseEvent& event) // using wxGetMousePosition() which returns (0,0) on Wayland. tipWindow->Position(this->ClientToScreen(wxPoint(0, 0)), this->GetSize()); tipWindow->Popup(); - } - else - { - if (tipWindow) - { + } else { + if (tipWindow) { tipWindow->Dismiss(); tipWindow->Destroy(); tipWindow = nullptr; @@ -534,15 +523,14 @@ void Button::OnParentMotion(wxMouseEvent& event) void Button::OnParentLeave(wxMouseEvent& event) { auto parent = this->GetParent(); - if (!parent) return event.Skip(); + if (!parent) + return event.Skip(); - if (tipWindow) - { - wxPoint pos = parent->ClientToScreen(event.GetPosition()); + if (tipWindow) { + wxPoint pos = parent->ClientToScreen(event.GetPosition()); wxRect screen_rect = this->GetScreenRect(); - wxString tip = this->GetToolTipText(); - if (!screen_rect.Contains(pos)) - { + wxString tip = this->GetToolTipText(); + if (!screen_rect.Contains(pos)) { tipWindow->Dismiss(); tipWindow->Destroy(); tipWindow = nullptr; diff --git a/src/slic3r/GUI/Widgets/Button.hpp b/src/slic3r/GUI/Widgets/Button.hpp index 2991edd425..a1a05e9831 100644 --- a/src/slic3r/GUI/Widgets/Button.hpp +++ b/src/slic3r/GUI/Widgets/Button.hpp @@ -8,24 +8,25 @@ class ButtonProps { public: - static int ChoiceButtonGap(){return 10;}; - static int WindowButtonGap(){return 10;}; + static int ChoiceButtonGap() { return 10; }; + static int WindowButtonGap() { return 10; }; }; -enum class ButtonStyle{ +enum class ButtonStyle { Regular, Confirm, Alert, Disabled, }; -enum class ButtonType{ - Compact , // Font10 FullyRounded For spaces with less areas - Window , // Font12 FullyRounded For regular buttons in windows and not related with parameter boxes - Choice , // Font14 Semi-Rounded For dialog/window choice buttons +enum class ButtonType { + Compact, // Font10 FullyRounded For spaces with less areas + Window, // Font12 FullyRounded For regular buttons in windows and not related with parameter boxes + Choice, // Font14 Semi-Rounded For dialog/window choice buttons 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 - Expanded , // Font14 Semi-Rounded For full length buttons. ex. buttons in static box + 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 + Expanded, // Font14 Semi-Rounded For full length buttons. ex. buttons in static box }; class Button : public StaticBox @@ -34,17 +35,18 @@ class Button : public StaticBox wxRect textSize; wxSize minSize; // set by outer wxSize paddingSize; + int m_icon_spacing = 5; ScalableBitmap active_icon; - StateColor text_color; + StateColor text_color; bool pressedDown = false; bool m_selected = true; - bool canFocus = true; + bool canFocus = true; bool isCenter = true; bool vertical = false; - static const int buttonWidth = 200; + static const int buttonWidth = 200; static const int buttonHeight = 50; public: @@ -66,21 +68,23 @@ public: void SetPaddingSize(const wxSize& size); + void SetIconSpacing(int spacing); + 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; } // Only meant to be used by inspector, not public API ButtonStyle GetStyle() const { return m_style; } - ButtonType GetType() const { return m_type; } - bool IsSelected() const { return m_selected; } + ButtonType GetType() const { return m_type; } + bool IsSelected() const { return m_selected; } 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; @@ -104,7 +108,7 @@ protected: private: bool m_has_style = false; ButtonStyle m_style; - ButtonType m_type; + ButtonType m_type; void paintEvent(wxPaintEvent& evt); @@ -115,10 +119,10 @@ private: // some useful events void mouseDown(wxMouseEvent& event); void mouseReleased(wxMouseEvent& event); - void mouseCaptureLost(wxMouseCaptureLostEvent &event); - void keyDownUp(wxKeyEvent &event); + void mouseCaptureLost(wxMouseCaptureLostEvent& event); + void keyDownUp(wxKeyEvent& event); - // + // void sendButtonEvent(); // parent motion