diff --git a/resources/web/dialog/SpeedDial/speeddial.js b/resources/web/dialog/SpeedDial/speeddial.js index b25df97723..65d2e24430 100644 --- a/resources/web/dialog/SpeedDial/speeddial.js +++ b/resources/web/dialog/SpeedDial/speeddial.js @@ -10,40 +10,118 @@ 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). +// ---- windowed list render ---------------------------------------------------- +// The command list is rendered in windows (append-on-scroll) so a huge settings pool doesn't build +// the whole DOM per keystroke. Rows are exactly ROW_H tall (matches .row min-height 44px; see --row-h, +// which is documented to stay in sync). `renderEnd` is the exclusive count of rows currently in the DOM; +// a bottom spacer fills the rest of the list so the scrollbar reflects the full match count and +// "scroll past the last rendered row" reveals the next window. +var K_ROWS = 50; +var ROW_H = 44; +var renderEnd = 0; +var builtKey = ""; // phase|query|listLen - when it changes, rows are rebuilt from the first window +var spacerEl = null; // the trailing height spacer, always the last child of listEl + +// search-cache: the normalized (folded+lowercased) needle for the current query pass. +var searchNeedle = ""; + +// Palette phase: 'commands' (one unified search over actions/commands/settings, recents on empty +// query), '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. // element handles, assigned in OnInit (kept null so load-time touches no DOM) -var qEl = null, listEl = null, favEl = null, clearEl = null, eyeEl = null, countEl = null; +var qEl = null, listEl = null, favEl = null, clearEl = null, eyeEl = null, countEl = null, headEl = null; + +// ---- inline setting editor state --------------------------------------------- +// The "setting" phase (opened by activating a setting action) replaces the list with an editor card +// for one option. phase transitions: commands -> setting -> (apply / open-in-sidebar) -> closed, or +// Esc back to commands. settingDesc is the C++ descriptor; settingRows are the per-index control +// descriptors (1 row for scalars, one per index for vectors); settingFieldEls hold the live controls. +var settingId = ""; // the setting action id being edited +var settingDesc = null; // {id,opt_key,type,title,breadcrumb,category,unit,tooltip,editable,control,cardinality,value|values,index_labels,enum_options,min,max,is_int} +var settingRows = []; // [{index,kind,value,label,enum_options,min,max,unit,is_int}] +var settingFieldEls = []; // [Element...] parallel to settingRows +var settingPreviewIcon = null; // beside the editor title, updated live on dropdown pick +var openDropDownEl = null; // the custom dropdown toggle button whose option list is expanded // ---- pure helpers (no DOM; unit-tested) ------------------------------------- -function filterActions(actions, query) { +// Pre-normalized haystacks, cached on the action object. The fold is length-preserving (1:1 per +// char) so the ranges FuzzyRangesNorm returns slice the ORIGINAL title/source text correctly. The +// action objects arrive from C++ and are stable for the dialog's lifetime, so we compute these once. +function titleNorm(a) { + if (a._tn === undefined) + a._tn = NormText(a.title, false); + return a._tn; +} +function otherNorm(a) { + if (a._on === undefined) + a._on = NormText((a.source || "") + " " + (a.group || ""), false); + return a._on; +} + +// Relevance score for a single field vs the current query needle, or -1 when there's no match. +// Higher is better: an earlier start and a more contiguous (fewer gaps) match beat a scattered late one. +function matchScoreNorm(haystackNorm) { + if (!searchNeedle) return -1; + var r = FuzzyRangesNorm(haystackNorm || "", searchNeedle); + if (!r) return -1; + var gaps = 0; + for (var i = 1; i < r.length; i++) + gaps += r[i][0] - r[i - 1][1]; + return 1000 - r[0][0] * 10 - gaps * 10; +} + +// Per-action score: title matches rank above a source/group-only match of equal quality. +function actionSearchScore(a) { + var title = matchScoreNorm(titleNorm(a)); + var other = matchScoreNorm(otherNorm(a)); + if (title < 0 && other < 0) return -1; + return Math.max(title < 0 ? -1e9 : title + 10000, other < 0 ? -1e9 : other); +} + +// The unified main-phase search: every action (command/plugin/setting) matching the query, ranked +// by relevance (not by action type). Sets matchIndex so rows highlight their match ranges. The query +// is normalized ONCE per pass - FuzzyRangesNorm then runs against each action's pre-normalized +// haystack, so per-keystroke cost is a cheap scan (no per-char normalize/regex). +function searchActions(actions, query) { var q = (query || "").trim(); var list = actions || []; matchIndex = {}; - if (!q) - return list.slice(0); + if (!q) { searchNeedle = ""; return list.slice(0); } + searchNeedle = NormText(q, false); - var out = []; + var scored = []; 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); + var s = actionSearchScore(a); + if (s < 0) continue; + var titleMatch = FuzzyRangesNorm(titleNorm(a), searchNeedle); + matchIndex[a.id] = { title: titleMatch, source: FuzzyRangesNorm(otherNorm(a), searchNeedle), useTitle: !!titleMatch }; + scored.push({ a: a, s: s }); } - return out; + scored.sort(function (x, y) { + if (x.s !== y.s) return y.s - x.s; + if (x.a.title !== y.a.title) return x.a.title < y.a.title ? -1 : 1; + return x.a.id < y.a.id ? -1 : x.a.id > y.a.id ? 1 : 0; + }); + return scored.map(function (e) { return e.a; }); } +// Pure: how many rows must be materialized to cover the given starting index plus `size` more. +// Clamped to the total; used to decide "render the next window" on scroll / arrow-nav. +function revealTarget(total, fromIndex, size) { + return Math.min(total, Math.max(0, fromIndex) + size); +} + +// buildKey: the command-list signature that decides whether rows must be rebuilt (new search / phase) +// or just have their selection refreshed in place (arrow-nav / click). Cheap to compute. +function buildKey() { return phase + "|" + (query || "").trim(); } + 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. @@ -54,6 +132,24 @@ function visibleFavourites(favourites, actions) { }); } +// Numbered quick-launch slots (mirrors ActionRegistry::kFavLimit). Pure so the node-vm test +// can exercise the digit<->slot mapping without a DOM. +var K_FAV_LIMIT = 10; + +// Badge label for a 0-based fav-bar index: 0..8 -> "1".."9", index 9 (the 10th) -> "0". +function favSlotForIndex(i) { + if (i < 0 || i >= K_FAV_LIMIT) return null; + return i < 9 ? String(i + 1) : "0"; +} + +// Digit key -> 0-based fav-bar index (1..9 -> 0..8, 0 -> 9); -1 for anything else. +function favIndexForDigit(d) { + var c = String(d || "").charCodeAt(0); + if (c >= 49 && c <= 57) return c - 49; + if (c === 48) return 9; + return -1; +} + function resultCountText(total, shown, query) { return (query || "").trim() ? "Showing " + shown + " of " + total + " actions" : total + " actions"; } @@ -80,11 +176,125 @@ function shouldRenderActionList(query) { 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). +// Label for a closed-enum entry, looked up from its enum_options by value; falls back to the value. +// Pure so the node-vm test can exercise the dropdown label mapping. `value` is the current int value. +function dropdownLabel(options, value) { + var want = String(value == null ? "" : value); + for (var i = 0; i < (options || []).length; i++) + if (String(options[i].value) === want) + return options[i].label != null && options[i].label !== "" ? String(options[i].label) : String(options[i].key != null ? options[i].key : options[i].value); + return want; +} + +// Pure: the data:URI pictogram for a dropdown's selected value, or "" when none of the options has +// one (most settings have no pattern icon). Mirrors dropdownLabel so tests can drive it without DOM. +function dropdownIcon(options, value) { + var want = String(value == null ? "" : value); + for (var i = 0; i < (options || []).length; i++) + if (String(options[i].value) === want && options[i].icon) + return options[i].icon; + return ""; +} + +// Put an action's pattern pictogram into a tile (search row or favourites tile) when it has one, +// otherwise fall back to the monogram. Toggles the has-icon class so CSS neutralises the hue. +function fillTile(tile, a) { + tile.classList.remove("has-icon"); + if (a && a.icon) { + tile.textContent = ""; + var img = document.createElement("img"); + img.className = "tile-icon"; + img.src = a.icon; + img.alt = ""; + tile.appendChild(img); + tile.classList.add("has-icon"); + } else { + tile.textContent = a ? tileCode(a, ACTIONS) : ""; + } +} + +// Per-index control descriptors for the inline setting editor, derived from the C++ descriptor. +// Pure so the node-vm test can exercise the scalar/vector + control mapping without a DOM. +// Values are the current config value(s); vector options get one row per index, each labelled. +function settingControlRows(desc) { + if (!desc || !desc.editable) return []; + var rows = []; + var values = desc.cardinality === "vector" ? (desc.values || []) : [desc.value]; + var labels = desc.cardinality === "vector" ? (desc.index_labels || []) : []; + for (var i = 0; i < values.length; i++) { + rows.push({ + index: i, + kind: desc.control, + value: values[i], + label: labels[i] != null ? String(labels[i]) : (desc.cardinality === "vector" ? String(i + 1) : null), + enum_options: desc.enum_options || [], + min: typeof desc.min === "number" ? desc.min : null, + max: typeof desc.max === "number" ? desc.max : null, + unit: desc.unit || "", + is_int: !!desc.is_int + }); + } + return rows; +} + +// Pure: read the value a control would submit back for a setting. `el` is a DOM element (never +// passed in tests). Returns undefined for an unusable value (empty/invalid number, out of range), +// boolean for toggles, number for numeric, string otherwise. +function settingControlValue(row, el) { + if (!row || !el) return undefined; + switch (row.kind) { + case "toggle": return !!el.checked; + case "number": { + var raw = String(el.value || "").trim(); + if (raw === "") return undefined; + var n = row.is_int ? parseInt(raw, 10) : parseFloat(raw); + if (!isFinite(n)) return undefined; + if (row.min != null && n < row.min) return undefined; + if (row.max != null && n > row.max) return undefined; + return n; + } + case "dropdown": { + // value is stored on the toggle button's dataset (set when an option is picked). + var v = parseInt(el.dataset ? el.dataset.value : "", 10); + return isFinite(v) ? v : undefined; + } + case "combo": { + // Open enum: free text field (never a select), so read it as the seeded integer value. + var v = parseInt(el.value, 10); + return isFinite(v) ? v : undefined; + } + case "color": + case "text": + case "percent": { + // percent submission is a string ("10%", "0.5"); C++ parses + clamps it. Empty is invalid. + var raw = String(el.value || "").trim(); + return raw === "" ? undefined : raw; + } + default: return undefined; + } +} + +// Pure: assemble the value payload for a setting from its edited control rows. Returns the scalar +// for scalar settings, an array for vector settings, or undefined when any control is invalid. +function settingCollectedValue(desc, rows, values) { + if (!desc || !desc.editable) return undefined; + if (desc.cardinality === "vector") { + var out = []; + for (var i = 0; i < rows.length; i++) { + var v = settingControlValue(rows[i], values[i]); + if (v === undefined) return undefined; + out.push(v); + } + return out; + } + return settingControlValue(rows[0], values[0]); +} + +// The active list for the main phase. A typed query ranks every action (commands/plugins/settings) +// by relevance; an empty query shows the recent list (recents are a mixed bag - no discrimination). function commandList(actions, recents, query) { if (shouldRenderActionList(query)) - return filterActions(actions || [], query); + return searchActions(actions || [], query); return (recents || []).slice(0); } @@ -146,8 +356,8 @@ function monogramFor(item, list, titleOf, sourceOf, idOf) { 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. +// Action tile code - see monogramFor for the escalation ladder. Settings are actions now, so +// they share this ladder (title initial, then source, then a stable ordinal). function tileCode(action, actions) { return monogramFor(action, actions, function (o) { return o.title; }, @@ -155,24 +365,6 @@ function tileCode(action, actions) { 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; @@ -187,7 +379,6 @@ function stateFromPayload(payload) { sel: { zone: "list", i: 0 }, lastResizeHeight: 0, phase: "commands", - settingsResults: [], tabOptions: [] }; } @@ -203,17 +394,19 @@ function resetScrollPositions(list, doc) { 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}. +// nextSel: pure arrow-nav transition. Down fav->list0; Down list wraps at the bottom (last -> first). +// Up list wraps at the top (first -> last) only when there's no fav bar above; with a fav bar, Up at +// the list top goes to fav0 (unchanged). Left/Right clamp within fav. Returns a fresh {zone,i}. function nextSel(sel, key, listLen, favLen) { var zone = sel.zone, i = sel.i; + var last = Math.max(0, listLen - 1); if (key === "ArrowDown") { if (zone === "fav") return { zone: "list", i: 0 }; - return { zone: "list", i: Math.min(i + 1, Math.max(0, listLen - 1)) }; + return { zone: "list", i: i >= last ? 0 : i + 1 }; } if (key === "ArrowUp") { if (zone === "list") { - if (i <= 0) return favLen ? { zone: "fav", i: 0 } : { zone: "list", i: 0 }; + if (i <= 0) return favLen ? { zone: "fav", i: 0 } : { zone: "list", i: last }; return { zone: "list", i: i - 1 }; } return { zone: zone, i: i }; @@ -245,8 +438,16 @@ window.HandleStudio = function (payload) { sel = next.sel; lastResizeHeight = next.lastResizeHeight; phase = next.phase; - settingsResults = next.settingsResults; tabOptions = next.tabOptions; + // Reset any half-open setting editor (the dialog was closed/reopened), restoring the search. + settingId = ""; settingDesc = null; settingRows = []; settingFieldEls = []; + settingPreviewIcon = null; + openDropDownEl = null; + // why: builtKey caches phase|query so renderCommandsList can skip a rebuild on arrow-nav. It + // survives an apply-then-reopen (which never goes through exitPhase), so without a reset the + // leftover editor card would be mistaken for the empty-query commands list and never rebuilt. + builtKey = ""; + if (headEl) headEl.hidden = false; if (qEl) { qEl.value = ""; qEl.placeholder = "Search " + ACTIONS.length + " actions"; @@ -254,16 +455,27 @@ window.HandleStudio = function (payload) { } 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)); + } else if (payload.command === "setting_descriptor") { + // Inline editor loaded: render the card. Guard against a stale response for a different id. + if (payload.descriptor && payload.descriptor.id === settingId) + settingDesc = payload.descriptor; render({ resize: true }); + // why: keyboard focus must land on the field after the card is built, not stay on the hidden + // search input. fire on a timeout so the element is attached and its content selectable. + focusSettingEditor(); + } else if (payload.command === "apply_failed") { + flashHint("Couldn't apply that value"); } 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 }); + } else if (payload.command === "favourite_full") { + // Favourites are at the quick-launch cap - undo the optimistic pin and flash a hint. + var fid = payload.id; + if (fid && FAVS.indexOf(fid) !== -1) FAVS.splice(FAVS.indexOf(fid), 1); + render({ resize: true }); + flashHint("Favourites are full (" + (payload.limit || K_FAV_LIMIT) + " max)"); } }; @@ -284,7 +496,6 @@ 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 @@ -349,10 +560,19 @@ function renderFav() { 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); + fillTile(tile, a); tile.title = a.title; tile.setAttribute("aria-label", actionLabel(a, ACTIONS)); tile.onclick = function () { sel = { zone: "fav", i: i }; activateEntry(a); }; + // Numbered quick-launch badge (Alt/Option+digit), drawn on the corner. + var slot = favSlotForIndex(i); + if (slot) { + var badge = document.createElement("span"); + badge.className = "fav-slot"; + badge.textContent = slot; + badge.title = slot === "0" ? "Favourite 10 (Alt+0)" : "Favourite " + slot + " (Alt+" + slot + ")"; + tile.appendChild(badge); + } // 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"); @@ -430,35 +650,8 @@ function updateFavEyebrow(favs) { } // 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). +// A command/action row - used for search results, recents, and (because settings are actions now) +// the setting options too. All rows are pinnable, so every row carries a star. function renderActionRow(a, i) { var on = FAVS.indexOf(a.id) !== -1; var row = document.createElement("div"); @@ -468,7 +661,7 @@ function renderActionRow(a, i) { var tile = document.createElement("div"); tile.className = "tile"; tile.style.setProperty("--h", hue(a.id)); - tile.textContent = tileCode(a, ACTIONS); + fillTile(tile, a); var left = document.createElement("div"); left.className = "row-left"; @@ -507,78 +700,103 @@ function renderActionRow(a, i) { 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); +// Append rows [from, to) into listEl, always inserting before the bottom spacer so row order is preserved. +function appendActionRows(list, from, to) { + var spacer = spacerEl || ensureSpacer(); + for (var i = from; i < to; i++) { + var row = renderActionRow(list[i], i); + row.setAttribute("data-idx", i); + listEl.insertBefore(row, spacer); } - - 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; +// Ensure the bottom spacer exists as the last child of listEl. It is (re)created on rebuild because +// listEl.innerHTML="" destroys the old node. +function ensureSpacer() { + if (!spacerEl || spacerEl.parentNode !== listEl) { + spacerEl = document.createElement("div"); + spacerEl.className = "dial-spacer-bottom"; + listEl.appendChild(spacerEl); } - if (q && !settingsResults.length) { + return spacerEl; +} + +// Size the spacer to the un-rendered tail so the scrollbar reflects the full match count. +function setBottomSpacer(total) { + ensureSpacer(); + spacerEl.style.height = Math.max(0, total - renderEnd) * ROW_H + "px"; +} + +// Reveal rows up to `upto` (an exclusive index), appending without rebuilding the whole list. Used by +// the scroll handler (viewport + overscan) and by arrow-nav that runs off the end of the current window. +function revealTo(list, upto) { + var need = Math.min(list.length, upto); + if (need <= renderEnd) + return; + appendActionRows(list, renderEnd, need); + renderEnd = need; + setBottomSpacer(list.length); +} + +// Rebuild the list from the first window (new search / phase change), clearing stale rows. +function rebuildCommandsList(list) { + listEl.innerHTML = ""; + listEl.className = "dial-list"; + ensureSpacer(); + renderEnd = 0; + appendActionRows(list, 0, Math.min(list.length, K_ROWS)); + renderEnd = Math.min(list.length, K_ROWS); + setBottomSpacer(list.length); +} + +// Toggle the .sel class in place - arrow-nav/click don't rebuild the DOM, just re-highlight the row. +function updateSelection() { + var rows = listEl ? listEl.querySelectorAll(".row") : []; + for (var i = 0; i < rows.length; i++) { + var idx = parseInt(rows[i].getAttribute("data-idx"), 10); + rows[i].classList.toggle("sel", sel.zone === "list" && idx === sel.i); + } +} + +function renderCommandsList() { + var list = currentList(); + var total = list.length; + if (sel.zone === "list") + sel.i = Math.max(0, Math.min(sel.i, total - 1)); + var showList = shouldRenderActionList(query); + // Recents are not filtered, so clear any stale match marks from a previous typed query. + if (!showList) + matchIndex = {}; + + if (!total) { + listEl.innerHTML = ""; + spacerEl = null; listEl.className = "dial-list empty"; if (countEl) countEl.hidden = true; var empty = document.createElement("div"); empty.className = "dial-empty"; - empty.textContent = "No settings match"; + empty.textContent = showList ? ("No actions match (Total: " + ACTIONS.length + ")") : "No actions yet"; listEl.appendChild(empty); + renderEnd = 0; + builtKey = buildKey() + "|0"; return; } - if (sel.zone === "list") - sel.i = Math.max(0, Math.min(sel.i, settingsResults.length - 1)); + + var key = buildKey() + "|" + total; + if (key !== builtKey) { + builtKey = key; + rebuildCommandsList(list); + } else if (sel.i >= renderEnd) { + // Arrow-nav walked past the rendered window - reveal enough to keep the selection visible. + revealTo(list, revealTarget(total, sel.i, K_ROWS)); + } + listEl.className = "dial-list"; if (countEl) { countEl.hidden = false; - countEl.textContent = showingRecents ? settingsResults.length + " recent" : settingsResults.length + " matches"; + countEl.textContent = showList ? resultCountText(ACTIONS.length, total, query) : total + " recent"; } - 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)); }); + updateSelection(); } // A tab row: no star/unpin (tabs aren't pinnable), tile monogram from the title. Uses tabTitle so @@ -646,13 +864,349 @@ function renderPercentList() { listEl.appendChild(ph); } +// ---- inline setting editor (DOM stage) --------------------------------------- + +// Collapse every open custom dropdown except `keep` (null collapses all). The menu list elements +// are the .ed-dropdown-menu siblings of the toggle buttons we track via openDropDownEl. +function closeOtherDropDowns(keep) { + if (openDropDownEl && openDropDownEl !== keep && openDropDownEl.parentNode) { + var m = openDropDownEl.parentNode.querySelector(".ed-dropdown-menu"); + if (m) m.hidden = true; + openDropDownEl.classList.remove("open"); + } + if (!keep) + openDropDownEl = null; +} + +// Collapse the currently open dropdown, if any (kept for the editor's export/import-adjacent helpers). +function closeEditorDropDown() { closeOtherDropDowns(null); } + +// Place an open dropdown menu as a fixed overlay just under its toggle, so the menu floats over the +// card (never resizing it) and is clamped to the popup's bottom edge with an internal scrollbar for +// long option lists. position:fixed escapes the card/launcher overflow clipping that an absolute +// menu would otherwise hit, keeping every option reachable within the window. +function positionDropDownMenu(btn, menu) { + var lrect = (document.querySelector(".launcher") || { getBoundingClientRect: function () { return { top: 0, bottom: window.innerHeight }; } }).getBoundingClientRect(); + var rect = btn.getBoundingClientRect(); + // Available room above and below the toggle, within the popup. Opening the menu must not push it + // past the window edge (that's the unreachable-overflow bug) - pick whichever side has more room + // and clamp the box to it. Overflow-y:auto scrolls any long list inside the menu itself. + var spaceBelow = lrect.bottom - (rect.bottom + 8); + var spaceAbove = (rect.top - 8) - lrect.top; + var openUp = spaceBelow < spaceAbove; + var maxH = Math.max(0, Math.min(openUp ? spaceAbove : spaceBelow, 200)); + menu.style.position = "fixed"; + menu.style.width = rect.width + "px"; + menu.style.left = rect.left + "px"; + menu.style.maxHeight = maxH + "px"; + if (openUp) { + // bottom edge sits just above the toggle; the box grows upward to content height. + menu.style.top = "auto"; + menu.style.bottom = (lrect.bottom - rect.top + 4) + "px"; + } else { + menu.style.top = (rect.bottom + 4) + "px"; + menu.style.bottom = "auto"; + } +} + +// Build the control element for one row (toggle/number/dropdown/combo/text/color) and seed it with +// the current value. Returns {el, node, extra} - node is what is appended, extra carries a datalist. +function settingInputFor(row) { + var el; + if (row.kind === "toggle") { + el = document.createElement("input"); + el.type = "checkbox"; + el.checked = !!row.value; + var sw = document.createElement("label"); + sw.className = "ed-switch"; + sw.appendChild(el); + var slider = document.createElement("span"); + slider.className = "ed-slider"; + sw.appendChild(slider); + return { el: el, node: sw }; + } + if (row.kind === "number") { + el = document.createElement("input"); + el.type = "number"; + el.step = row.is_int ? 1 : "any"; + if (row.min != null) el.min = row.min; + if (row.max != null) el.max = row.max; + if (row.value != null && row.value !== "") el.value = row.value; + return { el: el, node: el }; + } + if (row.kind === "dropdown") { + // Native popups are flaky in the embedded webview) */ +.ed-dropdown { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; } +.ed-dropdown-toggle { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + height: 30px; + padding: 0 8px; + border: 1px solid var(--border, var(--orca-border, #ddd)); + border-radius: 6px; + background: var(--panel, var(--orca-bg, #fff)); + color: var(--text, var(--orca-fg, #1b1c1e)); + font: inherit; + text-align: left; + cursor: pointer; +} +.ed-dropdown-toggle.open { border-color: var(--main-color, var(--orca-accent, #009688)); } +.ed-dropdown-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ed-dropdown-caret { flex: 0 0 auto; font-size: 10px; color: var(--muted, var(--orca-muted, #6b7280)); transition: transform .12s; } +.ed-dropdown-toggle.open .ed-dropdown-caret { transform: rotate(180deg); } +.ed-dropdown-menu { + /* Overlay popover, not in-flow: opening it must NOT grow the editor card or the popup window. + position/left/top/width/max-height are set from JS on open (position:fixed escapes the + scrollable card and launcher overflow, so the menu clips to the popup and scrolls itself). + These are only the visual defaults. */ + position: absolute; + z-index: 30; + border: 1px solid var(--border, var(--orca-border, #ddd)); + border-radius: 6px; + background: var(--panel, var(--orca-bg, #fff)); + box-shadow: 0 8px 24px rgba(0,0,0,.16); + max-height: 200px; + overflow-y: auto; +} +.ed-dropdown-option { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 8px; + border: 0; + background: none; + color: var(--text, var(--orca-fg, #1b1c1e)); + font: inherit; + text-align: left; + cursor: pointer; +} +.ed-dropdown-option:hover { background: var(--row-hover, rgba(127,127,127,.16)); } +.ed-dropdown-option.sel { font-weight: 600; color: var(--main-color, var(--orca-accent, #009688)); } +.ed-option-label { flex: 1 1 auto; min-width: 0; } +/* Pattern pictograms: filled with the option's icon, and (for values with none) absent. */ +.ed-option-icon, +.ed-dropdown-icon { + flex: 0 0 auto; + width: 16px; + height: 16px; +} +.ed-dropdown-icon[hidden] { display: none; } +/* toggle switch */ +.ed-switch { position: relative; flex: 0 0 auto; width: 36px; height: 20px; } +.ed-switch input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; } +.ed-slider { + position: absolute; + inset: 0; + background: #c4c4c4; + border-radius: 10px; + transition: background .15s; +} +.ed-slider::before { + content: ""; + position: absolute; + width: 16px; + height: 16px; + left: 2px; + top: 2px; + background: #fff; + border-radius: 50%; + transition: transform .15s; +} +.ed-switch input:checked + .ed-slider { background: var(--main-color, var(--orca-accent, #009688)); } +.ed-switch input:checked + .ed-slider::before { transform: translateX(16px); } +.editor-readonly { padding: 4px 0; font-size: 12px; color: var(--muted, var(--orca-muted, #6b7280)); } +.editor-actions { display: flex; gap: 8px; padding-top: 4px; } +.ed-btn { + padding: 6px 12px; + border: 1px solid var(--border, var(--orca-border, #ddd)); + border-radius: 6px; + background: var(--panel, var(--orca-bg, #fff)); + color: var(--text, var(--orca-fg, #1b1c1e)); + font: inherit; + cursor: pointer; +} +.ed-btn:hover { background: var(--row-hover, rgba(127,127,127,.16)); } +.ed-btn-primary { background: var(--main-color, var(--orca-accent, #009688)); border-color: var(--main-color, var(--orca-accent, #009688)); color: #fff; } +.ed-btn-primary:hover { filter: brightness(1.05); } +.editor-hint { font-size: 10px; text-align: center; color: var(--muted, var(--orca-muted, #6b7280)); } diff --git a/resources/web/js/fuzzy-search.js b/resources/web/js/fuzzy-search.js index 9e00e40eae..36dd9edb10 100644 --- a/resources/web/js/fuzzy-search.js +++ b/resources/web/js/fuzzy-search.js @@ -7,6 +7,10 @@ // Fold per-character so matched offsets stay in ORIGINAL string coordinates (highlighting slices the // original text; a separately-folded string would desync offsets). function FoldChar(ch) { + // why: fast path - ASCII is already NFD-stable and diacritic-free, so the normalize/regex below are + // no-ops. This skip is the hot cost in the Speed Dial search (thousands of settings per keystroke). + if (ch.length === 1 && ch.charCodeAt(0) < 0x80) + return ch; return ch.normalize("NFD").replace(/\p{Diacritic}/gu, ""); // accents always folded } @@ -15,6 +19,41 @@ function Norm(ch, caseSensitive) { return caseSensitive ? folded : folded.toLowerCase(); // case-sensitivity is the only toggle } +// Pre-normalize a whole haystack with the SAME per-char fold FuzzyRanges uses, so a caller can match +// it repeatedly against one cached string. The fold is 1:1 in length, so indices stay aligned to the +// ORIGINAL text - the highlight ranges that FuzzyRangesNorm returns slice the original correctly. +// Iterate by UTF-16 code unit (not Array.from code point) to mirror FuzzyRanges' own indexing exactly. +function NormText(text, caseSensitive) { + const src = text || ""; + let out = ""; + for (let i = 0; i < src.length; i++) + out += Norm(src[i], caseSensitive); + return out; +} + +// Match a PRE-normalized haystack against a PRE-normalized needle (both produced by NormText with the +// same caseSensitive flag). Skipping the per-character fold makes repeated matching (per keystroke over a +// cached pool) cheap. Returns ranges in original coordinates, or null on no match. +function FuzzyRangesNorm(haystackNorm, needleNorm) { + const t = haystackNorm || ""; + const needle = needleNorm || ""; + if (!needle) + return null; + const ranges = []; + let qi = 0; + for (let i = 0; i < t.length && qi < needle.length; i++) { + if (t[i] === needle[qi]) { + const last = ranges[ranges.length - 1]; + if (last && last[1] === i) + last[1] = i + 1; + else + ranges.push([i, i + 1]); + qi++; + } + } + return qi === needle.length ? ranges : null; +} + function EscapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/src/slic3r/GUI/ActionRegistry.cpp b/src/slic3r/GUI/ActionRegistry.cpp index 3b9e568a96..3e56c857cc 100644 --- a/src/slic3r/GUI/ActionRegistry.cpp +++ b/src/slic3r/GUI/ActionRegistry.cpp @@ -16,18 +16,27 @@ #include #include #include +#include #include #include #include +#include #include +#include #include #include +#include #include +#include #include +#include +#include +#include #include +#include namespace Slic3r { namespace GUI { @@ -131,6 +140,60 @@ std::unique_ptr make_action(const std::string& plugin_key, const std: constexpr const char* kCommandPrefix = "orca_command"; constexpr const char* kOrcaSourceKey = "orca"; constexpr const char* kOrcaSourceName = "OrcaSlicer"; +constexpr const char* kSettingPrefix = "orca_setting"; + +// Display context for a setting action's eyebrow, e.g. the "Process" in "Process : Quality : Layers". +// Keyed by the option's preset type so the palette reads like the settings sidebar tabs. +std::string setting_type_context(Preset::Type type) +{ + switch (type) { + case Preset::TYPE_FILAMENT: + case Preset::TYPE_SLA_MATERIAL: return _u8L("Filament"); + case Preset::TYPE_PRINTER: return _u8L("Printer"); + case Preset::TYPE_PRINT: + case Preset::TYPE_SLA_PRINT: + default: return _u8L("Process"); + } +} + +// A config setting exposed as a first-class action: selecting it jumps the sidebar to the option. +// The id is keyed by opt_key+type (NOT the display label), so renaming/localizing never re-keys +// the action; title/group/source are purely for display + search. run() performs the jump, and +// the generic registry run() bumps stats so a jump shows up in "recents" like any other action. +struct SettingAction : AppAction +{ + std::string opt_key; + Preset::Type type; + std::wstring category; // localized category, forwarded to jump_to_option + + static std::string id_for(const std::string& opt_key, Preset::Type type) + { return std::string(kSettingPrefix) + ":" + opt_key + ":" + std::to_string(int(type)); } + + SettingAction(std::string opt_key_in, Preset::Type type_in, std::string title, std::string group, + std::wstring category_in, std::string source_name) + : AppAction(AppActionId{id_for(opt_key_in, type_in)}, std::move(title), kOrcaSourceKey, std::move(source_name)) + , opt_key(std::move(opt_key_in)) + , type(type_in) + , category(std::move(category_in)) + { + // A setting is a two-phase command: activating it opens the inline editor (the "setting" + // phase) instead of running. Native run() is a no-op fallback; the editor applies through + // apply_setting(). + this->kind = AppActionKind::Command; + this->group = std::move(group); + this->input = "setting"; + } + + AppActionRunResult run(const std::string& /*param*/) const override + { + // Two-phase: the palette collects the edit; native run() is a no-op fallback. + return {AppActionRunResult::Level::Success}; + } + + // The current value's pattern pictogram (e.g. the selected infill pattern), for the search-result + // tile. Defined below after the icon helper it delegates to. + std::string icon() const override; +}; // 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 @@ -219,9 +282,9 @@ AppActionRunResult run_native_command(const std::string& command_key, const std: } 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") + // "go_to_tab" is two-phase: the palette collects the tab after activating it, so native + // dispatch here is a no-op (the jump goes through the go_to_tab web command). + if (command_key == "go_to_tab") return {AppActionRunResult::Level::Success}; return {AppActionRunResult::Level::Info, _L("Unknown command.")}; } @@ -253,10 +316,9 @@ std::vector> native_commands() // 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. + // Two-phase commands: activating them collects input in the palette, then runs. Settings are + // not a command here - they're materialised as first-class SettingActions (see materialize_). 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"))); @@ -268,19 +330,305 @@ std::vector> native_commands() 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() +// ---- inline setting editor helpers ------------------------------------------ + +// The inline editor "control" kind for an option, or "" when it can't be edited inline +// (plugin-backed values, points, serialized strings, etc.). readonly/legend options are +// filtered out of the search entirely in materialize_setting_actions(), so they never +// reach here. +std::string setting_control(const ConfigOptionDef& def) { - 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; + if (def.readonly || def.gui_type == ConfigOptionDef::GUIType::legend || + def.gui_type == ConfigOptionDef::GUIType::one_string || def.is_plugin_backed()) + return ""; + // Serialized vectors are entered as ONE semicolon-separated field (e.g. post_process), which the + // per-index editor doesn't model - keep them in the open-in-sidebar bucket. + if (def.gui_flags.find("serialized") != std::string::npos) + return ""; + switch (def.gui_type) { + case ConfigOptionDef::GUIType::color: return "color"; + case ConfigOptionDef::GUIType::i_enum_open: + case ConfigOptionDef::GUIType::f_enum_open: return "combo"; + default: break; + } + switch (def.type) { + case coBool: + case coBools: return "toggle"; + case coEnum: + case coEnums: return def.enum_values.empty() ? "combo" : "dropdown"; + case coInt: + case coInts: + case coFloat: + case coFloats: + case coPercent: + case coPercents: return "number"; + case coFloatOrPercent: + case coFloatsOrPercents: return "percent"; + case coString: + case coStrings: return "text"; + default: return ""; + } +} + +// Self-contained base64 encoder (for the tiny pictogram SVGs), avoiding a dependency on the exact +// wxBase64Encode overload/return type across wx versions. +std::string base64_encode(const std::string& data) +{ + static const char* tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + auto enc = [&](unsigned n, int pad) { + // pad = number of extraneous bytes in the final group (0, 1 or 2): + // 0 leftover -> 4 chars from all 24 bits + // 2 leftover (pad=1) -> 3 chars then '=' + // 1 leftover (pad=2) -> 2 chars then "==" + // The '=' padding always comes LAST; a misplaced '=' decodes as garbage in the webview. + std::string out; + out.push_back(tbl[(n >> 18) & 63]); + out.push_back(tbl[(n >> 12) & 63]); + out.push_back(pad >= 2 ? '=' : tbl[(n >> 6) & 63]); + out.push_back(pad >= 1 ? '=' : tbl[n & 63]); + return out; + }; + std::string out; + out.reserve(((data.size() + 2) / 3) * 4); + size_t i = 0; + for (; i + 3 <= data.size(); i += 3) + out += enc(((unsigned char) data[i]) << 16 | ((unsigned char) data[i + 1]) << 8 | ((unsigned char) data[i + 2]), 0); + if (i + 1 == data.size()) + out += enc(((unsigned char) data[i]) << 16, 2); + else if (i + 2 == data.size()) + out += enc(((unsigned char) data[i]) << 16 | ((unsigned char) data[i + 1]) << 8, 1); + return out; +} + +// data:URI for the pattern pictogram icons/param_.svg, or "" when there is no such icon. +// This mirrors the sidebar Choice field (Field.cpp add_item_bitmaps), which loads param_.svg +// per enum value - most settings have no icon, only pattern-style enums (infill/support patterns). +// Base64 data URIs are used so the embedded webview renders them identically on every backend +// (no file:// subresource / CORS restrictions). +std::string setting_icon_for_key(const std::string& key) +{ + if (key.empty()) + return {}; + + const std::string path = (boost::filesystem::path(resources_dir()) / "images" / ("param_" + key + ".svg")).string(); + // Non-throwing stat: a throwing filesystem_error here would propagate out of snapshot() and + // abort the app (the palette opener). exists(fs ::error_code) never throws. + boost::system::error_code ec; + if (!boost::filesystem::exists(path, ec)) + return {}; + + std::ifstream in(path, std::ios::binary); + if (!in) + return {}; + std::string data((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + if (data.empty()) + return {}; + + return "data:image/svg+xml;base64," + base64_encode(data); +} + +// [{value,key,label,icon}...] for an enum/combo. Ordered by enum_values when present, else by the +// keys_map iteration. label falls back to the key when enum_labels doesn't provide one. `icon` is +// the value's pattern pictogram when one exists, empty otherwise. +nlohmann::json setting_enum_options(const ConfigOptionDef& def) +{ + nlohmann::json out = nlohmann::json::array(); + auto label_at = [&](size_t i, const std::string& key) -> std::string { + return (i < def.enum_labels.size() && !def.enum_labels[i].empty()) ? def.enum_labels[i] : key; + }; + if (def.enum_keys_map != nullptr) { + std::vector ordered; + if (!def.enum_values.empty()) + ordered = def.enum_values; + else + for (const auto& kv : *def.enum_keys_map) + ordered.push_back(kv.first); + for (size_t i = 0; i < ordered.size(); ++i) { + auto it = def.enum_keys_map->find(ordered[i]); + if (it == def.enum_keys_map->end()) + continue; + out.push_back({{"value", it->second}, + {"key", ordered[i]}, + {"label", label_at(i, ordered[i])}, + {"icon", setting_icon_for_key(ordered[i])}}); + } + } else { + for (size_t i = 0; i < def.enum_values.size(); ++i) + out.push_back({{"value", (long long) i}, + {"key", def.enum_values[i]}, + {"label", label_at(i, def.enum_values[i])}, + {"icon", setting_icon_for_key(def.enum_values[i])}}); + } + return out; +} + +// The pattern pictogram for a setting's CURRENT value (its enum int), empty when it isn't a +// pattern-style enum or the value has no icon. Used for the search-result tile. +std::string setting_action_icon(const SettingAction& a) +{ + Tab* tab = wxGetApp().get_tab(a.type); + if (!tab || !tab->get_config()) + return {}; + DynamicPrintConfig* config = tab->get_config(); + const ConfigOptionDef* def = config->def()->get(a.opt_key); + if (!def || def->type != coEnum || (int(def->type) & int(coVectorType)) != 0) + return {}; + // Read the value WITHOUT config->opt_int(): the non-const overload routes through a type-checked + // option() that returns null for enum values (type() is coEnum, not coInt) and + // would deref null. Pull the ConfigOption* and dynamic_cast instead (succeeds: enums derive from + // ConfigOptionInt), falling back to the def default when the option is absent. + const ConfigOption* opt = (config->has(a.opt_key) ? config->option(a.opt_key) : def->default_value.get()); + const ConfigOptionInt* int_opt = dynamic_cast(opt); + if (!int_opt) + return {}; + const int value = int_opt->getInt(); + if (def->enum_keys_map) + for (const auto& kv : *def->enum_keys_map) + if (kv.second == value) + return setting_icon_for_key(kv.first); + return {}; +} + +std::string SettingAction::icon() const { return setting_action_icon(*this); } + +// Current value of the option at vector index `idx` as JSON (bool/number/string), or null for a +// type the inline editor doesn't render. `config` is the tab's live config; when an option is +// absent the def's default is shown. +nlohmann::json setting_value_json(const DynamicPrintConfig& config, const ConfigOptionDef& def, size_t idx) +{ + const ConfigOption* opt = config.option(def.opt_key); + const ConfigOption* root = opt ? opt : def.default_value.get(); + if (!root) + return nullptr; + switch (def.type) { + case coBool: return root->getBool(); + case coInt: return root->getInt(); + case coFloat: return root->getFloat(); + case coPercent: return root->getFloat(); + case coString: return static_cast(root)->value; + case coEnum: return root->getInt(); + case coBools: { + if (auto v = dynamic_cast(root)) + return bool(v->get_at(idx)); + if (auto v = dynamic_cast(root)) + return bool(v->get_at(idx) != 0); + return nullptr; + } + case coInts: { + if (auto v = dynamic_cast(root)) + return v->get_at(idx); + if (auto v = dynamic_cast(root)) { + const int nil = ConfigOptionIntsNullable::nil_value(); + int val = v->get_at(idx); + return val == nil ? nlohmann::json(nullptr) : nlohmann::json(val); + } + return nullptr; + } + case coFloats: { + if (auto v = dynamic_cast(root)) + return v->get_at(idx); + if (auto v = dynamic_cast(root)) { + double val = v->get_at(idx); + return std::isnan(val) ? nlohmann::json(nullptr) : nlohmann::json(val); + } + return nullptr; + } + case coPercents: { + if (auto v = dynamic_cast(root)) + return v->get_at(idx); + if (auto v = dynamic_cast(root)) { + double val = v->get_at(idx); + return std::isnan(val) ? nlohmann::json(nullptr) : nlohmann::json(val); + } + return nullptr; + } + case coStrings: return static_cast(root)->get_at(idx); + case coEnums: { + if (auto v = dynamic_cast(root)) + return v->get_at(idx); + if (auto v = dynamic_cast(root)) { + const int nil = ConfigOptionEnumsGenericNullable::nil_value(); + int val = v->get_at(idx); + return val == nil ? nlohmann::json(nullptr) : nlohmann::json(val); + } + return nullptr; + } + case coFloatOrPercent: return root->serialize(); + case coFloatsOrPercents: { + if (auto v = dynamic_cast(root)) { + auto ss = v->vserialize(); + return idx < ss.size() ? ss[idx] : nullptr; + } + if (auto v = dynamic_cast(root)) { + auto ss = v->vserialize(); + return idx < ss.size() ? ss[idx] : nullptr; + } + return nullptr; + } + default: return nullptr; + } +} + +// How many scalar values the option currently has (1 for scalars, the array length for vectors). +size_t setting_value_count(const DynamicPrintConfig& config, const ConfigOptionDef& def) +{ + if ((int(def.type) & int(coVectorType)) == 0) + return 1; + // size() lives on ConfigOptionVectorBase, not ConfigOption - dynamic_cast to it covers every + // vector type (and their nullable variants) polymorphically. + const ConfigOption* opt = config.option(def.opt_key); + if (opt) + if (auto v = dynamic_cast(opt)) + return v->size(); + if (def.default_value) + if (auto v = dynamic_cast(def.default_value.get())) + return v->size(); + return 1; +} + +// boost::any for a single element, matching what Slic3r::GUI::change_opt_value expects. +boost::any setting_any_from_json(const ConfigOptionDef& def, const nlohmann::json& v) +{ + if (!v.is_null()) { + switch (def.type) { + case coBool: return boost::any(v.is_boolean() ? v.get() : v.get() != 0); + case coInt: return boost::any(v.get()); + case coFloat: + case coPercent: return boost::any(v.get()); + case coString: return boost::any(v.get()); + case coEnum: return boost::any(v.get()); + case coBools: return boost::any(static_cast(v.get() ? 1 : 0)); + case coInts: return boost::any(v.get()); + case coFloats: + case coPercents: return boost::any(v.get()); + case coStrings: return boost::any(v.get()); + case coFloatOrPercent: + case coFloatsOrPercents: { + // change_opt_value detects "percent" via a trailing '%', so trim whitespace first or a + // stray space (e.g. "10% ") would be misread as mm. + std::string s = v.is_string() ? v.get() : std::to_string(v.get()); + boost::trim(s); + // An empty string would make change_opt_value's str.back() UB - bail out to a rejected apply. + return s.empty() ? boost::any() : boost::any(s); + } + case coEnums: return boost::any(v.get()); + default: break; + } + } + // Coerce numeric types that may arrive as a different JSON numeric type. + if (v.is_number()) { + switch (def.type) { + case coInt: + case coEnums: return boost::any(v.get()); + case coFloat: + case coPercent: + case coInts: + case coFloats: + case coPercents: return boost::any(v.get()); + default: break; + } + } + return boost::any(); } } // namespace @@ -411,7 +759,9 @@ void ActionRegistry::remove(const std::string& id) void ActionRegistry::seed_state(AppAction& a) const { - auto favs = read_string_array("favourite_actions"); + // Favourites carry the quick-launch order, so the persisted list is the source of truth + // (not re-derived from the frecency sort). Cap it so stale configs can't exceed kFavLimit. + auto favs = favourite_ids(); a.favourite = std::find(favs.begin(), favs.end(), a.id()) != favs.end(); nlohmann::json stats = read_section("stats", nlohmann::json::object()); @@ -469,18 +819,40 @@ AppActionRunResult ActionRegistry::run(const std::string& id, const std::string& return o; } -void ActionRegistry::set_favourite(const std::string& id, bool on) +bool ActionRegistry::set_favourite(const std::string& id, bool on) { assert(wxThread::IsMain()); - auto favs = read_string_array("favourite_actions"); + // Start from the capped, deduped list so a persisted config can never be written back larger. + auto favs = favourite_ids(); auto it = std::find(favs.begin(), favs.end(), id); - if (on && it == favs.end()) + if (on && it == favs.end()) { + if (favs.size() >= kFavLimit) + return false; // bar is full - the caller surfaces a hint favs.push_back(id); + } if (!on && it != favs.end()) favs.erase(it); write_section("favourite_actions", nlohmann::json(favs)); if (AppAction* live = find(id)) live->favourite = on; + return true; +} + +std::vector ActionRegistry::favourite_ids() const +{ + assert(wxThread::IsMain()); + // Enforce the cap + dedupe on read so the persisted order can never grow past kFavLimit, + // even from an older config. The pinned order is intentionally preserved (slice, not sort). + std::vector favs = read_string_array("favourite_actions"); + std::vector out; + out.reserve(std::min(favs.size(), kFavLimit)); + for (const auto& id : favs) { + if (out.size() >= kFavLimit) + break; + if (std::find(out.begin(), out.end(), id) == out.end()) + out.push_back(id); + } + return out; } void ActionRegistry::reorder_favourites(const std::vector& ids) @@ -496,9 +868,78 @@ void ActionRegistry::reorder_favourites(const std::vector& ids) for (const auto& id : cur) if (std::find(next.begin(), next.end(), id) == next.end()) next.push_back(id); + // never write the bar back larger than the quick-launch slots + if (next.size() > kFavLimit) + next.resize(kFavLimit); write_section("favourite_actions", nlohmann::json(next)); } +void ActionRegistry::materialize_setting_actions() +{ + assert(wxThread::IsMain()); + + // Reuse the Sidebar's live searcher: it's the only OptionsSearcher whose groups_and_categories + // map is populated (Tab::add_key feeds it at build time), and it already mirrors the current + // configs/mode/printer-technology - i.e. exactly what the sidebar's own search would show. A + // fresh OptionsSearcher has an empty groups_and_categories, so append_options() would drop every + // option and nothing would materialise. Turn each visible option into a SettingAction. + const std::vector& options = wxGetApp().sidebar().get_searcher().all_options(); + + // Load the persisted per-action state ONCE (not per-option) so a re-materialised setting keeps + // its recency/favourite; mirroring seed_state but amortised over the whole option set. + nlohmann::json stats = read_section("stats", nlohmann::json::object()); + if (!stats.is_object()) + stats = nlohmann::json::object(); + const std::vector favs = favourite_ids(); + + std::unordered_set seen; + for (const Search::Option& opt : options) { + // Omit rows the inline editor can't represent and that aren't useful as a jump target: + // readonly (e.g. the detected thread count) and legend (static text) GUI rows. They remain + // in the sidebar's own search; only the Speed Dial pool drops them. + Tab* tab = wxGetApp().get_tab(opt.type); + if (tab && tab->get_config()) { + const ConfigOptionDef* def = tab->get_config()->def()->get(opt.opt_key()); + if (!def || def->readonly || def->gui_type == ConfigOptionDef::GUIType::legend) + continue; + } + + const std::string id = SettingAction::id_for(opt.opt_key(), opt.type); + seen.insert(id); + + const std::wstring label_w = opt.label_local.empty() ? opt.label : opt.label_local; + + // Eyebrow/source = the full settings path "Process : Quality : Layers" (localized). The JS + // renders group || source and searches source + " " + group, so putting the whole path in + // source both displays it and makes it matchable by any segment (e.g. a "quality" query). + std::wstring path = boost::nowide::widen(setting_type_context(opt.type)); + if (!opt.category_local.empty()) + path += L" : " + opt.category_local; + if (!opt.group_local.empty()) + path += L" : " + opt.group_local; + + // title = the option leaf name (last label segment); group stays empty so the source path + // (above) is the single display/search breadcrumb rather than being duplicated. + auto action = std::make_unique(opt.opt_key(), opt.type, boost::nowide::narrow(label_w), + std::string(), opt.category_local, boost::nowide::narrow(path)); + action->favourite = std::find(favs.begin(), favs.end(), id) != favs.end(); + if (auto it = stats.find(id); it != stats.end() && it->is_object()) { + action->count = it->value("count", 0); + action->last = it->value("last", 0LL); + } + m_actions.insert_or_assign(action->id(), std::shared_ptr(std::move(action))); + } + + // Drop SettingActions whose option no longer exists in the current configs (e.g. the printer + // technology / UI mode changed). Non-setting actions are untouched. + for (auto it = m_actions.begin(); it != m_actions.end();) { + if (it->first.rfind(kSettingPrefix, 0) == 0 && !seen.count(it->first)) + it = m_actions.erase(it); + else + ++it; + } +} + bool ActionRegistry::should_ask(const std::string& id) const { assert(wxThread::IsMain()); @@ -517,9 +958,13 @@ void ActionRegistry::suppress_ask(const std::string& id) // ---- snapshot --------------------------------------------------------------- -nlohmann::json ActionRegistry::snapshot() const +nlohmann::json ActionRegistry::snapshot() { assert(wxThread::IsMain()); + // Settings are first-class actions; make sure the current visible option set is materialised + // before we serialise the pool (tabs_list is built by the time the palette opens). + materialize_setting_actions(); + std::vector sorted; sorted.reserve(m_actions.size()); for (const auto& entry : m_actions) @@ -544,7 +989,8 @@ nlohmann::json ActionRegistry::snapshot() const {"source", a->source_name()}, {"group", a->group}, {"input", a->input}, - {"shortcut", ""}}); + {"shortcut", ""}, + {"icon", a->icon()}}); }; nlohmann::json actions = nlohmann::json::array(); @@ -554,7 +1000,8 @@ nlohmann::json ActionRegistry::snapshot() const // 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")); + // Cap on read so the bar cannot exceed the quick-launch slots (kFavLimit). + nlohmann::json favourites(favourite_ids()); // Recent = the last-N launched actions by recency (only actions with a run history). constexpr size_t kRecentLimit = 5; @@ -576,49 +1023,6 @@ nlohmann::json ActionRegistry::snapshot() const 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 @@ -640,37 +1044,140 @@ nlohmann::json ActionRegistry::tab_options() const return out; } -// ---- settings recents (persisted, most-recent-first, capped at 8) ----------- +// ---- inline setting editor (read the current value) -------------------------- -nlohmann::json ActionRegistry::settings_recent() const +nlohmann::json ActionRegistry::setting_descriptor(const std::string& id) const { assert(wxThread::IsMain()); - return read_section("recent_settings", nlohmann::json::array()); + const AppAction* a = by_id(id); + const SettingAction* sa = dynamic_cast(a); + if (!sa) + return nlohmann::json::object(); + Tab* tab = wxGetApp().get_tab(sa->type); + if (!tab) + return nlohmann::json::object(); + DynamicPrintConfig* config = tab->get_config(); + if (!config) + return nlohmann::json::object(); + const ConfigOptionDef* def = config->def()->get(sa->opt_key); + if (!def) + return nlohmann::json::object(); + + const std::string control = setting_control(*def); + const bool vector = (int(def->type) & int(coVectorType)) != 0; + + nlohmann::json d = {{"id", sa->id()}, + {"opt_key", sa->opt_key}, + {"type", int(sa->type)}, + {"title", a->title()}, + {"breadcrumb", a->source_name()}, + {"category", boost::nowide::narrow(sa->category)}, + {"unit", def->sidetext}, + {"tooltip", def->tooltip}, + {"editable", !control.empty()}, + {"control", control}, + {"cardinality", vector ? "vector" : "scalar"}}; + + if (!control.empty()) { + // Hide unbounded min/max so the page doesn't clamp a sane value to ±FLT_MAX. + if (def->min > -FLT_MAX) + d["min"] = def->min; + if (def->max < FLT_MAX) + d["max"] = def->max; + if (control == "number") + d["is_int"] = (def->type == coInt || def->type == coInts); + if (control == "dropdown" || control == "combo") + d["enum_options"] = setting_enum_options(*def); + if (vector) { + nlohmann::json values = nlohmann::json::array(); + nlohmann::json labels = nlohmann::json::array(); + const size_t n = setting_value_count(*config, *def); + for (size_t i = 0; i < n; ++i) { + values.push_back(setting_value_json(*config, *def, i)); + labels.push_back(std::to_string(i + 1)); + } + d["values"] = std::move(values); + d["index_labels"] = std::move(labels); + } else { + d["value"] = setting_value_json(*config, *def, 0); + } + } + return d; } -void ActionRegistry::record_setting_recent( - const std::string& opt_key, int type, const std::string& label, const std::string& category, const std::string& group) +// ---- inline setting editor (write the edited value back) --------------------- + +bool ActionRegistry::apply_setting(const std::string& id, const nlohmann::json& value) { assert(wxThread::IsMain()); - if (opt_key.empty()) - return; + const AppAction* a = by_id(id); + const SettingAction* sa = dynamic_cast(a); + if (!sa) + return false; + Tab* tab = wxGetApp().get_tab(sa->type); + if (!tab) + return false; + DynamicPrintConfig* config = tab->get_config(); + if (!config) + return false; + const ConfigOptionDef* def = config->def()->get(sa->opt_key); + if (!def) + return false; + const std::string control = setting_control(*def); + if (control.empty()) + return false; - 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; - }; + const bool vector = (int(def->type) & int(coVectorType)) != 0; + const size_t n = vector ? (value.is_array() ? value.size() : 0) : 1; + if (vector && n == 0) + return false; - 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); + for (size_t i = 0; i < n; ++i) { + const nlohmann::json& elem = vector ? value[i] : value; + boost::any any = setting_any_from_json(*def, elem); + if (any.empty()) + return false; + if (control == "number" && elem.is_number()) { + const double d = elem.get(); + if (d < def->min || d > def->max) + return false; + } + if (control == "percent" && elem.is_string()) { + // "mm or %" value: strip a trailing %/whitespace, clamp the numeric part to [min,max]. + // Reject anything that isn't a well-formed number (which change_opt_value would throw on). + std::string s = elem.get(); + boost::trim(s); + if (!s.empty() && s.back() == '%') + s.pop_back(); + boost::trim(s); + if (s.empty()) + return false; + char* end = nullptr; + const double d = std::strtod(s.c_str(), &end); + if (end == s.c_str() || *end != '\0') + return false; + if (d < def->min || d > def->max) + return false; + } + Slic3r::GUI::change_opt_value(*config, sa->opt_key, any, int(i)); + } + + // Mark the preset modified like a sidebar edit. Scalar options also get the standard + // post-change hook so dependent settings refresh; vector options have no unambiguous scalar + // value to pass, so on_value_change is skipped (the config write + dirty flag is still correct). + tab->update_dirty(); + if (!vector) { + boost::any any = setting_any_from_json(*def, value); + if (!any.empty()) + tab->on_value_change(sa->opt_key, any); + } + + // The config write is separate from the on-screen Field, so repaint the field(s) that display + // this option (on whatever page they live, not just the active page) - otherwise the sidebar + // shows the "modified" arrow but keeps the stale value pushed to the last edit/reload. + if (Page* page = nullptr; tab->get_field(sa->opt_key, &page) && page) + page->reload_config(); + return true; } }} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/ActionRegistry.hpp b/src/slic3r/GUI/ActionRegistry.hpp index 7b718b4c09..1c481c1da9 100644 --- a/src/slic3r/GUI/ActionRegistry.hpp +++ b/src/slic3r/GUI/ActionRegistry.hpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -31,6 +32,14 @@ struct AppActionRunResult wxString message; // empty = "nothing worth showing" }; +// Tag carrying a precomputed action id, used by the explicit-id ctor below. It exists so the +// id ctor and the compose-from-prefix ctor are NOT both reachable from a `const char*` first +// argument (which would make calls like AppAction("orca_command", ...) ambiguous). +struct AppActionId +{ + std::string id; +}; + // A speed-dial action: identity + user-state seeded from config + how to run itself. // Abstract base - the only virtual is run(); concrete subclasses know how to run // and what their source is. @@ -72,6 +81,11 @@ struct AppAction // commands (e.g. a layer percentage); plugins ignore it. virtual AppActionRunResult run(const std::string& param = {}) const = 0; + // Optional data:URI for a small pictogram to show in the palette row/tile/the editor + // (e.g. the current infill/pattern). Empty string = fall back to the monogram. Only + // SettingAction overrides this; the base returns an empty string. + virtual std::string icon() const { return {}; } + protected: // The definition is constructor-set and immutable. Refreshes replace an action // instead of mutating identity after the registry has indexed it by id. @@ -83,6 +97,14 @@ protected: m_source_key(std::move(source_key)), m_source_name(std::move(source_name)) {} + // Explicit-id ctor: for actions whose id must NOT be derived from the display title + // (e.g. a setting action keyed by opt_key+type, so a rename/localization never re-keys it). + AppAction(AppActionId id, std::string title, std::string source_key, std::string source_name) + : m_id(std::move(id.id)), + m_title(std::move(title)), + m_source_key(std::move(source_key)), + m_source_name(std::move(source_name)) {} + private: std::string m_id; // ::<source_key> - stable identity + AppConfig key std::string m_title; // display name @@ -122,34 +144,27 @@ public: // Always-clean read surface. UI thread only. const AppAction* by_id(const std::string& id) const; + // Hard cap on the favourites bar: the numbered quick-launch slots (Alt/Option+1..9, 0). + static constexpr size_t kFavLimit = 10; + // Dispatch + write-through (registry is the only thing that touches AppConfig). AppActionRunResult run(const std::string& id, const std::string& param = {}); // runs + bumps stats - void set_favourite(const std::string& id, bool on); + // Pin/unpin. Returns false when `on` would exceed kFavLimit (the bar is full) so the + // caller can surface a "favourites are full" hint instead of silently dropping the pin. + bool set_favourite(const std::string& id, bool on); void reorder_favourites(const std::vector<std::string>& ids); // persist a new bar order + // Ordered pinned list (the source of truth), capped at kFavLimit and deduped, matching the + // visible bar the palette renders. + std::vector<std::string> favourite_ids() const; + // Run-confirm gate, keyed by action id (per-action "don't ask again"). bool should_ask(const std::string& id) const; void suppress_ask(const std::string& id); // 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); + nlohmann::json snapshot(); // "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/ @@ -158,10 +173,25 @@ public: // plugins) aren't separate pages and are not listed. Call on the UI thread; null-safe. nlohmann::json tab_options() const; + // Inline setting editor descriptor for a SettingAction id. Returns the JSON the palette + // renders: {id, opt_key, type, title, breadcrumb, category, group, unit, tooltip, editable, + // control ("toggle|number|dropdown|combo|text|color"), cardinality ("scalar"|"vector"), + // value|values, index_labels[], enum_options[], min|max}. Empty object for a non-setting id. + nlohmann::json setting_descriptor(const std::string& id) const; + // Apply an edit submitted by the palette. `value` is the control's JSON payload (scalar, or an + // array for vector settings). Writes the value(s) into the global preset config and marks the + // preset dirty, exactly like a sidebar edit. Returns false on a bad id/type/value. + bool apply_setting(const std::string& id, const nlohmann::json& value); + private: void seed_state(AppAction& a) const; // favourite/stats from config AppAction* find(const std::string& id); + // (Re)materialise the current visible config settings as SettingActions from the live + // searcher (respecting printer-tech + user-mode + visibility filtering), removing stale ones. + // Called at the top of snapshot() so the palette always reflects the current configs. + void materialize_setting_actions(); + // Loader callbacks (marshalled to the UI thread) land here. refresh_source rebuilds // one plugin's whole action set; refresh_capability touches a single capability. void refresh_source(const std::string& plugin_key, ActionChange change); diff --git a/src/slic3r/GUI/KBShortcutsDialog.cpp b/src/slic3r/GUI/KBShortcutsDialog.cpp index 82e499ac55..6ea121c16d 100644 --- a/src/slic3r/GUI/KBShortcutsDialog.cpp +++ b/src/slic3r/GUI/KBShortcutsDialog.cpp @@ -198,7 +198,8 @@ void KBShortcutsDialog::fill_shortcuts() // Switch table page { ctrl + L("Tab"), L("Switch table page")}, // Open speed dial - { ctrl + "K", L("Open speed dial") }, + { "Space", L("Open speed dial") }, + { alt + "1..9,0", L("Run a Speed Dial favourite") }, //DEL #ifdef __APPLE__ {"fn+⌫", L("Delete Selected")}, diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index 2b4c8754c8..a6a665d3f7 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -1,6 +1,7 @@ #include "MainFrame.hpp" #include <wx/panel.h> +#include <wx/textentry.h> #include <wx/notebook.h> #include <wx/listbook.h> #include <wx/simplebook.h> @@ -701,8 +702,18 @@ 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; } + // Orca: open the speed dial from any page with a bare Space. Only when no modifier is held (so + // editing shortcuts like Ctrl+Shift+Space in the canvas still reach it) and while no text field + // is focused, so typing a space into the search box or a parameter value isn't hijacked. + if (!evt.CmdDown() && !evt.ShiftDown() && !evt.AltDown() && evt.GetKeyCode() == WXK_SPACE) { + wxWindow* focus = wxWindow::FindFocus(); + if (focus && dynamic_cast<wxTextEntryBase*>(focus)) { + evt.Skip(); // typing in a text field - let the space reach it + return; + } + 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(); @@ -3350,7 +3361,7 @@ void MainFrame::init_menubar_as_editor() "", nullptr, []() { return true; }, this, 1); parent_menu->AppendSeparator(); append_menu_item( - parent_menu, wxID_ANY, _L("Open speed dial...") + sep + ctrl_t + "K", "", + parent_menu, wxID_ANY, _L("Open speed dial...") + sep + "Space", "", [](wxCommandEvent &) { wxGetApp().open_speed_dial(); }, "", nullptr, []() { return true; }, this); //parent_menu->Insert(1, preference_item); @@ -3378,7 +3389,7 @@ void MainFrame::init_menubar_as_editor() top_menu->AppendSeparator(); append_menu_item( - top_menu, wxID_ANY, _L("Open speed dial...") + "\t" + ctrl + "K", "", + top_menu, wxID_ANY, _L("Open speed dial...") + "\t" + "Space", "", [](wxCommandEvent &) { wxGetApp().open_speed_dial(); }, "", nullptr, []() { return true; }, this); top_menu->AppendSeparator(); @@ -3522,7 +3533,7 @@ void MainFrame::init_menubar_as_editor() // 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", "", + fileMenu, wxID_ANY, _L("Open speed dial...") + sep + "Space", "", [](wxCommandEvent&) { wxGetApp().open_speed_dial(); }, "", nullptr, []() { return true; }, this); append_menu_item( diff --git a/src/slic3r/GUI/ParamsPanel.cpp b/src/slic3r/GUI/ParamsPanel.cpp index a0834666ad..5a49df1280 100644 --- a/src/slic3r/GUI/ParamsPanel.cpp +++ b/src/slic3r/GUI/ParamsPanel.cpp @@ -324,7 +324,9 @@ ParamsPanel::ParamsPanel( wxWindow* parent, wxWindowID id, const wxPoint& pos, c wxID_ANY, wxDefaultPosition, wxDefaultSize, - wxVSCROLL) // hide hori-bar will cause hidden field mis-position + wxVSCROLL // hide hori-bar will cause hidden field mis-position + | wxTAB_TRAVERSAL // Allows for traversal via tab key + ) { // ShowScrollBar(GetHandle(), SB_BOTH, FALSE); Bind(wxEVT_SCROLL_CHANGED, [this](auto &e) { diff --git a/src/slic3r/GUI/Search.hpp b/src/slic3r/GUI/Search.hpp index 4ae43dbca0..859a012f46 100644 --- a/src/slic3r/GUI/Search.hpp +++ b/src/slic3r/GUI/Search.hpp @@ -148,6 +148,10 @@ public: void show_dialog(Preset::Type type, wxWindow *parent, TextInput *input, wxWindow *ssearch_btn); void dlg_sys_color_changed(); void dlg_msw_rescale(); + + // The full gated option set built by init() (after visibility/mode/printer-tech filtering). + // Used by the Speed Dial to materialise config settings as first-class actions. + const std::vector<Option>& all_options() const { return options; } }; //------------------------------------------ diff --git a/src/slic3r/GUI/SpeedDialDialog.cpp b/src/slic3r/GUI/SpeedDialDialog.cpp index 8a0aca5445..593d8a7344 100644 --- a/src/slic3r/GUI/SpeedDialDialog.cpp +++ b/src/slic3r/GUI/SpeedDialDialog.cpp @@ -11,6 +11,8 @@ #include <libslic3r/Preset.hpp> +#include <boost/nowide/convert.hpp> + #include <algorithm> #include <wx/display.h> @@ -127,9 +129,14 @@ void SpeedDialWebDialog::handle_web_command(const nlohmann::json& payload) if (command == "request_actions") { m_page_ready = true; send_actions(); - } else if (command == "toggle_favourite") - wxGetApp().action_registry().set_favourite(payload.value("id", ""), payload.value("fav", false)); - else if (command == "reorder_favourites") { + } else if (command == "toggle_favourite") { + // set_favourite() refuses once the bar hits kFavLimit; tell the page so it can undo the + // star and show a "favourites are full" hint instead of silently losing the pin. + const std::string fav_id = payload.value("id", ""); + const bool ok = wxGetApp().action_registry().set_favourite(fav_id, payload.value("fav", false)); + if (!ok) + call_web_handler({{"command", "favourite_full"}, {"limit", (int) ActionRegistry::kFavLimit}, {"id", fav_id}}); + } else if (command == "reorder_favourites") { std::vector<std::string> ids; if (payload.contains("ids") && payload["ids"].is_array()) for (const auto& id : payload["ids"]) @@ -138,21 +145,6 @@ void SpeedDialWebDialog::handle_web_command(const nlohmann::json& payload) wxGetApp().action_registry().reorder_favourites(ids); } else if (command == "run_action") run_action(payload.value("id", ""), payload.value("title", ""), payload.value("param", "")); - else if (command == "go_to_setting") { - // "Go to setting..." second phase: the page hands back the option it matched. - const std::string opt_key = payload.value("opt_key", ""); - if (!opt_key.empty()) { - const int type = json_int_or(payload, "type", int(Preset::TYPE_INVALID)); - const std::string label = payload.value("label", ""); - const std::string group = payload.value("group", ""); - const std::string cat = payload.value("category", ""); - // Track it in the palette's recent-settings list before jumping (persisted). - wxGetApp().action_registry().record_setting_recent(opt_key, type, label, cat, group); - Hide(); - wxGetApp().sidebar().jump_to_option(opt_key, Preset::Type(type), from_u8(cat).ToStdWstring()); - } - } else if (command == "search_settings") - search_settings(payload.value("q", "")); else if (command == "search_tabs") search_tabs(); else if (command == "go_to_tab") { @@ -163,6 +155,27 @@ void SpeedDialWebDialog::handle_web_command(const nlohmann::json& payload) if (wxGetApp().mainframe) wxGetApp().mainframe->select_tab(from_u8(tab_id)); } + } else if (command == "setting_descriptor") { + // Inline editor: hand the page the descriptor for the setting it's editing. + const std::string id = payload.value("id", ""); + call_web_handler( + {{"command", "setting_descriptor"}, {"descriptor", wxGetApp().action_registry().setting_descriptor(id)}}); + } else if (command == "set_setting") { + // Inline editor submit. Apply the value; on success close the dialog. + const std::string id = payload.value("id", ""); + const nlohmann::json value = payload.contains("value") ? payload["value"] : nlohmann::json(nullptr); + if (id.empty() || !wxGetApp().action_registry().apply_setting(id, value)) { + call_web_handler({{"command", "apply_failed"}, {"id", id}}); + return; + } + Hide(); + } else if (command == "open_setting_in_sidebar") { + // Non-inline-editable setting (points, plugin-backed, float-or-percent): jump the sidebar. + Hide(); + const std::string opt_key = payload.value("opt_key", ""); + const std::string category = payload.value("category", ""); + if (!opt_key.empty()) + wxGetApp().sidebar().jump_to_option(opt_key, Preset::Type(payload.value("type", int(Preset::TYPE_INVALID))), boost::nowide::widen(category)); } else if (command == "resize") resize_to_content(json_int_or(payload, "height", 0)); } @@ -179,18 +192,6 @@ void SpeedDialWebDialog::search_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) diff --git a/src/slic3r/GUI/SpeedDialDialog.hpp b/src/slic3r/GUI/SpeedDialDialog.hpp index 74e806dd85..26734dd841 100644 --- a/src/slic3r/GUI/SpeedDialDialog.hpp +++ b/src/slic3r/GUI/SpeedDialDialog.hpp @@ -22,7 +22,6 @@ private: void resize_to_content(int height); 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};