Speed Dial Enhancements (#15562)

This commit is contained in:
SoftFever
2026-09-18 23:21:19 +08:00
committed by GitHub
75 changed files with 70713 additions and 2576 deletions
+5 -2
View File
@@ -449,10 +449,13 @@ jobs:
# the bounds checks are compiled in, so a stripped exe still catches them.
find "$d/build_flatpak/tests" -type f -perm -u+x -exec strip --strip-unneeded {} + 2>/dev/null || true
# At runtime the tests read tests/ (TEST_DATA_DIR), scripts/, and under
# resources/ the shipped profiles (PROFILES_DIR) and the printers/ maps.
# resources/ the shipped profiles (PROFILES_DIR), the printers/ maps, and
# the icon SVGs (NativeCommands icon names are checked against them).
find "$d" -mindepth 1 -maxdepth 1 -type d \
! -name tests ! -name build_flatpak ! -name scripts ! -name resources -exec rm -rf {} +
find "$d/resources" -mindepth 1 -maxdepth 1 ! -name profiles ! -name printers -exec rm -rf {} +
find "$d/resources" -mindepth 1 -maxdepth 1 ! -name profiles ! -name printers ! -name images -exec rm -rf {} +
# Only the SVGs are read; the png/ico/icns/gif assets are ~35MB of dead weight.
find "$d/resources/images" -mindepth 1 -maxdepth 1 ! -name '*.svg' -exec rm -rf {} + 2>/dev/null || true
tar -cf flatpak-test-asset.tar flatpak_app "$d"
- name: Upload flatpak test asset
uses: actions/upload-artifact@v7
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4
View File
@@ -125,6 +125,7 @@ src/slic3r/GUI/ThermalPreconditioningDialog.hpp
src/slic3r/GUI/Jobs/SLAImportJob.cpp
src/slic3r/GUI/Jobs/UpgradeNetworkJob.cpp
src/slic3r/GUI/AboutDialog.cpp
src/slic3r/GUI/ActionRegistry.cpp
src/slic3r/GUI/AMSMaterialsSetting.cpp
src/slic3r/GUI/ExtrusionCalibration.cpp
src/slic3r/GUI/AmsMappingPopup.cpp
@@ -159,6 +160,7 @@ src/slic3r/GUI/SelectMachinePop.cpp
src/slic3r/GUI/StatusPanel.cpp
src/slic3r/GUI/Monitor.cpp
src/slic3r/GUI/MsgDialog.cpp
src/slic3r/GUI/NativeCommands.cpp
src/slic3r/GUI/NotificationManager.hpp
src/slic3r/GUI/NotificationManager.cpp
src/slic3r/GUI/ObjectDataViewModel.cpp
@@ -179,6 +181,8 @@ src/slic3r/GUI/PublishDialog.cpp
src/slic3r/GUI/PublishSettingsDialog.cpp
src/slic3r/GUI/SavePresetDialog.cpp
src/slic3r/GUI/Search.cpp
src/slic3r/GUI/SettingsIndex.cpp
src/slic3r/GUI/SpeedDialDialog.cpp
src/slic3r/GUI/Selection.cpp
src/slic3r/GUI/SelectMachine.cpp
src/slic3r/GUI/PrePrintChecker.cpp
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M14.5,1.5v12a1,1,0,0,1-1,1H1.5a1,1,0,0,1-1-1V1.5a1,1,0,0,1,1-1h12a1,1,0,0,1,1,1Z" style="fill:none;stroke:#949494;stroke-linecap:round;stroke-linejoin:round"/><polyline points="4,5 6.5,7.5 4,10" style="fill:none;stroke:#009688;stroke-linecap:round;stroke-linejoin:round"/><line x1="8" y1="10" x2="11" y2="10" style="fill:none;stroke:#009688;stroke-linecap:round;stroke-linejoin:round"/></svg>

After

Width:  |  Height:  |  Size: 524 B

+5 -1
View File
@@ -19,7 +19,10 @@
<span class="plugin-search-icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.5" y2="16.5"/></svg>
</span>
<input class="plugin-search-input" id="q" type="text" placeholder="Search actions" spellcheck="false" autocomplete="off" aria-label="Search actions" />
<span class="plugin-search-field">
<span class="search-ghost" id="ghost" hidden aria-hidden="true"><span class="search-ghost-typed" id="ghostTyped"></span><span class="search-ghost-suffix" id="ghostSuffix"></span></span>
<input class="plugin-search-input" id="q" type="text" placeholder="Search actions" spellcheck="false" autocomplete="off" aria-label="Search actions" />
</span>
<button class="plugin-search-clear" id="clear" type="button" title="Clear" aria-label="Clear search" hidden>
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><line x1="5" y1="5" x2="11" y2="11"/><line x1="11" y1="5" x2="5" y2="11"/></svg>
</button>
@@ -27,6 +30,7 @@
<div class="dial-count" id="count" hidden></div>
</div>
<div class="dial-list" id="list"></div>
<div class="dial-detail" id="detail" hidden></div>
</div>
</body>
</html>
File diff suppressed because it is too large Load Diff
+410 -16
View File
@@ -13,33 +13,427 @@ vm.runInContext(fs.readFileSync(__dirname + "/speeddial.js", "utf8"), ctx);
assert.equal(typeof ctx.parseId, "undefined", "opaque action ids must never be parsed");
const duplicateActions = [
{ id: "0123456789abcdef", title: "Repair", source: "Mesh Tools" },
{ id: "fedcba9876543210", title: "Repair", source: "Mesh Tools" }
{ id: "0123456789abcdef", title: "Repair", source: "Mesh Tools" },
{ id: "fedcba9876543210", title: "Repair", source: "Mesh Tools" }
];
assert.equal(
ctx.actionLabel(duplicateActions[0], duplicateActions),
"Repair from Mesh Tools (0123456789abcdef)",
"duplicate labels should use the opaque id without interpreting its contents"
ctx.actionLabel(duplicateActions[0], duplicateActions),
"Repair from Mesh Tools (0123456789abcdef)",
"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 shows the recents+pool list");
assert.equal(ctx.shouldRenderActionList(" "), false, "whitespace-only search shows the recents+pool list");
assert.equal(ctx.shouldRenderActionList("r"), true, "typing starts rendering matching actions");
// commandList: an empty query shows recents first, then every other action; a typed query filters all.
assert.deepEqual(ctx.commandList(duplicateActions, [], ""), duplicateActions,
"empty query + no recents shows the whole action pool");
assert.deepEqual(ctx.commandList(duplicateActions, [duplicateActions[0]], ""),
[duplicateActions[0], duplicateActions[1]],
"empty query shows the recent first, then the remaining actions");
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 stray leading space in a tab title is trimmed so the label shows cleanly");
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");
// The main phase is ONE pool: commands/plugins/settings are all actions, ranked by relevance
// (no group headers, no actions-vs-settings discrimination).
const pool = [
{ id: "c1", title: "Layer Height", source: "Quality", group: "Quality : Layers", input: "" },
{ id: "s1", title: "Go to layer (percent)", source: "OrcaSlicer", group: "Commands", input: "percent" },
{ id: "c2", title: "Top Surface Layers", source: "Quality", group: "Quality : Layers", input: "" }
];
assert.deepEqual(ctx.searchActions(pool, ""), pool, "an empty query returns the pool unchanged");
assert.equal(ctx.searchActions(pool, "zzz").length, 0, "a query with no match returns nothing");
// "layer" matches multiple; the exact-titled action ranks above the loosely-matching command.
assert.equal(ctx.searchActions(pool, "layer")[0].id, "c1",
"a title-exact match ranks above a partial match");
assert.equal(ctx.searchActions(pool, "layer").length >= 2, true,
"both a setting and a command match the same query in the same list");
assert.equal(ctx.searchActions(pool, "surface")[0].id, "c2",
"a later-but-precise match still ranks by relevance, not by pool type");
// A perfect match (the needle as one contiguous run) outranks a fuzzy match of the same field - and a
// contiguous GROUP/header hit ("Recent Projects") beats a scattered fuzzy TITLE hit ("Retraction Length"),
// which is what the old flat title-bonus ranking got backwards.
const perfectPool = [
{ id: "set", title: "Retraction Length", source: "Process : Quality : Retraction", group: "", input: "" },
{ id: "recent", title: "myproject.3mf", source: "/home/me/projects/myproject.3mf", group: "Recent Projects", input: "" }
];
assert.equal(ctx.searchActions(perfectPool, "recent")[0].id, "recent",
"a contiguous header/group match ranks above a scattered fuzzy title match");
// Within a perfect match, the row-name (title) outranks the header (group): the action whose TITLE
// contains the needle perfectly beats the action whose GROUP does, both being contiguous matches.
const titleFirstPool = [
{ id: "grp", title: "Delete Selected", source: "OrcaSlicer", group: "Object", input: "" },
{ id: "t", title: "Object Preview", source: "OrcaSlicer", group: "View", input: "" }
];
assert.equal(ctx.searchActions(titleFirstPool, "object")[0].id, "t",
"a perfect title match ranks above an equally-perfect group match");
// Highlighting: the needle is matched as a whole word / most-contiguous run, so "orient" lights up the
// whole word in "Auto-Orient" instead of the stray "o" of "Auto" plus "rient" (greedy-leftmost).
const orientPool = [
{ id: "ao", title: "Auto-Orient", source: "OrcaSlicer", group: "Object", input: "" }
];
ctx.searchActions(orientPool, "orient");
assert.deepEqual(ctx.matchIndex.ao.title, [[5, 11]],
"a whole-word match highlights the full word, not a scattered fuzzy pick");
// A fuzzy source-only match with a very late start still counts, even though its score is negative;
// the old `score < 0` sentinel mistook it for "no field matched" and dropped the action.
const negativePool = [
{ id: "n", title: "Unrelated", source: "o" + "x".repeat(200) + "rnt", group: "", input: "" }
];
assert.equal(ctx.searchActions(negativePool, "ornt").length, 1,
"a low-score fuzzy match is not mistaken for no match");
// A setting whose displayed title is the page row label keeps the descriptive ConfigOptionDef name as
// a search-only alias, so the old wording still finds it without being shown.
const aliasPool = [
{ id: "rev", title: "Reverse on even", full_label: "Overhang reversal", source: "Process : Quality : Overhangs", group: "", input: "" }
];
assert.deepEqual(ctx.searchActions(aliasPool, "overhang reversal").map(function (a) { return a.id; }), ["rev"],
"the descriptive full_label is searchable even though the title shows the row label");
assert.equal(ctx.matchIndex.rev.title, null,
"an alias-only match does not highlight the displayed title");
assert.deepEqual(ctx.searchActions(aliasPool, "reversal").map(function (a) { return a.id; }), ["rev"],
"a token that exists only in the full_label still matches");
// Multi-token cross-field search: each whitespace-separated word must match SOME searchable field,
// but different words may match different fields. "inner" is the title while "speed" and
// "acceleration" live in the source breadcrumb, so the query as a whole is never contiguous in one
// field - the old single-needle match found nothing for this.
const crossPool = [
{ id: "acc", title: "Inner wall", source: "Process : Speed : Acceleration", group: "", input: "" },
{ id: "spd", title: "Inner wall", source: "Process : Speed : Other layers speed", group: "", input: "" },
{ id: "other", title: "Outer wall", source: "Process : Quality : Walls", group: "", input: "" }
];
assert.deepEqual(
ctx.searchActions(crossPool, "speed acceleration inner").map(function (a) { return a.id; }),
["acc"],
"words may match different fields and every word is required"
);
assert.deepEqual(
ctx.searchActions(crossPool, "speed inner").map(function (a) { return a.id; }).sort(),
["acc", "spd"],
"a two-word title+source query matches every setting under Speed"
);
assert.deepEqual(
ctx.searchActions(crossPool, "quality inner").map(function (a) { return a.id; }),
[],
"an action is dropped when any one word matches no field"
);
// Highlighting merges the per-field ranges the tokens produced.
ctx.searchActions(crossPool, "speed acceleration inner");
assert.deepEqual(ctx.matchIndex.acc.title, [[0, 5]], "the title token highlights in the title");
assert.deepEqual(ctx.matchIndex.acc.source, [[10, 15], [18, 30]], "each path token highlights in the breadcrumb");
assert.deepEqual(ctx.queryTokens(" Speed Acceleration "), ["speed", "acceleration"],
"a query splits into normalized whitespace-separated tokens");
assert.deepEqual(ctx.queryTokens(""), [], "an empty query has no tokens");
// Inline completion: the LAST token is completed to a word in the top-ranked result, name first then
// breadcrumb. Pure - the caller appends `suffix`.
const compPool = [
{ id: "acc", title: "Inner wall", source: "Process : Speed : Acceleration", group: "", input: "" },
{ id: "smooth", title: "Smooth", source: "Process : Speed : Other layers speed", group: "", input: "" }
];
assert.equal(ctx.completionFor("speed acc", ctx.searchActions(compPool, "speed acc")).suffix, "eleration",
"the last token completes to the next word in the breadcrumb");
assert.equal(ctx.completionFor("inner w", ctx.searchActions(compPool, "inner w")).suffix, "all",
"the title is preferred over the breadcrumb for completion");
assert.equal(ctx.completionFor("inner wall", ctx.searchActions(compPool, "inner wall")), null,
"an already-complete word has nothing to add");
assert.equal(ctx.completionFor("", compPool), null, "an empty query has no completion");
assert.equal(ctx.completionFor("zzz", ctx.searchActions(compPool, "zzz")), null,
"a query with no match has no completion");
// actionCategory: a command/dynamic action's group is its category; a setting uses the top-level
// source segment; every plugin shares one header; a category-less action falls back to "Other".
assert.equal(ctx.actionCategory({ id: "c", group: "Help", source: "OrcaSlicer", kind: "command" }), "Help",
"a command's group is its category");
assert.equal(ctx.actionCategory({ id: "s", group: "", source: "Process : Quality : Layers", kind: "command" }), "Process",
"a setting's category is the top-level source segment");
assert.equal(ctx.actionCategory({ id: "s", group: "", source: "Filament : Cooling", kind: "command" }), "Filament",
"a Filament setting groups under Filament");
assert.equal(ctx.actionCategory({ id: "plugin_script_action:Foo:bar.py", group: "", source: "Gcode Optimizer", kind: "plugin" }), "Plugins",
"every plugin shares one Plugins header");
assert.equal(ctx.actionCategory({ id: "x", group: "", source: "", kind: "command" }), "Other",
"a category-less action falls back to Other");
// groupActions: bucket by category, order the groups alphabetically, keep the incoming order within
// each group (the pool arrives frecency-sorted).
const groupPool = [
{ id: "q1", title: "Q1", source: "Quality", group: "Quality", kind: "command" },
{ id: "h1", title: "H1", source: "OrcaSlicer", group: "Help", kind: "command" },
{ id: "p1", title: "P1", source: "Process : A", group: "", kind: "command" },
{ id: "h2", title: "H2", source: "OrcaSlicer", group: "Help", kind: "command" }
];
assert.deepEqual(ctx.groupActions(groupPool).map(function (a) { return a.id; }), ["h1", "h2", "p1", "q1"],
"groups are alphabetical and each group keeps its incoming order");
// commandList (the main-phase list) delegates to the ranked search for a typed query and returns
// recents + the category-grouped pool for an empty query.
const mixed = [
{ id: "cmd", title: "Slice", source: "OrcaSlicer", group: "Commands", kind: "command", input: "" },
{ id: "set", title: "Sparse Infill Density", source: "Quality", group: "Quality", kind: "command", input: "" }
];
assert.equal(ctx.commandList(mixed, [], "sli")[0].id, "cmd",
"a typed query keeps the relevance-ranked action list (best match first)");
assert.deepEqual(ctx.commandList(mixed, mixed.slice(0, 1), "").map(function (a) { return a.id; }), ["cmd", "set"],
"an empty query shows the recents first and de-dupes them out of the tail");
assert.deepEqual(ctx.commandList(mixed, [], "").map(function (a) { return a.id; }), ["cmd", "set"],
"empty query + no recents shows the whole action pool");
assert.deepEqual(ctx.commandList(mixed, [mixed[1]], "").map(function (a) { return a.id; }), ["set", "cmd"],
"the recent is hoisted above the alphabetically-ordered groups");
// commandSections: "Recent" (when recents exist) plus one header per category in the grouped list;
// a typed query or an empty list yields no headers. Uses a computed grouped list so the recents
// hoist and the category ordering are exercised together.
const sectionPool = [
{ id: "cmd", title: "Slice", source: "OrcaSlicer", group: "Commands", kind: "command" },
{ id: "help", title: "Shortcuts", source: "OrcaSlicer", group: "Help", kind: "command" },
{ id: "set", title: "Infill", source: "Quality", group: "Quality", kind: "command" }
];
const sectionList = ctx.commandList(sectionPool, [sectionPool[0]], "");
assert.deepEqual(sectionList.map(function (a) { return a.id; }), ["cmd", "help", "set"],
"recents are hoisted, then the rest is grouped alphabetically (Commands, Help, Quality)");
assert.deepEqual(ctx.commandSections(sectionList, 1, ""), { 0: "Recent", 1: "Help", 2: "Quality" },
"recents + grouped actions get one header per category");
assert.deepEqual(ctx.commandSections([sectionPool[0]], 1, ""), { 0: "Recent" },
"a list that is all recents gets only the Recent header");
assert.deepEqual(ctx.commandSections(ctx.commandList(sectionPool, [], ""), 0, ""),
{ 0: "Commands", 1: "Help", 2: "Quality" },
"with no recents the grouped list still gets category headers");
assert.equal(ctx.commandSections(sectionList, 1, "sli"), null,
"a typed query has no section headers");
assert.equal(ctx.commandSections([], 0, ""), null,
"an empty list has no section headers");
// selectedActionId: resolves the active list (recents+pool for an empty query, filtered list otherwise).
assert.equal(
ctx.selectedActionId({ zone: "list", i: 0 }, duplicateActions, [], ""),
null,
"Enter with an empty query must not resolve to an action the blank list never showed"
ctx.selectedActionId({ zone: "list", i: 0 }, ctx.commandList(duplicateActions, [], ""), []),
"0123456789abcdef",
"Enter with an empty query resolves the first action in the recents+pool list"
);
assert.equal(
ctx.selectedActionId({ zone: "list", i: 0 }, duplicateActions, [], "rep"),
"0123456789abcdef",
"a typed query resolves the list selection"
ctx.selectedActionId({ zone: "list", i: 0 }, ctx.commandList(duplicateActions, [], "rep"), []),
"0123456789abcdef",
"a typed query resolves the list selection"
);
assert.equal(
ctx.selectedActionId({ zone: "fav", i: 0 }, duplicateActions, ["fedcba9876543210"], ""),
"fedcba9876543210",
"favourites stay runnable with an empty query - the fav bar is always visible"
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",
"favourites stay runnable with an empty query - the fav bar is always visible"
);
// Fav quick-launch slots: digit 1..9 -> index 0..8, digit 0 -> index 9 (the 10th), else -1.
assert.equal(ctx.favIndexForDigit("1"), 0, "digit 1 maps to the 1st favourite slot");
assert.equal(ctx.favIndexForDigit("9"), 8, "digit 9 maps to the 9th favourite slot");
assert.equal(ctx.favIndexForDigit("0"), 9, "digit 0 maps to the 10th (last) favourite slot");
assert.equal(ctx.favIndexForDigit("x"), -1, "non-digit keys are not a slot");
assert.equal(ctx.favIndexForDigit(""), -1, "an empty key is not a slot");
// Badge label per 0-based index: 0..8 -> "1".."9", index 9 -> "0", out of range -> null.
assert.equal(ctx.favSlotForIndex(0), "1", "index 0 shows badge 1");
assert.equal(ctx.favSlotForIndex(8), "9", "index 8 shows badge 9");
assert.equal(ctx.favSlotForIndex(9), "0", "index 9 shows badge 0 (the 10th slot)");
assert.equal(ctx.favSlotForIndex(10), null, "index 10 is beyond the cap");
assert.equal(ctx.favSlotForIndex(-1), null, "negative index is not a slot");
assert.equal(ctx.K_FAV_LIMIT, 10, "the slot count matches the quick-launch cap");
// favDigitFromEvent: prefer the physical code (so macOS Option+digit still maps even though e.key
// is the composed symbol), and fall back to e.key for keyboards/synthetic events without a code.
assert.equal(ctx.favDigitFromEvent({ code: "Digit1", key: "¡" }), "1", "Digit1 wins over a composed key");
assert.equal(ctx.favDigitFromEvent({ code: "Digit0", key: "0" }), "0", "Digit0 is a physical digit");
assert.equal(ctx.favDigitFromEvent({ code: "Numpad7", key: "7" }), "7", "numpad digits count");
assert.equal(ctx.favDigitFromEvent({ code: "", key: "3" }), "3", "a missing code falls back to key");
assert.equal(ctx.favDigitFromEvent({ key: "a" }), "a", "non-digit input is passed through (maps to -1)");
assert.equal(ctx.favDigitFromEvent(null), "", "a null event yields no digit");
// nextSel: arrow-nav wrapping. Down wraps at the list bottom to the first row; Up wraps at the
// list top to the last row ONLY when there's no fav bar above (else it goes to the fav bar).
assert.deepEqual(ctx.nextSel({ zone: "list", i: 2 }, "ArrowDown", 3, 0), { zone: "list", i: 0 },
"ArrowDown at the last row wraps to the first row");
assert.deepEqual(ctx.nextSel({ zone: "list", i: 1 }, "ArrowDown", 3, 0), { zone: "list", i: 2 },
"ArrowDown in the middle advances by one");
assert.deepEqual(ctx.nextSel({ zone: "list", i: 0 }, "ArrowUp", 3, 0), { zone: "list", i: 2 },
"ArrowUp at the first row with no fav bar wraps to the last row");
assert.deepEqual(ctx.nextSel({ zone: "list", i: 0 }, "ArrowUp", 3, 2), { zone: "fav", i: 0 },
"ArrowUp at the first row with a fav bar goes to the fav bar (unchanged)");
assert.deepEqual(ctx.nextSel({ zone: "list", i: 2 }, "ArrowUp", 3, 0), { zone: "list", i: 1 },
"ArrowUp in the middle moves up by one");
assert.deepEqual(ctx.nextSel({ zone: "fav", i: 1 }, "ArrowDown", 3, 2), { zone: "list", i: 0 },
"ArrowDown from the fav bar lands on the first list row");
assert.deepEqual(ctx.nextSel({ zone: "list", i: 0 }, "ArrowDown", 1, 0), { zone: "list", i: 0 },
"a single-row list never wraps off the end");
assert.deepEqual(ctx.nextSel({ zone: "list", i: 0 }, "ArrowUp", 1, 0), { zone: "list", i: 0 },
"ArrowUp on the only row stays put");
// Windowed list reveal: how many rows must be materialized to cover `fromIndex` plus `size` more,
// clamped to the total. Drives the "render the next window on scroll / arrow-nav" append.
assert.equal(ctx.revealTarget(100, 0, 100), 100, "covers the whole list when the window reaches the end");
assert.equal(ctx.revealTarget(100, 60, 100), 100, "clamps to the total at the tail");
assert.equal(ctx.revealTarget(30, 5, 100), 30, "a short list is fully covered");
assert.equal(ctx.revealTarget(100, 5, 50), 55, "reveals exactly fromIndex + size");
assert.equal(ctx.revealTarget(100, -5, 50), 50, "negative start is clamped to the first row");
assert.equal(ctx.revealTarget(200, 50, 100), 150, "a scroll viewpoint reveals a window past the current rows");
assert.equal(ctx.revealTarget(10, 0, 50), 10, "a list shorter than one window stays fully materialized");
// spacerHeight: un-rendered rows (44px) plus un-rendered section headers (30px), never negative.
assert.equal(ctx.spacerHeight(100, 50, 0, 0), 50 * 44, "the tail rows reserve their full height");
assert.equal(ctx.spacerHeight(100, 100, 0, 0), 0, "a fully-rendered list needs no spacer");
assert.equal(ctx.spacerHeight(100, 50, 3, 1), 50 * 44 + 2 * 30, "pending section headers reserve their height too");
assert.equal(ctx.spacerHeight(10, 0, 2, 0), 10 * 44 + 2 * 30, "a short list still reserves its headers");
assert.equal(ctx.spacerHeight(0, 0, 0, 0), 0, "an empty list has no spacer");
assert.equal(ctx.spacerHeight(10, 20, 0, 5), 0, "over-rendered counters clamp to zero");
// visibleFavourites: the quick-bar drops pins whose action no longer exists (plugin unloaded,
// command removed) and collapses duplicate ids, keeping the persisted pin order.
assert.deepEqual(ctx.visibleFavourites(["a", "b", "c"], [{ id: "a" }, { id: "b" }]),
["a", "b"], "a pin with no live action is dropped from the quick-bar");
assert.deepEqual(ctx.visibleFavourites(["b", "a", "b"], [{ id: "a" }, { id: "b" }]),
["b", "a"], "duplicate pins collapse to the first occurrence");
assert.deepEqual(ctx.visibleFavourites([], [{ id: "a" }]), [], "no pins renders an empty quick-bar");
assert.deepEqual(ctx.visibleFavourites(["a"], []), [], "a stale config with no actions renders nothing");
// actionIcon: the SVG base name for a tile's pictogram, or "" when the action has none (blank tile).
assert.equal(ctx.actionIcon({ id: "x", title: "Slice", icon: "media_play" }), "media_play",
"an action's icon base name is returned verbatim");
assert.equal(ctx.actionIcon({ id: "x", title: "Go to tab...", icon: "" }), "",
"an empty icon renders a blank tile");
assert.equal(ctx.actionIcon({ id: "x", title: "Plugin action" }), "",
"a missing icon field renders a blank tile");
assert.equal(ctx.actionIcon(null), "",
"a null action (tab row) renders a blank tile");
// tileIcon: the base name a tile renders - the action's own icon when present, else the placeholder.
assert.equal(ctx.tileIcon({ id: "x", title: "Slice", icon: "media_play" }), "media_play",
"an action with an icon keeps it");
assert.equal(ctx.tileIcon({ id: "x", title: "Go to tab...", icon: "" }), "action_default",
"an empty icon falls back to the placeholder");
assert.equal(ctx.tileIcon({ id: "x", title: "Plugin action" }), "action_default",
"a missing icon falls back to the placeholder");
assert.equal(ctx.DEFAULT_ICON, "action_default", "the placeholder is the dedicated default glyph");
assert.ok(fs.existsSync(__dirname + "/../../../images/action_default.svg"),
"the placeholder SVG ships alongside the page's other icons");
// needsModeSwitch: a setting is gated only when its required mode outranks the user's current mode.
assert.equal(ctx.needsModeSwitch({ mode: "advanced" }, "simple"), true, "Advanced is gated in Simple mode");
assert.equal(ctx.needsModeSwitch({ mode: "expert" }, "simple"), true, "Expert is gated in Simple mode");
assert.equal(ctx.needsModeSwitch({ mode: "expert" }, "advanced"), true, "Expert is gated in Advanced mode");
assert.equal(ctx.needsModeSwitch({ mode: "develop" }, "expert"), true, "Developer is gated in Expert mode");
assert.equal(ctx.needsModeSwitch({ mode: "simple" }, "simple"), false, "a Simple setting is not gated");
assert.equal(ctx.needsModeSwitch({ mode: "advanced" }, "advanced"), false, "an Advanced setting is not gated in Advanced mode");
assert.equal(ctx.needsModeSwitch({ mode: "develop" }, "develop"), false, "a Developer setting is not gated in Developer mode");
assert.equal(ctx.needsModeSwitch({}, "simple"), false, "a command with no mode is never gated");
// MODE_RANK must match the C++ ConfigOptionMode order (comSimple < comAdvanced < comExpert < comDevelop).
assert.deepEqual(ctx.MODE_RANK, { simple: 0, advanced: 1, expert: 2, develop: 3 },
"mode rank matches the C++ ConfigOptionMode order");
// modeBadge: the tag text for gated settings, empty once the setting is available.
assert.equal(ctx.modeBadge({ mode: "advanced" }, "simple"), "Advanced", "Advanced badge text");
assert.equal(ctx.modeBadge({ mode: "expert" }, "simple"), "Expert", "Expert badge text");
assert.equal(ctx.modeBadge({ mode: "develop" }, "simple"), "Developer", "Developer badge text");
assert.equal(ctx.modeBadge({ mode: "advanced" }, "advanced"), "", "no badge when the mode already matches");
// modeFilterFromQuery: whole-word, case-insensitive mode keywords -> internal mode values. "developer"
// maps to the internal "develop"; "simple" is deliberately not a keyword; a prefix is not a match.
assert.deepEqual(ctx.modeFilterFromQuery("advanced"), ["advanced"], "Advanced is a mode keyword");
assert.deepEqual(ctx.modeFilterFromQuery("Expert"), ["expert"], "the keyword is case-insensitive");
assert.deepEqual(ctx.modeFilterFromQuery("developer"), ["develop"], "Developer maps to the develop value");
assert.deepEqual(ctx.modeFilterFromQuery("develop"), ["develop"], "the internal develop spelling also works");
assert.deepEqual(ctx.modeFilterFromQuery("expert retraction"), ["expert"], "a keyword is found among other text");
assert.deepEqual(ctx.modeFilterFromQuery("advanced expert"), ["advanced", "expert"], "multiple keywords are deduped in order");
assert.deepEqual(ctx.modeFilterFromQuery("advanced advanced"), ["advanced"], "a repeated keyword is deduped");
assert.deepEqual(ctx.modeFilterFromQuery("simple"), [], "Simple is not a mode keyword");
assert.deepEqual(ctx.modeFilterFromQuery("advance"), [], "a mode-word prefix is not a whole-word match");
assert.deepEqual(ctx.modeFilterFromQuery(""), [], "an empty query names no mode");
// searchActions mode union: a mode keyword keeps the normal text matches AND appends every setting
// requiring that mode. Not a filter - a Simple setting literally named "Advanced..." still shows, and
// commands (mode "simple") are never pulled in by a keyword.
const modePool = [
{ id: "a1", title: "Top Surface Layers", source: "Quality", group: "Quality : Layers", mode: "advanced" },
{ id: "a2", title: "Advanced Detection", source: "Quality", group: "Quality", mode: "simple" },
{ id: "a3", title: "Retraction Length", source: "Process", group: "Process : Quality", mode: "expert" },
{ id: "a4", title: "Slice", source: "OrcaSlicer", group: "Commands", mode: "simple" }
];
var adv = ctx.searchActions(modePool, "advanced");
assert.deepEqual(adv.map(function (a) { return a.id; }), ["a2", "a1"],
"a text match (a2) ranks above the mode-only setting (a1), and no expert/command leaks in");
var expert = ctx.searchActions(modePool, "expert");
assert.deepEqual(expert.map(function (a) { return a.id; }), ["a3"], "the expert keyword pulls in the expert setting");
assert.deepEqual(ctx.searchActions(modePool, "developer"), [], "no developer settings means no mode extras");
assert.equal(ctx.searchActions(modePool, "retraction")[0].id, "a3",
"a query with no mode keyword is unaffected by the mode union");
assert.equal(ctx.searchActions([{ id: "both", title: "Advanced", source: "Quality", group: "", mode: "advanced" }], "advanced").length,
1, "a setting that both matches text and requires the mode appears exactly once");
// actionHasWiki: the footer's wiki link/F1 path is offered only when the action carries a wiki flag.
assert.equal(ctx.actionHasWiki({ id: "x", wiki: true }), true, "a wiki-flagged setting offers the wiki action");
assert.equal(ctx.actionHasWiki({ id: "x", wiki: false }), false, "a setting without a wiki path offers nothing");
assert.equal(ctx.actionHasWiki({ id: "x" }), false, "a missing wiki field offers nothing");
assert.equal(ctx.actionHasWiki(null), false, "no action selected offers nothing");
// actionHasDetail: the footer strip appears only when the highlighted action has a description or
// wiki link; selecting a plain command hides it.
assert.equal(ctx.actionHasDetail({ id: "a", desc: "Layer height" }), true, "a description shows the footer");
assert.equal(ctx.actionHasDetail({ id: "a", wiki: true }), true, "a wiki link shows the footer");
assert.equal(ctx.actionHasDetail({ id: "a", desc: "Layer height", wiki: true }), true, "both show the footer");
assert.equal(ctx.actionHasDetail({ id: "a", desc: "" }), false, "an empty description hides the footer");
assert.equal(ctx.actionHasDetail({ id: "a", desc: "", wiki: false }), false, "empty description and false wiki hide the footer");
assert.equal(ctx.actionHasDetail({ id: "a" }), false, "an action with neither hides the footer");
assert.equal(ctx.actionHasDetail(null), false, "no selected action hides the footer");
// detailToggleVisible: the expand/collapse arrow is offered only when there is a description to
// toggle. Collapse is a global preference, so the control stays for short tooltips too.
assert.equal(ctx.detailToggleVisible({ id: "a", desc: "Layer height" }), true, "a description offers the toggle");
assert.equal(ctx.detailToggleVisible({ id: "a", desc: "" }), false, "an empty description offers no toggle");
assert.equal(ctx.detailToggleVisible({ id: "a", wiki: true }), false, "a wiki-only action has nothing to collapse");
assert.equal(ctx.detailToggleVisible({ id: "a" }), false, "an action with no description offers no toggle");
assert.equal(ctx.detailToggleVisible(null), false, "no selected action offers no toggle");
// stateFromPayload: the footer expansion is a persisted global and defaults to expanded when the
// C++ payload omits it (first run / older config).
assert.equal(ctx.stateFromPayload({}).tooltipExpanded, true, "expansion defaults to true when absent");
assert.equal(ctx.stateFromPayload({ tooltip_expanded: false }).tooltipExpanded, false, "a collapsed payload is honored");
assert.equal(ctx.stateFromPayload({ tooltip_expanded: true }).tooltipExpanded, true, "an expanded payload is honored");
console.log("ok");
+509 -185
View File
@@ -1,225 +1,549 @@
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
font-family: var(--orca-font, "Segoe UI", sans-serif);
font-size: 13px;
color: var(--text, var(--orca-fg, #1b1c1e));
background: var(--bg, var(--orca-bg, #fff));
overflow: hidden;
user-select: none;
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
}
body {
font-family: var(--orca-font, "Segoe UI", sans-serif);
font-size: 13px;
color: var(--text, var(--orca-fg, #1b1c1e));
background: var(--bg, var(--orca-bg, #fff));
overflow: hidden;
user-select: none;
}
.launcher {
display: flex;
flex-direction: column;
background: var(--panel, var(--orca-bg, #fff));
border: 1px solid var(--border, var(--orca-border, #ddd));
overflow: hidden;
/* why: no height cap here - the launcher reports its true natural height to C++, which
display: flex;
flex-direction: column;
background: var(--panel, var(--orca-bg, #fff));
border: 1px solid var(--border, var(--orca-border, #ddd));
border-radius: 7px;
overflow: hidden;
/* why: no height cap here - the launcher reports its true natural height to C++, which
sizes the popup to match (HTML is source of truth). The list's own max-height is what
bounds growth; capping the launcher would make the measurement circular. */
}
.fav-bar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
padding: 9px 10px;
border-bottom: 1px solid var(--border, var(--orca-border, #ddd));
overflow-x: auto; /* scroll horizontally once favourites overflow the row */
scrollbar-width: thin;
/* why: keep scrollIntoView (arrow-nav) from scrolling the first/last tile flush to the edge,
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
padding: 9px 10px;
border-bottom: 1px solid var(--border, var(--orca-border, #ddd));
overflow-x: auto;
/* scroll horizontally once favourites overflow the row */
scrollbar-width: thin;
/* why: keep scrollIntoView (arrow-nav) from scrolling the first/last tile flush to the edge,
which would clip its selected outline. Matches the 10px horizontal padding. */
scroll-padding-inline: 10px;
scroll-padding-inline: 10px;
}
.fav-bar[hidden] { display: none; }
.fav-bar[hidden] {
display: none;
}
/* Name of the selected favourite, above the bar; left-aligned to the tiles' 10px inset. */
.fav-eyebrow { flex: 0 0 auto; padding: 8px 10px 0; }
.fav-eyebrow {
flex: 0 0 auto;
padding: 8px 10px 0;
}
.fav-tile {
flex: 0 0 auto; /* keep tiles full-size; don't shrink to fit - scroll instead */
width: 30px;
height: 30px;
border: 0;
border-radius: 8px;
cursor: pointer;
color: hsl(var(--h) var(--speed-tile-text-s, 72%) var(--speed-tile-text-l, 38%));
font-weight: 700;
/* why: mirror .tile centering - tileCode can be 2-3 chars (e.g. "EA1") on collision, and a
bare <button> inherits 13px + UA padding, clipping the code (e.g. "AE1"). inline-flex + 12px + pad:0 fits it. */
font-size: 12px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: var(--speed-tile-bg, #f0f0f0);
border: 1px solid var(--speed-tile-border, #d8d8d8);
flex: 0 0 auto;
/* keep tiles full-size; don't shrink to fit - scroll instead */
width: 30px;
height: 30px;
border: 0;
border-radius: 8px;
cursor: pointer;
/* why: mirror .tile centering - icons/placeholder are 16px, and a bare <button> inherits
13px + UA padding, which would clip them. inline-flex + pad:0 + overflow:hidden fits them. */
font-size: 12px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: var(--speed-tile-bg, #f0f0f0);
border: 1px solid var(--speed-tile-border, #d8d8d8);
}
.fav-tile.sel { outline: 2px solid var(--main-color, var(--orca-accent, #009688)); outline-offset: 2px; }
.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);
}
/* Numbered quick-launch slot (Alt/Option+digit), top-left corner of the fav tile. */
.fav-slot {
position: absolute;
top: 1px;
left: 1px;
min-width: 11px;
height: 11px;
padding: 0 2px;
border-radius: 3px;
font-size: 8px;
font-weight: 600;
line-height: 11px;
text-align: center;
color: var(--muted, var(--orca-muted, #6b7280));
background: rgba(127, 127, 127, .22);
}
/* Transient flash banner pinned to the top of the launcher (e.g. "favourites are full"). */
.dial-flash {
flex: 0 0 auto;
padding: 4px 10px;
font-size: 11px;
color: var(--plugin-status-warn, #b45309);
background: var(--plugin-status-warn-bg, rgba(255, 193, 7, .22));
animation: dial-flash-fade 2.5s ease forwards;
}
@keyframes dial-flash-fade {
0% {
opacity: 0;
}
10% {
opacity: 1;
}
80% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.ctx-menu {
position: fixed;
z-index: 10;
min-width: 120px;
padding: 4px;
background: var(--panel, var(--orca-bg, #fff));
border: 1px solid var(--border, var(--orca-border, #ddd));
border-radius: 7px;
box-shadow: 0 4px 16px rgba(0,0,0,.18);
position: fixed;
z-index: 10;
min-width: 120px;
padding: 4px;
background: var(--panel, var(--orca-bg, #fff));
border: 1px solid var(--border, var(--orca-border, #ddd));
border-radius: 7px;
box-shadow: 0 4px 16px rgba(0, 0, 0, .18);
}
.ctx-menu[hidden] { display: none; }
.ctx-menu[hidden] {
display: none;
}
.ctx-item {
display: block;
width: 100%;
padding: 6px 10px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--text, var(--orca-fg, #1b1c1e));
font: inherit;
text-align: left;
cursor: pointer;
display: block;
width: 100%;
padding: 6px 10px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--text, var(--orca-fg, #1b1c1e));
font: inherit;
text-align: left;
cursor: pointer;
}
.ctx-item:hover { background: var(--row-hover, rgba(127,127,127,.16)); }
.ctx-item:disabled { opacity: .4; cursor: default; }
.ctx-item:disabled:hover { background: transparent; }
.dial-head { flex: 0 0 auto; padding: 8px; }
.ctx-item:hover {
background: var(--row-hover, rgba(127, 127, 127, .16));
}
.ctx-item:disabled {
opacity: .4;
cursor: default;
}
.ctx-item:disabled:hover {
background: transparent;
}
.dial-head {
flex: 0 0 auto;
padding: 8px;
}
.plugin-search {
display: flex;
align-items: center;
gap: 4px;
height: 30px;
padding: 0 8px;
background: var(--panel, var(--orca-bg, #fff));
border: 1px solid var(--border, var(--orca-border, #ddd));
border-radius: 7px;
display: flex;
align-items: center;
gap: 4px;
height: 30px;
padding: 0 8px;
background: var(--panel, var(--orca-bg, #fff));
border: 1px solid var(--border, var(--orca-border, #ddd));
border-radius: 7px;
}
.plugin-search:focus-within {
border-color: var(--main-color, var(--orca-accent, #009688));
box-shadow: 0 0 0 2px rgba(0,150,136,.25);
border-color: var(--main-color, var(--orca-accent, #009688));
box-shadow: 0 0 0 2px rgba(0, 150, 136, .25);
}
.plugin-search-icon { display: inline-flex; color: var(--muted, var(--orca-muted, #6b7280)); }
.plugin-search-icon {
display: inline-flex;
color: var(--muted, var(--orca-muted, #6b7280));
}
.plugin-search-input {
flex: 1;
min-width: 0;
background: transparent;
border: 0;
outline: 0;
color: var(--text, var(--orca-fg, #1b1c1e));
font: inherit;
flex: 1;
min-width: 0;
padding: 0;
background: transparent;
border: 0;
outline: 0;
color: var(--text, var(--orca-fg, #1b1c1e));
font: inherit;
}
/* Wraps the input so the ghost completion can sit exactly at the caret. The ghost mirrors the typed
text in an invisible run (reserving its width) followed by the muted suggested suffix. */
.plugin-search-field {
position: relative;
flex: 1;
min-width: 0;
display: flex;
align-items: center;
}
.search-ghost {
position: absolute;
inset: 0;
display: flex;
align-items: center;
overflow: hidden;
white-space: pre;
font: inherit;
pointer-events: none;
}
.search-ghost[hidden] {
display: none;
}
.search-ghost-typed {
visibility: hidden;
}
.search-ghost-suffix {
color: var(--muted, var(--orca-muted, #6b7280));
opacity: .7;
}
.plugin-search-clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
padding: 0;
border: 0;
border-radius: 50%;
color: var(--muted, var(--orca-muted, #6b7280));
cursor: pointer;
background: rgba(127,127,127,.20);
display: inline-flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
padding: 0;
border: 0;
border-radius: 50%;
color: var(--muted, var(--orca-muted, #6b7280));
cursor: pointer;
background: rgba(127, 127, 127, .20);
}
.plugin-search-clear[hidden] { display: none; }
.plugin-search-clear[hidden] {
display: none;
}
.dial-count {
padding: 4px 4px 0;
text-align: left;
font-size: 11px;
color: var(--muted, var(--orca-muted, #6b7280));
padding: 4px 4px 0;
text-align: left;
font-size: 11px;
color: var(--muted, var(--orca-muted, #6b7280));
}
.dial-count[hidden] { display: none; }
.dial-count[hidden] {
display: none;
}
.dial-list {
flex: 0 1 auto;
min-height: 0;
/* ADJUST HEIGHT HERE. The popup auto-resizes to the content (HTML is the source of truth), so
flex: 0 1 auto;
min-height: 0;
/* ADJUST HEIGHT HERE. The popup auto-resizes to the content (HTML is the source of truth), so
this list cap drives the whole window height: --rows full rows + a ~30% peek of the next row
as a "scroll for more" affordance. Bump --rows to show more rows; change 0.3 for a bigger/
smaller peek. --row-h MUST match .row min-height (44px). */
--row-h: 44px;
--rows: 5;
max-height: calc(var(--rows) * var(--row-h) + 0.3 * var(--row-h) + 4px);
overflow-y: auto;
padding: 4px 4px 6px;
scrollbar-width: thin;
--row-h: 44px;
--rows: 5;
max-height: calc(var(--rows) * var(--row-h) + 0.3 * var(--row-h) + 4px);
overflow-y: auto;
padding: 4px 4px 6px;
scrollbar-width: thin;
}
.dial-list.empty { overflow-y: hidden; }
.dial-list.empty {
overflow-y: hidden;
}
/* Section header in the empty-query list ("Recent" then one per category). Its 30px height MUST match
SECTION_H in speeddial.js, which reserves header space in the windowed-list bottom spacer. */
.dial-section {
height: 30px;
display: flex;
align-items: center;
padding: 0 8px;
font-size: 10px;
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--muted, var(--orca-muted, #6b7280));
opacity: .85;
}
/* Bottom spacer for the windowed command list: fills the un-rendered tail so the scrollbar reflects
the full match count, while only a near-viewport window of rows exists in the DOM. */
.dial-spacer-bottom {
flex: 0 0 auto;
}
.row {
display: flex;
align-items: center;
gap: 10px;
min-height: 44px;
padding: 6px 8px;
border-radius: 7px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
min-height: 44px;
padding: 6px 8px;
border-radius: 7px;
cursor: pointer;
}
.row:hover { background: var(--row-hover, rgba(127,127,127,.16)); }
.row.sel { background: var(--row-selected, rgba(0,150,136,.18)); }
.row:hover {
background: var(--row-hover, rgba(127, 127, 127, .16));
}
.row.sel {
background: var(--row-selected, rgba(0, 150, 136, .18));
}
.tile {
flex: 0 0 auto;
width: 26px;
height: 26px;
border-radius: 6px;
color: hsl(var(--h) var(--speed-tile-text-s, 72%) var(--speed-tile-text-l, 38%));
font-weight: 700;
font-size: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--speed-tile-bg, #f0f0f0);
border: 1px solid var(--speed-tile-border, #d8d8d8);
flex: 0 0 auto;
width: 26px;
height: 26px;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--speed-tile-bg, #f0f0f0);
border: 1px solid var(--speed-tile-border, #d8d8d8);
}
.row-left { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
/* Native SVG pictogram in a tile. The Orca icon grid draws 1px strokes from 0.5..14.5 of the 16
viewBox, so the visible glyph is 0..15 and, centered as-is, leaves one extra pixel on the right/
bottom (the "Save Project leans left" look). Shift by half a pixel to center the visible artwork. */
.tile-icon {
width: 16px;
height: 16px;
display: block;
pointer-events: none;
transform: translate(.5px, .5px);
}
/* Tab-strip glyphs are drawn white for the dark tab bar; recolor to the shared #949494 gray so
they read on both the light and dark tile backgrounds. */
.tile-icon.tab-mono {
filter: brightness(0) invert(.58);
}
.row-left {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
}
.row-eyebrow {
font-size: 10px;
line-height: 1.3;
color: var(--muted, var(--orca-muted, #6b7280));
opacity: .85;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 10px;
line-height: 1.3;
color: var(--muted, var(--orca-muted, #6b7280));
opacity: .85;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-line { display: flex; align-items: center; gap: 7px; min-width: 0; }
.row-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row-name mark, .row-eyebrow mark { background: var(--plugin-status-warn-bg); color: var(--plugin-status-warn); border-radius: 2px; }
.row-sc { flex: 0 0 auto; display: inline-flex; gap: 3px; }
kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: 11px;
color: var(--muted, var(--orca-muted, #6b7280));
background: rgba(127,127,127,.12);
border: 1px solid var(--border, var(--orca-border, #ddd));
border-radius: 4px;
.row-line {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
}
.star {
flex: 0 0 auto;
width: 24px;
height: 24px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--muted, var(--orca-muted, #6b7280));
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0;
.row-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.star svg { display: block; }
.row:hover .star:not(.on),
.row.sel .star:not(.on) { opacity: .5; }
.star.on { opacity: 1; color: var(--main-color, var(--orca-accent, #009688)); }
.star:hover { background: rgba(127,127,127,.18); }
.row-name mark,
.row-eyebrow mark {
background: var(--plugin-status-warn-bg);
color: var(--plugin-status-warn);
border-radius: 2px;
}
/* Mode tag on settings above the user's current mode (Advanced/Expert/Developer). */
.row-mode {
flex: 0 0 auto;
padding: 1px 6px;
font-size: 10px;
line-height: 1.4;
border-radius: 8px;
color: var(--plugin-status-warn);
background: var(--plugin-status-warn-bg);
white-space: nowrap;
}
.pin {
flex: 0 0 auto;
width: 24px;
height: 24px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--muted, var(--orca-muted, #6b7280));
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0;
}
.pin svg {
display: block;
}
.row:hover .pin:not(.on),
.row.sel .pin:not(.on) {
opacity: .5;
}
.pin.on {
opacity: 1;
color: var(--main-color, var(--orca-accent, #009688));
}
.pin:hover {
background: rgba(127, 127, 127, .18);
}
.dial-empty {
min-height: 64px;
padding: 18px 10px;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted, var(--orca-muted, #6b7280));
text-align: center;
min-height: 64px;
padding: 18px 10px;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted, var(--orca-muted, #6b7280));
text-align: center;
}
/* Footer detail strip: the selected action's description, its wiki link and the expand/collapse
arrow. Auto-sizes to the content; the description is clamped to 6 lines. */
.dial-detail {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-top: 1px solid var(--border, var(--orca-border, #ddd));
overflow: hidden;
}
.dial-detail[hidden] {
display: none;
}
.detail-desc {
flex: 1 1 auto;
min-width: 0;
font-size: 11px;
line-height: 1.4;
color: var(--muted, var(--orca-muted, #6b7280));
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 6;
overflow: hidden;
overflow-wrap: anywhere;
}
.detail-wiki {
flex: 0 0 auto;
border: 0;
padding: 0;
background: transparent;
font: inherit;
font-size: 11px;
color: var(--main-color, var(--orca-accent, #009688));
cursor: pointer;
white-space: nowrap;
}
.detail-wiki:hover {
text-decoration: underline;
}
/* Expand/collapse arrow for the tooltip. Pinned right so it stays put whether or not the
description/wiki are visible; the chevron points up (collapse) when expanded. */
.detail-toggle {
flex: 0 0 auto;
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
padding: 2px;
background: transparent;
color: var(--muted, var(--orca-muted, #6b7280));
cursor: pointer;
}
.detail-toggle:hover {
color: var(--text, var(--orca-fg, #1b1c1e));
}
.detail-toggle svg {
transition: transform .12s ease;
}
.detail-toggle[aria-expanded="true"] svg {
transform: rotate(180deg);
}
-4
View File
@@ -55,8 +55,6 @@
/* Speed dial icon tile treatment */
--speed-tile-bg: #efefef;
--speed-tile-border: #d8d8d8;
--speed-tile-text-s: 74%;
--speed-tile-text-l: 34%;
}
:root[data-orca-theme="dark"] {
@@ -95,8 +93,6 @@
/* Speed dial icon tile treatment */
--speed-tile-bg: #34343b;
--speed-tile-border: #50505a;
--speed-tile-text-s: 82%;
--speed-tile-text-l: 84%;
}
/* Re-theme the shared common.css chrome through variables (replaces dark.css's
+72
View File
@@ -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,74 @@ function Norm(ch, caseSensitive) {
return caseSensitive ? folded : folded.toLowerCase(); // case-sensitivity is the only toggle
}
// Pre-normalize a whole haystack with a length-preserving fold, so a caller can match it repeatedly
// against one cached string. One input UTF-16 code unit always maps to one output code unit, 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 += NormStable(src[i], caseSensitive);
return out;
}
// Length-preserving variant of Norm: NFD can expand a code unit (Hangul syllables become Jamo) or
// drop it (combining diacritics), and toLowerCase can expand one too (U+0130). Any of those would
// desync highlight offsets, so fall back to the original unit whenever the fold is not 1:1.
function NormStable(ch, caseSensitive) {
const folded = FoldChar(ch);
const stable = folded.length === 1 ? folded : ch;
if (caseSensitive)
return stable;
const lower = stable.toLowerCase();
return lower.length === 1 ? lower : stable;
}
// 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.
// Prefers the most-contiguous (smallest-span) occurrence over greedy-leftmost: a scattered match that
// spans a stray earlier character is worse than a tight run later, so "orient" against a normalized
// "auto-orient" returns [[5,11]] (the word) not [[3,4],[6,11]]. A fully contiguous run is the optimum
// and short-circuits early.
function FuzzyRangesNorm(haystackNorm, needleNorm) {
const t = haystackNorm || "";
const needle = needleNorm || "";
if (!needle)
return null;
const n = t.length, nl = needle.length;
let best = null; // {ranges, span, start}
for (let start = 0; start < n; start++) {
if (t[start] !== needle[0])
continue;
let qi = 0;
const ranges = [];
let lastEnd = start;
for (let i = start; i < n && qi < nl; 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]);
lastEnd = i + 1;
qi++;
}
}
if (qi !== nl)
continue;
const span = lastEnd - start;
if (!best || span < best.span || (span === best.span && start < best.start)) {
best = { ranges, span, start };
if (span === nl)
return ranges; // can't beat a fully contiguous run
}
}
return best ? best.ranges : null;
}
function EscapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+15 -1
View File
@@ -5,7 +5,7 @@ const vm = require("vm"), assert = require("assert"), fs = require("fs");
const ctx = {};
vm.createContext(ctx);
vm.runInContext(fs.readFileSync(__dirname + "/fuzzy-search.js", "utf8"), ctx);
const { FoldChar, Norm, EscapeRegExp, FuzzyRanges, WholeWordRanges } = ctx;
const { FoldChar, Norm, NormText, EscapeRegExp, FuzzyRanges, WholeWordRanges, FuzzyRangesNorm } = ctx;
// FoldChar / Norm: accents fold, case only folds when case-insensitive.
assert.equal(FoldChar("é"), "e");
@@ -34,4 +34,18 @@ assert.deepEqual(WholeWordRanges("a.b", "a", false), [[0, 1]]); // '.' is a
assert.equal(EscapeRegExp("a.b*"), "a\\.b\\*");
assert.deepEqual(WholeWordRanges("c++ tool", "c", false), [[0, 1]]); // '+' would be a regex error unescaped
// FuzzyRangesNorm: prefers the most-contiguous (smallest-span) occurrence over greedy-leftmost, so a
// stray earlier character does not steal the highlight from a later tight word.
assert.deepEqual(FuzzyRangesNorm(NormText("Auto-Orient", false), NormText("orient", false)), [[5, 11]]);
// A contiguous run inside a larger word is also preferred over a scattered greedy pick.
assert.deepEqual(FuzzyRangesNorm(NormText("AutoOriented", false), NormText("orient", false)), [[4, 10]]);
assert.equal(FuzzyRangesNorm(NormText("Measure", false), NormText("xyz", false)), null); // no subsequence
// NormText stays 1:1 with the original even when NFD expands (Hangul syllables) or drops (combining
// diacritics, U+0130) a code unit, so highlight ranges remain aligned to the original string.
assert.equal(NormText("각x", false).length, "각x".length);
assert.equal(NormText("e\u0301x", false).length, "e\u0301x".length);
assert.equal(NormText("\u0130x", false).length, "\u0130x".length);
assert.equal(FuzzyRangesNorm(NormText("각abcdeXfgh", false), NormText("X", false))[0][0], 6);
console.log("ok");
+18
View File
@@ -286,6 +286,8 @@ void AppConfig::set_defaults()
// The getter already defaults, parses and clamps; write back what it resolves to.
set(SETTING_PLUGIN_PAGES_VISIBLE_COUNT, std::to_string(get_plugin_pages_visible_count()));
set(SETTING_SPEED_DIAL_RECENT_COUNT, std::to_string(get_speed_dial_recent_count()));
if (get(SETTING_OPENGL_SHOW_FPS_OVERLAY).empty())
set_bool(SETTING_OPENGL_SHOW_FPS_OVERLAY, false);
@@ -1684,6 +1686,22 @@ int AppConfig::get_plugin_pages_visible_count() const
return std::clamp(visible_count, PLUGIN_PAGES_VISIBLE_COUNT_MIN, PLUGIN_PAGES_VISIBLE_COUNT_MAX);
}
int AppConfig::get_speed_dial_recent_count() const
{
std::string value = get(SETTING_SPEED_DIAL_RECENT_COUNT);
if (value.empty())
return SPEED_DIAL_RECENT_COUNT_DEFAULT;
int recent_count = SPEED_DIAL_RECENT_COUNT_DEFAULT;
try {
recent_count = std::stoi(value);
}
catch (...) {
return SPEED_DIAL_RECENT_COUNT_DEFAULT;
}
return std::clamp(recent_count, SPEED_DIAL_RECENT_COUNT_MIN, SPEED_DIAL_RECENT_COUNT_MAX);
}
std::vector<std::string> AppConfig::get_skipped_network_versions() const
{
std::vector<std::string> result;
+8
View File
@@ -46,6 +46,11 @@ using namespace nlohmann;
#define PLUGIN_PAGES_VISIBLE_COUNT_DEFAULT 5
#define PLUGIN_PAGES_VISIBLE_COUNT_MAX 10
#define SETTING_SPEED_DIAL_RECENT_COUNT "speed_dial_recent_count"
#define SPEED_DIAL_RECENT_COUNT_MIN 0
#define SPEED_DIAL_RECENT_COUNT_DEFAULT 5
#define SPEED_DIAL_RECENT_COUNT_MAX 10
#if defined(_WIN32) || defined(_WIN64)
#define BAMBU_NETWORK_AGENT_VERSION_LEGACY "01.10.01.09"
#else
@@ -394,6 +399,9 @@ public:
// dropdown on the last tab.
int get_plugin_pages_visible_count() const;
// Number of recently launched actions shown at the top of the Speed Dial; 0 hides them.
int get_speed_dial_recent_count() const;
std::vector<std::string> get_skipped_network_versions() const;
void add_skipped_network_version(const std::string& version);
bool is_network_version_skipped(const std::string& version) const;
+4
View File
@@ -129,6 +129,8 @@ set(SLIC3R_GUI_SOURCES
GUI/SpeedDialDialog.hpp
GUI/ActionRegistry.cpp
GUI/ActionRegistry.hpp
GUI/NativeCommands.cpp
GUI/NativeCommands.hpp
GUI/PluginsConfigDialog.cpp
GUI/PluginsConfigDialog.hpp
GUI/ProcessRunner.cpp
@@ -476,6 +478,8 @@ set(SLIC3R_GUI_SOURCES
GUI/SkipPartCanvas.hpp
GUI/Search.cpp
GUI/Search.hpp
GUI/SettingsIndex.cpp
GUI/SettingsIndex.hpp
GUI/Selection.cpp
GUI/Selection.hpp
GUI/SelectMachine.cpp
+523 -60
View File
@@ -3,20 +3,48 @@
#include "GUI.hpp"
#include "GUI_App.hpp"
#include "I18N.hpp"
#include "MainFrame.hpp"
#include "NativeCommands.hpp"
#include "Notebook.hpp"
#include "OptionsGroup.hpp"
#include "Plater.hpp"
#include "SettingsIndex.hpp"
#include "Tab.hpp"
#include "slic3r/plugin/PluginManager.hpp"
#include <libslic3r/AppConfig.hpp>
#include <libslic3r/Config.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <wx/thread.h>
#include <boost/filesystem.hpp>
#include <boost/nowide/convert.hpp>
#include <algorithm>
#include <cmath>
#include <ctime>
#include <exception>
#include <iterator>
#include <string>
#include <unordered_map>
#include <unordered_set>
namespace Slic3r { namespace GUI {
std::vector<std::string> cap_favourites(const std::vector<std::string>& ids, size_t limit)
{
std::vector<std::string> out;
out.reserve(std::min(ids.size(), limit));
for (const auto& id : ids) {
if (out.size() >= limit)
break;
if (std::find(out.begin(), out.end(), id) == out.end())
out.push_back(id);
}
return out;
}
namespace {
constexpr const char* kConfigSection = "speed_dial";
@@ -28,14 +56,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<std::string> read_string_array(const char* key)
{
@@ -53,7 +76,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 +102,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 +129,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<AppAction> make_action(const std::string& plugin_key, const std::string& capability,
const std::string& source_name)
std::unique_ptr<AppAction> 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 +140,151 @@ std::unique_ptr<AppAction> make_action(const std::string& plugin_key, const std:
return std::make_unique<PluginScriptAction>(plugin_key, capability, source_name);
}
// ---- built-in command actions (the speed dial "commands" section) ------
constexpr const char* kSettingPrefix = "orca_setting";
constexpr const char* kPlateGotoPrefix = "orca_plate_goto";
constexpr const char* kRecentProjectPrefix = "orca_recent_project";
// 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");
}
}
// Stable, non-localized mode token for the webview, which maps it to a badge ("Developer" etc.).
const char* mode_key(ConfigOptionMode mode)
{
switch (mode) {
case comAdvanced: return "advanced";
case comExpert: return "expert";
case comDevelop: return "develop";
default: return "simple";
}
}
// 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; // English category, forwarded to jump_to_option (it localizes)
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,
ConfigOptionMode mode_in)
: 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 single-phase command: activating it jumps the sidebar to the option
// (like the sidebar's own settings search), then the dial closes. run() performs the jump.
this->kind = AppActionKind::Command;
this->group = std::move(group);
this->required_mode = mode_in;
}
AppActionRunResult run(const std::string& /*param*/) const override
{
wxGetApp().sidebar().jump_to_option(opt_key, type, category);
return {AppActionRunResult::Level::Success};
}
};
// Seed one action's persisted state (favourite flag + frecency counters) from an already-parsed
// stats blob and capped favourite list. Shared by the dynamic materialisers.
void seed_from(const nlohmann::json& stats, const std::vector<std::string>& favs, const std::string& id, AppAction& a)
{
a.favourite = std::find(favs.begin(), favs.end(), id) != favs.end();
if (auto it = stats.find(id); it != stats.end() && it->is_object()) {
a.count = it->value("count", 0);
a.last = it->value("last", 0LL);
}
}
// Drop actions whose id starts with `prefix` but that were not seen in this pass (a stale materialisation).
void drop_stale(std::unordered_map<std::string, std::shared_ptr<AppAction>>& actions, const char* prefix,
const std::unordered_set<std::string>& seen)
{
for (auto it = actions.begin(); it != actions.end();) {
if (it->first.rfind(prefix, 0) == 0 && !seen.count(it->first))
it = actions.erase(it);
else
++it;
}
}
// A dynamic "Go to Plate N" action, one per live plate, rebuilt on every snapshot() (so a
// rename/move immediately shows up). id is keyed by plate index, NOT the display title, so
// renaming a plate never re-keys it - the same contract as SettingAction. A pinned "Go to
// Plate N" whose plate is deleted simply stops resolving (visibleFavourites drops dead pins).
struct PlateAction : AppAction
{
int plate_index;
static std::string id_for(int index) { return AppAction::compose_id(kPlateGotoPrefix, std::to_string(index), kOrcaSourceKey); }
PlateAction(int index, std::string title, std::string source_name)
: AppAction(AppActionId{id_for(index)}, std::move(title), kOrcaSourceKey, std::move(source_name)), plate_index(index)
{
this->kind = AppActionKind::Command;
this->group = _u8L("Plate");
}
AppActionRunResult run(const std::string& /*param*/) const override
{ return NativeCommands::run("plate_goto", std::to_string(plate_index)); }
};
// A dynamic "Open recent project <name>" action, one per recent project file, rebuilt on every
// snapshot() (like PlateAction) so the list always reflects the current recents. The id is keyed
// by the file PATH, NOT the display title - the same contract as SettingAction/PlateAction, so a
// rename of a project (or a reordered recents list) never re-keys the action. A pinned recent whose
// file is deleted simply stops resolving (visibleFavourites drops dead pins). run() loads the
// project through MainFrame::open_recent_project so the existing missing-file handling is reused.
struct RecentProjectAction : AppAction
{
std::string file_path;
static std::string id_for(const std::string& path) { return AppAction::compose_id(kRecentProjectPrefix, path, kOrcaSourceKey); }
RecentProjectAction(std::string path, std::string title, std::string source)
: AppAction(AppActionId{id_for(path)}, std::move(title), kOrcaSourceKey, std::move(source)), file_path(std::move(path))
{
this->kind = AppActionKind::Command;
this->group = _u8L("Recent Projects");
}
AppActionRunResult run(const std::string& /*param*/) const override
{
if (MainFrame* mf = wxGetApp().mainframe; mf)
mf->open_recent_project(size_t(-1), wxString::FromUTF8(file_path));
return {AppActionRunResult::Level::Success};
}
};
} // namespace
ActionRegistry::~ActionRegistry() = default;
void ActionRegistry::init()
{
assert(wxThread::IsMain());
@@ -151,18 +315,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<std::string, std::string> source_names;
@@ -173,12 +331,40 @@ 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. The catalog (and its thin AppAction adapter) lives in
// NativeCommands; the registry only stores and dispatches the result.
for (const NativeCommand& c : NativeCommands::catalog())
upsert(NativeCommands::make_action(c));
}
void ActionRegistry::relocalize_builtins()
{
assert(wxThread::IsMain());
if (!m_started)
return;
// Drop only the built-in commands; plugins and the dynamically materialised families are
// either unlocalized or rebuilt per snapshot. remove() only erases the map entry, and upsert()
// re-seeds favourites/stats from config, so key-based ids keep their pinned state.
std::vector<std::string> stale;
for (const auto& [id, action] : m_actions)
if (action->source_key() == kOrcaSourceKey && action->kind == AppActionKind::Command)
stale.push_back(id);
for (const std::string& id : stale)
remove(id);
NativeCommands::rebuild_catalog();
for (const NativeCommand& c : NativeCommands::catalog())
upsert(NativeCommands::make_action(c));
}
void ActionRegistry::refresh_source(const std::string& plugin_key, ActionChange change)
@@ -198,7 +384,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 +394,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 +418,7 @@ void ActionRegistry::upsert(std::unique_ptr<AppAction> action)
return;
seed_state(*action);
std::string id = action->id();
std::string id = action->id();
std::shared_ptr<AppAction> stored = std::move(action);
m_actions.insert_or_assign(std::move(id), std::move(stored));
}
@@ -246,11 +431,13 @@ 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());
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);
@@ -260,6 +447,14 @@ void ActionRegistry::seed_state(AppAction& a) const
}
}
void ActionRegistry::load_persisted(nlohmann::json& stats, std::vector<std::string>& favs) const
{
stats = read_section("stats", nlohmann::json::object());
if (!stats.is_object())
stats = nlohmann::json::object();
favs = favourite_ids();
}
// ---- read surface -----------------------------------------------------------
const AppAction* ActionRegistry::by_id(const std::string& id) const
@@ -269,14 +464,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<AppAction*>(by_id(id));
}
AppAction* ActionRegistry::find(const std::string& id) { return const_cast<AppAction*>(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 +478,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<AppAction> 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,22 +492,37 @@ 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;
}
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<std::string> 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.
return cap_favourites(read_string_array("favourite_actions"), kFavLimit);
}
void ActionRegistry::reorder_favourites(const std::vector<std::string>& ids)
@@ -325,16 +532,184 @@ void ActionRegistry::reorder_favourites(const std::vector<std::string>& ids)
std::vector<std::string> 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)
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
next = cap_favourites(next, kFavLimit);
write_section("favourite_actions", nlohmann::json(next));
}
void ActionRegistry::materialize_setting_actions()
{
assert(wxThread::IsMain());
// Reuse the Sidebar's live settings index: it's the only catalog whose group/category map is
// populated (Tab::add_key feeds it at build time), and it already mirrors the current
// configs/printer-technology. Use the all-modes view so the Speed Dial lists every setting,
// including those above the user's current mode, and can prompt to switch before jumping.
const std::vector<Search::Option>& options = wxGetApp().sidebar().settings_index().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;
std::vector<std::string> favs;
load_persisted(stats, favs);
std::unordered_set<std::string> seen;
for (const Search::Option& opt : options) {
// The row's live state drives both the hidden filter and the title (labels can change at
// runtime, e.g. brim_width -> "Brim ear radius"). Hidden rows are skipped, not marked seen.
Tab* tab = wxGetApp().get_tab(opt.type);
Tab::SettingRowState row;
if (tab)
row = tab->setting_row_state(opt.opt_key());
if (!row.visible)
continue;
const std::string id = SettingAction::id_for(opt.opt_key(), opt.type);
seen.insert(id);
// The page draws Line::label; the descriptive ConfigOptionDef name stays a search-only alias
// ("overhang reversal" still finds "Reverse on even").
const std::string search_label = boost::nowide::narrow(opt.label_local.empty() ? opt.label : opt.label_local);
std::string title = into_u8(Search::resolve_setting_title(from_u8(opt.display_label), row.label, row.multi));
if (title.empty())
title = search_label;
// 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 label the settings row draws; group stays empty so the source path (above) is
// the single display/search breadcrumb rather than being duplicated.
auto action = std::make_unique<SettingAction>(opt.opt_key(), opt.type, title, std::string(), opt.category,
boost::nowide::narrow(path), opt.mode);
if (title != search_label)
action->full_label = search_label;
// Tile pictogram = the icon of the setting's own group header (e.g. Advanced -> param_advanced),
// the one shown next to it in the page. Fall back to the page/category icon for groups
// without one. Keys are the English titles the GUI registers.
action->icon = opt.group_icon;
if (action->icon.empty() && !opt.category.empty() && tab) {
const auto& icons = tab->get_category_icon_map();
auto it = icons.find(wxString(opt.category));
if (it != icons.end())
action->icon = it->second;
}
// Footer description + wiki affordance; only settings whose row declared a wiki path have one.
action->tooltip = opt.tooltip;
if (!opt.wiki_path.empty())
action->help_url = into_u8(OptionsGroup::get_url(opt.wiki_path));
seed_from(stats, favs, id, *action);
auto const action_id = action->id();
auto const app_action = std::shared_ptr<AppAction>(std::move(action));
m_actions.insert_or_assign(action_id, app_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.
drop_stale(m_actions, kSettingPrefix, seen);
}
void ActionRegistry::materialize_plate_actions()
{
assert(wxThread::IsMain());
// Plates are a filament (FFF) feature: SLA has a single plate and no plate UI, and gcode-only
// mode has no editable project - so no "Go to Plate N" actions are offered there.
Plater* plater = wxTheApp ? wxGetApp().plater() : nullptr;
if (!plater || plater->printer_technology() != ptFFF || plater->only_gcode_mode()) {
// Drop any stale plate actions (e.g. the printer technology switched to SLA).
drop_stale(m_actions, kPlateGotoPrefix, {});
return;
}
// Persisted per-action state, read ONCE (mirrors materialize_setting_actions) so a relisted
// "Go to Plate N" keeps its recency/favourite when the plate is renamed - the id is index-keyed.
nlohmann::json stats;
std::vector<std::string> favs;
load_persisted(stats, favs);
const std::vector<PartPlate*>& list = plater->get_partplate_list().get_plate_list();
std::unordered_set<std::string> seen;
for (size_t i = 0; i < list.size(); ++i) {
PartPlate* plate = list[i];
if (!plate)
continue;
const std::string id = PlateAction::id_for(int(i));
seen.insert(id);
// "Go to Plate N" + " (name)" when the plate is named, matching the object-list label.
std::string title(_u8L("Go to Plate"));
title += " " + std::to_string(i + 1);
const std::string name = plate->get_plate_name();
if (!name.empty())
title += " (" + name + ")";
auto action = std::make_unique<PlateAction>(int(i), title, kOrcaSourceName);
seed_from(stats, favs, id, *action);
auto const action_id = action->id();
auto const app_action = std::shared_ptr<AppAction>(std::move(action));
m_actions.insert_or_assign(action_id, app_action);
}
// Drop plate actions whose index no longer exists (a plate was deleted / moved to the front).
drop_stale(m_actions, kPlateGotoPrefix, seen);
}
void ActionRegistry::materialize_recent_project_actions()
{
assert(wxThread::IsMain());
// Persisted per-action state, read ONCE (mirrors materialize_plate_actions) so a relisted recent
// project keeps its recency/favourite when the recents list reorders - the id is path-keyed.
nlohmann::json stats;
std::vector<std::string> favs;
load_persisted(stats, favs);
// app_config stores recents oldest-first; the palette shows newest-first.
std::vector<std::string> recents = wxGetApp().app_config->get_recent_projects();
std::reverse(recents.begin(), recents.end());
std::unordered_set<std::string> seen;
for (const std::string& path : recents) {
// Skip projects whose file is gone; the stale id is dropped below.
boost::system::error_code ec;
if (path.empty() || !boost::filesystem::exists(boost::filesystem::path(path), ec))
continue;
const std::string id = RecentProjectAction::id_for(path);
seen.insert(id);
// Title = file basename; source/eyebrow = the full path so search can match either.
boost::filesystem::path p(path);
std::string title = p.filename().string();
if (title.empty())
title = path;
auto action = std::make_unique<RecentProjectAction>(path, std::move(title), path);
seed_from(stats, favs, id, *action);
auto const action_id = action->id();
auto const app_action = std::shared_ptr<AppAction>(std::move(action));
m_actions.insert_or_assign(action_id, app_action);
}
// Drop recent-project actions whose file no longer exists / was removed from the recents list.
drop_stale(m_actions, kRecentProjectPrefix, seen);
}
bool ActionRegistry::should_ask(const std::string& id) const
{
assert(wxThread::IsMain());
@@ -351,11 +726,31 @@ void ActionRegistry::suppress_ask(const std::string& id)
write_section("ask_suppressed", nlohmann::json(arr));
}
// ---- snapshot ---------------------------------------------------------------
nlohmann::json ActionRegistry::snapshot() const
bool ActionRegistry::tooltip_expanded() const
{
assert(wxThread::IsMain());
const nlohmann::json j = read_section("tooltip_expanded", nlohmann::json(true));
return j.is_boolean() ? j.get<bool>() : true;
}
void ActionRegistry::set_tooltip_expanded(bool expanded)
{
assert(wxThread::IsMain());
write_section("tooltip_expanded", nlohmann::json(expanded));
}
// ---- snapshot ---------------------------------------------------------------
nlohmann::json ActionRegistry::snapshot()
{
assert(wxThread::IsMain());
// Settings and plates are first-class actions; make sure the current visible option set and the
// live plate list are materialised before we serialise the pool (tabs_list is built by the time
// the palette opens).
materialize_setting_actions();
materialize_plate_actions();
materialize_recent_project_actions();
std::vector<const AppAction*> sorted;
sorted.reserve(m_actions.size());
for (const auto& entry : m_actions)
@@ -374,17 +769,85 @@ 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()},
{"full_label", a->full_label},
{"source", a->source_name()},
{"group", a->group},
{"kind", a->kind == AppActionKind::Plugin ? "plugin" : "command"},
{"input", a->input},
{"icon", a->icon},
{"mode", mode_key(a->required_mode)},
{"desc", a->tooltip},
{"wiki", !a->help_url.empty()}});
};
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)}};
// reorder the favourites bar). Drop pins with no live action (an option hidden by the
// current mode, an unloaded plugin, a gone plate/project) and persist the pruned list, so
// invisible pins can't silently fill the quick-launch cap. Order is preserved.
std::vector<std::string> favs = favourite_ids();
std::vector<std::string> live_favs;
live_favs.reserve(favs.size());
for (const auto& id : favs)
if (m_actions.count(id))
live_favs.push_back(id);
if (live_favs.size() != favs.size())
write_section("favourite_actions", nlohmann::json(live_favs));
nlohmann::json favourites(live_favs);
// Recent = the last-N launched actions by recency (only actions with a run history). N is a
// user preference; 0 hides recents without affecting the frecency order below.
const size_t recent_limit = size_t(wxGetApp().app_config->get_speed_dial_recent_count());
std::vector<const AppAction*> recent;
for (const auto& entry : m_actions)
if (entry.second->last > 0)
recent.push_back(entry.second.get());
std::sort(recent.begin(), recent.end(), [](const AppAction* a, const AppAction* b) {
if (a->last != b->last)
return a->last > b->last;
return a->id() < b->id();
});
if (recent.size() > recent_limit)
recent.resize(recent_limit);
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)},
{"user_mode", mode_key(wxGetApp().get_mode())},
{"tooltip_expanded", tooltip_expanded()}};
}
// ---- 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->GetPageLabel(i).ToStdString()},
{"icon", notebook->GetPageIcon(i)}});
}
return out;
}
}} // namespace Slic3r::GUI
+137 -25
View File
@@ -2,10 +2,13 @@
#include <nlohmann/json.hpp>
#include <libslic3r/Config.hpp>
#include <wx/string.h>
#include <wx/thread.h>
#include <cassert>
#include <cstddef>
#include <memory>
#include <string>
#include <string_view>
@@ -18,14 +21,25 @@ 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 run-confirm gate (plugins ask, commands don't).
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
{
enum class Level { Success, Info, Error, Busy };
Level level = Level::Info;
wxString message; // empty = "nothing worth showing"
Level level = Level::Info;
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.
@@ -52,12 +66,35 @@ struct AppAction
}
// seeded from AppConfig for the snapshot / sort:
bool favourite = false;
int count = 0;
long long last = 0; // epoch seconds
bool favourite = false;
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: "percent" (jump to layer by a 0-100
// value) or "tab" (pick a notebook tab). Empty = run immediately on activation.
std::string input;
// Tile pictogram: SVG base name under resources/images; empty renders a blank tile (commands
// without a GUI icon, plugins). Set from NativeCommands / the setting's category icon.
std::string icon;
// Settings mode required to edit this action (SettingActions only). The palette prompts before
// running an action whose mode is above the user's current mode. comSimple for everything else.
ConfigOptionMode required_mode = comSimple;
// Description shown in the Speed Dial's footer strip (SettingActions: the localized tooltip).
std::string tooltip;
// Search-only alias when the title differs from the descriptive ConfigOptionDef name (e.g. title
// "Reverse on even", full_label "Overhang reversal"). Empty when the two agree.
std::string full_label;
// Full wiki URL, when the action has one (SettingActions whose row declared a label_path).
std::string help_url;
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
@@ -65,10 +102,17 @@ protected:
// why: source_key (not the display name) carries identity, so renaming the source's
// display name leaves the id - and its persisted stats/favourite - intact.
AppAction(std::string_view prefix, std::string title, std::string source_key, std::string source_name)
: m_id(compose_id(prefix, title, source_key)),
m_title(std::move(title)),
m_source_key(std::move(source_key)),
m_source_name(std::move(source_name)) {}
: m_id(compose_id(prefix, title, source_key))
, m_title(std::move(title))
, 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; // <prefix>:<title>:<source_key> - stable identity + AppConfig key
@@ -77,26 +121,53 @@ private:
std::string m_source_name; // display name of the action's source
};
// Stable identity/display name of the built-in ("OrcaSlicer") action source. Shared by the native
// command catalog and the dynamically materialised setting/plate/recent actions so every built-in
// action re-keys together.
inline constexpr const char* kOrcaSourceKey = "orca";
inline constexpr const char* kOrcaSourceName = "OrcaSlicer";
// True when a setting at `setting_mode` cannot be edited in `current_mode` and the UI must switch
// first. Developer settings are handled as a separate prompt by the Speed Dial.
inline bool requires_mode_switch(ConfigOptionMode setting_mode, ConfigOptionMode current_mode)
{
return setting_mode > current_mode;
}
// Cap + dedupe a persisted favourite-id list, preserving first-occurrence order. A stale or
// hand-edited config must never grow the quick-launch bar past `limit`, and a duplicated id must collapse to its first pin.
std::vector<std::string> cap_favourites(const std::vector<std::string>& ids, size_t limit);
// Self-contained sink and single owner of runnable actions for the app session.
//
// Workflow:
// 1. init() (once, UI thread) subscribes to the plugin loader and enumerates the
// current script capabilities into actions.
// 1. init() (once, UI thread) subscribes to the plugin loader and enumerates the current script
// capabilities into actions, then materialises the static built-ins from the NativeCommands catalog.
// 2. Loader load/unload callbacks route through refresh_source()/refresh_capability(),
// which upsert()/remove() actions. The registry keeps the only action list and
// restores persisted user state as actions arrive.
// 3. Consumers use by_id(), snapshot(), and run() without knowing the source.
// 3. Dynamic built-in families (settings, plates, recent projects) are re-materialised at the top of
// snapshot(), because their membership follows live state (the current configs, plate list, recents).
// 4. Consumers use by_id(), snapshot(), and run() without knowing the source.
//
// note: there is exactly one source (script plugins), so it lives inline here rather
// than behind a polymorphic source interface.
// note: the static catalog lives in NativeCommands; the registry owns the pool, persistence and
// dispatch, and materialises the dynamic families inline rather than behind a source interface.
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.
void init();
// Rebuilds the built-in command actions in the current UI locale. The command catalog copies
// translated titles/groups at construction, so after a live language switch the stored titles
// are stale until this runs. Ids are key-based and upsert re-seeds persisted state, so
// favourites/run history survive. UI thread only. No-op before init().
void relocalize_builtins();
// Takes ownership, seeds persisted state, then inserts the action or replaces
// the action with the same id. A null action is ignored.
void upsert(std::unique_ptr<AppAction> action);
@@ -105,31 +176,72 @@ public:
void remove(const std::string& id);
// Always-clean read surface. UI thread only.
const AppAction* by_id(const std::string& id) const;
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); // runs + bumps stats
void set_favourite(const std::string& id, bool on);
void reorder_favourites(const std::vector<std::string>& ids); // persist a new bar order
AppActionRunResult run(const std::string& id, const std::string& param = {}); // runs + bumps stats
// 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:[...]}.
nlohmann::json snapshot() const;
// Footer expand/collapse preference. Global (applies to every action) and persisted; absent
// means expanded, so a fresh config picks the richer default with no migration.
bool tooltip_expanded() const;
void set_tooltip_expanded(bool expanded);
// Flat, frecency-sorted snapshot for the webview:
// {actions:[...], favourites:[...], recent:[...]} (recent = last-N launched by recency).
nlohmann::json snapshot();
// "Go to tab..." Speed Dial helper: enumerate the MainFrame notebook's current pages
// as [{id,title,icon},...], using the page's real label (not the compact-blanked button text).
// Live by construction - built-in tabs (Home/Prepare/Preview/Device/Project/Calibration) and
// plugin tabs (plugin.<key>.<name>) are all Notebook pages, so a page appears/disappears with
// the notebook. Plugin tabs hidden in the overflow menu (many plugins) aren't separate pages and
// are not listed. Call on the UI thread; null-safe.
nlohmann::json tab_options() const;
private:
void seed_state(AppAction& a) const; // favourite/stats from config
AppAction* find(const std::string& id);
void seed_state(AppAction& a) const; // favourite/stats from config
AppAction* find(const std::string& id);
// Read the persisted stats blob + capped favourite list once for a materialisation pass.
void load_persisted(nlohmann::json& stats, std::vector<std::string>& favs) const;
// (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();
// (Re)materialise one "Go to Plate N" action per live plate, so the palette lists every plate
// directly on each spawn (no second-phase picker). FFF-editor only; SLA/gcode modes have no
// plate UI, so nothing is materialised and stale ids are dropped. Called at the top of snapshot().
void materialize_plate_actions();
// (Re)materialise one "Open recent project <name>" action per recent project file, so the
// palette lists every recent project and can load it by clicking. Keyed by file path (stable);
// files that no longer exist are skipped and their stale ids dropped. Called at the top of snapshot().
void materialize_recent_project_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);
void refresh_capability(const std::string& plugin_key, const std::string& capability, ActionChange change);
bool m_started = false; // init() runs exactly once; guards double-subscription
std::unordered_map<std::string, std::shared_ptr<AppAction>> m_actions; // UI-thread confined; no lock
bool m_started = false; // init() runs exactly once; guards double-subscription
std::unordered_map<std::string, std::shared_ptr<AppAction>> m_actions; // UI-thread confined; no lock
};
}} // namespace Slic3r::GUI
-6
View File
@@ -1042,7 +1042,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<int>);
wxDEFINE_EVENT(EVT_GLCANVAS_INSTANCE_MOVED, SimpleEvent);
wxDEFINE_EVENT(EVT_GLCANVAS_INSTANCE_ROTATED, SimpleEvent);
@@ -3582,11 +3581,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':
{
-1
View File
@@ -169,7 +169,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<int>); // data: +1 => increase, -1 => decrease
wxDECLARE_EVENT(EVT_GLCANVAS_INSTANCE_MOVED, SimpleEvent);
wxDECLARE_EVENT(EVT_GLCANVAS_FORCE_UPDATE, SimpleEvent);
+88
View File
@@ -2624,6 +2624,10 @@ void GUI_App::init_app_config()
}
#endif // _WIN32
}
// Speed Dial opens on a bare Space from any page by default. Seed the flag so Preferences and the
// MainFrame shortcut read the same value; an existing config (true or false) is left untouched.
if (app_config->get("enable_speed_dial").empty())
app_config->set_bool("enable_speed_dial", true);
set_logging_level(Slic3r::level_string_to_boost(app_config->get("log_severity_level")));
}
@@ -4603,6 +4607,12 @@ void GUI_App::recreate_GUI(const wxString &msg_name)
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "recreate_GUI enter";
m_is_recreating_gui = true;
// The palette injects its translated strings once, at creation; drop the cached dialog so the
// next open rebuilds it in the current locale (and can't outlive the old mainframe).
if (m_speed_dial_dialog) {
m_speed_dial_dialog->Destroy();
m_speed_dial_dialog = nullptr;
}
mainframe->shutdown();
ProgressDialog dlg(msg_name, msg_name, 100, nullptr, wxPD_AUTO_HIDE);
@@ -8171,6 +8181,22 @@ void GUI_App::save_mode(const /*ConfigOptionMode*/int mode)
update_mode();
}
void GUI_App::set_mode(ConfigOptionMode mode)
{
const bool was_developer = app_config->get_bool("developer_mode");
if (was_developer)
app_config->set_bool("developer_mode", false);
save_mode(mode);
if (was_developer)
app_config->save();
}
void GUI_App::enable_developer_mode()
{
app_config->set_bool("developer_mode", true);
update_mode();
}
// Update view mode according to selected menu
void GUI_App::update_mode()
{
@@ -8319,6 +8345,65 @@ void GUI_App::open_plugins_dialog(size_t open_on_tab, const std::string& highlig
}
}
void GUI_App::refresh_plugins()
{
// The metadata refresh blocks on disc discovery and a cloud round-trip, so run it on a worker
// and report completion through the notification manager -- the speed dial needs no dialog.
std::thread([]() {
wxString error;
try {
refresh_plugin_metadata_blocking(/*fetch_cloud=*/true);
} catch (const std::exception& ex) {
error = from_u8(ex.what());
} catch (...) {
error = "Unknown error"; // plain literal: wx translation isn't safe off the UI thread
}
if (!wxTheApp)
return;
wxTheApp->CallAfter([error]() {
if (wxGetApp().is_closing())
return;
Plater* plater = wxGetApp().plater();
if (plater == nullptr)
return;
if (error.IsEmpty())
plater->get_notification_manager()->push_notification(
NotificationType::CustomNotification,
NotificationManager::NotificationLevel::RegularNotificationLevel,
into_u8(_L("Plugins refreshed.")));
else
plater->get_notification_manager()->push_notification(
NotificationType::CustomNotification,
NotificationManager::NotificationLevel::ErrorNotificationLevel,
into_u8(wxString::Format(_L("Failed to refresh plugins: %s"), error)));
});
}).detach();
}
void GUI_App::install_local_plugin()
{
if (mainframe == nullptr)
return;
wxFileDialog dialog(mainframe, _L("Select plugin package"), wxEmptyString, wxEmptyString, _L("Plugin files (*.py;*.whl)|*.py;*.whl"),
wxFD_OPEN | wxFD_FILE_MUST_EXIST);
if (dialog.ShowModal() != wxID_OK)
return;
wxString message;
const bool ok = install_local_plugin_package(boost::filesystem::path(dialog.GetPath().ToUTF8().data()), mainframe, message);
if (message.IsEmpty())
return; // user cancelled the overwrite prompt
Plater* plater = this->plater();
if (plater == nullptr)
return;
plater->get_notification_manager()->push_notification(
NotificationType::CustomNotification,
ok ? NotificationManager::NotificationLevel::RegularNotificationLevel : NotificationManager::NotificationLevel::ErrorNotificationLevel,
into_u8(message));
}
void GUI_App::open_terminal_dialog()
{
// Reached from the plugins dialog's webview ("open_terminal" command), i.e. from
@@ -8444,6 +8529,9 @@ void GUI_App::open_preferences(size_t open_on_tab, const std::string& highlight_
this->plater_->get_current_canvas3D()->force_set_focus();
return;
}
// Built-in Speed Dial command titles are copied from the catalog at init and don't follow a
// live locale switch; rebuild them in the new language before the GUI (and palette) rebuilds.
m_action_registry.relocalize_builtins();
}
if (need_recreate_gui)
+8
View File
@@ -599,6 +599,11 @@ public:
std::string get_saved_mode_str();
std::string get_mode_str();
void save_mode(const /*ConfigOptionMode*/int mode) ;
// Switch to `mode` from the Speed Dial: a developer-mode override hides the saved mode
// (get_mode returns comDevelop), so clear it first and persist the choice.
void set_mode(ConfigOptionMode mode);
// Turn the developer-mode override on and refresh the UI (used before jumping to a Developer setting).
void enable_developer_mode();
void update_mode();
void update_internal_development();
void show_ip_address_enter_dialog(wxString title = wxEmptyString);
@@ -637,6 +642,9 @@ public:
void open_preferences(size_t open_on_tab = 0, const std::string& highlight_option = std::string());
void open_presetbundledialog(size_t open_on_tab = 0, const std::string& highlight_option = std::string());
void open_plugins_dialog(size_t open_on_tab = 0, const std::string& highlight_option = std::string());
// Dialog-free plugin actions used by the speed dial: they never require the Plugins dialog to be open.
void refresh_plugins();
void install_local_plugin();
void open_terminal_dialog();
void open_speed_dial();
ActionRegistry& action_registry() { return m_action_registry; }
+116 -98
View File
@@ -578,113 +578,131 @@ wxMenu* MenuFactory::append_submenu_add_generic(wxMenu* menu, ModelVolumeType ty
return sub_menu;
}
// Orca: handy models shipped under <resources>/handy_models. Defining everything in one table keeps
// the menu label, the files to load and the per-model behavior in a single place. Labels are wrapped
// in L() so they are picked up for translation. Shared with the command palette.
const std::vector<MenuFactory::HandyModel>& MenuFactory::handy_models()
{
static const std::vector<HandyModel> models = {
{"orca_cube", L("Orca Cube"), {"OrcaCube_v2.drc", "OrcaPlug_v2.drc"}, true},
{"orcasliced_combo", L("OrcaSliced Combo"), {"OrcaSliced.3mf", "OrcaCube_v2.drc", "OrcaPlug_v2.drc"}, true},
{"orca_badge", L("Orca Badge"), {"OrcaBadge.3mf"}},
{"orca_tolerance_test", L("Orca Tolerance Test"), {"OrcaToleranceTest.drc"}},
{"3dbenchy", L("3DBenchy"), {"3DBenchy.drc"}},
{"cali_cat", L("Cali Cat"), {"calicat.drc"}},
{"autodesk_fdm_test", L("Autodesk FDM Test"), {"ksr_fdmtest_v4.drc"}},
{"voron_cube", L("Voron Cube"), {"Voron_Design_Cube_v7.drc"}},
{"stanford_bunny", L("Stanford Bunny"), {"Stanford_Bunny.drc"}},
{"orca_string_hell", L("Orca String Hell"), {"Orca_stringhell.drc"}, false, true},
};
return models;
}
void MenuFactory::load_handy_model(std::size_t index)
{
const std::vector<HandyModel>& models = handy_models();
if (index >= models.size())
return;
const HandyModel& model = models[index];
std::vector<boost::filesystem::path> input_files;
input_files.reserve(model.file_names.size());
for (const auto& file_name : model.file_names)
input_files.push_back((boost::filesystem::path(Slic3r::resources_dir()) / "handy_models" / file_name));
Plater* pl = plater();
if (!pl)
return;
pl->load_files(input_files, LoadStrategy::LoadModel);
if (model.arrange_after_import) {
pl->set_prepare_state(Job::PREPARE_STATE_MENU);
pl->arrange();
}
// Suggest to change settings for stringhell
// This serves as mini tutorial for new users
if (model.is_stringhell) {
wxGetApp().CallAfter([=] {
DynamicPrintConfig* m_config = &wxGetApp().preset_bundle->prints.get_edited_preset().config;
bool is_only_one_wall_top = m_config->opt_bool("only_one_wall_top");
auto min_width_top_surface = m_config->option<ConfigOptionFloatOrPercent>("min_width_top_surface")->value;
if (is_only_one_wall_top && min_width_top_surface > 0) {
wxString msg_text = _L("This model features text embossment on the top surface. For optimal results, it is "
"advisable to set the 'One Wall Threshold (min_width_top_surface)' "
"to 0 for the 'Only One Wall on Top Surfaces' to work best.\n"
"Yes - Change these settings automatically\n"
"No - Do not change these settings for me");
MessageDialog dialog(wxGetApp().plater(), msg_text, _L("Suggestion"), wxICON_WARNING | wxYES | wxNO);
if (dialog.ShowModal() == wxID_YES) {
m_config->set_key_value("min_width_top_surface", new ConfigOptionFloatOrPercent(0, false));
wxGetApp().get_tab(Preset::TYPE_PRINT)->update_dirty();
wxGetApp().get_tab(Preset::TYPE_PRINT)->reload_config();
}
wxGetApp().plater()->update();
}
});
}
}
// Orca: add submenu for adding handy models
wxMenu* MenuFactory::append_submenu_add_handy_model(wxMenu* menu, ModelVolumeType type) {
auto sub_menu = new wxMenu;
// Orca: handy models shipped under <resources>/handy_models. Defining everything in one table
// keeps the menu label, the files to load and the per-model behavior in a single place and
// avoids repeating the label strings (and the value-vs-pointer comparison pitfalls that come
// with that). Labels are wrapped in L() so they are picked up for translation.
struct HandyModel
{
const char* label;
std::vector<std::string> file_names;
bool arrange_after_import = false;
bool is_stringhell = false;
};
static const std::vector<HandyModel> handy_models = {
{L("Orca Cube"), {"OrcaCube_v2.drc", "OrcaPlug_v2.drc"}, true},
{L("OrcaSliced Combo"), {"OrcaSliced.3mf", "OrcaCube_v2.drc", "OrcaPlug_v2.drc"}, true},
{L("Orca Badge"), {"OrcaBadge.3mf"}},
{L("Orca Tolerance Test"), {"OrcaToleranceTest.drc"}},
{L("3DBenchy"), {"3DBenchy.drc"}},
{L("Cali Cat"), {"calicat.drc"}},
{L("Autodesk FDM Test"), {"ksr_fdmtest_v4.drc"}},
{L("Voron Cube"), {"Voron_Design_Cube_v7.drc"}},
{L("Stanford Bunny"), {"Stanford_Bunny.drc"}},
{L("Orca String Hell"), {"Orca_stringhell.drc"}, false, true},
};
for (const auto& model : handy_models) {
append_menu_item(
sub_menu, wxID_ANY, _(model.label), "",
[&model](wxCommandEvent&) {
std::vector<boost::filesystem::path> input_files;
input_files.reserve(model.file_names.size());
for (const auto& file_name : model.file_names)
input_files.push_back((boost::filesystem::path(Slic3r::resources_dir()) / "handy_models" / file_name));
plater()->load_files(input_files, LoadStrategy::LoadModel);
if (model.arrange_after_import) {
plater()->set_prepare_state(Job::PREPARE_STATE_MENU);
plater()->arrange();
}
// Suggest to change settings for stringhell
// This serves as mini tutorial for new users
if (model.is_stringhell) {
wxGetApp().CallAfter([=] {
DynamicPrintConfig* m_config = &wxGetApp().preset_bundle->prints.get_edited_preset().config;
bool is_only_one_wall_top = m_config->opt_bool("only_one_wall_top");
auto min_width_top_surface = m_config->option<ConfigOptionFloatOrPercent>("min_width_top_surface")->value;
if (is_only_one_wall_top && min_width_top_surface > 0) {
wxString msg_text = _L("This model features text embossment on the top surface. For optimal results, it is "
"advisable to set the 'One Wall Threshold (min_width_top_surface)' "
"to 0 for the 'Only One Wall on Top Surfaces' to work best.\n"
"Yes - Change these settings automatically\n"
"No - Do not change these settings for me");
MessageDialog dialog(wxGetApp().plater(), msg_text, _L("Suggestion"), wxICON_WARNING | wxYES | wxNO);
if (dialog.ShowModal() == wxID_YES) {
m_config->set_key_value("min_width_top_surface", new ConfigOptionFloatOrPercent(0, false));
wxGetApp().get_tab(Preset::TYPE_PRINT)->update_dirty();
wxGetApp().get_tab(Preset::TYPE_PRINT)->reload_config();
}
wxGetApp().plater()->update();
}
});
}
},
"", menu);
const std::vector<HandyModel>& models = handy_models();
for (std::size_t i = 0; i < models.size(); ++i) {
append_menu_item(sub_menu, wxID_ANY, _(models[i].label), "",
[i](wxCommandEvent&) { MenuFactory::load_handy_model(i); }, "", menu);
}
return sub_menu;
}
// Create a Text/SVG volume through the matching gizmo. `type == INVALID` means "create a new object".
// Shared by the add menu and the command palette.
static void add_volume_with_gizmo(GLGizmosManager::EType gizmo_type, ModelVolumeType type)
{
Plater* pl = plater();
if (!pl)
return;
const GLCanvas3D* canvas = pl->canvas3D();
if (!canvas)
return;
GLGizmoBase* gizmo_base = canvas->get_gizmos_manager().get_gizmo(gizmo_type);
if (!gizmo_base)
return;
ModelVolumeType volume_type = type;
// no selected object means create new object
if (volume_type == ModelVolumeType::INVALID)
volume_type = ModelVolumeType::MODEL_PART;
auto screen_position = canvas->get_popup_menu_position();
if (gizmo_type == GLGizmosManager::Emboss) {
auto* emboss = dynamic_cast<GLGizmoEmboss*>(gizmo_base);
if (emboss == nullptr)
return;
if (screen_position.has_value())
emboss->create_volume(volume_type, *screen_position);
else
emboss->create_volume(volume_type);
} else if (gizmo_type == GLGizmosManager::Svg) {
auto* svg = dynamic_cast<GLGizmoSVG*>(gizmo_base);
if (svg == nullptr)
return;
if (screen_position.has_value())
svg->create_volume(volume_type, *screen_position);
else
svg->create_volume(volume_type);
}
}
void MenuFactory::add_text_volume(ModelVolumeType type) { add_volume_with_gizmo(GLGizmosManager::Emboss, type); }
void MenuFactory::add_svg_volume(ModelVolumeType type) { add_volume_with_gizmo(GLGizmosManager::Svg, type); }
static void append_menu_itemm_add_(const wxString& name, GLGizmosManager::EType gizmo_type, wxMenu *menu, ModelVolumeType type, bool is_submenu_item) {
auto add_ = [type, gizmo_type](const wxCommandEvent & /*unnamed*/) {
const GLCanvas3D *canvas = plater()->canvas3D();
const GLGizmosManager &mng = canvas->get_gizmos_manager();
GLGizmoBase *gizmo_base = mng.get_gizmo(gizmo_type);
ModelVolumeType volume_type = type;
// no selected object means create new object
if (volume_type == ModelVolumeType::INVALID)
volume_type = ModelVolumeType::MODEL_PART;
auto screen_position = canvas->get_popup_menu_position();
if (gizmo_type == GLGizmosManager::Emboss) {
auto emboss = dynamic_cast<GLGizmoEmboss *>(gizmo_base);
assert(emboss != nullptr);
if (emboss == nullptr) return;
if (screen_position.has_value()) {
emboss->create_volume(volume_type, *screen_position);
} else {
emboss->create_volume(volume_type);
}
} else if (gizmo_type == GLGizmosManager::Svg) {
auto svg = dynamic_cast<GLGizmoSVG *>(gizmo_base);
assert(svg != nullptr);
if (svg == nullptr) return;
if (screen_position.has_value()) {
svg->create_volume(volume_type, *screen_position);
} else {
svg->create_volume(volume_type);
}
}
};
auto add_ = [type, gizmo_type](const wxCommandEvent & /*unnamed*/) { add_volume_with_gizmo(gizmo_type, type); };
if (type == ModelVolumeType::MODEL_PART || type == ModelVolumeType::NEGATIVE_VOLUME || type == ModelVolumeType::PARAMETER_MODIFIER ||
type == ModelVolumeType::INVALID // cannot use gizmo without selected object
+18
View File
@@ -4,6 +4,7 @@
#include <map>
#include <vector>
#include <array>
#include <cstddef>
#include <wx/bitmap.h>
@@ -51,6 +52,23 @@ public:
static std::vector<wxBitmap> get_text_volume_bitmaps();
static std::vector<wxBitmap> get_svg_volume_bitmaps();
// Orca: handy models shipped under <resources>/handy_models. The menu and the command palette
// share this table so the model list and its per-model behavior live in one place.
struct HandyModel
{
const char* key;
const char* label;
std::vector<std::string> file_names;
bool arrange_after_import = false;
bool is_stringhell = false;
};
static const std::vector<HandyModel>& handy_models();
static void load_handy_model(std::size_t index);
// Add a Text/SVG volume through the Emboss/SVG gizmo. Shared by the add menu and the palette.
static void add_text_volume(ModelVolumeType type);
static void add_svg_volume(ModelVolumeType type);
MenuFactory();
~MenuFactory() = default;
+2
View File
@@ -478,6 +478,8 @@ int get_dpi_for_window(const wxWindow *window);
#ifdef __WXOSX__
void dataview_remove_insets(wxDataViewCtrl* dv);
void staticbox_remove_margin(wxStaticBox* sb);
// Clip a top-level window (and its webview) to a rounded rect with a native layer.
void set_window_corner_radius(wxWindow* win, int radius);
#endif
#ifdef __WXGTK__
+17
View File
@@ -1,5 +1,6 @@
#include <unistd.h>
#include <sys/sysctl.h>
#import <Cocoa/Cocoa.h>
#import <wx/osx/cocoa/dataview.h>
#import "GUI_Utils.hpp"
@@ -21,6 +22,22 @@ void staticbox_remove_margin(wxStaticBox* sb) {
[nativeBox setBorderWidth:0];
}
// wxOSX SetShape only clears the window background; it cannot clip to a region. Clipping the
// window's view layer to a rounded rect is what actually rounds the opaque webview inside.
void set_window_corner_radius(wxWindow* win, int radius) {
if (!win)
return;
NSView* view = (NSView*)win->GetHandle();
if (!view)
return;
NSWindow* window = [view window];
[window setOpaque:NO];
[window setBackgroundColor:[NSColor clearColor]];
[view setWantsLayer:YES];
[[view layer] setCornerRadius:radius];
[[view layer] setMasksToBounds:YES];
}
bool is_debugger_present()
// Returns true if the current process is being debugged (either
// running under the debugger or has a debugger attached post facto).
+3 -2
View File
@@ -198,6 +198,9 @@ void KBShortcutsDialog::fill_shortcuts()
// Switch table page
{ ctrl + L("Tab"), L("Switch table page")},
// Open speed dial
{ L_CONTEXT("Space", "Keyboard Shortcut"), L("Open speed dial") },
{ alt + "1..9,0", L("Run a Speed Dial favourite (while the Speed Dial is open)") },
//DEL
#ifdef __APPLE__
{"fn+⌫", L("Delete Selected")},
@@ -268,8 +271,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 });
+144 -93
View File
@@ -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>
@@ -46,7 +47,9 @@
// BBS
#include "PartPlate.hpp"
#include "Preferences.hpp"
#include "Widgets/Button.hpp"
#include "Widgets/ProgressDialog.hpp"
#include "Widgets/StaticBox.hpp"
#include "BindDialog.hpp"
#include "../Utils/MacDarkMode.hpp"
#include "../Utils/NetworkAgentFactory.hpp"
@@ -107,6 +110,31 @@ enum class ERescaleTarget
SettingsDialog
};
namespace {
// Space opens the speed dial, but it is the activation key for buttons, checkboxes and other
// controls. CHAR_HOOK runs before the focused child, so only take Space when the focused window has
// no keyboard-activation meaning of its own. Canvases (GLCanvas3D) and panels are not controls and
// fall through to "open"; the Notebook itself does too, so Space still opens the dial on any page.
bool focus_keeps_space(wxWindow* focus)
{
if (!focus)
return false;
if (dynamic_cast<wxTextEntryBase*>(focus))
return true; // typing a space into a text field
if (dynamic_cast<wxWebView*>(focus))
return true; // web content scrolls and hosts its own text fields
if (dynamic_cast<::Button*>(focus))
return true; // custom button: Space clicks it (it is a wxWindow, not a wxControl)
if (dynamic_cast<StaticBox*>(focus))
return true; // custom composites (ComboBox, SpinInput, ...) activate with Space and are wxWindow
if (dynamic_cast<wxControl*>(focus) && !dynamic_cast<Notebook*>(focus))
return true; // stock button/checkbox/choice/list/etc. keep Space
return false;
}
} // namespace
#ifdef __WXGTK__
// A thin transparent panel placed at a window edge to handle resize.
// Works regardless of underlying content (GLCanvas3D, wxWebView, etc.)
@@ -702,6 +730,22 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_
}
return;}
#endif
// 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 the focused window
// doesn't use Space to activate itself (buttons, checkboxes, list/choice controls, text fields),
// so a bare Space there still clicks/toggles instead of being hijacked. Gated by a preference
// (default on) so users can hand Space back to the focused control entirely.
if (wxGetApp().app_config->get_bool("enable_speed_dial") && !evt.CmdDown() && !evt.ShiftDown() &&
!evt.AltDown() && evt.GetKeyCode() == WXK_SPACE) {
if (focus_keeps_space(wxWindow::FindFocus())) {
evt.Skip(); // let the focused control keep Space
return;
}
// Defer out of the native key-event stack: open_speed_dial() may create a WebView and
// run script, the same window work the codebase avoids doing on native callbacks.
this->CallAfter([] { 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();
@@ -3344,6 +3388,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 + "Space", "",
[](wxCommandEvent &) { wxGetApp().open_speed_dial(); },
"", nullptr, []() { return true; }, this);
//parent_menu->Insert(1, preference_item);
#endif
// Help menu
@@ -3368,7 +3417,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" + "Space", "",
[](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
@@ -3414,88 +3469,51 @@ void MainFrame::init_menubar_as_editor()
// Temperature
append_menu_item(m_topbar->GetCalibMenu(), wxID_ANY, _L("Temperature"), _L("Temperature Calibration"),
[this](wxCommandEvent&) {
if (!m_temp_calib_dlg)
m_temp_calib_dlg = new Temp_Calibration_Dlg((wxWindow*)this, wxID_ANY, m_plater);
m_temp_calib_dlg->ShowModal();
}, "", nullptr,
[this](wxCommandEvent&) { run_calibration(CalibKind::Temperature); }, "", nullptr,
[this]() {return m_plater->is_view3D_shown();; }, this);
// Max Volumetric Speed
append_menu_item(m_topbar->GetCalibMenu(), wxID_ANY, _L("Max flowrate"), _L("Max flowrate"),
[this](wxCommandEvent&) {
if (!m_vol_test_dlg)
m_vol_test_dlg = new MaxVolumetricSpeed_Test_Dlg((wxWindow*)this, wxID_ANY, m_plater);
m_vol_test_dlg->ShowModal();
}, "", nullptr,
[this](wxCommandEvent&) { run_calibration(CalibKind::MaxVolumetric); }, "", nullptr,
[this]() {return m_plater->is_view3D_shown();; }, this);
// Pressure Advance
append_menu_item(m_topbar->GetCalibMenu(), wxID_ANY, _L("Pressure advance"), _L("Pressure advance"),
[this](wxCommandEvent&) {
if (!m_pa_calib_dlg)
m_pa_calib_dlg = new PA_Calibration_Dlg((wxWindow*)this, wxID_ANY, m_plater);
m_pa_calib_dlg->ShowModal();
}, "", nullptr,
[this](wxCommandEvent&) { run_calibration(CalibKind::PressureAdvance); }, "", nullptr,
[this]() {return m_plater->is_view3D_shown();; }, this);
// Flow rate (Wizard Dialog)
append_menu_item(m_topbar->GetCalibMenu(), wxID_ANY, _L("Flow ratio"), _L("Flow Rate Calibration"),
[this](wxCommandEvent&) {
if (!m_plater) return;
if (!m_flow_rate_calib_dlg)
m_flow_rate_calib_dlg = new FlowRateCalibrationDialog((wxWindow*)this, wxID_ANY, m_plater);
m_flow_rate_calib_dlg->ShowModal();
}, "", nullptr,
[this](wxCommandEvent&) { run_calibration(CalibKind::FlowRatio); }, "", nullptr,
[this]() {return m_plater->is_view3D_shown();; }, this);
// Retraction
append_menu_item(m_topbar->GetCalibMenu(), wxID_ANY, _L("Retraction"), _L("Retraction"),
[this](wxCommandEvent&) {
if (!m_retraction_calib_dlg)
m_retraction_calib_dlg = new Retraction_Test_Dlg((wxWindow*)this, wxID_ANY, m_plater);
m_retraction_calib_dlg->ShowModal();
}, "", nullptr,
[this](wxCommandEvent&) { run_calibration(CalibKind::Retraction); }, "", nullptr,
[this]() {return m_plater->is_view3D_shown();; }, this);
// Cornering
append_menu_item(m_topbar->GetCalibMenu(), wxID_ANY, _L("Cornering"), _L("Cornering calibration"),
[this](wxCommandEvent&) {
auto dlg = new Cornering_Test_Dlg((wxWindow*)this, wxID_ANY, m_plater);
dlg->ShowModal();
dlg->Destroy();
}, "", nullptr,
[this](wxCommandEvent&) { run_calibration(CalibKind::Cornering); }, "", nullptr,
[this]() {return m_plater->is_view3D_shown();; }, this);
// Input Shaping (with submenu)
auto input_shaping_menu = new wxMenu();
append_menu_item(
input_shaping_menu, wxID_ANY, _L("Input Shaping Frequency"), _L("Input Shaping Frequency"),
[this](wxCommandEvent&) {
auto dlg = new Input_Shaping_Freq_Test_Dlg((wxWindow*)this, wxID_ANY, m_plater);
dlg->ShowModal();
dlg->Destroy();
},
[this](wxCommandEvent&) { run_calibration(CalibKind::InputShapingFreq); },
"", nullptr,
[this]() {return m_plater->is_view3D_shown();; }, this);
append_menu_item(
input_shaping_menu, wxID_ANY, _L("Input Shaping Damping/zeta factor"), _L("Input Shaping Damping/zeta factor"),
[this](wxCommandEvent&) {
auto dlg = new Input_Shaping_Damp_Test_Dlg((wxWindow*)this, wxID_ANY, m_plater);
dlg->ShowModal();
dlg->Destroy();
},
[this](wxCommandEvent&) { run_calibration(CalibKind::InputShapingDamp); },
"", nullptr,
[this]() {return m_plater->is_view3D_shown();; }, this);
m_topbar->GetCalibMenu()->AppendSubMenu(input_shaping_menu, _L("Input Shaping"));
// VFA
append_menu_item(m_topbar->GetCalibMenu(), wxID_ANY, _L("VFA"), _L("VFA"),
[this](wxCommandEvent&) {
if (!m_vfa_test_dlg)
m_vfa_test_dlg = new VFA_Test_Dlg((wxWindow*)this, wxID_ANY, m_plater);
m_vfa_test_dlg->ShowModal();
}, "", nullptr,
[this](wxCommandEvent&) { run_calibration(CalibKind::VFA); }, "", nullptr,
[this]() {return m_plater->is_view3D_shown();; }, this);
// help
@@ -3506,6 +3524,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 + "Space", "",
[](wxCommandEvent&) { wxGetApp().open_speed_dial(); },
"", nullptr, []() { return true; }, this);
append_menu_item(
fileMenu, wxID_ANY, _L("Preset Bundle"), "",
[this](wxCommandEvent&) {
@@ -3552,89 +3574,52 @@ void MainFrame::init_menubar_as_editor()
// Temperature
append_menu_item(calib_menu, wxID_ANY, _L("Temperature"), _L("Temperature"),
[this](wxCommandEvent&) {
if (!m_temp_calib_dlg)
m_temp_calib_dlg = new Temp_Calibration_Dlg((wxWindow*)this, wxID_ANY, m_plater);
m_temp_calib_dlg->ShowModal();
}, "", nullptr,
[this](wxCommandEvent&) { run_calibration(CalibKind::Temperature); }, "", nullptr,
[this]() {return m_plater->is_view3D_shown();; }, this);
// Max Volumetric Speed
append_menu_item(calib_menu, wxID_ANY, _L("Max flowrate"), _L("Max flowrate"),
[this](wxCommandEvent&) {
if (!m_vol_test_dlg)
m_vol_test_dlg = new MaxVolumetricSpeed_Test_Dlg((wxWindow*)this, wxID_ANY, m_plater);
m_vol_test_dlg->ShowModal();
}, "", nullptr,
[this](wxCommandEvent&) { run_calibration(CalibKind::MaxVolumetric); }, "", nullptr,
[this]() {return m_plater->is_view3D_shown();; }, this);
// Pressure Advance
append_menu_item(calib_menu, wxID_ANY, _L("Pressure advance"), _L("Pressure advance"),
[this](wxCommandEvent&) {
if (!m_pa_calib_dlg)
m_pa_calib_dlg = new PA_Calibration_Dlg((wxWindow*)this, wxID_ANY, m_plater);
m_pa_calib_dlg->ShowModal();
}, "", nullptr,
[this](wxCommandEvent&) { run_calibration(CalibKind::PressureAdvance); }, "", nullptr,
[this]() {return m_plater->is_view3D_shown();; }, this);
// Flowrate (with submenu)
// ORCA: Flow rate (Wizard Dialog)
append_menu_item(calib_menu, wxID_ANY, _L("Flow ratio"), _L("Flow Rate Calibration"),
[this](wxCommandEvent&) {
if (!m_plater) return;
if (!m_flow_rate_calib_dlg)
m_flow_rate_calib_dlg = new FlowRateCalibrationDialog((wxWindow*)this, wxID_ANY, m_plater);
m_flow_rate_calib_dlg->ShowModal();
}, "", nullptr,
[this](wxCommandEvent&) { run_calibration(CalibKind::FlowRatio); }, "", nullptr,
[this]() {return m_plater->is_view3D_shown();; }, this);
// Retraction
append_menu_item(calib_menu, wxID_ANY, _L("Retraction"), _L("Retraction"),
[this](wxCommandEvent&) {
if (!m_retraction_calib_dlg)
m_retraction_calib_dlg = new Retraction_Test_Dlg((wxWindow*)this, wxID_ANY, m_plater);
m_retraction_calib_dlg->ShowModal();
}, "", nullptr,
[this](wxCommandEvent&) { run_calibration(CalibKind::Retraction); }, "", nullptr,
[this]() {return m_plater->is_view3D_shown();; }, this);
// Cornering
append_menu_item(calib_menu, wxID_ANY, _L("Cornering"), _L("Cornering calibration"),
[this](wxCommandEvent&) {
auto dlg = new Cornering_Test_Dlg((wxWindow*)this, wxID_ANY, m_plater);
dlg->ShowModal();
dlg->Destroy();
}, "", nullptr,
[this](wxCommandEvent&) { run_calibration(CalibKind::Cornering); }, "", nullptr,
[this]() {return m_plater->is_view3D_shown();; }, this);
// Input Shaping (with submenu)
auto input_shaping_menu = new wxMenu();
append_menu_item(
input_shaping_menu, wxID_ANY, _L("Input Shaping Frequency"), _L("Input Shaping Frequency"),
[this](wxCommandEvent&) {
auto dlg = new Input_Shaping_Freq_Test_Dlg((wxWindow*)this, wxID_ANY, m_plater);
dlg->ShowModal();
dlg->Destroy();
},
[this](wxCommandEvent&) { run_calibration(CalibKind::InputShapingFreq); },
"", nullptr,
[this]() {return m_plater->is_view3D_shown();; }, this);
append_menu_item(
input_shaping_menu, wxID_ANY, _L("Input Shaping Damping/zeta factor"), _L("Input Shaping Damping/zeta factor"),
[this](wxCommandEvent&) {
auto dlg = new Input_Shaping_Damp_Test_Dlg((wxWindow*)this, wxID_ANY, m_plater);
dlg->ShowModal();
dlg->Destroy();
},
[this](wxCommandEvent&) { run_calibration(CalibKind::InputShapingDamp); },
"", nullptr,
[this]() {return m_plater->is_view3D_shown();; }, this);
calib_menu->AppendSubMenu(input_shaping_menu, _L("Input Shaping"));
// VFA
append_menu_item(calib_menu, wxID_ANY, _L("VFA"), _L("VFA"),
[this](wxCommandEvent&) {
if (!m_vfa_test_dlg)
m_vfa_test_dlg = new VFA_Test_Dlg((wxWindow*)this, wxID_ANY, m_plater);
m_vfa_test_dlg->ShowModal();
}, "", nullptr,
[this](wxCommandEvent&) { run_calibration(CalibKind::VFA); }, "", nullptr,
[this]() {return m_plater->is_view3D_shown();; }, this);
// help
append_menu_item(calib_menu, wxID_ANY, _L("Calibration Guide"), _L("Calibration Guide"),
@@ -4408,6 +4393,72 @@ void MainFrame::technology_changed()
m_menubar->SetMenuLabel(id, pt == ptSLA ? _omitL("Material Settings") : _L("Filament settings"));
}
// Opens the calibration wizard for `calib_kind`. Single source of truth for the wizard lifecycle:
// the Calibration menu handlers and the Speed Dial native commands both call it. Most wizards are
// cached members reused across launches; cornering/input-shaping build a fresh transient dialog.
// Call while the Prepare (3D) panel is shown.
void MainFrame::run_calibration(CalibKind calib_kind)
{
switch (calib_kind) {
case CalibKind::Temperature: {
if (!m_temp_calib_dlg)
m_temp_calib_dlg = new Temp_Calibration_Dlg((wxWindow*) this, wxID_ANY, m_plater);
m_temp_calib_dlg->ShowModal();
break;
}
case CalibKind::MaxVolumetric: {
if (!m_vol_test_dlg)
m_vol_test_dlg = new MaxVolumetricSpeed_Test_Dlg((wxWindow*) this, wxID_ANY, m_plater);
m_vol_test_dlg->ShowModal();
break;
}
case CalibKind::PressureAdvance: {
if (!m_pa_calib_dlg)
m_pa_calib_dlg = new PA_Calibration_Dlg((wxWindow*) this, wxID_ANY, m_plater);
m_pa_calib_dlg->ShowModal();
break;
}
case CalibKind::FlowRatio: {
if (!m_plater)
break;
if (!m_flow_rate_calib_dlg)
m_flow_rate_calib_dlg = new FlowRateCalibrationDialog((wxWindow*) this, wxID_ANY, m_plater);
m_flow_rate_calib_dlg->ShowModal();
break;
}
case CalibKind::Retraction: {
if (!m_retraction_calib_dlg)
m_retraction_calib_dlg = new Retraction_Test_Dlg((wxWindow*) this, wxID_ANY, m_plater);
m_retraction_calib_dlg->ShowModal();
break;
}
case CalibKind::Cornering: {
auto dlg = new Cornering_Test_Dlg((wxWindow*) this, wxID_ANY, m_plater);
dlg->ShowModal();
dlg->Destroy();
break;
}
case CalibKind::InputShapingFreq: {
auto dlg = new Input_Shaping_Freq_Test_Dlg((wxWindow*) this, wxID_ANY, m_plater);
dlg->ShowModal();
dlg->Destroy();
break;
}
case CalibKind::InputShapingDamp: {
auto dlg = new Input_Shaping_Damp_Test_Dlg((wxWindow*) this, wxID_ANY, m_plater);
dlg->ShowModal();
dlg->Destroy();
break;
}
case CalibKind::VFA: {
if (!m_vfa_test_dlg)
m_vfa_test_dlg = new VFA_Test_Dlg((wxWindow*) this, wxID_ANY, m_plater);
m_vfa_test_dlg->ShowModal();
break;
}
}
}
//
// Called after the Preferences dialog is closed and the program settings are saved.
+19
View File
@@ -113,6 +113,20 @@ protected:
void on_dpi_changed(const wxRect& suggested_rect) override;
};
// Calibration wizard identity, shared by MainFrame::run_calibration and the Speed Dial command runners.
enum class CalibKind : int
{
Temperature,
MaxVolumetric,
PressureAdvance,
FlowRatio,
Retraction,
Cornering,
InputShapingFreq,
InputShapingDamp,
VFA
};
class MainFrame : public DPIFrame
{
#ifdef __APPLE__
@@ -360,6 +374,11 @@ public:
void technology_changed();
// Opens the calibration wizard for `kind`. Single source of truth for the wizard lifecycle:
// the Calibration menu handlers and the Speed Dial native commands both call this. Most wizards
// are cached members; cornering/input-shaping are transient. Call while the Prepare (3D) panel
// is shown (menu items are gated on is_view3D_shown; the speed dial ensures it first).
void run_calibration(CalibKind calib_kind);
//BBS
void load_url(wxString url);
+668
View File
@@ -0,0 +1,668 @@
#include "NativeCommands.hpp"
#include "calib_dlg.hpp"
#include "Camera.hpp"
#include "DailyTips.hpp"
#include "GCodeViewer.hpp"
#include "GLCanvas3D.hpp"
#include "GUI.hpp"
#include "GUI_App.hpp"
#include "GUI_Factories.hpp"
#include "GUI_ObjectList.hpp"
#include "I18N.hpp"
#include "IMSlider.hpp"
#include "MainFrame.hpp"
#include "NetworkTestDialog.hpp"
#include "Plater.hpp"
#include "PluginsDialog.hpp"
#include "PlateSettingsDialog.hpp"
#include "DeviceCore/DevManager.h"
#include <libslic3r/Model.hpp>
#include <libslic3r/Utils.hpp>
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <exception>
#include <memory>
#include <string>
#include <tuple>
#include <utility>
#include <wx/utils.h>
namespace Slic3r { namespace GUI {
namespace {
// Plate ops are an FFF feature: SLA has a single plate and no plate UI, gcode-only mode has no
// editable project - so gate every plate op on FFF + the normal editor.
bool is_fff_plater(Plater* plater) { return plater && plater->printer_technology() == ptFFF && !plater->only_gcode_mode(); }
AppActionRunResult plate_unavailable() { return {AppActionRunResult::Level::Info, _L("Plates are a filament (FFF) feature.")}; }
// Switch to the Prepare (3D) panel so object/calibration ops have a live canvas + selection, and
// the notebook page label matches. A no-op when the 3D panel is already shown.
void ensure_3d_view(Plater* plater)
{
if (plater && !plater->is_view3D_shown()) {
plater->select_view_3D("3D");
if (MainFrame* mf = wxGetApp().mainframe; mf)
mf->select_tab(TAB_ID_PREPARE);
}
}
// Object op guard + run: object ops read the Prepare canvas selection, so ensure that view first so
// a launch from another tab doesn't report a spuriously empty selection.
AppActionRunResult object_op(Plater* plater, bool (*ok)(Plater*), void (*op)(Plater*))
{
if (!plater)
return {AppActionRunResult::Level::Info, _L("Open the 3D view first.")};
ensure_3d_view(plater);
if (!ok(plater))
return {AppActionRunResult::Level::Info, _L("Select an object first.")};
op(plater);
return {AppActionRunResult::Level::Success};
}
// Jump the preview to a layer selected by a 0-100 percent of the layer range. The caller has already
// switched to Preview (which may request a slice); if a slicer result is present the slider is
// repositioned immediately, otherwise the jump is a no-op until the user re-slices.
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;
const double max = double(layers->GetMaxValue());
const int target = int(std::lround(pct / 100.0 * max));
layers->SetHigherValue(target);
if (layers->is_one_layer())
layers->SetLowerValue(target);
layers->set_as_dirty();
if (moves) {
moves->SetHigherValue(moves->GetMaxValue());
moves->set_as_dirty();
}
}
// Select a named camera view. Plater::select_view dispatches to the current panel.
AppActionRunResult view_command(Plater* plater, const std::string& dir)
{
if (plater)
plater->select_view(dir);
return {AppActionRunResult::Level::Success};
}
// Calibration wizards. Routes through MainFrame::run_calibration, the same entry point as the
// Calibration menu (which caches most of the wizard dialogs).
AppActionRunResult calib_command(CalibKind kind)
{
MainFrame* mf = wxGetApp().mainframe;
if (!mf)
return {AppActionRunResult::Level::Info, _L("Open the 3D view first.")};
ensure_3d_view(wxGetApp().plater());
mf->run_calibration(kind);
return {AppActionRunResult::Level::Success};
}
constexpr const char* kCommandPrefix = "orca_command";
// Thin AppAction wrapper for one catalog entry: identity and presentation come from the catalog,
// run() routes back to it. The id is keyed by the stable catalog key (not the display title), so a
// rename or a UI-language switch never re-keys the action.
struct CommandAction : AppAction
{
std::string command_key;
AppActionRunResult run(const std::string& param) const override { return NativeCommands::run(command_key, param); }
explicit CommandAction(const NativeCommand& c)
: AppAction(AppActionId{AppAction::compose_id(kCommandPrefix, c.key, kOrcaSourceKey)}, c.title, kOrcaSourceKey, kOrcaSourceName)
, command_key(c.key)
{
this->kind = AppActionKind::Command;
this->group = c.group;
this->input = c.input;
this->icon = c.icon;
}
};
std::vector<NativeCommand> build_command_catalog()
{
std::vector<NativeCommand> out;
auto add = [&](std::string key, std::string title, std::string group, std::function<AppActionRunResult(const std::string&)> runner,
std::string input = {}, std::string icon = {}) {
out.push_back({std::move(key), std::move(title), std::move(group), std::move(input), std::move(icon), std::move(runner)});
};
// Presentation-first overload: keeps the tile icon next to the title/group it belongs to.
auto add_with_icon = [&](std::string key, std::string title, std::string group, std::string icon,
std::function<AppActionRunResult(const std::string&)> runner, std::string input = {}) {
add(std::move(key), std::move(title), std::move(group), std::move(runner), std::move(input), std::move(icon));
};
// ---- Slice & Export ----
add_with_icon("slice_and_preview", _u8L("Slice and Preview"), _u8L("Slice & Export"), "media_play", [](const std::string&) {
Plater* plater = wxGetApp().plater();
if (plater) {
plater->reslice();
plater->select_view_3D("Preview", false);
if (MainFrame* mf = wxGetApp().mainframe; mf)
mf->select_tab(TAB_ID_PREVIEW);
}
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon(
"go_to_layer", _u8L("Go to layer (percent)"), _u8L("Commands"), "height_range_layer",
[](const std::string& param) {
Plater* plater = wxGetApp().plater();
if (plater) {
plater->select_view_3D("Preview", false);
if (MainFrame* mf = wxGetApp().mainframe; mf)
mf->select_tab(TAB_ID_PREVIEW);
go_to_layer(plater, param);
}
return AppActionRunResult{AppActionRunResult::Level::Success};
},
"percent");
// "go_to_tab" is two-phase: the palette collects the tab after activating it, then hands the tab
// id back as `param` (same contract as go_to_layer's percent).
add(
"go_to_tab", _u8L("Go to tab..."), _u8L("Commands"),
[](const std::string& param) {
if (MainFrame* mf = wxGetApp().mainframe; mf && !param.empty())
mf->select_tab(from_u8(param));
return AppActionRunResult{AppActionRunResult::Level::Success};
},
"tab");
add_with_icon("load_project", _u8L("Load Project"), _u8L("Commands"), "menu_open", [](const std::string&) {
if (Plater* plater = wxGetApp().plater())
plater->load_project();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("save_project", _u8L("Save Project"), _u8L("Commands"), "menu_save", [](const std::string&) {
if (Plater* plater = wxGetApp().plater())
plater->save_project(false);
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("save_project_as", _u8L("Save Project As"), _u8L("Commands"), "menu_save", [](const std::string&) {
if (Plater* plater = wxGetApp().plater())
plater->save_project(true);
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("open_preferences", _u8L("Preferences"), _u8L("Commands"), "cog", [](const std::string&) {
wxGetApp().open_preferences();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
// ---- Mode ----
add_with_icon("mode_simple", _u8L("Mode: Simple"), _u8L("Mode"), "advanced", [](const std::string&) {
wxGetApp().set_mode(comSimple);
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("mode_advanced", _u8L("Mode: Advanced"), _u8L("Mode"), "advanced", [](const std::string&) {
wxGetApp().set_mode(comAdvanced);
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("mode_expert", _u8L("Mode: Expert"), _u8L("Mode"), "advanced", [](const std::string&) {
wxGetApp().set_mode(comExpert);
return AppActionRunResult{AppActionRunResult::Level::Success};
});
// Mirrors Preferences > Developer > Developer mode: flip the flag, persist, refresh the UI.
add_with_icon("toggle_developer_mode", _u8L("Toggle Developer Mode"), _u8L("Mode"), "advanced", [](const std::string&) {
GUI_App& app = wxGetApp();
const bool on = !app.app_config->get_bool("developer_mode");
app.app_config->set_bool("developer_mode", on);
app.app_config->save();
app.update_mode();
return AppActionRunResult{AppActionRunResult::Level::Success, on ? _L("Developer mode enabled.") : _L("Developer mode disabled.")};
});
// ---- Export pipeline ----
add_with_icon("export_gcode", _u8L("Export G-code"), _u8L("Slice & Export"), "custom-gcode_gcode", [](const std::string&) {
if (Plater* plater = wxGetApp().plater())
plater->export_gcode(false);
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("export_stl", _u8L("Export STL"), _u8L("Slice & Export"), "save", [](const std::string&) {
if (Plater* plater = wxGetApp().plater())
plater->export_stl();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("export_3mf", _u8L("Export 3MF"), _u8L("Slice & Export"), "menu_save", [](const std::string&) {
if (Plater* plater = wxGetApp().plater())
plater->export_core_3mf();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("export_sliced_file", _u8L("Export Sliced File"), _u8L("Slice & Export"), "save", [](const std::string&) {
if (Plater* plater = wxGetApp().plater())
plater->export_gcode_3mf(false);
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("export_all_sliced_file", _u8L("Export All Sliced Files"), _u8L("Slice & Export"), "save", [](const std::string&) {
if (Plater* plater = wxGetApp().plater())
plater->export_gcode_3mf(true);
return AppActionRunResult{AppActionRunResult::Level::Success};
});
// ---- Calibration ----
// The tab-strip calib_sf glyph is drawn white for the dark tab bar and vanishes on the palette's
// light tile, so each wizard borrows the matching settings-group icon instead (gray + accent green).
add_with_icon("calib_temperature", _u8L("Temperature Calibration"), _u8L("Calibration"), "param_temperature",
[](const std::string&) { return calib_command(CalibKind::Temperature); });
add_with_icon("calib_max_volumetric", _u8L("Max Volumetric Speed Calibration"), _u8L("Calibration"), "param_volumetric_speed",
[](const std::string&) { return calib_command(CalibKind::MaxVolumetric); });
add_with_icon("calib_pressure_advance", _u8L("Pressure Advance Calibration"), _u8L("Calibration"), "param_flow_ratio_and_pressure_advance",
[](const std::string&) { return calib_command(CalibKind::PressureAdvance); });
add_with_icon("calib_flow_ratio", _u8L("Flow Ratio Calibration"), _u8L("Calibration"), "param_flow_ratio_and_pressure_advance",
[](const std::string&) { return calib_command(CalibKind::FlowRatio); });
add_with_icon("calib_retraction", _u8L("Retraction Calibration"), _u8L("Calibration"), "param_retraction",
[](const std::string&) { return calib_command(CalibKind::Retraction); });
add_with_icon("calib_cornering", _u8L("Cornering Calibration"), _u8L("Calibration"), "param_precision",
[](const std::string&) { return calib_command(CalibKind::Cornering); });
add_with_icon("calib_input_shaping_freq", _u8L("Input Shaping Frequency Calibration"), _u8L("Calibration"), "param_resonance_avoidance",
[](const std::string&) { return calib_command(CalibKind::InputShapingFreq); });
add_with_icon("calib_input_shaping_damp", _u8L("Input Shaping Damping Calibration"), _u8L("Calibration"), "param_resonance_avoidance",
[](const std::string&) { return calib_command(CalibKind::InputShapingDamp); });
add_with_icon("calib_vfa", _u8L("VFA Calibration"), _u8L("Calibration"), "param_speed", [](const std::string&) { return calib_command(CalibKind::VFA); });
// ---- View ----
// Titles are built with _u8L here (not via a variable) so xgettext can extract them.
for (auto [key, dir, title] :
std::initializer_list<std::tuple<const char*, const char*, std::string>>{{"view_top", "top", _u8L("View: Top")},
{"view_bottom", "bottom", _u8L("View: Bottom")},
{"view_front", "front", _u8L("View: Front")},
{"view_rear", "rear", _u8L("View: Rear")},
{"view_left", "left", _u8L("View: Left")},
{"view_right", "right", _u8L("View: Right")},
{"view_iso", "iso", _u8L("View: Isometric")}}) {
std::string k = key, d = dir;
add(k, title, _u8L("View"),
[d](const std::string&) { return view_command(wxGetApp().plater(), d); });
}
add("view_default", _u8L("View: Default"), _u8L("View"), [](const std::string&) {
Plater* plater = wxGetApp().plater();
if (plater) {
plater->select_view("plate");
if (GLCanvas3D* canvas = plater->get_current_canvas3D())
canvas->zoom_to_bed();
}
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add("view_fit_bed", _u8L("Fit Bed to View"), _u8L("View"), [](const std::string&) {
if (Plater* plater = wxGetApp().plater())
if (GLCanvas3D* canvas = plater->get_current_canvas3D())
canvas->zoom_to_bed();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add("view_toggle_perspective", _u8L("Toggle Perspective"), _u8L("View"), [](const std::string&) {
if (Plater* plater = wxGetApp().plater())
plater->get_camera().select_next_type();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("reset_window_layout", _u8L("Reset Window Layout"), _u8L("View"), "toolbar_reset", [](const std::string&) {
if (Plater* plater = wxGetApp().plater())
plater->reset_window_layout();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
// ---- Object ----
add_with_icon("obj_delete", _u8L("Delete Selected"), _u8L("Object"), "delete", [](const std::string&) {
return object_op(wxGetApp().plater(), [](Plater* p) { return !p->is_selection_empty(); }, [](Plater* p) { p->remove_selected(); });
});
add_with_icon("obj_delete_all", _u8L("Delete All Objects"), _u8L("Object"), "delete", [](const std::string&) {
return object_op(
wxGetApp().plater(), [](Plater* p) { return p->can_delete_all(); }, [](Plater* p) { p->delete_all_objects_from_model(); });
});
add_with_icon("obj_mirror_x", _u8L("Mirror X"), _u8L("Object"), "menu_mirror_x", [](const std::string&) {
return object_op(wxGetApp().plater(), [](Plater* p) { return p->can_mirror(); }, [](Plater* p) { p->mirror(Axis::X); });
});
add_with_icon("obj_mirror_y", _u8L("Mirror Y"), _u8L("Object"), "menu_mirror_y", [](const std::string&) {
return object_op(wxGetApp().plater(), [](Plater* p) { return p->can_mirror(); }, [](Plater* p) { p->mirror(Axis::Y); });
});
add_with_icon("obj_mirror_z", _u8L("Mirror Z"), _u8L("Object"), "menu_mirror_z", [](const std::string&) {
return object_op(wxGetApp().plater(), [](Plater* p) { return p->can_mirror(); }, [](Plater* p) { p->mirror(Axis::Z); });
});
add_with_icon("obj_split_objects", _u8L("Split to Objects"), _u8L("Object"), "menu_split_objects", [](const std::string&) {
return object_op(wxGetApp().plater(), [](Plater* p) { return p->can_split_to_objects(); }, [](Plater* p) { p->split_object(true); });
});
add_with_icon("obj_split_parts", _u8L("Split to Parts"), _u8L("Object"), "menu_split_parts", [](const std::string&) {
return object_op(wxGetApp().plater(), [](Plater* p) { return p->can_split_to_volumes(); }, [](Plater* p) { p->split_volume(); });
});
add("obj_center", _u8L("Center Selected on Plate"), _u8L("Object"), [](const std::string&) {
return object_op(wxGetApp().plater(), [](Plater* p) { return !p->is_selection_empty(); }, [](Plater* p) { p->center_selection(); });
});
add_with_icon("obj_drop", _u8L("Drop to Bed"), _u8L("Object"), "toolbar_flatten", [](const std::string&) {
return object_op(wxGetApp().plater(), [](Plater* p) { return !p->is_selection_empty(); }, [](Plater* p) { p->drop_selection(); });
});
add("obj_fit_volume", _u8L("Scale to Fit Print Volume"), _u8L("Object"), [](const std::string&) {
return object_op(
wxGetApp().plater(), [](Plater* p) { return p->can_scale_to_print_volume(); },
[](Plater* p) { p->scale_selection_to_fit_print_volume(); });
});
add_with_icon("obj_instances_up", _u8L("Increase Instances"), _u8L("Object"), "instance_add", [](const std::string&) {
return object_op(
wxGetApp().plater(), [](Plater* p) { return p->can_increase_instances(); }, [](Plater* p) { p->increase_instances(); });
});
add_with_icon("obj_instances_down", _u8L("Decrease Instances"), _u8L("Object"), "instance_remove", [](const std::string&) {
return object_op(
wxGetApp().plater(), [](Plater* p) { return p->can_decrease_instances(); }, [](Plater* p) { p->decrease_instances(); });
});
add_with_icon("obj_arrange", _u8L("Auto-Arrange"), _u8L("Object"), "toolbar_arrange", [](const std::string&) {
return object_op(wxGetApp().plater(), [](Plater* p) { return p->can_arrange(); }, [](Plater* p) { p->arrange(); });
});
add_with_icon("obj_orient", _u8L("Auto-Orient"), _u8L("Object"), "toolbar_orient", [](const std::string&) {
return object_op(wxGetApp().plater(), [](Plater* p) { return p->can_arrange(); }, [](Plater* p) { p->orient(); });
});
// ---- Add Primitive ---- (the Add > Add Primitive submenu; creates a new object)
auto add_primitive = [&](std::string key, std::string title, std::string icon, const char* type_name) {
add_with_icon(std::move(key), std::move(title), _u8L("Add Primitive"), std::move(icon), [type_name](const std::string&) {
Plater* plater = wxGetApp().plater();
if (plater) {
ensure_3d_view(plater);
if (ObjectList* list = wxGetApp().obj_list())
list->load_generic_subobject(type_name, ModelVolumeType::INVALID);
}
return AppActionRunResult{AppActionRunResult::Level::Success};
});
};
add_primitive("add_primitive_cube", _u8L("Cube"), "menu_obj_cube", "Cube");
add_primitive("add_primitive_cylinder", _u8L("Cylinder"), "menu_obj_cylinder", "Cylinder");
add_primitive("add_primitive_sphere", _u8L("Sphere"), "menu_obj_sphere", "Sphere");
add_primitive("add_primitive_cone", _u8L("Cone"), "menu_obj_cone", "Cone");
add_primitive("add_primitive_disc", _u8L("Disc"), "menu_obj_disc", "Disc");
add_primitive("add_primitive_torus", _u8L("Torus"), "menu_obj_torus", "Torus");
add_with_icon("add_primitive_text", _u8L("Text"), _u8L("Add Primitive"), "menu_obj_text", [](const std::string&) {
Plater* plater = wxGetApp().plater();
if (plater) {
ensure_3d_view(plater);
if (GLCanvas3D* canvas = plater->canvas3D())
canvas->clear_popup_menu_position();
MenuFactory::add_text_volume(ModelVolumeType::INVALID);
}
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("add_primitive_svg", _u8L("SVG"), _u8L("Add Primitive"), "menu_obj_svg", [](const std::string&) {
Plater* plater = wxGetApp().plater();
if (plater) {
ensure_3d_view(plater);
if (GLCanvas3D* canvas = plater->canvas3D())
canvas->clear_popup_menu_position();
MenuFactory::add_svg_volume(ModelVolumeType::INVALID);
}
return AppActionRunResult{AppActionRunResult::Level::Success};
});
// ---- Add Handy models ---- (the Add > Add Handy models submenu)
const std::vector<MenuFactory::HandyModel>& handy = MenuFactory::handy_models();
for (std::size_t i = 0; i < handy.size(); ++i) {
add("add_handy_" + std::string(handy[i].key), Slic3r::GUI::I18N::translate_utf8(handy[i].label), _u8L("Add Handy models"),
[i](const std::string&) {
if (Plater* plater = wxGetApp().plater())
ensure_3d_view(plater);
MenuFactory::load_handy_model(i);
return AppActionRunResult{AppActionRunResult::Level::Success};
});
}
// ---- Plate ----
add_with_icon("plate_add", _u8L("Add Plate"), _u8L("Plate"), "toolbar_add_plate", [](const std::string&) {
Plater* plater = wxGetApp().plater();
if (!is_fff_plater(plater))
return plate_unavailable();
if (!plater->can_add_plate())
return AppActionRunResult{AppActionRunResult::Level::Info, _L("Cannot add another plate (maximum reached).")};
plater->add_plate();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("plate_duplicate", _u8L("Duplicate Plate"), _u8L("Plate"), "menu_copy", [](const std::string&) {
Plater* plater = wxGetApp().plater();
if (!is_fff_plater(plater))
return plate_unavailable();
if (!plater->can_add_plate())
return AppActionRunResult{AppActionRunResult::Level::Info, _L("Cannot duplicate a plate (maximum reached).")};
plater->duplicate_plate();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("plate_delete", _u8L("Delete Plate"), _u8L("Plate"), "delete", [](const std::string&) {
Plater* plater = wxGetApp().plater();
if (!is_fff_plater(plater))
return plate_unavailable();
if (!plater->can_delete_plate())
return AppActionRunResult{AppActionRunResult::Level::Info, _L("Cannot delete the only plate.")};
plater->delete_plate();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("plate_rename", _u8L("Rename Plate"), _u8L("Plate"), "plate_name_edit", [](const std::string&) {
Plater* plater = wxGetApp().plater();
if (!is_fff_plater(plater))
return plate_unavailable();
PartPlate* curr = plater->get_partplate_list().get_curr_plate();
PlateNameEditDialog dlg((wxWindow*) wxGetApp().mainframe, wxID_ANY, _L("Edit Plate Name"));
dlg.set_plate_name(from_u8(curr->get_plate_name()));
if (dlg.ShowModal() == wxID_YES)
curr->set_plate_name(dlg.get_plate_name().ToUTF8().data());
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("plate_toggle_lock", _u8L("Toggle Plate Lock"), _u8L("Plate"), "lock_normal", [](const std::string&) {
Plater* plater = wxGetApp().plater();
if (!is_fff_plater(plater))
return plate_unavailable();
PartPlateList& plates = plater->get_partplate_list();
const int index = plates.get_curr_plate_index();
plater->take_snapshot("lock partplate");
plates.lock_plate(index, !plates.is_locked(index));
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("plate_goto", _u8L("Go to Plate"), _u8L("Plate"), "go_next_plate", [](const std::string& param) {
Plater* plater = wxGetApp().plater();
if (!is_fff_plater(plater))
return plate_unavailable();
PartPlateList& plates = plater->get_partplate_list();
const int count = plates.get_plate_count();
if (count <= 0)
return AppActionRunResult{AppActionRunResult::Level::Info, _L("No plates available.")};
int index = 0;
try {
index = std::stoi(param);
} catch (const std::exception&) {}
index = std::clamp(index, 0, count - 1);
plater->select_plate(index, false);
return AppActionRunResult{AppActionRunResult::Level::Success};
});
// ---- Printer / device connection ----
add_with_icon("sync_ams", _u8L("Synchronize Filament List from AMS"), _u8L("Printer"), "ams_fila_sync", [](const std::string&) {
Plater* plater = wxGetApp().plater();
DeviceManager* dev = wxGetApp().getDeviceManager();
if (dev && dev->get_selected_machine() && plater) {
plater->sidebar().sync_ams_list();
return AppActionRunResult{AppActionRunResult::Level::Success};
}
return AppActionRunResult{AppActionRunResult::Level::Info, _L("Connect a printer to synchronize the AMS filament list.")};
});
// ---- Presets / cloud ----
add_with_icon("preset_bundle", _u8L("Open Preset Bundle"), _u8L("Presets"), "menu_edit_preset", [](const std::string&) {
wxGetApp().open_presetbundledialog();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("sync_presets", _u8L("Sync Presets"), _u8L("Presets"), "printer_sync_ok", [](const std::string&) {
if (!wxGetApp().is_user_login())
return AppActionRunResult{AppActionRunResult::Level::Info, _L("Sign in to sync presets.")};
wxGetApp().restart_sync_user_preset();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
// ---- Import ----
add_with_icon("import_file", _u8L("Import 3MF/STL/STEP/SVG/OBJ/AMF"), _u8L("Import"), "menu_open", [](const std::string&) {
if (Plater* plater = wxGetApp().plater()) {
#ifdef __APPLE__
plater->add_model();
#else
plater->add_file();
#endif
}
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("import_zip_archive", _u8L("Import ZIP Archive"), _u8L("Import"), "menu_open", [](const std::string&) {
if (Plater* plater = wxGetApp().plater())
plater->import_zip_archive();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("import_configs", _u8L("Import Configs"), _u8L("Import"), "menu_open", [](const std::string&) {
if (MainFrame* mf = wxGetApp().mainframe)
mf->load_config_file();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
// ---- Export extras ----
add_with_icon("export_stl_multi", _u8L("Export All Objects as STLs"), _u8L("Export"), "save", [](const std::string&) {
if (Plater* plater = wxGetApp().plater())
plater->export_stl(false, false, true);
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("export_drc_single", _u8L("Export All Objects as DRC (one file)"), _u8L("Export"), "save", [](const std::string&) {
if (Plater* plater = wxGetApp().plater())
plater->export_stl(false, false, false, FT_DRC);
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("export_drc_multi", _u8L("Export All Objects as DRCs"), _u8L("Export"), "save", [](const std::string&) {
if (Plater* plater = wxGetApp().plater())
plater->export_stl(false, false, true, FT_DRC);
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("export_toolpaths_obj", _u8L("Export Toolpaths as OBJ"), _u8L("Export"), "custom-gcode_gcode", [](const std::string&) {
if (Plater* plater = wxGetApp().plater())
plater->export_toolpaths_to_obj();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("export_config", _u8L("Export Preset Bundle"), _u8L("Export"), "save", [](const std::string&) {
if (MainFrame* mf = wxGetApp().mainframe)
mf->export_config();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
// ---- Help ---- (mirrors the top-bar Help menu, plus the wiki/YouTube links)
add("help_keyboard_shortcuts", _u8L("Keyboard Shortcuts"), _u8L("Help"), [](const std::string&) {
wxGetApp().keyboard_shortcuts();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add("help_setup_wizard", _u8L("Setup Wizard"), _u8L("Help"), [](const std::string&) {
wxGetApp().ShowUserGuide();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("help_open_config_folder", _u8L("Show Configuration Folder"), _u8L("Help"), "open_project", [](const std::string&) {
Slic3r::GUI::desktop_open_datadir_folder();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add("help_troubleshoot", _u8L("Troubleshoot Center"), _u8L("Help"), [](const std::string&) {
wxGetApp().troubleshoot();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add("help_network_test", _u8L("Open Network Test"), _u8L("Help"), [](const std::string&) {
NetworkTestDialog dlg(wxGetApp().mainframe);
dlg.ShowModal();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("help_tip_of_the_day", _u8L("Show Tip of the Day"), _u8L("Help"), "info", [](const std::string&) {
if (Plater* plater = wxGetApp().plater()) {
plater->get_dailytips()->open();
if (GLCanvas3D* canvas = plater->get_current_canvas3D())
canvas->set_as_dirty();
}
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("help_check_updates", _u8L("Check for Updates"), _u8L("Help"), "refresh", [](const std::string&) {
wxGetApp().check_new_version_sf(true, 1);
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("help_about", _u8L("About OrcaSlicer"), _u8L("Help"), "OrcaSlicer_gradient_circle", [](const std::string&) {
Slic3r::GUI::about();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add_with_icon("open_wiki", _u8L("Open Wiki"), _u8L("Help"), "link_wiki_img", [](const std::string&) {
wxLaunchDefaultBrowser("https://www.orcaslicer.com/wiki/", wxBROWSER_NEW_WINDOW);
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add("open_youtube", _u8L("Open YouTube Channel"), _u8L("Help"), [](const std::string&) {
wxLaunchDefaultBrowser("https://www.youtube.com/@OfficialOrcaSlicer/videos", wxBROWSER_NEW_WINDOW);
return AppActionRunResult{AppActionRunResult::Level::Success};
});
// ---- Plugins ----
add("open_plugins", _u8L("Open Plugins"), _u8L("Plugins"), [](const std::string&) {
wxGetApp().open_plugins_dialog();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add("refresh_plugins", _u8L("Refresh Plugins"), _u8L("Plugins"), [](const std::string&) {
wxGetApp().refresh_plugins();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add("install_plugin", _u8L("Install Plugin"), _u8L("Plugins"), [](const std::string&) {
open_plugin_hub();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
add("install_local_plugin", _u8L("Install Local Plugin"), _u8L("Plugins"), [](const std::string&) {
wxGetApp().install_local_plugin();
return AppActionRunResult{AppActionRunResult::Level::Success};
});
return out;
}
std::vector<NativeCommand>& catalog_storage()
{
static std::vector<NativeCommand> commands = build_command_catalog();
return commands;
}
} // namespace
const std::vector<NativeCommand>& NativeCommands::catalog()
{
return catalog_storage();
}
void NativeCommands::rebuild_catalog()
{
// build_command_catalog() re-runs _u8L under the current locale, so replacing the storage
// refreshes every translated title/group after a language switch.
catalog_storage() = build_command_catalog();
}
std::unique_ptr<AppAction> NativeCommands::make_action(const NativeCommand& command)
{
return std::make_unique<CommandAction>(command);
}
AppActionRunResult NativeCommands::run(const std::string& key, const std::string& param)
{
GUI_App& app = wxGetApp();
if (app.is_closing())
return {};
for (const NativeCommand& c : catalog())
if (c.key == key)
return c.runner(param);
return {AppActionRunResult::Level::Info, _L("Unknown command.")};
}
}} // namespace Slic3r::GUI
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <functional>
#include <memory>
#include <string>
#include <vector>
#include "ActionRegistry.hpp" // for AppAction / AppActionRunResult
namespace Slic3r { namespace GUI {
// A built-in speed-dial command: identity + how to run it. make_action() wraps a value as a thin
// AppAction for the registry, so this catalog is the single source of truth for the behaviour
// (runner => an owner method), the presentation (title/group/input), and the tile pictogram
// (icon = an SVG base name under resources/images, "" for no icon).
struct NativeCommand
{
std::string key;
std::string title;
std::string group;
std::string input; // "percent"/"tab" or "" for immediate run
std::string icon; // SVG base name, or "" to render a blank tile
std::function<AppActionRunResult(const std::string& param)> runner;
};
namespace NativeCommands {
// The full built-in command catalog. Built on first use and reused; call rebuild_catalog() after a
// live UI language switch so the translated titles/groups match the new locale. UI thread only.
const std::vector<NativeCommand>& catalog();
// Rebuilds the catalog in the current locale. UI thread only.
void rebuild_catalog();
// Dispatches `key` to its runner (unknown keys return a quiet Info). UI thread only.
AppActionRunResult run(const std::string& key, const std::string& param = {});
// Materialises one catalog entry as a runnable AppAction. Keeps the catalog's identity,
// presentation and behaviour as the single source of truth; ActionRegistry only stores and
// dispatches the result. UI thread only.
std::unique_ptr<AppAction> make_action(const NativeCommand& command);
} // namespace NativeCommands
}} // namespace Slic3r::GUI
+21 -2
View File
@@ -10,6 +10,7 @@
#include "Widgets/Label.hpp"
#include <wx/button.h>
#include <wx/dcclient.h>
#include <wx/sizer.h>
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});
@@ -190,6 +201,7 @@ bool ButtonsListCtrl::InsertPage(size_t n, const wxString &text, bool bSelect /*
Slic3r::GUI::wxGetApp().UpdateDarkUI(btn);
m_pageButtons.insert(m_pageButtons.begin() + n, btn);
m_pageLabels.insert(m_pageLabels.begin() + n, text); // ORCA
m_pageIcons.insert(m_pageIcons.begin() + n, bmp_name);
m_buttons_sizer->Insert(n, new wxSizerItem(btn));
m_buttons_sizer->SetCols(m_buttons_sizer->GetCols() + 1);
m_sizer->Layout();
@@ -209,6 +221,7 @@ void ButtonsListCtrl::RemovePage(size_t n)
Button* btn = m_pageButtons[n];
m_pageButtons.erase(m_pageButtons.begin() + n);
m_pageLabels.erase(m_pageLabels.begin() + n); // ORCA
m_pageIcons.erase(m_pageIcons.begin() + n);
m_buttons_sizer->Remove(n);
#if __WXOSX__
RemoveChild(btn);
@@ -245,7 +258,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
@@ -254,6 +267,12 @@ wxString ButtonsListCtrl::GetPageText(size_t n) const
return btn->GetLabel();
}
// ORCA
wxString ButtonsListCtrl::GetPageLabel(size_t n) const
{
return n < m_pageLabels.size() ? m_pageLabels[n] : wxString();
}
// ORCA
void ButtonsListCtrl::SetOverflowButton(wxWindow* button)
{
+23
View File
@@ -33,6 +33,14 @@ public:
void SetPageText(size_t n, const wxString& strText);
void SetCompact(size_t n, bool compact); // ORCA
wxString GetPageText(size_t n) const;
// ORCA: the full page label, unaffected by SetCompact() blanking the button text.
wxString GetPageLabel(size_t n) const;
// Resource name the page was inserted with (empty for plugin pages, which pass a wxBitmap).
const std::string& GetPageIcon(size_t n) const
{
static const std::string empty;
return n < m_pageIcons.size() ? m_pageIcons[n] : empty;
}
wxFlexGridSizer* GetBtnsSizer(){return m_buttons_sizer;}; // ORCA
// ORCA: a companion widget shown right after the tab buttons (before any side_tools), e.g.
// an overflow indicator. Pass nullptr to remove it; ownership stays with the caller.
@@ -47,6 +55,7 @@ private:
int m_btn_margin;
int m_line_margin;
std::vector<wxString> m_pageLabels; // ORCA
std::vector<std::string> m_pageIcons; // ORCA: resource icon name per page, plugin pages empty
wxWindow* m_overflow_button{nullptr}; // ORCA
};
@@ -241,6 +250,20 @@ public:
return GetBtnsListCtrl()->GetPageText(n);
}
// ORCA: the real page label. GetPageText() returns the button label, which SetCompact() blanks.
wxString GetPageLabel(size_t n) const
{
wxCHECK_MSG(n < GetPageCount(), wxString(), wxS("Invalid page"));
return GetBtnsListCtrl()->GetPageLabel(n);
}
// Resource icon name the page was inserted with; empty for pages added with a wxBitmap.
std::string GetPageIcon(size_t n) const
{
wxCHECK_MSG(n < GetPageCount(), std::string(), wxS("Invalid page"));
return GetBtnsListCtrl()->GetPageIcon(n);
}
virtual bool SetPageImage(size_t WXUNUSED(n), int WXUNUSED(imageId)) override
{
return false;
+21 -1
View File
@@ -1,6 +1,7 @@
#include "OptionsGroup.hpp"
#include "ConfigExceptions.hpp"
#include "Plater.hpp"
#include "SettingsIndex.hpp"
#include "GUI_App.hpp"
#include "MainFrame.hpp"
#include "OG_CustomCtrl.hpp"
@@ -244,6 +245,25 @@ void OptionsGroup::append_line(const Line& line)
{
m_lines.emplace_back(line);
// Feed the searcher the row's wiki path (Line::label_path, for the Speed Dial's "open wiki"
// affordance) and the label the row actually draws, so a setting action is named like the page.
if (m_use_custom_ctrl) {
Search::SettingsIndex& index = wxGetApp().sidebar().settings_index();
const Preset::Type type = static_cast<Preset::Type>(config_type());
const bool multi = line.get_options().size() > 1;
for (const auto& opt : line.get_options()) {
if (!line.label_path.empty())
index.set_path(opt.opt_id, type, line.label_path);
// Mirror the sub-label OG_CustomCtrl draws for a multi-option row, so the palette
// names each field like the page does.
const std::string& leaf_src = opt.opt.label;
const wxString leaf = (leaf_src == L_CONTEXT("Top", "Layers") || leaf_src == L_CONTEXT("Bottom", "Layers")) ?
_L_CONTEXT(leaf_src, "Layers") :
_(leaf_src);
index.set_line_label(opt.opt_id, type, Search::compose_display_label(line.label, leaf, multi));
}
}
if (line.full_width && (line.widget != nullptr || !line.get_extra_widgets().empty()))
return;
@@ -650,7 +670,7 @@ Option ConfigOptionsGroup::get_option(const std::string& opt_key, int opt_index
m_opt_map.emplace(opt_id, pair);
if (m_use_custom_ctrl) // fill group and category values just for options from Settings Tab
wxGetApp().sidebar().get_searcher().add_key(opt_id, static_cast<Preset::Type>(this->config_type()), title, this->config_category());
wxGetApp().sidebar().settings_index().add_key(opt_id, static_cast<Preset::Type>(this->config_type()), title, this->config_category(), this->icon);
return Option(*m_config->def()->get(opt_key), opt_id);
}
+5 -1
View File
@@ -250,6 +250,10 @@ protected:
virtual void back_to_initial_value(const std::string& opt_key) {}
virtual void back_to_sys_value(const std::string& opt_key) {}
// Preset::Type of a settings group; -1 for groups not tied to a preset. Used by append_line to
// register each option's wiki path with the searcher. Overridden by ConfigOptionsGroup.
virtual int config_type() const { return -1; }
public:
static wxString get_url(const std::string& path_end);
static bool launch_browser(const std::string& path_end);
@@ -273,7 +277,7 @@ public:
OptionsGroup(parent, wxEmptyString, wxEmptyString, true, nullptr) {}
const wxString& config_category() const throw() { return m_config_category; }
int config_type() const throw() { return m_config_type; }
int config_type() const throw() override { return m_config_type; }
const t_opt_map& opt_map() const throw() { return m_opt_map; }
void set_config_category_and_type(const wxString &category, int type) { m_config_category = category; m_config_type = type; }
+3 -1
View File
@@ -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) {
+25 -15
View File
@@ -6422,6 +6422,11 @@ Search::OptionsSearcher& Sidebar::get_searcher()
return p->searcher;
}
Search::SettingsIndex& Sidebar::settings_index()
{
return p->searcher.index();
}
std::string& Sidebar::get_search_line()
{
return p->searcher.search_string();
@@ -7571,10 +7576,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<int>& 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(); });
@@ -12831,17 +12832,8 @@ void Plater::priv::on_action_add(SimpleEvent&)
//BBS: add plate from toolbar
void Plater::priv::on_action_add_plate(SimpleEvent&)
{
if (q != nullptr) {
take_snapshot("add partplate");
this->partplate_list.create_plate();
int new_plate = this->partplate_list.get_plate_count() - 1;
this->partplate_list.select_plate(new_plate);
update();
// BBS set default view
//q->get_camera().select_view("topfront");
q->get_camera().requires_zoom_to_plate = REQUIRES_ZOOM_TO_ALL_PLATE;
}
if (q != nullptr)
q->add_plate();
}
//BBS: remove plate from toolbar
@@ -21913,6 +21905,24 @@ int Plater::select_plate_by_hover_id(int hover_id, bool right_click, bool isModi
return ret;
}
//BBS: add an empty plate and switch to it (mirrors the toolbar's Add Plate).
int Plater::add_plate()
{
if (!p->can_add_plate())
return -1;
take_snapshot("add partplate");
int new_plate = p->partplate_list.create_plate();
if (new_plate < 0)
return new_plate;
p->partplate_list.select_plate(new_plate);
update();
// BBS set default view
//get_camera().select_view("topfront");
p->camera.requires_zoom_to_plate = REQUIRES_ZOOM_TO_ALL_PLATE;
return new_plate;
}
int Plater::duplicate_plate(int plate_index)
{
int index = plate_index, ret;
+4
View File
@@ -283,6 +283,7 @@ public:
std::vector<std::string>& types,
std::vector<size_t>* config_indices = nullptr);
Search::OptionsSearcher& get_searcher();
Search::SettingsIndex& settings_index();
std::string& get_search_line();
void update_printer_thumbnail();
@@ -781,6 +782,9 @@ public:
void apply_background_progress();
//BBS: select the plate by hover_id
int select_plate_by_hover_id(int hover_id, bool right_click = false, bool isModidyPlateName = false);
//BBS: add an empty plate and switch to it (the toolbar's Add Plate). Returns the new
// plate index, or -1 when the plate cap is reached.
int add_plate();
//BBS: delete the plate, index= -1 means the current plate
int delete_plate(int plate_index = -1);
int duplicate_plate(int plate_index = -1);
+185 -122
View File
@@ -126,15 +126,6 @@ PluginCapabilityType primary_capability_type_of(PluginManager& manager, const st
return capabilities.empty() ? PluginCapabilityType::Unknown : capabilities.front()->type();
}
std::vector<PluginDescriptor> current_cloud_metadata_snapshot()
{
std::vector<PluginDescriptor> cloud_entries;
for (const PluginDescriptor& entry : PluginManager::instance().get_plugin_descriptors(/*include_invalid=*/true))
if (entry.is_cloud_plugin())
cloud_entries.push_back(entry);
return cloud_entries;
}
PluginDescriptor as_cloud_only_descriptor(PluginDescriptor descriptor)
{
descriptor.plugin_root.clear();
@@ -150,41 +141,6 @@ PluginDescriptor as_cloud_only_descriptor(PluginDescriptor descriptor)
return descriptor;
}
void refresh_plugin_metadata_blocking(bool fetch_cloud)
{
PluginManager& manager = PluginManager::instance();
std::vector<std::string> not_found, unauthorized;
const std::vector<PluginDescriptor> current_cloud_metadata = fetch_cloud ? std::vector<PluginDescriptor>{} :
current_cloud_metadata_snapshot();
manager.rescan_plugins();
if (!fetch_cloud) {
manager.update_cloud_metadata(current_cloud_metadata);
return;
}
manager.fetch_plugins_from_cloud(&not_found, &unauthorized);
wxGetApp().CallAfter([not_found = std::move(not_found), unauthorized = std::move(unauthorized)]() {
if (wxGetApp().is_closing())
return;
Plater* plater = wxGetApp().plater();
if (plater == nullptr)
return;
for (const auto& uuid : not_found)
plater->get_notification_manager()->push_notification(NotificationType::CustomNotification,
NotificationManager::NotificationLevel::RegularNotificationLevel,
format(_L("Plugin %s is no longer available."), uuid));
for (const auto& uuid : unauthorized)
plater->get_notification_manager()->push_notification(NotificationType::CustomNotification,
NotificationManager::NotificationLevel::RegularNotificationLevel,
format(_L("Plugin %s access is unauthorized."), uuid));
});
}
std::string to_string(PluginUpdateStatus status);
nlohmann::json build_context_actions_payload(const PluginAvailableActions& available_actions);
@@ -448,6 +404,176 @@ bool take_plugin_operation_result(const std::shared_ptr<PluginOperationState>& s
}
} // namespace
// ── Dialog-independent plugin actions (also used by the speed dial) ───────────────────────────
namespace {
// Snapshot of the currently-known cloud plugin descriptors, used to refresh metadata without a
// network round-trip (kUseCurrentCloudMeta).
std::vector<PluginDescriptor> current_cloud_metadata_snapshot()
{
std::vector<PluginDescriptor> cloud_entries;
for (const PluginDescriptor& entry : PluginManager::instance().get_plugin_descriptors(/*include_invalid=*/true))
if (entry.is_cloud_plugin())
cloud_entries.push_back(entry);
return cloud_entries;
}
} // namespace
void refresh_plugin_metadata_blocking(bool fetch_cloud)
{
PluginManager& manager = PluginManager::instance();
std::vector<std::string> not_found, unauthorized;
const std::vector<PluginDescriptor> current_cloud_metadata = fetch_cloud ? std::vector<PluginDescriptor>{} :
current_cloud_metadata_snapshot();
manager.rescan_plugins();
if (!fetch_cloud) {
manager.update_cloud_metadata(current_cloud_metadata);
return;
}
manager.fetch_plugins_from_cloud(&not_found, &unauthorized);
wxGetApp().CallAfter([not_found = std::move(not_found), unauthorized = std::move(unauthorized)]() {
if (wxGetApp().is_closing())
return;
Plater* plater = wxGetApp().plater();
if (plater == nullptr)
return;
for (const auto& uuid : not_found)
plater->get_notification_manager()->push_notification(NotificationType::CustomNotification,
NotificationManager::NotificationLevel::RegularNotificationLevel,
format(_L("Plugin %s is no longer available."), uuid));
for (const auto& uuid : unauthorized)
plater->get_notification_manager()->push_notification(NotificationType::CustomNotification,
NotificationManager::NotificationLevel::RegularNotificationLevel,
format(_L("Plugin %s access is unauthorized."), uuid));
});
}
void open_plugin_hub()
{
std::string cloud_base_url = "https://cloud.orcaslicer.com";
if (wxGetApp().getAgent()) {
auto orca_agent = std::dynamic_pointer_cast<OrcaCloudServiceAgent>(wxGetApp().getAgent()->get_cloud_agent());
if (orca_agent && !orca_agent->get_cloud_base_url().empty())
cloud_base_url = orca_agent->get_cloud_base_url();
}
while (!cloud_base_url.empty() && cloud_base_url.back() == '/')
cloud_base_url.pop_back();
if (cloud_base_url.empty())
cloud_base_url = "https://cloud.orcaslicer.com";
wxLaunchDefaultBrowser(wxString::FromUTF8(cloud_base_url + "/app/plugins/plugin-hub"));
}
bool install_local_plugin_package(const boost::filesystem::path& package_file, wxWindow* parent, wxString& message)
{
message.clear();
if (package_file.empty())
return false;
// ---- pre-flight (main thread): validate + inspect + overwrite prompt ----
const wxString package_name = from_u8(package_file.filename().string());
std::string extension = package_file.extension().string();
std::transform(extension.begin(), extension.end(), extension.begin(),
[](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
if (extension != ".py" && extension != ".whl") {
message = _L("Select a .py or .whl plugin package.");
return false;
}
PluginDescriptor plugin_descriptor;
bool existing_installation = false;
std::string error;
try {
if (!PluginManager::instance().inspect_local_plugin_package(package_file, plugin_descriptor, existing_installation, error)) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": Plugin package inspection failed for " << package_file << " error=" << error;
message = _L("Failed to install plugin package. See the log for details.");
return false;
}
} catch (const std::exception& ex) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": Plugin package inspection failed for " << package_file << " error=" << ex.what();
message = _L("Failed to install plugin package. See the log for details.");
return false;
} catch (...) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": Plugin package inspection failed for " << package_file;
message = _L("Failed to install plugin package. See the log for details.");
return false;
}
if (existing_installation) {
const wxString plugin_name = from_u8(plugin_descriptor.name.empty() ? package_file.filename().string() : plugin_descriptor.name);
wxMessageDialog dialog(parent,
wxString::Format(_L("Plugin \"%s\" is already installed.\n\nInstalling this package will overwrite the existing plugin."),
plugin_name),
kOverwritePluginTitle, wxOK | wxCANCEL | wxCANCEL_DEFAULT | wxICON_WARNING);
dialog.SetOKCancelLabels(_L("Overwrite"), _L("Cancel"));
if (dialog.ShowModal() != wxID_OK) {
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Plugin package installation cancelled before overwrite. package=" << package_file
<< " plugin=" << plugin_descriptor.name;
return false; // cancelled: message stays empty so callers stay silent
}
}
// ---- install + refresh on a worker behind a modal progress dialog (keeps the UI live) ----
bool installed = false;
{
struct Result
{
std::mutex mutex;
bool ok = false;
std::string error;
};
auto state = std::make_shared<Result>();
detail::run_wait_with_progress(
[state, package_file]() {
std::string error;
bool ok = false;
try {
ok = PluginManager::instance().install_plugin(package_file, error);
} catch (const std::exception& ex) {
error = ex.what();
} catch (...) {
error = "Unknown error";
}
if (ok) {
// Reflect the new package in discovery/cloud metadata without blocking the caller.
try { refresh_plugin_metadata_blocking(kUseCurrentCloudMeta); } catch (...) {}
}
std::lock_guard<std::mutex> lock(state->mutex);
state->ok = ok;
state->error = std::move(error);
},
parent, _L("Installing plugin"), _L("Installing plugin") + ": " + package_name, 100,
wxPD_APP_MODAL | wxPD_AUTO_HIDE | wxPD_ELAPSED_TIME, /*alive=*/nullptr, /*restore=*/{});
std::lock_guard<std::mutex> lock(state->mutex);
installed = state->ok;
error = std::move(state->error);
}
if (!installed) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << ": Plugin package installation failed for " << package_file << " error=" << error;
message = _L("Failed to install plugin package. See the log for details.");
return false;
}
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Plugin package installed successfully from " << package_file;
const wxString installed_name = from_u8(plugin_descriptor.name.empty() ? package_file.filename().string() : plugin_descriptor.name);
message = wxString::Format(_L("Installed \"%s\"."), installed_name);
return true;
}
PluginsDialog::PluginsDialog(wxWindow* parent, wxWindowID id, const wxString&, const wxPoint& pos, const wxSize& size, long style)
: WebViewHostDialog(parent, id, _L("Plugins"), pos, size, style)
{ create_webview("web/dialog/PluginsDialog/index.html", _L("Plugins"), wxSize(900, 820), wxSize(760, 715)); }
@@ -819,78 +945,31 @@ bool PluginsDialog::install_plugin_package(const std::string& package_path)
{
if (package_path.empty())
return false;
BOOST_LOG_TRIVIAL(info) << "Installing local plugin package from path: " << package_path;
std::string error;
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Installing local plugin package from path: " << package_path;
const boost::filesystem::path package_file(package_path);
const wxString package_name = from_u8(package_file.filename().string());
wxString message;
const bool installed = install_local_plugin_package(package_file, this, message);
// The helper's overwrite prompt and progress dialog can push this webview behind; re-raise it
// once, after both have closed (the speed-dial path parents to the mainframe instead).
restore_z_order();
std::string extension = package_file.extension().string();
std::transform(extension.begin(), extension.end(), extension.begin(),
[](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
if (extension != ".py" && extension != ".whl") {
show_status(_L("Select a .py or .whl plugin package."), "info");
return false;
}
PluginDescriptor plugin_descriptor;
bool existing_installation = false;
auto report_inspection_failure = [&]() {
BOOST_LOG_TRIVIAL(error) << "Plugin package inspection failed for " << package_path << " error=" << error;
show_status(_L("Failed to install plugin package. See the log for details."), "warn");
// The shared helper reports a user-cancelled overwrite with an empty message: stay silent.
if (message.IsEmpty()) {
send_plugins();
return false;
};
try {
if (!PluginManager::instance().inspect_local_plugin_package(package_file, plugin_descriptor, existing_installation, error))
return report_inspection_failure();
} catch (const std::exception& ex) {
error = ex.what();
return report_inspection_failure();
} catch (...) {
error = "Unknown error";
return report_inspection_failure();
}
if (existing_installation) {
const wxString plugin_name = from_u8(plugin_descriptor.name.empty() ? package_file.filename().string() : plugin_descriptor.name);
wxMessageDialog dialog(
this,
wxString::Format(_L("Plugin \"%s\" is already installed.\n\nInstalling this package will overwrite the existing plugin."),
plugin_name),
kOverwritePluginTitle, wxOK | wxCANCEL | wxCANCEL_DEFAULT | wxICON_WARNING);
dialog.SetOKCancelLabels(_L("Overwrite"), _L("Cancel"));
const int overwrite_rc = dialog.ShowModal();
restore_z_order();
if (overwrite_rc != wxID_OK) {
BOOST_LOG_TRIVIAL(info) << "Plugin package installation cancelled before overwrite. package=" << package_path
<< " plugin=" << plugin_descriptor.name;
return false;
}
}
bool installed = false;
try {
installed = run_with_dialog_wait([package_file, &error]() { return PluginManager::instance().install_plugin(package_file, error); },
_L("Installing plugin"), _L("Installing plugin") + ": " + package_name, 100,
wxPD_APP_MODAL | wxPD_AUTO_HIDE | wxPD_ELAPSED_TIME);
} catch (const std::exception& ex) {
error = ex.what();
} catch (...) {
error = "Unknown error";
}
if (!installed) {
BOOST_LOG_TRIVIAL(error) << "Plugin package installation failed for " << package_path << " error=" << error;
show_status(_L("Failed to install plugin package. See the log for details."), "warn");
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": Failed to install plugin package.";
show_status(message, "warn");
send_plugins();
return false;
}
BOOST_LOG_TRIVIAL(info) << "Plugin package installed successfully from " << package_path;
const wxString installed_name = from_u8(plugin_descriptor.name.empty() ? package_file.filename().string() : plugin_descriptor.name);
show_status(wxString::Format(_L("Installed \"%s\"."), installed_name), "success");
refresh_plugin_metadata_async(_L("Refreshing"), _L("Refreshing plugins data"), kUseCurrentCloudMeta);
show_status(message, "success");
prompt_for_missing_plugins();
send_plugins();
return true;
}
@@ -1086,23 +1165,7 @@ void PluginsDialog::open_plugin_on_cloud(const std::string& sharing_token)
wxLaunchDefaultBrowser(wxString::FromUTF8(orca_agent->get_cloud_base_url() + "/p/" + sharing_token));
}
void PluginsDialog::open_plugin_hub()
{
std::string cloud_base_url = "https://cloud.orcaslicer.com";
if (wxGetApp().getAgent()) {
auto orca_agent = std::dynamic_pointer_cast<OrcaCloudServiceAgent>(wxGetApp().getAgent()->get_cloud_agent());
if (orca_agent && !orca_agent->get_cloud_base_url().empty())
cloud_base_url = orca_agent->get_cloud_base_url();
}
while (!cloud_base_url.empty() && cloud_base_url.back() == '/')
cloud_base_url.pop_back();
if (cloud_base_url.empty())
cloud_base_url = "https://cloud.orcaslicer.com";
wxLaunchDefaultBrowser(wxString::FromUTF8(cloud_base_url + "/app/plugins/plugin-hub"));
}
void PluginsDialog::open_plugin_hub() { Slic3r::GUI::open_plugin_hub(); }
void PluginsDialog::delete_local_plugin(const PluginDescriptor& plugin)
{
+183 -124
View File
@@ -25,6 +25,8 @@
#include <wx/string.h>
#include <wx/timer.h>
#include <boost/filesystem.hpp>
class wxTimer;
namespace Slic3r {
@@ -35,6 +37,184 @@ enum class PluginCapabilityType;
namespace GUI {
// Dialog-independent plugin-management actions, shared by the Plugins dialog and the speed dial:
// they never require the webview dialog to be open.
// Rescans local plugins and (optionally) re-fetches cloud metadata. Blocking: run off the UI
// thread. Used by PluginsDialog (behind its progress dialog) and GUI_App::refresh_plugins().
void refresh_plugin_metadata_blocking(bool fetch_cloud);
// Opens the Cloud plugin hub in the default browser. No dialog needed.
void open_plugin_hub();
// Synchronously installs a local plugin package (.py/.whl). Runs on the UI thread but keeps it
// responsive by performing the install on a worker behind a modal progress dialog. `parent` owns
// the overwrite prompt and the progress dialog. On success `message` carries the localized
// confirmation; on a user-cancelled overwrite it is empty; on failure it carries the reason.
bool install_local_plugin_package(const boost::filesystem::path& package_file, wxWindow* parent, wxString& message);
namespace detail {
// Shared worker + modal-progress machinery: pulse a progress dialog while `run` executes on a
// detached worker, then run `on_finish` back on the UI thread. `alive`, when non-null, gates both
// the pulse and `on_finish` so a worker outliving its dialog can't touch freed windows; pass null
// for a dialog-independent caller. `restore` runs after the progress dialog is destroyed and before
// `on_finish`, so a webview host can re-raise itself. `finish_after_dialog_destroyed` still calls
// `on_finish` (without touching the dialog) when the host died, so a waiting loop can exit.
template<typename Run, typename OnFinish>
void run_off_thread_with_progress(Run&& run,
OnFinish&& on_finish,
wxWindow* parent,
const wxString& title,
const wxString& message,
int maximum,
int style,
std::shared_ptr<std::atomic<bool>> alive,
bool finish_after_dialog_destroyed,
std::function<void()> restore)
{
wxProgressDialog* progress = new wxProgressDialog(title, message, maximum, parent, style);
wxTimer* timer = new wxTimer();
timer->Bind(wxEVT_TIMER, [alive, progress, message](wxTimerEvent&) {
if ((!alive || alive->load(std::memory_order_acquire)) && progress)
progress->Pulse(message);
});
timer->Start(100);
std::thread([alive,
progress,
timer,
run = std::forward<Run>(run),
on_finish = std::forward<OnFinish>(on_finish),
finish_after_dialog_destroyed,
restore = std::move(restore)]() mutable {
try {
run();
} catch (const std::exception& ex) {
BOOST_LOG_TRIVIAL(error) << "Plugin dialog worker failed: " << ex.what();
} catch (...) {
BOOST_LOG_TRIVIAL(error) << "Plugin dialog worker failed with an unknown exception";
}
if (wxTheApp == nullptr)
return;
wxTheApp->CallAfter([alive,
progress,
timer,
on_finish = std::move(on_finish),
finish_after_dialog_destroyed,
restore = std::move(restore)]() mutable {
timer->Stop();
delete timer;
if (!alive || alive->load(std::memory_order_acquire)) {
progress->Destroy();
if (restore)
restore();
on_finish();
} else if (finish_after_dialog_destroyed) {
on_finish();
}
});
}).detach();
}
// Wait for a worker behind a progress dialog, returning its result (or rethrowing). The waiting
// loop stays responsive because it pumps the event loop the worker posts its completion into.
template<typename Run>
std::invoke_result_t<std::decay_t<Run>&> run_wait_with_progress(Run&& run,
wxWindow* parent,
const wxString& title,
const wxString& message,
int maximum,
int style,
std::shared_ptr<std::atomic<bool>> alive,
std::function<void()> restore)
{
using Result = std::invoke_result_t<std::decay_t<Run>&>;
bool finished = false;
wxEventLoop loop;
auto on_finish = [&finished, &loop]() {
finished = true;
if (loop.IsRunning())
loop.Exit();
};
if constexpr (std::is_void_v<Result>) {
struct WaitState
{
std::mutex mutex;
std::exception_ptr exception;
};
auto state = std::make_shared<WaitState>();
run_off_thread_with_progress(
[run = std::forward<Run>(run), state]() mutable {
try {
run();
} catch (...) {
std::lock_guard<std::mutex> lock(state->mutex);
state->exception = std::current_exception();
}
},
on_finish, parent, title, message, maximum, style, std::move(alive), /*finish_after_dialog_destroyed=*/true, std::move(restore));
if (!finished)
loop.Run();
std::exception_ptr exception;
{
std::lock_guard<std::mutex> lock(state->mutex);
exception = state->exception;
}
if (exception)
std::rethrow_exception(exception);
} else {
using StoredResult = std::decay_t<Result>;
struct WaitState
{
std::mutex mutex;
std::optional<StoredResult> result;
std::exception_ptr exception;
};
auto state = std::make_shared<WaitState>();
run_off_thread_with_progress(
[run = std::forward<Run>(run), state]() mutable {
try {
StoredResult result = run();
std::lock_guard<std::mutex> lock(state->mutex);
state->result.emplace(std::move(result));
} catch (...) {
std::lock_guard<std::mutex> lock(state->mutex);
state->exception = std::current_exception();
}
},
on_finish, parent, title, message, maximum, style, std::move(alive), /*finish_after_dialog_destroyed=*/true, std::move(restore));
if (!finished)
loop.Run();
std::optional<StoredResult> result;
std::exception_ptr exception;
{
std::lock_guard<std::mutex> lock(state->mutex);
if (state->result)
result.emplace(std::move(*state->result));
exception = state->exception;
}
if (exception)
std::rethrow_exception(exception);
return std::move(*result);
}
}
} // namespace detail
class PluginsDialog : public Slic3r::GUI::WebViewHostDialog
{
public:
@@ -111,53 +291,8 @@ private:
int style = wxPD_APP_MODAL | wxPD_AUTO_HIDE, // | wxPD_CAN_ABORT for cancel button
bool finish_after_dialog_destroyed = false)
{
const auto alive = m_alive;
ProgressDialog* progress = new ProgressDialog(title, message, maximum, this, style);
wxTimer* timer = new wxTimer();
timer->Bind(wxEVT_TIMER, [alive, progress, message](wxTimerEvent&) {
if (alive->load(std::memory_order_acquire) && progress)
progress->Pulse(message);
});
timer->Start(100);
std::thread([this,
alive,
progress,
timer,
run = std::forward<Run>(run),
on_finish = std::forward<OnFinish>(on_finish),
finish_after_dialog_destroyed]() mutable {
try {
run();
} catch (const std::exception& ex) {
BOOST_LOG_TRIVIAL(error) << "Plugin dialog worker failed: " << ex.what();
} catch (...) {
BOOST_LOG_TRIVIAL(error) << "Plugin dialog worker failed with an unknown exception";
}
if (wxTheApp == nullptr)
return;
wxTheApp->CallAfter([this,
alive,
progress,
timer,
on_finish = std::move(on_finish),
finish_after_dialog_destroyed]() mutable {
timer->Stop();
delete timer;
if (alive->load(std::memory_order_acquire)) {
progress->Destroy();
restore_z_order();
on_finish();
} else if (finish_after_dialog_destroyed) {
on_finish();
}
});
}).detach();
detail::run_off_thread_with_progress(std::forward<Run>(run), std::forward<OnFinish>(on_finish), this, title, message, maximum, style,
m_alive, finish_after_dialog_destroyed, [this] { restore_z_order(); });
}
template<typename Run>
@@ -167,83 +302,7 @@ private:
int maximum = 100,
int style = wxPD_APP_MODAL | wxPD_AUTO_HIDE)
{
using Result = std::invoke_result_t<std::decay_t<Run>&>;
bool finished = false;
wxEventLoop loop;
auto on_finish = [&finished, &loop]() {
finished = true;
if (loop.IsRunning())
loop.Exit();
};
if constexpr (std::is_void_v<Result>) {
struct WaitState
{
std::mutex mutex;
std::exception_ptr exception;
};
auto state = std::make_shared<WaitState>();
run_with_dialog(
[run = std::forward<Run>(run), state]() mutable {
try {
run();
} catch (...) {
std::lock_guard<std::mutex> lock(state->mutex);
state->exception = std::current_exception();
}
},
on_finish, title, message, maximum, style, true);
if (!finished)
loop.Run();
std::exception_ptr exception;
{
std::lock_guard<std::mutex> lock(state->mutex);
exception = state->exception;
}
if (exception)
std::rethrow_exception(exception);
} else {
using StoredResult = std::decay_t<Result>;
struct WaitState
{
std::mutex mutex;
std::optional<StoredResult> result;
std::exception_ptr exception;
};
auto state = std::make_shared<WaitState>();
run_with_dialog(
[run = std::forward<Run>(run), state]() mutable {
try {
StoredResult result = run();
std::lock_guard<std::mutex> lock(state->mutex);
state->result.emplace(std::move(result));
} catch (...) {
std::lock_guard<std::mutex> lock(state->mutex);
state->exception = std::current_exception();
}
},
on_finish, title, message, maximum, style, true);
if (!finished)
loop.Run();
std::optional<StoredResult> result;
std::exception_ptr exception;
{
std::lock_guard<std::mutex> lock(state->mutex);
if (state->result)
result.emplace(std::move(*state->result));
exception = state->exception;
}
if (exception)
std::rethrow_exception(exception);
return std::move(*result);
}
return detail::run_wait_with_progress(std::forward<Run>(run), this, title, message, maximum, style, m_alive, [this] { restore_z_order(); });
}
std::function<void()> m_open_terminal_dlg_fn;
+15
View File
@@ -1726,6 +1726,21 @@ void PreferencesDialog::create_items()
auto item_multi_machine = create_item_checkbox(_L("Multi device management"), _L("With this option enabled, you can send a task to multiple devices at the same time and manage multiple devices."), "enable_multi_machine", _L("(Requires restart)"));
g_sizer->Add(item_multi_machine);
auto item_speed_dial = create_item_checkbox(_L("Open the Speed Dial with the Space key"),
_L("When enabled, pressing Space (with no other key held) opens the Speed Dial action search from any page."),
"enable_speed_dial");
g_sizer->Add(item_speed_dial);
auto item_speed_dial_recents = create_item_spinctrl(
_L("Recent actions"),
"",
_L("actions"),
_L("How many recently launched actions to show at the top of the Speed Dial. Set to 0 to hide recent actions."),
SETTING_SPEED_DIAL_RECENT_COUNT,
SPEED_DIAL_RECENT_COUNT_MIN,
SPEED_DIAL_RECENT_COUNT_MAX);
g_sizer->Add(item_speed_dial_recents);
#ifdef SLIC3R_CAD
auto item_cad_feature = create_item_checkbox(_L("CAD feature (experimental)"),
_L("With this option enabled, the Design tab is shown, where models can be built and edited "
+6 -200
View File
@@ -62,95 +62,12 @@ static char marker_by_type(Preset::Type type, PrinterTechnology pt)
}
}
std::string Option::opt_key() const { return key.size() < 2 ? std::string() : into_u8(key).substr(2); }
void FoundOption::get_marked_label_and_tooltip(const char **label_, const char **tooltip_) const
{
*label_ = marked_label.c_str();
*tooltip_ = tooltip.c_str();
}
template<class T>
// void change_opt_key(std::string& opt_key, DynamicPrintConfig* config)
void change_opt_key(std::string &opt_key, DynamicPrintConfig *config, int &cnt)
{
T *opt_cur = static_cast<T *>(config->option(opt_key));
cnt = opt_cur->values.size();
return;
if (opt_cur->values.size() > 0) opt_key += "#" + std::to_string(0);
}
static std::string get_key(const std::string &opt_key, Preset::Type type) { return std::to_string(int(type)) + ";" + opt_key; }
void OptionsSearcher::append_options(DynamicPrintConfig *config, Preset::Type type, ConfigOptionMode mode)
{
auto emplace = [this, type](const std::string key, const wxString &label) {
const GroupAndCategory &gc = groups_and_categories[key];
if (gc.group.IsEmpty() || gc.category.IsEmpty()) return;
wxString suffix;
wxString suffix_local;
if (gc.category == "Machine limits") {
//suffix = key.back() == '1' ? L("Stealth") : L("Normal");
suffix = key.back() == '1' ? wxEmptyString : wxEmptyString;
suffix_local = " " + _(suffix);
suffix = " " + suffix;
}
if (!label.IsEmpty())
options.emplace_back(Option{boost::nowide::widen(key), type, (label + suffix).ToStdWstring(), (_(label) + suffix_local).ToStdWstring(), gc.group.ToStdWstring(),
_(gc.group).ToStdWstring(), gc.category.ToStdWstring(), GUI::Tab::translate_category(gc.category, type).ToStdWstring()});
};
for (std::string opt_key : config->keys()) {
const ConfigOptionDef &opt = config->def()->options.at(opt_key);
if (opt.mode > mode) continue;
int cnt = 0;
if ((type == Preset::TYPE_SLA_MATERIAL || type == Preset::TYPE_PRINTER || type == Preset::TYPE_PRINT) && opt_key != "printable_area")
switch (config->option(opt_key)->type()) {
case coInts: change_opt_key<ConfigOptionInts>(opt_key, config, cnt); break;
case coBools: change_opt_key<ConfigOptionBools>(opt_key, config, cnt); break;
case coFloats: change_opt_key<ConfigOptionFloats>(opt_key, config, cnt); break;
case coStrings: change_opt_key<ConfigOptionStrings>(opt_key, config, cnt); break;
case coPercents: change_opt_key<ConfigOptionPercents>(opt_key, config, cnt); break;
case coFloatsOrPercents: change_opt_key<ConfigOptionVector<FloatOrPercent>>(opt_key, config, cnt); break;
case coPoints: change_opt_key<ConfigOptionPoints>(opt_key, config, cnt); break;
// BBS
case coEnums: change_opt_key<ConfigOptionInts>(opt_key, config, cnt); break;
default: break;
}
if (type == Preset::TYPE_FILAMENT && filament_options_with_variant.find(opt_key) != filament_options_with_variant.end())
opt_key += "#0";
wxString label = opt.full_label.empty() ? opt.label : opt.full_label;
std::string key = get_key(opt_key, type);
if (cnt == 0)
emplace(key, label);
else
for (int i = 0; i < cnt; ++i)
// ! It's very important to use "#". opt_key#n is a real option key used in GroupAndCategory
emplace(key + "#" + std::to_string(i), label);
}
}
inline void OptionsSearcher::sort_options()
{
std::sort(options.begin(), options.end(), [](const Option &o1, const Option &o2) { return o1.label < o2.label; });
Option * last = nullptr;
for (auto& opt : options) {
if (last && last->label == opt.label && last->group == opt.group && last->type == opt.type && last->category != opt.category) {
last->multi_category = true;
opt.multi_category = true;
}
last = &opt;
}
}
// Mark a string using ColorMarkerStart and ColorMarkerEnd symbols
static std::wstring mark_string(const std::wstring &str, const std::vector<uint16_t> &matches, Preset::Type type, PrinterTechnology pt)
{
@@ -235,7 +152,8 @@ bool OptionsSearcher::search(const std::string &search, bool force /* = false*/,
return wxString(marker_by_type(opt.type, printer_technology)) + opt.category_local + sep + opt.group_local + sep + opt.label_local;
};
std::vector<uint16_t> matches, matches2;
std::vector<uint16_t> matches, matches2;
const std::vector<Option> &options = m_index.options();
for (size_t i = 0; i < options.size(); i++) {
const Option &opt = options[i];
if (full_list) {
@@ -307,128 +225,21 @@ OptionsSearcher::~OptionsSearcher() {}
void OptionsSearcher::init(std::vector<InputInfo> input_values)
{
options.clear();
for (auto i : input_values) append_options(i.config, i.type, i.mode);
sort_options();
m_index.init(std::move(input_values));
search(search_line, true, search_type);
}
void OptionsSearcher::apply(DynamicPrintConfig *config, Preset::Type type, ConfigOptionMode mode)
{
if (options.empty()) return;
options.erase(std::remove_if(options.begin(), options.end(), [type](Option opt) { return opt.type == type; }), options.end());
append_options(config, type, mode);
sort_options();
search(search_line, true, search_type);
if (m_index.apply(config, type, mode))
search(search_line, true, search_type);
}
const Option &OptionsSearcher::get_option(size_t pos_in_filter) const
{
assert(pos_in_filter != size_t(-1) && found[pos_in_filter].option_idx != size_t(-1));
return options[found[pos_in_filter].option_idx];
}
const Option &OptionsSearcher::get_option(const std::string &opt_key, Preset::Type type, int &variant_index) const
{
auto not_found = [&variant_index]() -> const Option& {
static const Option empty_option;
variant_index = -2;
return empty_option;
};
variant_index = -1;
std::string opt_key2 = opt_key;
if (auto n = opt_key.find('#'); n != std::string::npos) {
variant_index = std::atoi(opt_key.c_str() + n + 1);
opt_key2 = opt_key.substr(0, n);
}
const std::wstring key = boost::nowide::widen(get_key(opt_key2, type));
auto it = std::lower_bound(options.begin(), options.end(), Option({key}));
if (it == options.end()) return not_found();
if (it->key == key) {
variant_index = -1;
} else {
const std::wstring prefix = key + L"#";
it = std::lower_bound(it, options.end(), Option({prefix}));
if (it == options.end() || it->key.compare(0, prefix.length(), prefix) != 0)
return not_found();
// Orca: Copy-parameters dialogs request the base key, without a vector index.
if (variant_index < 0) return *it;
const bool has_mode = type == Preset::TYPE_PRINTER && printer_options_with_variant_2.count(opt_key2) > 0;
const bool has_variant =
(type == Preset::TYPE_PRINT && print_options_with_variant.count(opt_key2) > 0) ||
(type == Preset::TYPE_FILAMENT && filament_options_with_variant.count(opt_key2) > 0) ||
(type == Preset::TYPE_PRINTER && printer_options_with_variant_1.count(opt_key2) > 0) || has_mode;
if (!has_variant || has_mode) {
// Orca: Machine limits store (Normal, Silent) pairs per variant; the UI registers only #0/#1.
const std::wstring indexed_key = has_mode ? prefix + std::to_wstring(variant_index % 2) :
boost::nowide::widen(get_key(opt_key, type));
it = std::lower_bound(it, options.end(), Option({indexed_key}));
if (it == options.end() || it->key != indexed_key)
return not_found();
if (!has_variant)
variant_index = -1;
}
}
return options[it - options.begin()];
}
static Option create_option(const std::string &opt_key, const wxString &label, Preset::Type type, const GroupAndCategory &gc)
{
wxString suffix;
wxString suffix_local;
if (gc.category == "Machine limits") {
//suffix = opt_key.back() == '1' ? L("Stealth") : L("Normal");
suffix = opt_key.back() == '1' ? wxEmptyString : wxEmptyString;
suffix_local = " " + _(suffix);
suffix = " " + suffix;
}
wxString category = gc.category;
if (type == Preset::TYPE_PRINTER && category.Contains("Extruder ")) {
std::string opt_idx = opt_key.substr(opt_key.find("#") + 1);
category = wxString::Format("%s %d", "Extruder", atoi(opt_idx.c_str()) + 1);
}
return Option{boost::nowide::widen(get_key(opt_key, type)),
type,
(label + suffix).ToStdWstring(),
(_(label) + suffix_local).ToStdWstring(),
gc.group.ToStdWstring(),
_(gc.group).ToStdWstring(),
gc.category.ToStdWstring(),
GUI::Tab::translate_category(category, type).ToStdWstring()};
}
Option OptionsSearcher::get_option(const std::string &opt_key, const wxString &label, Preset::Type type) const
{
std::string key = get_key(opt_key, type);
auto it = std::lower_bound(options.begin(), options.end(), Option({boost::nowide::widen(key)}));
// BBS: return the 0th option when not found in searcher caused by mode difference
if (it == options.end()) return options[0];
if (it->key == boost::nowide::widen(key)) return options[it - options.begin()];
if (groups_and_categories.find(key) == groups_and_categories.end()) {
size_t pos = key.find('#');
if (pos == std::string::npos) return options[it - options.begin()];
std::string zero_opt_key = key.substr(0, pos + 1) + "0";
if (groups_and_categories.find(zero_opt_key) == groups_and_categories.end()) return options[it - options.begin()];
return create_option(opt_key, label, type, groups_and_categories.at(zero_opt_key));
}
const GroupAndCategory &gc = groups_and_categories.at(key);
if (gc.group.IsEmpty() || gc.category.IsEmpty()) return options[it - options.begin()];
return create_option(opt_key, label, type, gc);
return m_index.option_at(found[pos_in_filter].option_idx);
}
void OptionsSearcher::show_dialog(Preset::Type type, wxWindow *parent, TextInput *input, wxWindow* ssearch_btn)
@@ -455,11 +266,6 @@ void OptionsSearcher::dlg_msw_rescale()
{
if (search_dialog) search_dialog->msw_rescale();
}
void OptionsSearcher::add_key(const std::string &opt_key, Preset::Type type, const wxString &group, const wxString &category)
{
groups_and_categories[get_key(opt_key, type)] = GroupAndCategory{group, category};
}
//------------------------------------------
// SearchItem
//------------------------------------------
+10 -54
View File
@@ -19,6 +19,7 @@
#include "wxExtensions.hpp"
#include "GUI_Utils.hpp"
#include "libslic3r/Preset.hpp"
#include "SettingsIndex.hpp"
#include "Widgets/ScrolledWindow.hpp"
#include "Widgets/TextInput.hpp"
#include "Widgets/PopupWindow.hpp"
@@ -34,39 +35,6 @@ namespace Search {
class SearchDialog;
struct InputInfo
{
DynamicPrintConfig *config{nullptr};
Preset::Type type{Preset::TYPE_INVALID};
ConfigOptionMode mode{comSimple};
};
struct GroupAndCategory
{
wxString group;
wxString category;
};
struct Option
{
// bool operator<(const Option& other) const { return other.label > this->label; }
bool operator<(const Option &other) const { return other.key > this->key; }
// Fuzzy matching works at a character level. Thus matching with wide characters is a safer bet than with short characters,
// though for some languages (Chinese?) it may not work correctly.
std::wstring key;
Preset::Type type{Preset::TYPE_INVALID};
std::wstring label;
std::wstring label_local;
std::wstring group;
std::wstring group_local;
std::wstring category;
std::wstring category_local;
bool multi_category { false };
std::string opt_key() const;
};
struct FoundOption
{
// UTF8 encoding, to be consumed by ImGUI by reference.
@@ -90,25 +58,19 @@ struct OptionViewParameters
class OptionsSearcher
{
std::string search_line;
Preset::Type search_type = Preset::TYPE_INVALID;
SettingsIndex m_index;
std::map<std::string, GroupAndCategory> groups_and_categories;
PrinterTechnology printer_technology;
std::vector<Option> options{};
std::string search_line;
Preset::Type search_type = Preset::TYPE_INVALID;
PrinterTechnology printer_technology;
std::vector<FoundOption> found{};
void append_options(DynamicPrintConfig *config, Preset::Type type, ConfigOptionMode mode);
void sort_options();
void sort_found()
{
std::sort(found.begin(), found.end(),
[](const FoundOption &f1, const FoundOption &f2) { return f1.outScore > f2.outScore || (f1.outScore == f2.outScore && f1.label < f2.label); });
};
size_t options_size() const { return options.size(); }
size_t found_size() const { return found.size(); }
public:
@@ -119,32 +81,26 @@ public:
OptionsSearcher();
~OptionsSearcher();
SettingsIndex & index() { return m_index; }
const SettingsIndex &index() const { return m_index; }
// Rebuild the catalog and re-run the current query so the cached results track it.
void init(std::vector<InputInfo> input_values);
void apply(DynamicPrintConfig *config, Preset::Type type, ConfigOptionMode mode);
bool search();
bool search(const std::string &search, bool force = false, Preset::Type type = Preset::TYPE_INVALID);
void add_key(const std::string &opt_key, Preset::Type type, const wxString &group, const wxString &category);
size_t size() const { return found_size(); }
const FoundOption &operator[](const size_t pos) const noexcept { return found[pos]; }
const Option & get_option(size_t pos_in_filter) const;
const Option & get_option(const std::string &opt_key, Preset::Type type, int &variant_index) const;
Option get_option(const std::string &opt_key, const wxString &label, Preset::Type type) const;
const std::vector<FoundOption> &found_options() { return found; }
const GroupAndCategory & get_group_and_category(const std::string &opt_key) { return groups_and_categories[opt_key]; }
std::string & search_string() { return search_line; }
void set_printer_technology(PrinterTechnology pt) { printer_technology = pt; }
void sort_options_by_key()
{
std::sort(options.begin(), options.end(), [](const Option &o1, const Option &o2) { return o1.key < o2.key; });
}
void sort_options_by_label() { sort_options(); }
void show_dialog(Preset::Type type, wxWindow *parent, TextInput *input, wxWindow *ssearch_btn);
void dlg_sys_color_changed();
void dlg_msw_rescale();
+258
View File
@@ -0,0 +1,258 @@
#include "SettingsIndex.hpp"
#include <algorithm>
#include <cstddef>
#include <cstdlib>
#include <string>
#include <vector>
#include <boost/nowide/convert.hpp>
#include "GUI.hpp"
#include "I18N.hpp"
#include "Tab.hpp"
#include "libslic3r/PrintConfig.hpp"
namespace Slic3r {
using GUI::into_u8;
namespace Search {
static std::string get_key(const std::string &opt_key, Preset::Type type) { return std::to_string(int(type)) + ";" + opt_key; }
std::string Option::opt_key() const { return key.size() < 2 ? std::string() : into_u8(key).substr(2); }
template<class T>
// void change_opt_key(std::string& opt_key, DynamicPrintConfig* config)
void change_opt_key(std::string &opt_key, DynamicPrintConfig *config, int &cnt)
{
T *opt_cur = static_cast<T *>(config->option(opt_key));
cnt = opt_cur->values.size();
return;
if (opt_cur->values.size() > 0) opt_key += "#" + std::to_string(0);
}
// Single assembler for an indexed Option, shared by append_options() and create_option(), so a new
// Option field is only wired up in one place.
static Option make_option(const std::string &key, Preset::Type type, const wxString &label, const GroupAndCategory &gc,
ConfigOptionMode mode, const std::string &tooltip, bool rewrite_extruder_category)
{
wxString suffix;
wxString suffix_local;
if (gc.category == "Machine limits") {
//suffix = key.back() == '1' ? L("Stealth") : L("Normal");
suffix = key.back() == '1' ? wxEmptyString : wxEmptyString;
suffix_local = " " + _(suffix);
suffix = " " + suffix;
}
wxString category = gc.category;
if (rewrite_extruder_category && type == Preset::TYPE_PRINTER && category.Contains("Extruder ")) {
std::string opt_idx = key.substr(key.find("#") + 1);
category = wxString::Format("%s %d", "Extruder", atoi(opt_idx.c_str()) + 1);
}
Option option{boost::nowide::widen(key), type, (label + suffix).ToStdWstring(), (_(label) + suffix_local).ToStdWstring(),
gc.group.ToStdWstring(), _(gc.group).ToStdWstring(), into_u8(gc.icon), gc.category.ToStdWstring(),
GUI::Tab::translate_category(category, type).ToStdWstring(), false, mode, tooltip, gc.path,
// The settings page draws Line::label; carrying it lets the Speed Dial name a setting
// the way the page does. `label`/`label_local` stay the search-oriented name.
into_u8(gc.line_label)};
return option;
}
void SettingsIndex::append_options(DynamicPrintConfig *config, Preset::Type type, ConfigOptionMode mode)
{
for (std::string opt_key : config->keys()) {
const ConfigOptionDef &opt = config->def()->options.at(opt_key);
const bool in_filtered = opt.mode <= mode;
int cnt = 0;
if ((type == Preset::TYPE_SLA_MATERIAL || type == Preset::TYPE_PRINTER || type == Preset::TYPE_PRINT) && opt_key != "printable_area")
switch (config->option(opt_key)->type()) {
case coInts: change_opt_key<ConfigOptionInts>(opt_key, config, cnt); break;
case coBools: change_opt_key<ConfigOptionBools>(opt_key, config, cnt); break;
case coFloats: change_opt_key<ConfigOptionFloats>(opt_key, config, cnt); break;
case coStrings: change_opt_key<ConfigOptionStrings>(opt_key, config, cnt); break;
case coPercents: change_opt_key<ConfigOptionPercents>(opt_key, config, cnt); break;
case coFloatsOrPercents: change_opt_key<ConfigOptionVector<FloatOrPercent>>(opt_key, config, cnt); break;
case coPoints: change_opt_key<ConfigOptionPoints>(opt_key, config, cnt); break;
// BBS
case coEnums: change_opt_key<ConfigOptionInts>(opt_key, config, cnt); break;
default: break;
}
if (type == Preset::TYPE_FILAMENT && filament_options_with_variant.find(opt_key) != filament_options_with_variant.end())
opt_key += "#0";
wxString label = opt.full_label.empty() ? opt.label : opt.full_label;
std::string key = get_key(opt_key, type);
auto add = [&](const std::string &k) {
const GroupAndCategory &gc = m_groups_and_categories[k];
if (gc.group.IsEmpty() || gc.category.IsEmpty() || label.IsEmpty()) return;
const std::string tooltip = into_u8(_(opt.tooltip));
if (in_filtered)
m_options.emplace_back(make_option(k, type, label, gc, opt.mode, tooltip, false));
m_all_modes.emplace_back(make_option(k, type, label, gc, opt.mode, tooltip, false));
};
if (cnt == 0)
add(key);
else
for (int i = 0; i < cnt; ++i)
// ! It's very important to use "#". opt_key#n is a real option key used in GroupAndCategory
add(key + "#" + std::to_string(i));
}
}
void SettingsIndex::sort_options()
{
// Both views are label-sorted and multi_category-marked. They are separate consumers (the sidebar
// search and the Speed Dial); keeping them in sync here prevents the all-modes view from silently
// diverging in order or flags.
auto sort_and_mark = [](std::vector<Option> &v) {
std::sort(v.begin(), v.end(), [](const Option &o1, const Option &o2) { return o1.label < o2.label; });
Option *last = nullptr;
for (auto &opt : v) {
if (last && last->label == opt.label && last->group == opt.group && last->type == opt.type && last->category != opt.category) {
last->multi_category = true;
opt.multi_category = true;
}
last = &opt;
}
};
sort_and_mark(m_options);
sort_and_mark(m_all_modes);
}
void SettingsIndex::init(std::vector<InputInfo> input_values)
{
m_options.clear();
m_all_modes.clear();
for (auto i : input_values) append_options(i.config, i.type, i.mode);
sort_options();
}
bool SettingsIndex::apply(DynamicPrintConfig *config, Preset::Type type, ConfigOptionMode mode)
{
// m_all_modes is a separate consumer (the Speed Dial), so "nothing initialised yet" means both
// views are empty - the mode-filtered m_options can be empty while m_all_modes is not.
if (m_options.empty() && m_all_modes.empty()) return false;
m_options.erase(std::remove_if(m_options.begin(), m_options.end(), [type](Option opt) { return opt.type == type; }), m_options.end());
m_all_modes.erase(std::remove_if(m_all_modes.begin(), m_all_modes.end(), [type](Option opt) { return opt.type == type; }), m_all_modes.end());
append_options(config, type, mode);
sort_options();
return true;
}
const Option &SettingsIndex::get_option(const std::string &opt_key, Preset::Type type, int &variant_index) const
{
auto not_found = [&variant_index]() -> const Option & {
static const Option empty_option;
variant_index = -2;
return empty_option;
};
variant_index = -1;
std::string opt_key2 = opt_key;
if (auto n = opt_key.find('#'); n != std::string::npos) {
variant_index = std::atoi(opt_key.c_str() + n + 1);
opt_key2 = opt_key.substr(0, n);
}
const std::wstring key = boost::nowide::widen(get_key(opt_key2, type));
auto it = std::lower_bound(m_options.begin(), m_options.end(), Option({key}));
if (it == m_options.end()) return not_found();
if (it->key == key) {
variant_index = -1;
} else {
const std::wstring prefix = key + L"#";
it = std::lower_bound(it, m_options.end(), Option({prefix}));
if (it == m_options.end() || it->key.compare(0, prefix.length(), prefix) != 0)
return not_found();
// Orca: Copy-parameters dialogs request the base key, without a vector index.
if (variant_index < 0) return *it;
const bool has_mode = type == Preset::TYPE_PRINTER && printer_options_with_variant_2.count(opt_key2) > 0;
const bool has_variant =
(type == Preset::TYPE_PRINT && print_options_with_variant.count(opt_key2) > 0) ||
(type == Preset::TYPE_FILAMENT && filament_options_with_variant.count(opt_key2) > 0) ||
(type == Preset::TYPE_PRINTER && printer_options_with_variant_1.count(opt_key2) > 0) || has_mode;
if (!has_variant || has_mode) {
// Orca: Machine limits store (Normal, Silent) pairs per variant; the UI registers only #0/#1.
const std::wstring indexed_key = has_mode ? prefix + std::to_wstring(variant_index % 2) :
boost::nowide::widen(get_key(opt_key, type));
it = std::lower_bound(it, m_options.end(), Option({indexed_key}));
if (it == m_options.end() || it->key != indexed_key)
return not_found();
if (!has_variant)
variant_index = -1;
}
}
return m_options[it - m_options.begin()];
}
static Option create_option(const std::string &opt_key, const wxString &label, Preset::Type type, const GroupAndCategory &gc)
{
return make_option(get_key(opt_key, type), type, label, gc, comSimple, std::string(), true);
}
Option SettingsIndex::get_option(const std::string &opt_key, const wxString &label, Preset::Type type) const
{
std::string key = get_key(opt_key, type);
auto it = std::lower_bound(m_options.begin(), m_options.end(), Option({boost::nowide::widen(key)}));
// BBS: return the 0th option when not found in searcher caused by mode difference
if (it == m_options.end()) return m_options[0];
if (it->key == boost::nowide::widen(key)) return m_options[it - m_options.begin()];
if (m_groups_and_categories.find(key) == m_groups_and_categories.end()) {
size_t pos = key.find('#');
if (pos == std::string::npos) return m_options[it - m_options.begin()];
std::string zero_opt_key = key.substr(0, pos + 1) + "0";
if (m_groups_and_categories.find(zero_opt_key) == m_groups_and_categories.end()) return m_options[it - m_options.begin()];
return create_option(opt_key, label, type, m_groups_and_categories.at(zero_opt_key));
}
const GroupAndCategory &gc = m_groups_and_categories.at(key);
if (gc.group.IsEmpty() || gc.category.IsEmpty()) return m_options[it - m_options.begin()];
return create_option(opt_key, label, type, gc);
}
void SettingsIndex::add_key(const std::string &opt_key, Preset::Type type, const wxString &group, const wxString &category, const wxString &icon)
{
// Update fields in place so a page rebuild (get_option after set_path) doesn't drop the
// previously recorded wiki path.
GroupAndCategory &gc = m_groups_and_categories[get_key(opt_key, type)];
gc.group = group;
gc.category = category;
gc.icon = icon;
}
void SettingsIndex::set_path(const std::string &opt_key, Preset::Type type, const std::string &path)
{
if (path.empty())
return;
m_groups_and_categories[get_key(opt_key, type)].path = path;
}
void SettingsIndex::set_line_label(const std::string &opt_key, Preset::Type type, const wxString &label)
{
if (label.IsEmpty())
return;
m_groups_and_categories[get_key(opt_key, type)].line_label = label;
}
} // namespace Search
} // namespace Slic3r
+123
View File
@@ -0,0 +1,123 @@
#ifndef slic3r_SettingsIndex_hpp_
#define slic3r_SettingsIndex_hpp_
#include <algorithm>
#include <cstddef>
#include <map>
#include <string>
#include <vector>
#include <wx/string.h>
#include <libslic3r/Config.hpp>
#include <libslic3r/Preset.hpp>
namespace Slic3r {
namespace Search {
struct InputInfo
{
DynamicPrintConfig *config{nullptr};
Preset::Type type{Preset::TYPE_INVALID};
ConfigOptionMode mode{comSimple};
};
struct GroupAndCategory
{
wxString group;
wxString category;
wxString icon; // icon of the group's own header, or empty
wxString line_label; // label the settings row actually draws (Line::label), or empty
std::string path; // wiki path (Line::label_path) of the option's line, or empty
};
// Title for a setting: the row label, qualified with the field leaf when the row packs several
// options (e.g. "Cool Plate \u2013 First layer"). Pure; inputs are already localized.
inline wxString compose_display_label(const wxString& line_label, const wxString& leaf_label, bool multi)
{
if (line_label.empty())
return leaf_label;
if (!multi || leaf_label.empty() || leaf_label == line_label)
return line_label;
return line_label + L" \u2013 " + leaf_label; // en dash separator
}
// Title to show. A single-option row uses its live label, which can be renamed at runtime
// (brim_width -> "Brim ear radius"); otherwise fall back to the precomposed label.
inline wxString resolve_setting_title(const wxString& precomposed, const wxString& live_label, bool live_multi)
{
if (!live_multi && !live_label.empty())
return live_label;
return precomposed;
}
struct Option
{
// bool operator<(const Option& other) const { return other.label > this->label; }
bool operator<(const Option &other) const { return other.key > this->key; }
// Fuzzy matching works at a character level. Thus matching with wide characters is a safer bet than with short characters,
// though for some languages (Chinese?) it may not work correctly.
std::wstring key;
Preset::Type type{Preset::TYPE_INVALID};
std::wstring label;
std::wstring label_local;
std::wstring group;
std::wstring group_local;
std::string group_icon; // SVG base name of the group's own header icon, or empty
std::wstring category;
std::wstring category_local;
bool multi_category { false };
ConfigOptionMode mode{comSimple}; // option's visibility threshold; drives the Speed Dial's mode prompt
std::string tooltip; // localized ConfigOptionDef::tooltip, or empty
std::string wiki_path; // Line::label_path for the option's row, or empty
std::string display_label; // label the settings row draws (localized); empty falls back to label
std::string opt_key() const;
};
// Catalog of settings and their metadata. Owns the group/category registry populated by the
// settings pages, plus two views of the options: the mode-filtered view the sidebar search
// queries, and every option regardless of mode for the Speed Dial.
class SettingsIndex
{
std::map<std::string, GroupAndCategory> m_groups_and_categories;
std::vector<Option> m_options; // mode-filtered view used by the sidebar search
std::vector<Option> m_all_modes; // every option regardless of mode, for the Speed Dial
void append_options(DynamicPrintConfig *config, Preset::Type type, ConfigOptionMode mode);
void sort_options();
public:
void init(std::vector<InputInfo> input_values);
// Rebuild the given type's options; returns false when the index was never initialised.
bool apply(DynamicPrintConfig *config, Preset::Type type, ConfigOptionMode mode);
void add_key(const std::string &opt_key, Preset::Type type, const wxString &group, const wxString &category,
const wxString &icon = wxEmptyString);
void set_path(const std::string &opt_key, Preset::Type type, const std::string &path);
// Record the label the option's row draws, so the Speed Dial names a setting like the page
// (ConfigOptionDef::label/full_label is a search name, not the row text).
void set_line_label(const std::string &opt_key, Preset::Type type, const wxString &label);
const std::vector<Option> &options() const { return m_options; }
const std::vector<Option> &all_options() const { return m_all_modes; }
const Option & option_at(size_t pos) const { return m_options[pos]; }
const Option &get_option(const std::string &opt_key, Preset::Type type, int &variant_index) const;
Option get_option(const std::string &opt_key, const wxString &label, Preset::Type type) const;
const GroupAndCategory &get_group_and_category(const std::string &opt_key) { return m_groups_and_categories[opt_key]; }
void sort_options_by_key()
{
std::sort(m_options.begin(), m_options.end(), [](const Option &o1, const Option &o2) { return o1.key < o2.key; });
}
void sort_options_by_label() { sort_options(); }
};
} // namespace Search
} // namespace Slic3r
#endif // slic3r_SettingsIndex_hpp_
+282 -46
View File
@@ -11,9 +11,16 @@
#include <algorithm>
#include <wx/dcmemory.h>
#include <wx/display.h>
#include <wx/region.h>
#include <wx/sizer.h>
#include <wx/stattext.h>
#include <wx/utils.h>
#ifdef __linux__
#include <gtk/gtk.h>
#endif
namespace Slic3r { namespace GUI {
@@ -21,8 +28,8 @@ 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)
@@ -31,47 +38,157 @@ int json_int_or(const nlohmann::json& j, const char* key, int fallback)
return it != j.end() && it->is_number() ? it->get<int>() : fallback;
}
wxColour bg_color() { return wxGetApp().get_window_default_clr(); }
}
SpeedDialWebDialog::SpeedDialWebDialog(wxWindow* parent)
: WebViewHostDialog(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize,
wxBORDER_NONE | wxFRAME_NO_TASKBAR)
// Display name of a settings mode, for the mode-switch confirmation.
wxString mode_label(ConfigOptionMode mode)
{
SetBackgroundColour(bg_color());
Bind(wxEVT_ACTIVATE, [this](wxActivateEvent& event) {
if (!event.GetActive() && IsShown())
Hide();
event.Skip();
});
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));
SetSizer(sizer);
SetClientSize(FromDIP(wxSize(kPopupWidth, kPopupMinHeight)));
switch (mode) {
case comAdvanced: return _L("Advanced");
case comExpert: return _L("Expert");
case comDevelop: return _L("Developer");
default: return _L("Simple");
}
}
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();");
}
// Localized strings for the Speed Dial page, injected as a document-start user script. The page's
// T() reads window.ORCA_UI_STRINGS, so these flow through the same .po pipeline as the rest of the
// UI (the JS literals are only a fallback before the script runs / in the node vm test).
// %% is a literal '%': T() collapses it after substituting %s. Keep the shortcut tokens out of the
// translated text so the platform prefix (Alt+/⌥+, Ctrl+/⌘+) stays correct.
nlohmann::json speed_dial_ui_strings()
{
const std::string alt = GUI::shortkey_alt_prefix();
const std::string ctrl = GUI::shortkey_ctrl_prefix();
return {
{"shortcut_alt", alt},
{"shortcut_ctrl", ctrl},
{"sd_search", _u8L("Search actions")},
{"sd_clear", _u8L("Clear")},
{"sd_search_n", _u8L("Search %s actions")},
{"sd_recent", _u8L("Recent")},
{"sd_plugins", _u8L("Plugins")},
{"sd_other", _u8L("Other")},
{"sd_no_match_total", _u8L("No actions match (Total: %s)")},
{"sd_no_actions", _u8L("No actions yet")},
{"sd_no_tabs_match", _u8L("No tabs match")},
{"sd_no_tabs", _u8L("No tabs")},
{"sd_result_count", _u8L("Showing %s of %s actions")},
{"sd_result_count_all", _u8L("%s actions")},
{"sd_tab_count", _u8L("%s tabs")},
{"sd_tab_match_count", _u8L("%s matches")},
{"sd_favs_full", _u8L("Favourites are full (%s max)")},
{"sd_go_to_pct", _u8L("Go to %s%% of the layer range")},
{"sd_enter_pct", _u8L("Enter a layer percentage (0-100)")},
{"sd_go_layer_ph", _u8L("Go to layer %% (0-100)")},
{"sd_go_tab_ph", _u8L("Go to tab")},
{"sd_fav_slot", _u8L("Favourite %s (%s)")},
{"sd_pin_fav", _u8L("Pin to favourites (%s)")},
{"sd_unpin_fav", _u8L("Unpin from favourites (%s)")},
{"sd_remove_fav", _u8L("Remove from favourites")},
{"sd_move_left", _u8L("Move left")},
{"sd_move_right", _u8L("Move right")},
{"sd_unpin", _u8L("Unpin")},
{"sd_mode_advanced", _u8L("Advanced")},
{"sd_mode_expert", _u8L("Expert")},
{"sd_mode_develop", _u8L("Developer")},
{"sd_wiki_f1", _u8L("Wiki (F1)")},
{"sd_no_wiki", _u8L("No wiki page for this action")},
{"sd_show_details", _u8L("Show details")},
{"sd_hide_details", _u8L("Hide details")},
};
}
} // namespace
SpeedDialWebDialog::SpeedDialWebDialog(wxWindow* parent)
: WebViewHostDialog(parent,
wxID_ANY,
wxEmptyString,
wxDefaultPosition,
wxDefaultSize,
wxBORDER_NONE | wxFRAME_NO_TASKBAR | wxFRAME_FLOAT_ON_PARENT | wxFRAME_SHAPED)
{
SetBackgroundColour(bg_color());
Bind(wxEVT_ACTIVATE, [this](wxActivateEvent& event) {
// 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))) {
auto* sizer = new wxBoxSizer(wxVERTICAL);
sizer->Add(new wxStaticText(this, wxID_ANY, wxS("wxWebView unavailable")), wxSizerFlags().Border(wxALL, 20));
SetSizer(sizer);
SetClientSize(FromDIP(wxSize(kPopupWidth, kPopupMinHeight)));
}
// WebView2's browser accelerator keys include Ctrl +/-/0 and Ctrl+wheel zoom, which would resize
// the page inside the fixed-size popup. No-op on the other backends (wxWidgets 3.3 base virtual).
if (wxWebView* wv = browser())
wv->EnableBrowserAcceleratorKeys(false);
// Re-cut the shape whenever layout changes the client size. wxOSX SetShape resizes the
// NSWindow, which fires this synchronously; apply_rounded_shape() guards re-entry.
Bind(wxEVT_SIZE, [this](wxSizeEvent& event) {
event.Skip();
apply_rounded_shape();
});
apply_rounded_shape();
}
SpeedDialWebDialog::~SpeedDialWebDialog() { m_alive->store(false, std::memory_order_release); }
// Document-start hook: hand the page its translated strings before speeddial.js runs, so the first
// paint is already localized. The table is built when the dialog is created; a live language switch
// rebuilds the GUI (and with it this dialog), so the next open re-injects the new locale.
void SpeedDialWebDialog::add_user_scripts()
{
if (wxWebView* wv = browser()) {
const std::string js = "window.ORCA_UI_STRINGS = " +
speed_dial_ui_strings().dump(-1, ' ', false, nlohmann::json::error_handler_t::ignore) + ";";
wv->AddUserScript(wxString::FromUTF8(js));
}
}
void SpeedDialWebDialog::request_show()
{
if (IsShown()) {
Raise();
if (browser())
browser()->SetFocus();
focus_webview(browser(), m_page_ready);
return;
}
Show();
Raise();
apply_rounded_shape();
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,23 +212,44 @@ 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
// pin and show a "favourites are full" hint instead of silently losing the favourite.
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"])
if (id.is_string())
ids.push_back(id.get<std::string>());
wxGetApp().action_registry().reorder_favourites(ids);
}
} else if (command == "set_tooltip_expanded")
wxGetApp().action_registry().set_tooltip_expanded(payload.value("expanded", true));
else if (command == "run_action")
run_action(payload.value("id", ""), payload.value("title", ""));
run_action(payload.value("id", ""), payload.value("title", ""), payload.value("param", ""));
else if (command == "open_wiki")
open_wiki(payload.value("id", ""));
else if (command == "search_tabs")
search_tabs();
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::resize_to_content(int height)
{
if (height <= 0)
@@ -125,17 +263,101 @@ void SpeedDialWebDialog::resize_to_content(int height)
const int height_dip = std::max(kPopupMinHeight, std::min(height, max_dip));
SetClientSize(FromDIP(wxSize(kPopupWidth, height_dip)));
Layout();
#ifdef __WXOSX__
// WKWebView can lag the dialog's new client size; force the viewport to match so the page is
// never painted (and clipped by the rounded layer) below the footer.
if (wxWebView* wv = browser()) {
const wxSize client = GetClientSize();
if (wv->GetSize() != client)
wv->SetSize(client);
}
#endif
apply_rounded_shape();
}
void SpeedDialWebDialog::run_action(const std::string& id, const std::string& title)
// Rounded corners: the webview paints an opaque rectangle, so round the whole top-level window.
// GTK/MSW use a shape region (same mask trick as FilamentPickerDialog, binary edges, no
// anti-aliasing); macOS clips the native view layer instead, since SetShape cannot shape there.
void SpeedDialWebDialog::apply_rounded_shape()
{
// wxOSX SetShape resizes the NSWindow (setContentSize 10x10 then back), which synchronously
// fires wxEVT_SIZE -> apply_rounded_shape() -> SetShape() and recurses until the stack
// overflows. GTK/MSW set a region without resizing, so they are unaffected.
if (m_applying_shape)
return;
// BORDER_NONE means the window is all client area, so the client size is the shape size.
const wxSize size = GetClientSize();
if (size.GetWidth() <= 0 || size.GetHeight() <= 0)
return;
m_applying_shape = true;
#ifdef __WXOSX__
// wxOSX ignores the region (it only clears the window background), so round the native view.
set_window_corner_radius(this, FromDIP(m_corner_radius));
#else
m_shape_bmp.Create(size.GetWidth(), size.GetHeight(), 32);
if (m_shape_bmp.IsOk()) {
wxMemoryDC dc;
dc.SelectObject(m_shape_bmp);
dc.SetBackground(wxBrush(wxColour(0, 0, 0)));
dc.Clear();
dc.SetBrush(wxBrush(wxColour(255, 255, 255, 255)));
dc.SetPen(*wxTRANSPARENT_PEN);
dc.DrawRoundedRectangle(0, 0, size.GetWidth(), size.GetHeight(), FromDIP(m_corner_radius));
dc.SelectObject(wxNullBitmap);
wxRegion region(m_shape_bmp, wxColour(0, 0, 0));
if (region.IsOk())
SetShape(region);
}
#endif
m_applying_shape = false;
}
void SpeedDialWebDialog::on_dpi_changed(const wxRect&)
{
apply_rounded_shape();
Refresh();
}
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();
const ConfigOptionMode required = a->required_mode;
// Settings the current mode hides require a switch first. Ask while the dial is still up; a
// cancel dismisses both (the dial also auto-hides when the modal takes activation).
if (requires_mode_switch(required, wxGetApp().get_mode())) {
const wxString setting = title.empty() ? from_u8(atitle) : from_u8(title);
if (required == comDevelop) {
RichMessageDialog dlg(wxGetApp().mainframe,
wxString::Format(_L("\"%s\" is a Developer setting. Enable Developer mode to edit it?"),
setting),
_L("Developer setting"), wxOK | wxCANCEL);
if (dlg.ShowModal() != wxID_OK)
return;
wxGetApp().enable_developer_mode();
} else {
RichMessageDialog dlg(wxGetApp().mainframe,
wxString::Format(_L("\"%s\" is a %s setting. Switch from %s mode to %s mode to edit it?"),
setting, mode_label(required), mode_label(wxGetApp().get_mode()), mode_label(required)),
_L("Switch settings mode"), wxOK | wxCANCEL);
if (dlg.ShowModal() != wxID_OK)
return;
wxGetApp().save_mode(required);
}
}
if (IsModal())
EndModal(wxID_CANCEL);
else
@@ -143,8 +365,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,27 +373,42 @@ 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));
});
}
void SpeedDialWebDialog::open_wiki(const std::string& id)
{
const AppAction* a = wxGetApp().action_registry().by_id(id);
if (!a || a->help_url.empty())
return;
Hide();
wxLaunchDefaultBrowser(from_u8(a->help_url));
}
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"])},
{"user_mode", std::move(snap["user_mode"])},
{"tooltip_expanded", std::move(snap["tooltip_expanded"])}});
}
}}
}} // namespace Slic3r::GUI
+13 -1
View File
@@ -7,6 +7,8 @@
#include <memory>
#include <string>
#include <wx/bitmap.h>
namespace Slic3r { namespace GUI {
class SpeedDialWebDialog : public WebViewHostDialog
@@ -17,13 +19,23 @@ public:
void request_show();
private:
void add_user_scripts() override;
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 open_wiki(const std::string& id);
void send_actions();
void search_tabs();
void apply_rounded_shape();
void on_dpi_changed(const wxRect& suggested_rect) override;
bool m_page_ready{false};
// Rounded corners (shape region on GTK/MSW, native layer on macOS), since the webview is opaque.
int m_corner_radius{7};
wxBitmap m_shape_bmp;
// wxOSX SetShape resizes the window, which re-enters apply_rounded_shape() through wxEVT_SIZE.
bool m_applying_shape{false};
// Guards the CallAfter in on_script_message across dialog destruction, same as
// PluginsDialog::m_alive (PluginsDialog.hpp:249).
std::shared_ptr<std::atomic<bool>> m_alive = std::make_shared<std::atomic<bool>>(true);
+42 -12
View File
@@ -1736,16 +1736,46 @@ void Tab::toggle_option(const std::string& opt_key, bool toggle, int opt_index/*
void Tab::toggle_line(const std::string &opt_key, bool toggle, int opt_index)
{
if (!m_active_page) return;
Line *line = m_active_page->get_line(opt_key, opt_index);
if (line) line->toggle_visible = toggle;
// Apply to every page that owns the option, not just m_active_page. ConfigManipulation runs while
// each tab updates at preset load, so the Speed Dial sees the same visibility regardless of page.
for (const PageShp& page : m_pages) {
if (!page) continue;
if (Line *line = page->get_line(opt_key, opt_index))
line->toggle_visible = toggle;
}
};
void Tab::set_option_label(const std::string &opt_key, const wxString &label, int opt_index)
{
if (!m_active_page) return;
Line *line = m_active_page->get_line(opt_key, opt_index);
if (line) line->set_label(label);
// Same as toggle_line: a runtime rename (brim_width -> "Brim ear radius") must reach every page
// so the Speed Dial titles the setting before the page has been shown.
for (const PageShp& page : m_pages) {
if (!page) continue;
if (Line *line = page->get_line(opt_key, opt_index))
line->set_label(label);
}
}
Tab::SettingRowState Tab::setting_row_state(const std::string &opt_id) const
{
bool found = false;
for (const PageShp& page : m_pages) {
if (!page) continue;
for (const ConfigOptionsGroupShp& group : page->m_optgroups) {
if (!group) continue;
for (const Line& line : group->get_lines()) {
for (const Option& opt : line.get_options()) {
if (opt.opt_id != opt_id)
continue;
if (line.toggle_visible) // shown on any owning page is enough
return {true, line.label, line.get_options().size() > 1};
found = true;
}
}
}
}
// Never registered on a page -> visible, but with no row label to contribute.
return {!found, wxString(), false};
}
// To be called by custom widgets, load a value into a config,
@@ -5782,11 +5812,11 @@ if (is_marlin_flavor)
} else if (m_extruders_count_old == 1) {
first_extruder_title = wxString::Format("Extruder %d", 1);
}
auto & searcher = wxGetApp().sidebar().get_searcher();
auto & index = wxGetApp().sidebar().settings_index();
for (auto &group : m_pages[n_before_extruders]->m_optgroups) {
group->set_config_category_and_type(first_extruder_title, m_type);
for (auto &opt : group->opt_map())
searcher.add_key(opt.first + "#0", m_type, group->title, first_extruder_title);
index.add_key(opt.first + "#0", m_type, group->title, first_extruder_title, group->icon);
}
Thaw();
@@ -7870,10 +7900,10 @@ wxSizer* TabPrinter::create_bed_shape_widget(wxWindow* parent)
}));
{
Search::OptionsSearcher& searcher = wxGetApp().sidebar().get_searcher();
const Search::GroupAndCategory& gc = searcher.get_group_and_category("printable_area");
searcher.add_key("bed_custom_texture", m_type, gc.group, gc.category);
searcher.add_key("bed_custom_model", m_type, gc.group, gc.category);
Search::SettingsIndex& index = wxGetApp().sidebar().settings_index();
const Search::GroupAndCategory& gc = index.get_group_and_category("printable_area");
index.add_key("bed_custom_texture", m_type, gc.group, gc.category, gc.icon);
index.add_key("bed_custom_model", m_type, gc.group, gc.category, gc.icon);
}
return sizer;
+10
View File
@@ -401,6 +401,16 @@ public:
void toggle_option(const std::string &opt_key, bool toggle, int opt_index = -1);
void toggle_line(const std::string &opt_key, bool toggle, int opt_index = -1); // BBS: hide some line
void set_option_label(const std::string &opt_key, const wxString &label, int opt_index = -1);
// Live state of the settings row that owns an option, read from the built pages.
struct SettingRowState
{
bool visible{true}; // false when ConfigManipulation hides the row
wxString label; // Line::label the row draws (may change at runtime)
bool multi{false}; // row packs several options, so label is precomposed
};
SettingRowState setting_row_state(const std::string &opt_id) const;
wxSizer* description_line_widget(wxWindow* parent, ogStaticText** StaticText, wxString text = wxEmptyString);
bool current_preset_is_dirty() const;
bool saved_preset_is_dirty() const;
+16 -16
View File
@@ -1485,12 +1485,12 @@ std::string UnsavedChangesDialog::subreplace(std::string resource_str, std::stri
void UnsavedChangesDialog::update_tree(Preset::Type type, DynamicConfig * config, int from, int to)
{
Search::OptionsSearcher &searcher = wxGetApp().sidebar().get_searcher();
searcher.sort_options_by_key();
Search::SettingsIndex &index = wxGetApp().sidebar().settings_index();
index.sort_options_by_key();
for (const std::string &opt_key : config->keys()) {
int variant_index = -2;
Search::Option option = searcher.get_option(opt_key, type, variant_index);
Search::Option option = index.get_option(opt_key, type, variant_index);
if (variant_index == -2) {
// Orca: Every transferred setting must remain visible even when it is absent from the search index.
const ConfigOptionDef* def = print_config_def.get(opt_key);
@@ -1510,8 +1510,8 @@ void UnsavedChangesDialog::update_tree(Preset::Type type, DynamicConfig * config
void UnsavedChangesDialog::update_tree(Preset::Type type, PresetCollection* presets_)
{
Search::OptionsSearcher& searcher = wxGetApp().sidebar().get_searcher();
searcher.sort_options_by_key();
Search::SettingsIndex& index = wxGetApp().sidebar().settings_index();
index.sort_options_by_key();
// list of the presets with unsaved changes
std::vector<PresetCollection*> presets_list;
@@ -1569,11 +1569,11 @@ void UnsavedChangesDialog::update_tree(Preset::Type type, PresetCollection* pres
for (const std::string& opt_key : dirty_options) {
int variant_index = -2;
const Search::Option &option = searcher.get_option(opt_key, type, variant_index);
const Search::Option &option = index.get_option(opt_key, type, variant_index);
if (variant_index == -2) {
// When founded option isn't the correct one.
// It can be for dirty_options: "default_print_profile", "printer_model", "printer_settings_id",
// because of they don't exist in searcher
// because of they don't exist in the index
continue;
}
wxString category = option.category_local;
@@ -1612,8 +1612,8 @@ void UnsavedChangesDialog::update_tree(Preset::Type type, PresetCollection* pres
}
}
// Revert sort of searcher back
searcher.sort_options_by_label();
// Revert sort of index back
index.sort_options_by_label();
}
void UnsavedChangesDialog::on_dpi_changed(const wxRect& suggested_rect)
@@ -2065,8 +2065,8 @@ void DiffPresetDialog::update_bottom_info(wxString bottom_info)
void DiffPresetDialog::update_tree()
{
Search::OptionsSearcher& searcher = wxGetApp().sidebar().get_searcher();
searcher.sort_options_by_key();
Search::SettingsIndex& index = wxGetApp().sidebar().settings_index();
index.sort_options_by_key();
m_tree->Clear();
wxString bottom_info = "";
@@ -2146,14 +2146,14 @@ void DiffPresetDialog::update_tree()
wxString right_val = get_string_value(opt_key, right_congig);
const std::string lookup_key = get_pure_opt_key(opt_key);
Search::Option option = searcher.get_option(lookup_key, get_full_label(lookup_key, left_config), type);
Search::Option option = index.get_option(lookup_key, get_full_label(lookup_key, left_config), type);
if (get_pure_opt_key(option.opt_key()) != lookup_key)
option = searcher.get_option(opt_key, get_full_label(opt_key, left_config), type);
option = index.get_option(opt_key, get_full_label(opt_key, left_config), type);
if (get_pure_opt_key(option.opt_key()) != lookup_key) {
// When the found option is not the requested one.
// This can happen for dirty_options such as:
// "default_print_profile", "printer_model", "printer_settings_id",
// because they do not exist in the searcher.
// because they do not exist in the index.
continue;
}
m_tree->Append(opt_key, type, option.category_local, option.group_local, option.label_local,
@@ -2177,8 +2177,8 @@ void DiffPresetDialog::update_tree()
Refresh();
}
// Revert sort of searcher back
searcher.sort_options_by_label();
// Revert sort of index back
index.sort_options_by_label();
}
void DiffPresetDialog::on_dpi_changed(const wxRect&)
+109 -121
View File
@@ -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<StateColor const*>{&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::SetIndicator(bool on)
{
@@ -186,38 +184,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);
}
@@ -226,39 +219,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()
@@ -269,7 +256,7 @@ void Button::Rescale()
messureSize();
if(m_has_style)
if (m_has_style)
SetStyle(m_style, m_type);
Refresh();
@@ -290,7 +277,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
@@ -298,15 +285,15 @@ 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);
}
}
@@ -316,7 +303,7 @@ void Button::render(wxDC& dc)
const bool gap_reserved = szContent.y > 0;
if (icon.bmp().IsOk()) {
if (gap_reserved) {
//BBS norrow size between text and icon
// BBS norrow size between text and icon
if (vertical)
szContent.y += spacing;
else
@@ -325,10 +312,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);
@@ -344,10 +333,11 @@ void Button::render(wxDC& dc)
szContent.x += dot + FromDIP(6);
}
// 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
@@ -358,7 +348,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 + (gap_reserved ? spacing : 0);
pt.x = rcContent.x;
@@ -403,19 +393,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;
}
}
if (m_show_indicator) {
@@ -467,13 +459,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);
@@ -482,8 +474,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();
@@ -500,7 +492,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) {
@@ -521,8 +515,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);
};
@@ -532,22 +525,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);
}
@@ -555,11 +547,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;
@@ -572,15 +561,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;
+3
View File
@@ -36,6 +36,7 @@ class Button : public StaticBox
wxRect textSize;
wxSize minSize; // set by outer
wxSize paddingSize;
int m_icon_spacing = 5;
ScalableBitmap active_icon;
StateColor text_color;
@@ -70,6 +71,8 @@ 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);
+38
View File
@@ -43,3 +43,41 @@ TEST_CASE("AppConfig network version helpers", "[AppConfig]") {
REQUIRE(config.is_network_version_skipped("02.01.01.52"));
}
}
TEST_CASE("AppConfig Speed Dial recent count defaults, clamps and parses", "[AppConfig]") {
AppConfig config;
SECTION("unset falls back to the default") {
REQUIRE(config.get_speed_dial_recent_count() == SPEED_DIAL_RECENT_COUNT_DEFAULT);
}
SECTION("zero disables recents") {
config.set(SETTING_SPEED_DIAL_RECENT_COUNT, "0");
REQUIRE(config.get_speed_dial_recent_count() == 0);
}
SECTION("a value in range is returned as-is") {
config.set(SETTING_SPEED_DIAL_RECENT_COUNT, "7");
REQUIRE(config.get_speed_dial_recent_count() == 7);
}
SECTION("the maximum is kept") {
config.set(SETTING_SPEED_DIAL_RECENT_COUNT, "10");
REQUIRE(config.get_speed_dial_recent_count() == SPEED_DIAL_RECENT_COUNT_MAX);
}
SECTION("values above the maximum clamp down") {
config.set(SETTING_SPEED_DIAL_RECENT_COUNT, "42");
REQUIRE(config.get_speed_dial_recent_count() == SPEED_DIAL_RECENT_COUNT_MAX);
}
SECTION("negative values clamp up to 0") {
config.set(SETTING_SPEED_DIAL_RECENT_COUNT, "-3");
REQUIRE(config.get_speed_dial_recent_count() == 0);
}
SECTION("garbage falls back to the default") {
config.set(SETTING_SPEED_DIAL_RECENT_COUNT, "abc");
REQUIRE(config.get_speed_dial_recent_count() == SPEED_DIAL_RECENT_COUNT_DEFAULT);
}
}
+290 -5
View File
@@ -1,10 +1,16 @@
#include <catch2/catch_test_macros.hpp>
#include "slic3r/GUI/ActionRegistry.hpp"
#include "slic3r/GUI/NativeCommands.hpp"
#include "slic3r/GUI/SettingsIndex.hpp"
#include <boost/filesystem.hpp>
#include <memory>
#include <set>
#include <string>
#include <type_traits>
#include <vector>
using Slic3r::GUI::AppAction;
using Slic3r::GUI::AppActionRunResult;
@@ -19,21 +25,21 @@ class TestAppAction final : public AppAction
public:
TestAppAction() : AppAction("test", "Action title", "src-key", "Action source") {}
AppActionRunResult run() const override { return {}; }
AppActionRunResult run(const std::string& param = {}) const override { return {}; }
};
} // namespace
TEST_CASE("AppAction composes a stable id from prefix:title:source_key", "[speeddial][actions]")
TEST_CASE("AppAction composes a stable id from prefix:title:source_key", "[ActionSource][SpeedDial]")
{
CHECK(AppAction::compose_id("test", "Action title", "src-key") == "test:Action title:src-key");
// source_key (not the display name) carries identity, so it is the third field.
CHECK(AppAction::compose_id("script", "Do Thing", "pack.py") == "script:Do Thing:pack.py");
}
TEST_CASE("AppAction definitions are immutable after construction", "[speeddial][actions]")
TEST_CASE("AppAction definitions are immutable after construction", "[ActionSource][SpeedDial]")
{
using StringAccessor = const std::string& (AppAction::*)() const;
using StringAccessor = const std::string& (AppAction::*) () const;
STATIC_CHECK(std::is_same_v<decltype(&AppAction::id), StringAccessor>);
STATIC_CHECK(std::is_same_v<decltype(&AppAction::title), StringAccessor>);
@@ -47,9 +53,288 @@ TEST_CASE("AppAction definitions are immutable after construction", "[speeddial]
CHECK(action.source_name() == "Action source");
}
TEST_CASE("ActionRegistry takes exclusive ownership of published actions", "[speeddial][actions]")
TEST_CASE("ActionRegistry takes exclusive ownership of published actions", "[ActionSource][SpeedDial]")
{
using ExpectedUpsert = void (ActionRegistry::*)(std::unique_ptr<AppAction>);
STATIC_CHECK(std::is_same_v<decltype(&ActionRegistry::upsert), ExpectedUpsert>);
}
// A dynamic "Go to Plate N" action is keyed by plate index (not the display title), so renaming
// a plate never re-keys it - the same contract as a setting action.
TEST_CASE("Go-to-plate actions are keyed by index, not title", "[ActionSource][SpeedDial]")
{
CHECK(AppAction::compose_id("orca_plate_goto", "0", "orca") == "orca_plate_goto:0:orca");
CHECK(AppAction::compose_id("orca_plate_goto", "2", "orca") == "orca_plate_goto:2:orca");
}
// A dynamic "Open recent project" action is keyed by file path (not the display name), so renaming
// a project or reordering the recents list never re-keys it - the same contract as a setting action.
TEST_CASE("Recent-project actions are keyed by path, not title", "[ActionSource][SpeedDial]")
{
CHECK(AppAction::compose_id("orca_recent_project", "/a/b/project.3mf", "orca") ==
"orca_recent_project:/a/b/project.3mf:orca");
CHECK(AppAction::compose_id("orca_recent_project", "C:/Data/cube.3mf", "orca") ==
"orca_recent_project:C:/Data/cube.3mf:orca");
}
// A built-in command is keyed by its stable catalog key (not the localized display title), so a
// rename or a UI-language switch never re-keys the action and its persisted favourite/stats survive.
TEST_CASE("Command actions are keyed by catalog key, not display title", "[ActionSource][SpeedDial]")
{
CHECK(AppAction::compose_id("orca_command", "save_project", "orca") == "orca_command:save_project:orca");
// The second field is the stable key, so distinct commands never collide.
CHECK(AppAction::compose_id("orca_command", "save_project", "orca") !=
AppAction::compose_id("orca_command", "load_project", "orca"));
}
// The real catalog -> action mapping keys by the stable catalog key and copies presentation from the
// catalog, so a rename or a UI-language switch never re-keys the action.
TEST_CASE("Command action construction keys by catalog key", "[ActionSource][SpeedDial]")
{
const std::vector<Slic3r::GUI::NativeCommand>& commands = Slic3r::GUI::NativeCommands::catalog();
REQUIRE_FALSE(commands.empty());
const Slic3r::GUI::NativeCommand& c = commands.front();
std::unique_ptr<AppAction> action = Slic3r::GUI::NativeCommands::make_action(c);
REQUIRE(action != nullptr);
CHECK(action->id() == AppAction::compose_id("orca_command", c.key, "orca"));
CHECK(action->id() != AppAction::compose_id("orca_command", c.title, "orca"));
CHECK(action->title() == c.title);
CHECK(action->group == c.group);
CHECK(action->input == c.input);
CHECK(action->icon == c.icon);
}
// The footer description/wiki link is settings-only: built-in commands leave both fields empty, so
// the palette's detail strip depends on list-level visibility for them.
TEST_CASE("Actions default to no description or wiki link", "[ActionSource][SpeedDial]")
{
const TestAppAction action;
CHECK(action.tooltip.empty());
CHECK(action.help_url.empty());
REQUIRE_FALSE(Slic3r::GUI::NativeCommands::catalog().empty());
std::unique_ptr<AppAction> command = Slic3r::GUI::NativeCommands::make_action(Slic3r::GUI::NativeCommands::catalog().front());
REQUIRE(command != nullptr);
CHECK(command->tooltip.empty());
CHECK(command->help_url.empty());
}
// Two-phase commands declare the input the palette must collect before they can run.
TEST_CASE("Two-phase commands declare their input phase", "[ActionSource][SpeedDial]")
{
auto input_of = [](const std::string& key) -> std::string {
for (const auto& c : Slic3r::GUI::NativeCommands::catalog())
if (c.key == key)
return c.input;
return {};
};
CHECK(input_of("go_to_layer") == "percent");
CHECK(input_of("go_to_tab") == "tab");
}
// The input token vocabulary is a JS<->C++ contract (speeddial.js dispatches "percent"/"tab").
// A typo here would leave a command that never enters its second phase, so pin the allowed set.
TEST_CASE("Command input tokens stay in the known vocabulary", "[ActionSource][SpeedDial]")
{
for (const auto& c : Slic3r::GUI::NativeCommands::catalog()) {
INFO(c.key << " input=" << c.input);
CHECK((c.input.empty() || c.input == "percent" || c.input == "tab"));
}
}
// The quick-launch cap must stay 10 to match the numbered Alt/Option+1..9,0 keys. The web palette
// mirrors it as K_FAV_LIMIT (asserted in speeddial.test.js); the C++ side pins it here.
static_assert(Slic3r::GUI::ActionRegistry::kFavLimit == 10, "kFavLimit must stay 10");
TEST_CASE("Favourite lists are capped and deduped preserving order", "[ActionSource][SpeedDial]")
{
using Slic3r::GUI::cap_favourites;
CHECK(cap_favourites({}, 10) == std::vector<std::string>{});
CHECK(cap_favourites({"a", "b", "a"}, 10) == std::vector<std::string>{"a", "b"});
CHECK(cap_favourites({"c", "a", "b", "c"}, 3) == std::vector<std::string>{"c", "a", "b"});
CHECK(cap_favourites({"a", "b"}, 0) == std::vector<std::string>{});
}
TEST_CASE("Native command catalog has unique keys and present titles", "[ActionSource][SpeedDial]")
{
const std::vector<Slic3r::GUI::NativeCommand>& commands = Slic3r::GUI::NativeCommands::catalog();
CHECK_FALSE(commands.empty());
std::set<std::string> seen;
for (const auto& c : commands) {
CHECK_FALSE(c.key.empty());
CHECK_FALSE(c.title.empty());
// A duplicated key would silently shadow the earlier command in the palette.
CHECK(seen.insert(c.key).second);
}
}
// Every command's tile pictogram is a theme-neutral SVG (the matching GUI control's icon, or the
// equivalent settings-group icon); an absent icon gets the page's generic placeholder. Guard
// representative names and that every non-empty value resolves to a shipped file, so a rename/typo
// cannot leave broken images in the palette.
TEST_CASE("Native command icons resolve to shipped SVGs", "[ActionSource][SpeedDial]")
{
const std::vector<Slic3r::GUI::NativeCommand>& commands = Slic3r::GUI::NativeCommands::catalog();
auto icon_of = [&commands](const std::string& key) -> const std::string* {
for (const auto& c : commands)
if (c.key == key)
return &c.icon;
return nullptr;
};
struct Expected
{
const char* key;
const char* icon;
};
for (const Expected& e : {Expected{"load_project", "menu_open"},
Expected{"save_project", "menu_save"},
Expected{"sync_ams", "ams_fila_sync"},
Expected{"mode_simple", "advanced"},
Expected{"calib_temperature", "param_temperature"},
Expected{"calib_cornering", "param_precision"},
Expected{"plate_add", "toolbar_add_plate"},
Expected{"add_primitive_cube", "menu_obj_cube"},
// These previously pointed at blank placeholder SVGs or theme-broken ones.
Expected{"obj_delete", "delete"},
Expected{"export_gcode", "custom-gcode_gcode"},
Expected{"import_file", "menu_open"},
Expected{"help_open_config_folder", "open_project"},
Expected{"help_check_updates", "refresh"},
Expected{"help_about", "OrcaSlicer_gradient_circle"},
Expected{"go_to_tab", ""}}) {
const std::string* icon = icon_of(e.key);
INFO(e.key);
REQUIRE(icon != nullptr);
CHECK(*icon == e.icon);
}
const boost::filesystem::path images = boost::filesystem::path(PROFILES_DIR).parent_path() / "images";
for (const auto& c : commands) {
if (c.icon.empty())
continue;
INFO(c.key << " -> " << c.icon);
CHECK(boost::filesystem::exists(images / (c.icon + ".svg")));
}
}
// The Help-menu commands, wiki/YouTube links and the developer-mode toggle are part of the palette.
// Guard their presence and that they stay grouped with their peers, so a catalog edit cannot drop
// or scatter them. Groups are compared to the peer's own group to stay independent of translation.
TEST_CASE("Native command catalog includes the Help and developer-mode commands", "[ActionSource][SpeedDial]")
{
const std::vector<Slic3r::GUI::NativeCommand>& commands = Slic3r::GUI::NativeCommands::catalog();
auto find = [&commands](const std::string& key) -> const Slic3r::GUI::NativeCommand* {
for (const auto& c : commands)
if (c.key == key)
return &c;
return nullptr;
};
const Slic3r::GUI::NativeCommand* first = find("help_keyboard_shortcuts");
REQUIRE(first != nullptr);
for (const char* key : {"help_setup_wizard", "help_open_config_folder", "help_troubleshoot", "help_network_test",
"help_tip_of_the_day", "help_check_updates", "help_about", "open_wiki", "open_youtube"}) {
const Slic3r::GUI::NativeCommand* c = find(key);
REQUIRE(c != nullptr);
CHECK(c->group == first->group);
}
const Slic3r::GUI::NativeCommand* mode_simple = find("mode_simple");
const Slic3r::GUI::NativeCommand* dev_mode = find("toggle_developer_mode");
REQUIRE(mode_simple != nullptr);
REQUIRE(dev_mode != nullptr);
CHECK(dev_mode->group == mode_simple->group);
}
// Every "Add Primitive" item and shipped handy model has a palette command, grouped as in the Add
// menu. Groups are compared to a peer's own group to stay independent of translation.
TEST_CASE("Native command catalog covers the Add menus", "[ActionSource][SpeedDial]")
{
const std::vector<Slic3r::GUI::NativeCommand>& commands = Slic3r::GUI::NativeCommands::catalog();
auto group_of = [&commands](const std::string& key) -> const std::string* {
for (const auto& c : commands)
if (c.key == key)
return &c.group;
return nullptr;
};
const std::string* primitive_group = group_of("add_primitive_cube");
REQUIRE(primitive_group != nullptr);
for (const char* key : {"add_primitive_cylinder", "add_primitive_sphere", "add_primitive_cone", "add_primitive_disc",
"add_primitive_torus", "add_primitive_text", "add_primitive_svg"}) {
const std::string* group = group_of(key);
INFO(key);
REQUIRE(group != nullptr);
CHECK(*group == *primitive_group);
}
const std::string* handy_group = group_of("add_handy_orca_cube");
REQUIRE(handy_group != nullptr);
for (const char* key : {"add_handy_orcasliced_combo", "add_handy_orca_badge", "add_handy_orca_tolerance_test",
"add_handy_3dbenchy", "add_handy_cali_cat", "add_handy_autodesk_fdm_test", "add_handy_voron_cube",
"add_handy_stanford_bunny", "add_handy_orca_string_hell"}) {
const std::string* group = group_of(key);
INFO(key);
REQUIRE(group != nullptr);
CHECK(*group == *handy_group);
}
}
// A setting action is named like its settings row, not the ConfigOptionDef label: the row's
// Line::label, plus the field leaf when the row packs several options.
TEST_CASE("Setting display labels mirror the settings row", "[ActionSource][SpeedDial]")
{
using Slic3r::Search::compose_display_label;
// Single-option row: the row label is the whole title.
CHECK(compose_display_label(L"Reverse on even", L"Reverse on even", false) == L"Reverse on even");
// No recorded row label falls back to the field leaf.
CHECK(compose_display_label(L"", L"Outer wall", false) == L"Outer wall");
// Multi-option row: qualify with the leaf so the plate-temperature fields are distinct.
CHECK(compose_display_label(L"Cool Plate", L"First layer", true) == wxString(L"Cool Plate \u2013 First layer"));
CHECK(compose_display_label(L"Cool Plate", L"Other layers", true) == wxString(L"Cool Plate \u2013 Other layers"));
// A leaf equal to the row label is not repeated.
CHECK(compose_display_label(L"Skirt loops", L"Skirt loops", true) == L"Skirt loops");
using Slic3r::Search::resolve_setting_title;
// A single-option row's live label wins, so a runtime rename is reflected.
CHECK(resolve_setting_title(L"Brim width", L"Brim ear radius", false) == L"Brim ear radius");
// Multi-option rows keep their precomposed "row field" label (the leaf disambiguates them).
CHECK(resolve_setting_title(L"Cool Plate \u2013 First layer", L"Cool Plate", true) ==
wxString(L"Cool Plate \u2013 First layer"));
// No live row label (option not on a built page) keeps the precomposed label.
CHECK(resolve_setting_title(L"Reverse on even", L"", false) == L"Reverse on even");
// Neither present: empty, so the caller falls back to the descriptive label.
CHECK(resolve_setting_title(L"", L"", false).IsEmpty());
}
// A setting whose mode is above the user's current mode must be prompted before it can be edited.
// Developer settings (comDevelop) are above every non-developer mode, so they always prompt then.
TEST_CASE("Settings above the current mode require a switch", "[ActionSource][SpeedDial]")
{
using Slic3r::GUI::requires_mode_switch;
using Slic3r::comAdvanced;
using Slic3r::comDevelop;
using Slic3r::comExpert;
using Slic3r::comSimple;
CHECK(requires_mode_switch(comAdvanced, comSimple));
CHECK(requires_mode_switch(comExpert, comSimple));
CHECK(requires_mode_switch(comExpert, comAdvanced));
CHECK(requires_mode_switch(comDevelop, comSimple));
CHECK(requires_mode_switch(comDevelop, comAdvanced));
CHECK(requires_mode_switch(comDevelop, comExpert));
CHECK_FALSE(requires_mode_switch(comSimple, comSimple));
CHECK_FALSE(requires_mode_switch(comSimple, comAdvanced));
CHECK_FALSE(requires_mode_switch(comAdvanced, comAdvanced));
CHECK_FALSE(requires_mode_switch(comAdvanced, comExpert));
CHECK_FALSE(requires_mode_switch(comExpert, comExpert));
CHECK_FALSE(requires_mode_switch(comDevelop, comDevelop));
}