mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-20 15:32:33 +00:00
Back to spacebar for speed dial shortcut. Integrated inline process settings editing. Optimized saerch results
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -65,32 +65,33 @@ assert.equal(ctx.filterTabs([{ id: "home", title: "" }], "home").length, 1,
|
||||
assert.equal(ctx.filterTabs([{ id: "prepare", title: " Prepare" }], "prepare").length, 1,
|
||||
"a leading-space tab title still matches a typed query");
|
||||
|
||||
// settingCode: a setting tile uses the leaf option-name initial (label's last " : "-segment),
|
||||
// escalated by category, then a stable ordinal - so settings aren't all a generic "S".
|
||||
const settings = [
|
||||
{ opt_key: "layer_height", label: "Quality : Layer Height", category: "Quality", type: 0 },
|
||||
{ opt_key: "smooth", label: "Quality : Smooth", category: "Quality", type: 0 }
|
||||
// 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.equal(ctx.leafLabel({ label: "Quality : Layer Height", opt_key: "x" }), "Layer Height",
|
||||
"leafLabel takes the last segment of the label");
|
||||
assert.equal(ctx.settingCode(settings[0], settings), "L",
|
||||
"a unique leaf initial yields a single letter");
|
||||
// renderSettingRow iterates settingsResults, so the coded item is always a member of the list.
|
||||
const infillQ = { opt_key: "a", label: "Quality : Infill", category: "Quality", type: 0 };
|
||||
const infillS = { opt_key: "b", label: "Supports : Infill", category: "Supports", type: 0 };
|
||||
const sameLeaf = [infillQ, infillS];
|
||||
assert.equal(ctx.settingCode(infillQ, sameLeaf), "QI",
|
||||
"a colliding leaf initial escalates to category + initial");
|
||||
assert.equal(ctx.settingCode(infillS, sameLeaf), "SI",
|
||||
"a different category disambiguates the same leaf initial");
|
||||
const dupQ = [
|
||||
{ opt_key: "a", label: "Quality : Infill", category: "Quality", type: 0 },
|
||||
{ opt_key: "b", label: "Quality : Infill", category: "Quality", type: 0 }
|
||||
assert.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");
|
||||
|
||||
// commandList (the main-phase list) delegates to the ranked search for a typed query and returns
|
||||
// the mixed recents (no discrimination) for an empty query.
|
||||
const mixed = [
|
||||
{ id: "cmd", title: "Slice", source: "OrcaSlicer", group: "Commands", input: "" },
|
||||
{ id: "set", title: "Sparse Infill Density", source: "Quality", group: "Quality", input: "" }
|
||||
];
|
||||
assert.equal(ctx.settingCode(dupQ[0], dupQ), "QI1",
|
||||
"both title and category collide -> stable ordinal by opt_key");
|
||||
assert.equal(ctx.settingCode(dupQ[1], dupQ), "QI2",
|
||||
"the ordinal advances by opt_key order");
|
||||
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"],
|
||||
"an empty query shows the mixed recents list verbatim");
|
||||
|
||||
// selectedActionId: resolves the active list (recents for an empty query, filtered list otherwise).
|
||||
assert.equal(
|
||||
@@ -114,4 +115,117 @@ assert.equal(
|
||||
"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");
|
||||
|
||||
// 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");
|
||||
|
||||
// ---- inline setting editor helpers (settingControlRows / Value / CollectedValue) ----
|
||||
const scalarBoolDesc = {
|
||||
editable: true, control: "toggle", cardinality: "scalar", value: true,
|
||||
min: undefined, max: undefined, is_int: false, unit: ""
|
||||
};
|
||||
const scalarRows = ctx.settingControlRows(scalarBoolDesc);
|
||||
assert.equal(scalarRows.length, 1, "a scalar setting yields exactly one control row");
|
||||
assert.equal(scalarRows[0].kind, "toggle", "the control kind is carried through");
|
||||
assert.equal(scalarRows[0].index, 0, "the single row is indexed 0");
|
||||
assert.deepEqual(ctx.settingControlRows({ editable: false }), [], "a non-editable setting yields no control rows");
|
||||
|
||||
const vectorDesc = {
|
||||
editable: true, control: "number", cardinality: "vector", values: [0.2, 0.4],
|
||||
index_labels: ["1", "2"], min: 0, max: 1, is_int: false, unit: "mm"
|
||||
};
|
||||
const vectorRows = ctx.settingControlRows(vectorDesc);
|
||||
assert.equal(vectorRows.length, 2, "a vector setting yields one row per value");
|
||||
assert.deepEqual(vectorRows.map(function (r) { return r.value; }), [0.2, 0.4], "each row carries its current value");
|
||||
assert.deepEqual(vectorRows.map(function (r) { return r.label; }), ["1", "2"], "vector rows use the per-index labels");
|
||||
assert.equal(vectorRows[0].unit, "mm", "the unit is carried to each row");
|
||||
|
||||
// settingControlValue reads a control element (checked/value) as the shipped value.
|
||||
assert.equal(ctx.settingControlValue({ kind: "toggle" }, { checked: true }), true, "a toggle submits its checked state");
|
||||
assert.equal(ctx.settingControlValue({ kind: "toggle" }, { checked: false }), false, "an off toggle submits false");
|
||||
assert.equal(ctx.settingControlValue({ kind: "number", is_int: false, min: 0, max: 1 }, { value: "0.5" }), 0.5, "a float number parses");
|
||||
assert.equal(ctx.settingControlValue({ kind: "number", is_int: true, min: 0, max: 10 }, { value: "5" }), 5, "an int number parses");
|
||||
assert.equal(ctx.settingControlValue({ kind: "number", is_int: false, min: 0, max: 1 }, { value: "2" }), undefined, "an out-of-range number is rejected");
|
||||
assert.equal(ctx.settingControlValue({ kind: "number", is_int: false, min: 0, max: 1 }, { value: "" }), undefined, "an empty number is rejected");
|
||||
assert.equal(ctx.settingControlValue({ kind: "dropdown" }, { dataset: { value: "3" } }), 3, "an enum dropdown submits its stored int value");
|
||||
assert.equal(ctx.settingControlValue({ kind: "dropdown" }, { dataset: { value: "nope" } }), undefined, "a non-numeric dropdown value is rejected");
|
||||
assert.equal(ctx.settingControlValue({ kind: "dropdown" }, {}), undefined, "a dropdown with no value is rejected");
|
||||
// dropdownLabel maps the current int value to its human label, falling back to the value.
|
||||
const seamOptions = [
|
||||
{ value: 0, key: "nearest", label: "Nearest" },
|
||||
{ value: 1, key: "aligned", label: "Aligned" },
|
||||
{ value: 2, key: "random", label: "Random" }
|
||||
];
|
||||
assert.equal(ctx.dropdownLabel(seamOptions, 1), "Aligned", "the dropdown label for a known value is its label");
|
||||
assert.equal(ctx.dropdownLabel(seamOptions, 9), "9", "an unknown value falls back to the raw value");
|
||||
assert.equal(ctx.dropdownLabel([], 3), "3", "empty options fall back to the raw value");
|
||||
assert.equal(ctx.dropdownLabel(
|
||||
[{ value: 1, key: "aligned", label: "" }], 1), "aligned", "a blank label falls back to the key");
|
||||
// dropdownIcon maps the current int value to its pattern pictogram, empty when there is none.
|
||||
const patternOptions = [
|
||||
{ value: 0, key: "rectilinear", label: "Rectilinear" },
|
||||
{ value: 3, key: "gyroid", label: "Gyroid", icon: "data:image/svg+xml;base64,AAA" },
|
||||
{ value: 5, key: "grid", label: "Grid", icon: "data:image/svg+xml;base64,BBB" }
|
||||
];
|
||||
assert.equal(ctx.dropdownIcon(patternOptions, 3), "data:image/svg+xml;base64,AAA", "a known value returns its icon");
|
||||
assert.equal(ctx.dropdownIcon(patternOptions, 0), "", "a value with no icon returns empty");
|
||||
assert.equal(ctx.dropdownIcon(patternOptions, 9), "", "an unknown value returns empty");
|
||||
assert.equal(ctx.dropdownIcon([], 3), "", "empty options return empty");
|
||||
assert.equal(ctx.settingControlValue({ kind: "text" }, { value: "hello" }), "hello", "text submits as a string");
|
||||
// percent (coFloatOrPercent / coFloatsOrPercents): submits the raw typed string; empty is invalid.
|
||||
assert.equal(ctx.settingControlValue({ kind: "percent" }, { value: "10%" }), "10%", "a percent value submits as its string");
|
||||
assert.equal(ctx.settingControlValue({ kind: "percent" }, { value: "0.5" }), "0.5", "an mm value submits as a plain string");
|
||||
assert.equal(ctx.settingControlValue({ kind: "percent" }, { value: " " }), undefined, "a blank percent value is rejected");
|
||||
assert.equal(ctx.settingControlValue({ kind: "percent" }, {}), undefined, "a percent field with no value is rejected");
|
||||
// A percent scalar-to-scalar payload carries the raw string through unchanged.
|
||||
assert.equal(ctx.settingCollectedValue(
|
||||
{ editable: true, control: "percent", cardinality: "scalar", value: "10%" },
|
||||
[{ kind: "percent", value: "10%" }], [{ value: "10%" }]), "10%", "a percent scalar assembles its string");
|
||||
|
||||
// settingCollectedValue assembles the payload (scalar vs vector) for the set_setting message.
|
||||
assert.equal(ctx.settingCollectedValue({ editable: false }, [], []), undefined, "a non-editable setting yields no payload");
|
||||
assert.equal(ctx.settingCollectedValue(scalarBoolDesc, scalarRows, [{ checked: true }]), true, "a scalar assembles a single value");
|
||||
assert.deepEqual(ctx.settingCollectedValue(vectorDesc, vectorRows, [{ value: "0.3" }, { value: "0.7" }]), [0.3, 0.7], "a vector assembles an array");
|
||||
assert.equal(ctx.settingCollectedValue(vectorDesc, vectorRows, [{ value: "0.3" }, { value: "2" }]), undefined, "an invalid vector element aborts the whole payload");
|
||||
|
||||
console.log("ok");
|
||||
|
||||
@@ -77,6 +77,32 @@ body {
|
||||
.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;
|
||||
@@ -173,6 +199,9 @@ body {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.dial-list.empty { overflow-y: hidden; }
|
||||
/* 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;
|
||||
@@ -198,6 +227,17 @@ body {
|
||||
background: var(--speed-tile-bg, #f0f0f0);
|
||||
border: 1px solid var(--speed-tile-border, #d8d8d8);
|
||||
}
|
||||
/* A tile holding a pattern pictogram: drop the hue fill so the stroked SVG reads cleanly. */
|
||||
.tile.has-icon,
|
||||
.fav-tile.has-icon {
|
||||
background: none;
|
||||
border-color: transparent;
|
||||
}
|
||||
.tile-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: block;
|
||||
}
|
||||
.row-left { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
|
||||
.row-eyebrow {
|
||||
font-size: 10px;
|
||||
@@ -254,3 +294,145 @@ kbd {
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---- inline setting editor card (phase "setting") --------------------------- */
|
||||
.dial-list.setting { overflow-y: auto; }
|
||||
.dial-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
.editor-title { font-size: 14px; font-weight: 600; line-height: 1.3; }
|
||||
.editor-title-row { display: flex; align-items: center; gap: 8px; }
|
||||
.editor-preview-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.editor-preview-icon[hidden] { display: none; }
|
||||
.editor-tooltip { font-size: 11px; line-height: 1.4; color: var(--muted, var(--orca-muted, #6b7280)); }
|
||||
.editor-row { display: flex; align-items: center; gap: 8px; min-height: 32px; }
|
||||
.editor-label {
|
||||
flex: 0 0 auto;
|
||||
min-width: 64px;
|
||||
font-size: 12px;
|
||||
color: var(--muted, var(--orca-muted, #6b7280));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.editor-unit { font-size: 12px; color: var(--muted, var(--orca-muted, #6b7280)); }
|
||||
.editor-row input[type="number"],
|
||||
.editor-row input[type="text"],
|
||||
.editor-row select,
|
||||
.editor-row input[type="color"] {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border, var(--orca-border, #ddd));
|
||||
border-radius: 6px;
|
||||
background: var(--panel, var(--orca-bg, #fff));
|
||||
color: var(--text, var(--orca-fg, #1b1c1e));
|
||||
font: inherit;
|
||||
}
|
||||
.editor-row input[type="color"] { padding: 2px 4px; cursor: pointer; }
|
||||
.editor-row input:focus { outline: none; border-color: var(--main-color, var(--orca-accent, #009688)); box-shadow: 0 0 0 2px rgba(0,150,136,.22); }
|
||||
/* custom dropdown (native <select> popups are flaky in the embedded webview) */
|
||||
.ed-dropdown { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
|
||||
.ed-dropdown-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border, var(--orca-border, #ddd));
|
||||
border-radius: 6px;
|
||||
background: var(--panel, var(--orca-bg, #fff));
|
||||
color: var(--text, var(--orca-fg, #1b1c1e));
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ed-dropdown-toggle.open { border-color: var(--main-color, var(--orca-accent, #009688)); }
|
||||
.ed-dropdown-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ed-dropdown-caret { flex: 0 0 auto; font-size: 10px; color: var(--muted, var(--orca-muted, #6b7280)); transition: transform .12s; }
|
||||
.ed-dropdown-toggle.open .ed-dropdown-caret { transform: rotate(180deg); }
|
||||
.ed-dropdown-menu {
|
||||
/* Overlay popover, not in-flow: opening it must NOT grow the editor card or the popup window.
|
||||
position/left/top/width/max-height are set from JS on open (position:fixed escapes the
|
||||
scrollable card and launcher overflow, so the menu clips to the popup and scrolls itself).
|
||||
These are only the visual defaults. */
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
border: 1px solid var(--border, var(--orca-border, #ddd));
|
||||
border-radius: 6px;
|
||||
background: var(--panel, var(--orca-bg, #fff));
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.16);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ed-dropdown-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text, var(--orca-fg, #1b1c1e));
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ed-dropdown-option:hover { background: var(--row-hover, rgba(127,127,127,.16)); }
|
||||
.ed-dropdown-option.sel { font-weight: 600; color: var(--main-color, var(--orca-accent, #009688)); }
|
||||
.ed-option-label { flex: 1 1 auto; min-width: 0; }
|
||||
/* Pattern pictograms: filled with the option's icon, and (for values with none) absent. */
|
||||
.ed-option-icon,
|
||||
.ed-dropdown-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.ed-dropdown-icon[hidden] { display: none; }
|
||||
/* toggle switch */
|
||||
.ed-switch { position: relative; flex: 0 0 auto; width: 36px; height: 20px; }
|
||||
.ed-switch input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }
|
||||
.ed-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #c4c4c4;
|
||||
border-radius: 10px;
|
||||
transition: background .15s;
|
||||
}
|
||||
.ed-slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform .15s;
|
||||
}
|
||||
.ed-switch input:checked + .ed-slider { background: var(--main-color, var(--orca-accent, #009688)); }
|
||||
.ed-switch input:checked + .ed-slider::before { transform: translateX(16px); }
|
||||
.editor-readonly { padding: 4px 0; font-size: 12px; color: var(--muted, var(--orca-muted, #6b7280)); }
|
||||
.editor-actions { display: flex; gap: 8px; padding-top: 4px; }
|
||||
.ed-btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border, var(--orca-border, #ddd));
|
||||
border-radius: 6px;
|
||||
background: var(--panel, var(--orca-bg, #fff));
|
||||
color: var(--text, var(--orca-fg, #1b1c1e));
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ed-btn:hover { background: var(--row-hover, rgba(127,127,127,.16)); }
|
||||
.ed-btn-primary { background: var(--main-color, var(--orca-accent, #009688)); border-color: var(--main-color, var(--orca-accent, #009688)); color: #fff; }
|
||||
.ed-btn-primary:hover { filter: brightness(1.05); }
|
||||
.editor-hint { font-size: 10px; text-align: center; color: var(--muted, var(--orca-muted, #6b7280)); }
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
// Fold per-character so matched offsets stay in ORIGINAL string coordinates (highlighting slices the
|
||||
// original text; a separately-folded string would desync offsets).
|
||||
function FoldChar(ch) {
|
||||
// why: fast path - ASCII is already NFD-stable and diacritic-free, so the normalize/regex below are
|
||||
// no-ops. This skip is the hot cost in the Speed Dial search (thousands of settings per keystroke).
|
||||
if (ch.length === 1 && ch.charCodeAt(0) < 0x80)
|
||||
return ch;
|
||||
return ch.normalize("NFD").replace(/\p{Diacritic}/gu, ""); // accents always folded
|
||||
}
|
||||
|
||||
@@ -15,6 +19,41 @@ function Norm(ch, caseSensitive) {
|
||||
return caseSensitive ? folded : folded.toLowerCase(); // case-sensitivity is the only toggle
|
||||
}
|
||||
|
||||
// Pre-normalize a whole haystack with the SAME per-char fold FuzzyRanges uses, so a caller can match
|
||||
// it repeatedly against one cached string. The fold is 1:1 in length, so indices stay aligned to the
|
||||
// ORIGINAL text - the highlight ranges that FuzzyRangesNorm returns slice the original correctly.
|
||||
// Iterate by UTF-16 code unit (not Array.from code point) to mirror FuzzyRanges' own indexing exactly.
|
||||
function NormText(text, caseSensitive) {
|
||||
const src = text || "";
|
||||
let out = "";
|
||||
for (let i = 0; i < src.length; i++)
|
||||
out += Norm(src[i], caseSensitive);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Match a PRE-normalized haystack against a PRE-normalized needle (both produced by NormText with the
|
||||
// same caseSensitive flag). Skipping the per-character fold makes repeated matching (per keystroke over a
|
||||
// cached pool) cheap. Returns ranges in original coordinates, or null on no match.
|
||||
function FuzzyRangesNorm(haystackNorm, needleNorm) {
|
||||
const t = haystackNorm || "";
|
||||
const needle = needleNorm || "";
|
||||
if (!needle)
|
||||
return null;
|
||||
const ranges = [];
|
||||
let qi = 0;
|
||||
for (let i = 0; i < t.length && qi < needle.length; i++) {
|
||||
if (t[i] === needle[qi]) {
|
||||
const last = ranges[ranges.length - 1];
|
||||
if (last && last[1] === i)
|
||||
last[1] = i + 1;
|
||||
else
|
||||
ranges.push([i, i + 1]);
|
||||
qi++;
|
||||
}
|
||||
}
|
||||
return qi === needle.length ? ranges : null;
|
||||
}
|
||||
|
||||
function EscapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
@@ -16,18 +16,27 @@
|
||||
#include <libslic3r/AppConfig.hpp>
|
||||
#include <libslic3r/Config.hpp>
|
||||
#include <libslic3r/PresetBundle.hpp>
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
|
||||
#include <wx/thread.h>
|
||||
|
||||
#include <boost/algorithm/string/predicate.hpp>
|
||||
#include <boost/any.hpp>
|
||||
#include <boost/algorithm/string/trim.hpp>
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <boost/nowide/convert.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cfloat>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace Slic3r { namespace GUI {
|
||||
|
||||
@@ -131,6 +140,60 @@ std::unique_ptr<AppAction> make_action(const std::string& plugin_key, const std:
|
||||
constexpr const char* kCommandPrefix = "orca_command";
|
||||
constexpr const char* kOrcaSourceKey = "orca";
|
||||
constexpr const char* kOrcaSourceName = "OrcaSlicer";
|
||||
constexpr const char* kSettingPrefix = "orca_setting";
|
||||
|
||||
// Display context for a setting action's eyebrow, e.g. the "Process" in "Process : Quality : Layers".
|
||||
// Keyed by the option's preset type so the palette reads like the settings sidebar tabs.
|
||||
std::string setting_type_context(Preset::Type type)
|
||||
{
|
||||
switch (type) {
|
||||
case Preset::TYPE_FILAMENT:
|
||||
case Preset::TYPE_SLA_MATERIAL: return _u8L("Filament");
|
||||
case Preset::TYPE_PRINTER: return _u8L("Printer");
|
||||
case Preset::TYPE_PRINT:
|
||||
case Preset::TYPE_SLA_PRINT:
|
||||
default: return _u8L("Process");
|
||||
}
|
||||
}
|
||||
|
||||
// A config setting exposed as a first-class action: selecting it jumps the sidebar to the option.
|
||||
// The id is keyed by opt_key+type (NOT the display label), so renaming/localizing never re-keys
|
||||
// the action; title/group/source are purely for display + search. run() performs the jump, and
|
||||
// the generic registry run() bumps stats so a jump shows up in "recents" like any other action.
|
||||
struct SettingAction : AppAction
|
||||
{
|
||||
std::string opt_key;
|
||||
Preset::Type type;
|
||||
std::wstring category; // localized category, forwarded to jump_to_option
|
||||
|
||||
static std::string id_for(const std::string& opt_key, Preset::Type type)
|
||||
{ return std::string(kSettingPrefix) + ":" + opt_key + ":" + std::to_string(int(type)); }
|
||||
|
||||
SettingAction(std::string opt_key_in, Preset::Type type_in, std::string title, std::string group,
|
||||
std::wstring category_in, std::string source_name)
|
||||
: AppAction(AppActionId{id_for(opt_key_in, type_in)}, std::move(title), kOrcaSourceKey, std::move(source_name))
|
||||
, opt_key(std::move(opt_key_in))
|
||||
, type(type_in)
|
||||
, category(std::move(category_in))
|
||||
{
|
||||
// A setting is a two-phase command: activating it opens the inline editor (the "setting"
|
||||
// phase) instead of running. Native run() is a no-op fallback; the editor applies through
|
||||
// apply_setting().
|
||||
this->kind = AppActionKind::Command;
|
||||
this->group = std::move(group);
|
||||
this->input = "setting";
|
||||
}
|
||||
|
||||
AppActionRunResult run(const std::string& /*param*/) const override
|
||||
{
|
||||
// Two-phase: the palette collects the edit; native run() is a no-op fallback.
|
||||
return {AppActionRunResult::Level::Success};
|
||||
}
|
||||
|
||||
// The current value's pattern pictogram (e.g. the selected infill pattern), for the search-result
|
||||
// tile. Defined below after the icon helper it delegates to.
|
||||
std::string icon() const override;
|
||||
};
|
||||
|
||||
// Jump the preview to a layer selected by a 0-100 percent of the layer range. Best-effort:
|
||||
// switches to the preview tab and requests a slice (select_view_3D("Preview", false)); if the
|
||||
@@ -219,9 +282,9 @@ AppActionRunResult run_native_command(const std::string& command_key, const std:
|
||||
}
|
||||
return {AppActionRunResult::Level::Success};
|
||||
}
|
||||
// "go_to_setting"/"go_to_tab" are two-phase: the palette collects the option after
|
||||
// activating it, so dispatch here is a no-op (the actual jump goes through the web command).
|
||||
if (command_key == "go_to_setting" || command_key == "go_to_tab")
|
||||
// "go_to_tab" is two-phase: the palette collects the tab after activating it, so native
|
||||
// dispatch here is a no-op (the jump goes through the go_to_tab web command).
|
||||
if (command_key == "go_to_tab")
|
||||
return {AppActionRunResult::Level::Success};
|
||||
return {AppActionRunResult::Level::Info, _L("Unknown command.")};
|
||||
}
|
||||
@@ -253,10 +316,9 @@ std::vector<std::unique_ptr<AppAction>> native_commands()
|
||||
// why: _u8L (std::string) for titles/groups - make_command takes std::string; _L would
|
||||
// return a wxString and silently fail to convert here.
|
||||
out.push_back(make_command("slice_and_preview", _u8L("Slice and Preview"), _u8L("Commands")));
|
||||
// Two-phase commands: activating them collects input in the palette, then runs.
|
||||
// Two-phase commands: activating them collects input in the palette, then runs. Settings are
|
||||
// not a command here - they're materialised as first-class SettingActions (see materialize_).
|
||||
out.push_back(make_command("go_to_layer", _u8L("Go to layer (percent)"), _u8L("Commands"), "percent"));
|
||||
// The "…" is avoided in the msgid: use ASCII "..." to keep the .pot extraction simple.
|
||||
out.push_back(make_command("go_to_setting", _u8L("Go to setting..."), _u8L("Commands"), "settings"));
|
||||
out.push_back(make_command("go_to_tab", _u8L("Go to tab..."), _u8L("Commands"), "tab"));
|
||||
out.push_back(make_command("load_project", _u8L("Load Project"), _u8L("Commands")));
|
||||
out.push_back(make_command("save_project", _u8L("Save Project"), _u8L("Commands")));
|
||||
@@ -268,19 +330,305 @@ std::vector<std::unique_ptr<AppAction>> native_commands()
|
||||
return out;
|
||||
}
|
||||
|
||||
// Replicates Sidebar's get_search_inputs(): the configs of every tab supporting the current
|
||||
// printer technology, in the current UI mode.
|
||||
std::vector<Search::InputInfo> settings_inputs()
|
||||
// ---- inline setting editor helpers ------------------------------------------
|
||||
|
||||
// The inline editor "control" kind for an option, or "" when it can't be edited inline
|
||||
// (plugin-backed values, points, serialized strings, etc.). readonly/legend options are
|
||||
// filtered out of the search entirely in materialize_setting_actions(), so they never
|
||||
// reach here.
|
||||
std::string setting_control(const ConfigOptionDef& def)
|
||||
{
|
||||
std::vector<Search::InputInfo> ret;
|
||||
GUI_App& app = wxGetApp();
|
||||
if (!app.preset_bundle)
|
||||
return ret;
|
||||
auto print_tech = app.preset_bundle->printers.get_selected_preset().printer_technology();
|
||||
for (Tab* tab : app.tabs_list)
|
||||
if (tab && tab->supports_printer_technology(print_tech))
|
||||
ret.emplace_back(Search::InputInfo{tab->get_config(), tab->type(), app.get_mode()});
|
||||
return ret;
|
||||
if (def.readonly || def.gui_type == ConfigOptionDef::GUIType::legend ||
|
||||
def.gui_type == ConfigOptionDef::GUIType::one_string || def.is_plugin_backed())
|
||||
return "";
|
||||
// Serialized vectors are entered as ONE semicolon-separated field (e.g. post_process), which the
|
||||
// per-index editor doesn't model - keep them in the open-in-sidebar bucket.
|
||||
if (def.gui_flags.find("serialized") != std::string::npos)
|
||||
return "";
|
||||
switch (def.gui_type) {
|
||||
case ConfigOptionDef::GUIType::color: return "color";
|
||||
case ConfigOptionDef::GUIType::i_enum_open:
|
||||
case ConfigOptionDef::GUIType::f_enum_open: return "combo";
|
||||
default: break;
|
||||
}
|
||||
switch (def.type) {
|
||||
case coBool:
|
||||
case coBools: return "toggle";
|
||||
case coEnum:
|
||||
case coEnums: return def.enum_values.empty() ? "combo" : "dropdown";
|
||||
case coInt:
|
||||
case coInts:
|
||||
case coFloat:
|
||||
case coFloats:
|
||||
case coPercent:
|
||||
case coPercents: return "number";
|
||||
case coFloatOrPercent:
|
||||
case coFloatsOrPercents: return "percent";
|
||||
case coString:
|
||||
case coStrings: return "text";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Self-contained base64 encoder (for the tiny pictogram SVGs), avoiding a dependency on the exact
|
||||
// wxBase64Encode overload/return type across wx versions.
|
||||
std::string base64_encode(const std::string& data)
|
||||
{
|
||||
static const char* tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
auto enc = [&](unsigned n, int pad) {
|
||||
// pad = number of extraneous bytes in the final group (0, 1 or 2):
|
||||
// 0 leftover -> 4 chars from all 24 bits
|
||||
// 2 leftover (pad=1) -> 3 chars then '='
|
||||
// 1 leftover (pad=2) -> 2 chars then "=="
|
||||
// The '=' padding always comes LAST; a misplaced '=' decodes as garbage in the webview.
|
||||
std::string out;
|
||||
out.push_back(tbl[(n >> 18) & 63]);
|
||||
out.push_back(tbl[(n >> 12) & 63]);
|
||||
out.push_back(pad >= 2 ? '=' : tbl[(n >> 6) & 63]);
|
||||
out.push_back(pad >= 1 ? '=' : tbl[n & 63]);
|
||||
return out;
|
||||
};
|
||||
std::string out;
|
||||
out.reserve(((data.size() + 2) / 3) * 4);
|
||||
size_t i = 0;
|
||||
for (; i + 3 <= data.size(); i += 3)
|
||||
out += enc(((unsigned char) data[i]) << 16 | ((unsigned char) data[i + 1]) << 8 | ((unsigned char) data[i + 2]), 0);
|
||||
if (i + 1 == data.size())
|
||||
out += enc(((unsigned char) data[i]) << 16, 2);
|
||||
else if (i + 2 == data.size())
|
||||
out += enc(((unsigned char) data[i]) << 16 | ((unsigned char) data[i + 1]) << 8, 1);
|
||||
return out;
|
||||
}
|
||||
|
||||
// data:URI for the pattern pictogram icons/param_<key>.svg, or "" when there is no such icon.
|
||||
// This mirrors the sidebar Choice field (Field.cpp add_item_bitmaps), which loads param_<value>.svg
|
||||
// per enum value - most settings have no icon, only pattern-style enums (infill/support patterns).
|
||||
// Base64 data URIs are used so the embedded webview renders them identically on every backend
|
||||
// (no file:// subresource / CORS restrictions).
|
||||
std::string setting_icon_for_key(const std::string& key)
|
||||
{
|
||||
if (key.empty())
|
||||
return {};
|
||||
|
||||
const std::string path = (boost::filesystem::path(resources_dir()) / "images" / ("param_" + key + ".svg")).string();
|
||||
// Non-throwing stat: a throwing filesystem_error here would propagate out of snapshot() and
|
||||
// abort the app (the palette opener). exists(fs ::error_code) never throws.
|
||||
boost::system::error_code ec;
|
||||
if (!boost::filesystem::exists(path, ec))
|
||||
return {};
|
||||
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
if (!in)
|
||||
return {};
|
||||
std::string data((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||
if (data.empty())
|
||||
return {};
|
||||
|
||||
return "data:image/svg+xml;base64," + base64_encode(data);
|
||||
}
|
||||
|
||||
// [{value,key,label,icon}...] for an enum/combo. Ordered by enum_values when present, else by the
|
||||
// keys_map iteration. label falls back to the key when enum_labels doesn't provide one. `icon` is
|
||||
// the value's pattern pictogram when one exists, empty otherwise.
|
||||
nlohmann::json setting_enum_options(const ConfigOptionDef& def)
|
||||
{
|
||||
nlohmann::json out = nlohmann::json::array();
|
||||
auto label_at = [&](size_t i, const std::string& key) -> std::string {
|
||||
return (i < def.enum_labels.size() && !def.enum_labels[i].empty()) ? def.enum_labels[i] : key;
|
||||
};
|
||||
if (def.enum_keys_map != nullptr) {
|
||||
std::vector<std::string> ordered;
|
||||
if (!def.enum_values.empty())
|
||||
ordered = def.enum_values;
|
||||
else
|
||||
for (const auto& kv : *def.enum_keys_map)
|
||||
ordered.push_back(kv.first);
|
||||
for (size_t i = 0; i < ordered.size(); ++i) {
|
||||
auto it = def.enum_keys_map->find(ordered[i]);
|
||||
if (it == def.enum_keys_map->end())
|
||||
continue;
|
||||
out.push_back({{"value", it->second},
|
||||
{"key", ordered[i]},
|
||||
{"label", label_at(i, ordered[i])},
|
||||
{"icon", setting_icon_for_key(ordered[i])}});
|
||||
}
|
||||
} else {
|
||||
for (size_t i = 0; i < def.enum_values.size(); ++i)
|
||||
out.push_back({{"value", (long long) i},
|
||||
{"key", def.enum_values[i]},
|
||||
{"label", label_at(i, def.enum_values[i])},
|
||||
{"icon", setting_icon_for_key(def.enum_values[i])}});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The pattern pictogram for a setting's CURRENT value (its enum int), empty when it isn't a
|
||||
// pattern-style enum or the value has no icon. Used for the search-result tile.
|
||||
std::string setting_action_icon(const SettingAction& a)
|
||||
{
|
||||
Tab* tab = wxGetApp().get_tab(a.type);
|
||||
if (!tab || !tab->get_config())
|
||||
return {};
|
||||
DynamicPrintConfig* config = tab->get_config();
|
||||
const ConfigOptionDef* def = config->def()->get(a.opt_key);
|
||||
if (!def || def->type != coEnum || (int(def->type) & int(coVectorType)) != 0)
|
||||
return {};
|
||||
// Read the value WITHOUT config->opt_int(): the non-const overload routes through a type-checked
|
||||
// option<ConfigOptionInt>() that returns null for enum values (type() is coEnum, not coInt) and
|
||||
// would deref null. Pull the ConfigOption* and dynamic_cast instead (succeeds: enums derive from
|
||||
// ConfigOptionInt), falling back to the def default when the option is absent.
|
||||
const ConfigOption* opt = (config->has(a.opt_key) ? config->option(a.opt_key) : def->default_value.get());
|
||||
const ConfigOptionInt* int_opt = dynamic_cast<const ConfigOptionInt*>(opt);
|
||||
if (!int_opt)
|
||||
return {};
|
||||
const int value = int_opt->getInt();
|
||||
if (def->enum_keys_map)
|
||||
for (const auto& kv : *def->enum_keys_map)
|
||||
if (kv.second == value)
|
||||
return setting_icon_for_key(kv.first);
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string SettingAction::icon() const { return setting_action_icon(*this); }
|
||||
|
||||
// Current value of the option at vector index `idx` as JSON (bool/number/string), or null for a
|
||||
// type the inline editor doesn't render. `config` is the tab's live config; when an option is
|
||||
// absent the def's default is shown.
|
||||
nlohmann::json setting_value_json(const DynamicPrintConfig& config, const ConfigOptionDef& def, size_t idx)
|
||||
{
|
||||
const ConfigOption* opt = config.option(def.opt_key);
|
||||
const ConfigOption* root = opt ? opt : def.default_value.get();
|
||||
if (!root)
|
||||
return nullptr;
|
||||
switch (def.type) {
|
||||
case coBool: return root->getBool();
|
||||
case coInt: return root->getInt();
|
||||
case coFloat: return root->getFloat();
|
||||
case coPercent: return root->getFloat();
|
||||
case coString: return static_cast<const ConfigOptionString*>(root)->value;
|
||||
case coEnum: return root->getInt();
|
||||
case coBools: {
|
||||
if (auto v = dynamic_cast<const ConfigOptionBools*>(root))
|
||||
return bool(v->get_at(idx));
|
||||
if (auto v = dynamic_cast<const ConfigOptionBoolsNullable*>(root))
|
||||
return bool(v->get_at(idx) != 0);
|
||||
return nullptr;
|
||||
}
|
||||
case coInts: {
|
||||
if (auto v = dynamic_cast<const ConfigOptionInts*>(root))
|
||||
return v->get_at(idx);
|
||||
if (auto v = dynamic_cast<const ConfigOptionIntsNullable*>(root)) {
|
||||
const int nil = ConfigOptionIntsNullable::nil_value();
|
||||
int val = v->get_at(idx);
|
||||
return val == nil ? nlohmann::json(nullptr) : nlohmann::json(val);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
case coFloats: {
|
||||
if (auto v = dynamic_cast<const ConfigOptionFloats*>(root))
|
||||
return v->get_at(idx);
|
||||
if (auto v = dynamic_cast<const ConfigOptionFloatsNullable*>(root)) {
|
||||
double val = v->get_at(idx);
|
||||
return std::isnan(val) ? nlohmann::json(nullptr) : nlohmann::json(val);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
case coPercents: {
|
||||
if (auto v = dynamic_cast<const ConfigOptionPercents*>(root))
|
||||
return v->get_at(idx);
|
||||
if (auto v = dynamic_cast<const ConfigOptionPercentsNullable*>(root)) {
|
||||
double val = v->get_at(idx);
|
||||
return std::isnan(val) ? nlohmann::json(nullptr) : nlohmann::json(val);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
case coStrings: return static_cast<const ConfigOptionStrings*>(root)->get_at(idx);
|
||||
case coEnums: {
|
||||
if (auto v = dynamic_cast<const ConfigOptionEnumsGeneric*>(root))
|
||||
return v->get_at(idx);
|
||||
if (auto v = dynamic_cast<const ConfigOptionEnumsGenericNullable*>(root)) {
|
||||
const int nil = ConfigOptionEnumsGenericNullable::nil_value();
|
||||
int val = v->get_at(idx);
|
||||
return val == nil ? nlohmann::json(nullptr) : nlohmann::json(val);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
case coFloatOrPercent: return root->serialize();
|
||||
case coFloatsOrPercents: {
|
||||
if (auto v = dynamic_cast<const ConfigOptionFloatsOrPercents*>(root)) {
|
||||
auto ss = v->vserialize();
|
||||
return idx < ss.size() ? ss[idx] : nullptr;
|
||||
}
|
||||
if (auto v = dynamic_cast<const ConfigOptionFloatsOrPercentsNullable*>(root)) {
|
||||
auto ss = v->vserialize();
|
||||
return idx < ss.size() ? ss[idx] : nullptr;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// How many scalar values the option currently has (1 for scalars, the array length for vectors).
|
||||
size_t setting_value_count(const DynamicPrintConfig& config, const ConfigOptionDef& def)
|
||||
{
|
||||
if ((int(def.type) & int(coVectorType)) == 0)
|
||||
return 1;
|
||||
// size() lives on ConfigOptionVectorBase, not ConfigOption - dynamic_cast to it covers every
|
||||
// vector type (and their nullable variants) polymorphically.
|
||||
const ConfigOption* opt = config.option(def.opt_key);
|
||||
if (opt)
|
||||
if (auto v = dynamic_cast<const ConfigOptionVectorBase*>(opt))
|
||||
return v->size();
|
||||
if (def.default_value)
|
||||
if (auto v = dynamic_cast<const ConfigOptionVectorBase*>(def.default_value.get()))
|
||||
return v->size();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// boost::any for a single element, matching what Slic3r::GUI::change_opt_value expects.
|
||||
boost::any setting_any_from_json(const ConfigOptionDef& def, const nlohmann::json& v)
|
||||
{
|
||||
if (!v.is_null()) {
|
||||
switch (def.type) {
|
||||
case coBool: return boost::any(v.is_boolean() ? v.get<bool>() : v.get<int>() != 0);
|
||||
case coInt: return boost::any(v.get<int>());
|
||||
case coFloat:
|
||||
case coPercent: return boost::any(v.get<double>());
|
||||
case coString: return boost::any(v.get<std::string>());
|
||||
case coEnum: return boost::any(v.get<int>());
|
||||
case coBools: return boost::any(static_cast<unsigned char>(v.get<bool>() ? 1 : 0));
|
||||
case coInts: return boost::any(v.get<int>());
|
||||
case coFloats:
|
||||
case coPercents: return boost::any(v.get<double>());
|
||||
case coStrings: return boost::any(v.get<std::string>());
|
||||
case coFloatOrPercent:
|
||||
case coFloatsOrPercents: {
|
||||
// change_opt_value detects "percent" via a trailing '%', so trim whitespace first or a
|
||||
// stray space (e.g. "10% ") would be misread as mm.
|
||||
std::string s = v.is_string() ? v.get<std::string>() : std::to_string(v.get<double>());
|
||||
boost::trim(s);
|
||||
// An empty string would make change_opt_value's str.back() UB - bail out to a rejected apply.
|
||||
return s.empty() ? boost::any() : boost::any(s);
|
||||
}
|
||||
case coEnums: return boost::any(v.get<int>());
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
// Coerce numeric types that may arrive as a different JSON numeric type.
|
||||
if (v.is_number()) {
|
||||
switch (def.type) {
|
||||
case coInt:
|
||||
case coEnums: return boost::any(v.get<int>());
|
||||
case coFloat:
|
||||
case coPercent:
|
||||
case coInts:
|
||||
case coFloats:
|
||||
case coPercents: return boost::any(v.get<double>());
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
return boost::any();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -411,7 +759,9 @@ void ActionRegistry::remove(const std::string& id)
|
||||
|
||||
void ActionRegistry::seed_state(AppAction& a) const
|
||||
{
|
||||
auto favs = read_string_array("favourite_actions");
|
||||
// Favourites carry the quick-launch order, so the persisted list is the source of truth
|
||||
// (not re-derived from the frecency sort). Cap it so stale configs can't exceed kFavLimit.
|
||||
auto favs = favourite_ids();
|
||||
a.favourite = std::find(favs.begin(), favs.end(), a.id()) != favs.end();
|
||||
|
||||
nlohmann::json stats = read_section("stats", nlohmann::json::object());
|
||||
@@ -469,18 +819,40 @@ AppActionRunResult ActionRegistry::run(const std::string& id, const std::string&
|
||||
return o;
|
||||
}
|
||||
|
||||
void ActionRegistry::set_favourite(const std::string& id, bool on)
|
||||
bool ActionRegistry::set_favourite(const std::string& id, bool on)
|
||||
{
|
||||
assert(wxThread::IsMain());
|
||||
auto favs = read_string_array("favourite_actions");
|
||||
// Start from the capped, deduped list so a persisted config can never be written back larger.
|
||||
auto favs = favourite_ids();
|
||||
auto it = std::find(favs.begin(), favs.end(), id);
|
||||
if (on && it == favs.end())
|
||||
if (on && it == favs.end()) {
|
||||
if (favs.size() >= kFavLimit)
|
||||
return false; // bar is full - the caller surfaces a hint
|
||||
favs.push_back(id);
|
||||
}
|
||||
if (!on && it != favs.end())
|
||||
favs.erase(it);
|
||||
write_section("favourite_actions", nlohmann::json(favs));
|
||||
if (AppAction* live = find(id))
|
||||
live->favourite = on;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<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. The pinned order is intentionally preserved (slice, not sort).
|
||||
std::vector<std::string> favs = read_string_array("favourite_actions");
|
||||
std::vector<std::string> out;
|
||||
out.reserve(std::min(favs.size(), kFavLimit));
|
||||
for (const auto& id : favs) {
|
||||
if (out.size() >= kFavLimit)
|
||||
break;
|
||||
if (std::find(out.begin(), out.end(), id) == out.end())
|
||||
out.push_back(id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void ActionRegistry::reorder_favourites(const std::vector<std::string>& ids)
|
||||
@@ -496,9 +868,78 @@ void ActionRegistry::reorder_favourites(const std::vector<std::string>& ids)
|
||||
for (const auto& id : cur)
|
||||
if (std::find(next.begin(), next.end(), id) == next.end())
|
||||
next.push_back(id);
|
||||
// never write the bar back larger than the quick-launch slots
|
||||
if (next.size() > kFavLimit)
|
||||
next.resize(kFavLimit);
|
||||
write_section("favourite_actions", nlohmann::json(next));
|
||||
}
|
||||
|
||||
void ActionRegistry::materialize_setting_actions()
|
||||
{
|
||||
assert(wxThread::IsMain());
|
||||
|
||||
// Reuse the Sidebar's live searcher: it's the only OptionsSearcher whose groups_and_categories
|
||||
// map is populated (Tab::add_key feeds it at build time), and it already mirrors the current
|
||||
// configs/mode/printer-technology - i.e. exactly what the sidebar's own search would show. A
|
||||
// fresh OptionsSearcher has an empty groups_and_categories, so append_options() would drop every
|
||||
// option and nothing would materialise. Turn each visible option into a SettingAction.
|
||||
const std::vector<Search::Option>& options = wxGetApp().sidebar().get_searcher().all_options();
|
||||
|
||||
// Load the persisted per-action state ONCE (not per-option) so a re-materialised setting keeps
|
||||
// its recency/favourite; mirroring seed_state but amortised over the whole option set.
|
||||
nlohmann::json stats = read_section("stats", nlohmann::json::object());
|
||||
if (!stats.is_object())
|
||||
stats = nlohmann::json::object();
|
||||
const std::vector<std::string> favs = favourite_ids();
|
||||
|
||||
std::unordered_set<std::string> seen;
|
||||
for (const Search::Option& opt : options) {
|
||||
// Omit rows the inline editor can't represent and that aren't useful as a jump target:
|
||||
// readonly (e.g. the detected thread count) and legend (static text) GUI rows. They remain
|
||||
// in the sidebar's own search; only the Speed Dial pool drops them.
|
||||
Tab* tab = wxGetApp().get_tab(opt.type);
|
||||
if (tab && tab->get_config()) {
|
||||
const ConfigOptionDef* def = tab->get_config()->def()->get(opt.opt_key());
|
||||
if (!def || def->readonly || def->gui_type == ConfigOptionDef::GUIType::legend)
|
||||
continue;
|
||||
}
|
||||
|
||||
const std::string id = SettingAction::id_for(opt.opt_key(), opt.type);
|
||||
seen.insert(id);
|
||||
|
||||
const std::wstring label_w = opt.label_local.empty() ? opt.label : opt.label_local;
|
||||
|
||||
// Eyebrow/source = the full settings path "Process : Quality : Layers" (localized). The JS
|
||||
// renders group || source and searches source + " " + group, so putting the whole path in
|
||||
// source both displays it and makes it matchable by any segment (e.g. a "quality" query).
|
||||
std::wstring path = boost::nowide::widen(setting_type_context(opt.type));
|
||||
if (!opt.category_local.empty())
|
||||
path += L" : " + opt.category_local;
|
||||
if (!opt.group_local.empty())
|
||||
path += L" : " + opt.group_local;
|
||||
|
||||
// title = the option leaf name (last label segment); group stays empty so the source path
|
||||
// (above) is the single display/search breadcrumb rather than being duplicated.
|
||||
auto action = std::make_unique<SettingAction>(opt.opt_key(), opt.type, boost::nowide::narrow(label_w),
|
||||
std::string(), opt.category_local, boost::nowide::narrow(path));
|
||||
action->favourite = std::find(favs.begin(), favs.end(), id) != favs.end();
|
||||
if (auto it = stats.find(id); it != stats.end() && it->is_object()) {
|
||||
action->count = it->value("count", 0);
|
||||
action->last = it->value("last", 0LL);
|
||||
}
|
||||
m_actions.insert_or_assign(action->id(), std::shared_ptr<AppAction>(std::move(action)));
|
||||
}
|
||||
|
||||
// Drop SettingActions whose option no longer exists in the current configs (e.g. the printer
|
||||
// technology / UI mode changed). Non-setting actions are untouched.
|
||||
for (auto it = m_actions.begin(); it != m_actions.end();) {
|
||||
if (it->first.rfind(kSettingPrefix, 0) == 0 && !seen.count(it->first))
|
||||
it = m_actions.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
bool ActionRegistry::should_ask(const std::string& id) const
|
||||
{
|
||||
assert(wxThread::IsMain());
|
||||
@@ -517,9 +958,13 @@ void ActionRegistry::suppress_ask(const std::string& id)
|
||||
|
||||
// ---- snapshot ---------------------------------------------------------------
|
||||
|
||||
nlohmann::json ActionRegistry::snapshot() const
|
||||
nlohmann::json ActionRegistry::snapshot()
|
||||
{
|
||||
assert(wxThread::IsMain());
|
||||
// Settings are first-class actions; make sure the current visible option set is materialised
|
||||
// before we serialise the pool (tabs_list is built by the time the palette opens).
|
||||
materialize_setting_actions();
|
||||
|
||||
std::vector<const AppAction*> sorted;
|
||||
sorted.reserve(m_actions.size());
|
||||
for (const auto& entry : m_actions)
|
||||
@@ -544,7 +989,8 @@ nlohmann::json ActionRegistry::snapshot() const
|
||||
{"source", a->source_name()},
|
||||
{"group", a->group},
|
||||
{"input", a->input},
|
||||
{"shortcut", ""}});
|
||||
{"shortcut", ""},
|
||||
{"icon", a->icon()}});
|
||||
};
|
||||
|
||||
nlohmann::json actions = nlohmann::json::array();
|
||||
@@ -554,7 +1000,8 @@ nlohmann::json ActionRegistry::snapshot() const
|
||||
// why: favourites is the ORDERED pin list - it must come from favourite_actions
|
||||
// as stored, not be re-derived from the frecency-sorted actions (that would
|
||||
// reorder the favourites bar). The page (js) filters out ids with no live action itself.
|
||||
nlohmann::json favourites(read_string_array("favourite_actions"));
|
||||
// Cap on read so the bar cannot exceed the quick-launch slots (kFavLimit).
|
||||
nlohmann::json favourites(favourite_ids());
|
||||
|
||||
// Recent = the last-N launched actions by recency (only actions with a run history).
|
||||
constexpr size_t kRecentLimit = 5;
|
||||
@@ -576,49 +1023,6 @@ nlohmann::json ActionRegistry::snapshot() const
|
||||
return {{"actions", std::move(actions)}, {"favourites", std::move(favourites)}, {"recent", std::move(recent_json)}};
|
||||
}
|
||||
|
||||
nlohmann::json ActionRegistry::settings_search(const std::string& query)
|
||||
{
|
||||
assert(wxThread::IsMain());
|
||||
std::string q = boost::trim_copy(query);
|
||||
// Empty query: show the recently-jumped-to settings instead of a blank list.
|
||||
if (q.empty())
|
||||
return settings_recent();
|
||||
|
||||
// Use the sidebar's live searcher. It is the instance Tab registration (add_key) populates
|
||||
// with each option's group/category, and it carries the current printer technology. A fresh
|
||||
// OptionsSearcher has an empty groups_and_categories, so init()/append_options() drops every
|
||||
// option and the search returns nothing.
|
||||
Search::OptionsSearcher& searcher = wxGetApp().sidebar().get_searcher();
|
||||
searcher.init(settings_inputs());
|
||||
searcher.search(q, true);
|
||||
auto& found = searcher.found_options();
|
||||
|
||||
constexpr size_t kLimit = 20;
|
||||
const size_t n = std::min<size_t>(kLimit, found.size());
|
||||
nlohmann::json out = nlohmann::json::array();
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
const auto& opt = searcher.get_option(i);
|
||||
// Clean plain label "category : group : label" - OptionsSearcher's own label string
|
||||
// carries ImGui icon control chars + <b>/</b> markup (SUPPORTS_MARKUP), which render
|
||||
// as garbage in the webview. Build it from the Option's localized strings instead.
|
||||
std::wstring plain;
|
||||
const std::wstring* prev = nullptr;
|
||||
for (const std::wstring* const s : {&opt.category_local, &opt.group_local, &opt.label_local})
|
||||
if (s != nullptr && !s->empty() && (prev == nullptr || *prev != *s)) {
|
||||
if (!plain.empty())
|
||||
plain += L" : ";
|
||||
plain += *s;
|
||||
prev = s;
|
||||
}
|
||||
out.push_back({{"opt_key", opt.opt_key()},
|
||||
{"type", int(opt.type)},
|
||||
{"label", boost::nowide::narrow(plain)},
|
||||
{"category", boost::nowide::narrow(opt.category)},
|
||||
{"group", boost::nowide::narrow(opt.group)}});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- tab options (enumerate the MainFrame notebook's current pages) ----------
|
||||
|
||||
nlohmann::json ActionRegistry::tab_options() const
|
||||
@@ -640,37 +1044,140 @@ nlohmann::json ActionRegistry::tab_options() const
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- settings recents (persisted, most-recent-first, capped at 8) -----------
|
||||
// ---- inline setting editor (read the current value) --------------------------
|
||||
|
||||
nlohmann::json ActionRegistry::settings_recent() const
|
||||
nlohmann::json ActionRegistry::setting_descriptor(const std::string& id) const
|
||||
{
|
||||
assert(wxThread::IsMain());
|
||||
return read_section("recent_settings", nlohmann::json::array());
|
||||
const AppAction* a = by_id(id);
|
||||
const SettingAction* sa = dynamic_cast<const SettingAction*>(a);
|
||||
if (!sa)
|
||||
return nlohmann::json::object();
|
||||
Tab* tab = wxGetApp().get_tab(sa->type);
|
||||
if (!tab)
|
||||
return nlohmann::json::object();
|
||||
DynamicPrintConfig* config = tab->get_config();
|
||||
if (!config)
|
||||
return nlohmann::json::object();
|
||||
const ConfigOptionDef* def = config->def()->get(sa->opt_key);
|
||||
if (!def)
|
||||
return nlohmann::json::object();
|
||||
|
||||
const std::string control = setting_control(*def);
|
||||
const bool vector = (int(def->type) & int(coVectorType)) != 0;
|
||||
|
||||
nlohmann::json d = {{"id", sa->id()},
|
||||
{"opt_key", sa->opt_key},
|
||||
{"type", int(sa->type)},
|
||||
{"title", a->title()},
|
||||
{"breadcrumb", a->source_name()},
|
||||
{"category", boost::nowide::narrow(sa->category)},
|
||||
{"unit", def->sidetext},
|
||||
{"tooltip", def->tooltip},
|
||||
{"editable", !control.empty()},
|
||||
{"control", control},
|
||||
{"cardinality", vector ? "vector" : "scalar"}};
|
||||
|
||||
if (!control.empty()) {
|
||||
// Hide unbounded min/max so the page doesn't clamp a sane value to ±FLT_MAX.
|
||||
if (def->min > -FLT_MAX)
|
||||
d["min"] = def->min;
|
||||
if (def->max < FLT_MAX)
|
||||
d["max"] = def->max;
|
||||
if (control == "number")
|
||||
d["is_int"] = (def->type == coInt || def->type == coInts);
|
||||
if (control == "dropdown" || control == "combo")
|
||||
d["enum_options"] = setting_enum_options(*def);
|
||||
if (vector) {
|
||||
nlohmann::json values = nlohmann::json::array();
|
||||
nlohmann::json labels = nlohmann::json::array();
|
||||
const size_t n = setting_value_count(*config, *def);
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
values.push_back(setting_value_json(*config, *def, i));
|
||||
labels.push_back(std::to_string(i + 1));
|
||||
}
|
||||
d["values"] = std::move(values);
|
||||
d["index_labels"] = std::move(labels);
|
||||
} else {
|
||||
d["value"] = setting_value_json(*config, *def, 0);
|
||||
}
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
void ActionRegistry::record_setting_recent(
|
||||
const std::string& opt_key, int type, const std::string& label, const std::string& category, const std::string& group)
|
||||
// ---- inline setting editor (write the edited value back) ---------------------
|
||||
|
||||
bool ActionRegistry::apply_setting(const std::string& id, const nlohmann::json& value)
|
||||
{
|
||||
assert(wxThread::IsMain());
|
||||
if (opt_key.empty())
|
||||
return;
|
||||
const AppAction* a = by_id(id);
|
||||
const SettingAction* sa = dynamic_cast<const SettingAction*>(a);
|
||||
if (!sa)
|
||||
return false;
|
||||
Tab* tab = wxGetApp().get_tab(sa->type);
|
||||
if (!tab)
|
||||
return false;
|
||||
DynamicPrintConfig* config = tab->get_config();
|
||||
if (!config)
|
||||
return false;
|
||||
const ConfigOptionDef* def = config->def()->get(sa->opt_key);
|
||||
if (!def)
|
||||
return false;
|
||||
const std::string control = setting_control(*def);
|
||||
if (control.empty())
|
||||
return false;
|
||||
|
||||
constexpr size_t kLimit = 8;
|
||||
auto arr = read_section("recent_settings", nlohmann::json::array());
|
||||
if (!arr.is_array())
|
||||
arr = nlohmann::json::array();
|
||||
auto same = [&](const nlohmann::json& e) {
|
||||
return e.is_object() && e.value("opt_key", std::string()) == opt_key && e.value("type", int(-1)) == type;
|
||||
};
|
||||
const bool vector = (int(def->type) & int(coVectorType)) != 0;
|
||||
const size_t n = vector ? (value.is_array() ? value.size() : 0) : 1;
|
||||
if (vector && n == 0)
|
||||
return false;
|
||||
|
||||
nlohmann::json next = nlohmann::json::array();
|
||||
next.push_back({{"opt_key", opt_key}, {"type", type}, {"label", label}, {"category", category}, {"group", group}});
|
||||
for (const auto& e : arr)
|
||||
if (!same(e))
|
||||
next.push_back(e);
|
||||
if (next.size() > kLimit)
|
||||
next.erase(next.begin() + long(kLimit), next.end());
|
||||
write_section("recent_settings", next);
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
const nlohmann::json& elem = vector ? value[i] : value;
|
||||
boost::any any = setting_any_from_json(*def, elem);
|
||||
if (any.empty())
|
||||
return false;
|
||||
if (control == "number" && elem.is_number()) {
|
||||
const double d = elem.get<double>();
|
||||
if (d < def->min || d > def->max)
|
||||
return false;
|
||||
}
|
||||
if (control == "percent" && elem.is_string()) {
|
||||
// "mm or %" value: strip a trailing %/whitespace, clamp the numeric part to [min,max].
|
||||
// Reject anything that isn't a well-formed number (which change_opt_value would throw on).
|
||||
std::string s = elem.get<std::string>();
|
||||
boost::trim(s);
|
||||
if (!s.empty() && s.back() == '%')
|
||||
s.pop_back();
|
||||
boost::trim(s);
|
||||
if (s.empty())
|
||||
return false;
|
||||
char* end = nullptr;
|
||||
const double d = std::strtod(s.c_str(), &end);
|
||||
if (end == s.c_str() || *end != '\0')
|
||||
return false;
|
||||
if (d < def->min || d > def->max)
|
||||
return false;
|
||||
}
|
||||
Slic3r::GUI::change_opt_value(*config, sa->opt_key, any, int(i));
|
||||
}
|
||||
|
||||
// Mark the preset modified like a sidebar edit. Scalar options also get the standard
|
||||
// post-change hook so dependent settings refresh; vector options have no unambiguous scalar
|
||||
// value to pass, so on_value_change is skipped (the config write + dirty flag is still correct).
|
||||
tab->update_dirty();
|
||||
if (!vector) {
|
||||
boost::any any = setting_any_from_json(*def, value);
|
||||
if (!any.empty())
|
||||
tab->on_value_change(sa->opt_key, any);
|
||||
}
|
||||
|
||||
// The config write is separate from the on-screen Field, so repaint the field(s) that display
|
||||
// this option (on whatever page they live, not just the active page) - otherwise the sidebar
|
||||
// shows the "modified" arrow but keeps the stale value pushed to the last edit/reload.
|
||||
if (Page* page = nullptr; tab->get_field(sa->opt_key, &page) && page)
|
||||
page->reload_config();
|
||||
return true;
|
||||
}
|
||||
|
||||
}} // namespace Slic3r::GUI
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <wx/thread.h>
|
||||
|
||||
#include <cassert>
|
||||
#include <cstddef>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
@@ -31,6 +32,14 @@ struct AppActionRunResult
|
||||
wxString message; // empty = "nothing worth showing"
|
||||
};
|
||||
|
||||
// Tag carrying a precomputed action id, used by the explicit-id ctor below. It exists so the
|
||||
// id ctor and the compose-from-prefix ctor are NOT both reachable from a `const char*` first
|
||||
// argument (which would make calls like AppAction("orca_command", ...) ambiguous).
|
||||
struct AppActionId
|
||||
{
|
||||
std::string id;
|
||||
};
|
||||
|
||||
// A speed-dial action: identity + user-state seeded from config + how to run itself.
|
||||
// Abstract base - the only virtual is run(); concrete subclasses know how to run
|
||||
// and what their source is.
|
||||
@@ -72,6 +81,11 @@ struct AppAction
|
||||
// commands (e.g. a layer percentage); plugins ignore it.
|
||||
virtual AppActionRunResult run(const std::string& param = {}) const = 0;
|
||||
|
||||
// Optional data:URI for a small pictogram to show in the palette row/tile/the editor
|
||||
// (e.g. the current infill/pattern). Empty string = fall back to the monogram. Only
|
||||
// SettingAction overrides this; the base returns an empty string.
|
||||
virtual std::string icon() const { return {}; }
|
||||
|
||||
protected:
|
||||
// The definition is constructor-set and immutable. Refreshes replace an action
|
||||
// instead of mutating identity after the registry has indexed it by id.
|
||||
@@ -83,6 +97,14 @@ protected:
|
||||
m_source_key(std::move(source_key)),
|
||||
m_source_name(std::move(source_name)) {}
|
||||
|
||||
// Explicit-id ctor: for actions whose id must NOT be derived from the display title
|
||||
// (e.g. a setting action keyed by opt_key+type, so a rename/localization never re-keys it).
|
||||
AppAction(AppActionId id, std::string title, std::string source_key, std::string source_name)
|
||||
: m_id(std::move(id.id)),
|
||||
m_title(std::move(title)),
|
||||
m_source_key(std::move(source_key)),
|
||||
m_source_name(std::move(source_name)) {}
|
||||
|
||||
private:
|
||||
std::string m_id; // <prefix>:<title>:<source_key> - stable identity + AppConfig key
|
||||
std::string m_title; // display name
|
||||
@@ -122,34 +144,27 @@ public:
|
||||
// Always-clean read surface. UI thread only.
|
||||
const AppAction* by_id(const std::string& id) const;
|
||||
|
||||
// Hard cap on the favourites bar: the numbered quick-launch slots (Alt/Option+1..9, 0).
|
||||
static constexpr size_t kFavLimit = 10;
|
||||
|
||||
// Dispatch + write-through (registry is the only thing that touches AppConfig).
|
||||
AppActionRunResult run(const std::string& id, const std::string& param = {}); // runs + bumps stats
|
||||
void set_favourite(const std::string& id, bool on);
|
||||
// Pin/unpin. Returns false when `on` would exceed kFavLimit (the bar is full) so the
|
||||
// caller can surface a "favourites are full" hint instead of silently dropping the pin.
|
||||
bool set_favourite(const std::string& id, bool on);
|
||||
void reorder_favourites(const std::vector<std::string>& ids); // persist a new bar order
|
||||
|
||||
// Ordered pinned list (the source of truth), capped at kFavLimit and deduped, matching the
|
||||
// visible bar the palette renders.
|
||||
std::vector<std::string> favourite_ids() const;
|
||||
|
||||
// Run-confirm gate, keyed by action id (per-action "don't ask again").
|
||||
bool should_ask(const std::string& id) const;
|
||||
void suppress_ask(const std::string& id);
|
||||
|
||||
// Flat, frecency-sorted snapshot for the webview:
|
||||
// {actions:[...], favourites:[...], recent:[...]} (recent = last-N launched by recency).
|
||||
nlohmann::json snapshot() const;
|
||||
|
||||
// "Go to setting..." Speed Dial helper: query the current print/filament/printer
|
||||
// config options via the sidebar's live OptionsSearcher (the instance Tab registration
|
||||
// populates with group/category, and which carries the current printer technology) and
|
||||
// return the top matches as JSON. The searcher is re-seeded from the current configs +
|
||||
// user mode on every call so the result always reflects what the sidebar's own search
|
||||
// would show. An empty/whitespace query returns the recent settings list (below), and the
|
||||
// page shows a "type to search" hint when there are no recents.
|
||||
nlohmann::json settings_search(const std::string& query);
|
||||
|
||||
// Recently-jumped-to settings, persisted (most-recent-first, capped at 8). Returns the
|
||||
// stored JSON array [{opt_key,type,label,category,group},...]; record_setting_recent()
|
||||
// prepends an entry (deduped by opt_key+type) and re-persists.
|
||||
nlohmann::json settings_recent() const;
|
||||
void record_setting_recent(const std::string& opt_key, int type, const std::string& label,
|
||||
const std::string& category, const std::string& group);
|
||||
nlohmann::json snapshot();
|
||||
|
||||
// "Go to tab..." Speed Dial helper: enumerate the MainFrame notebook's current pages
|
||||
// as [{id,title},...]. Live by construction - built-in tabs (Home/Prepare/Preview/Device/
|
||||
@@ -158,10 +173,25 @@ public:
|
||||
// plugins) aren't separate pages and are not listed. Call on the UI thread; null-safe.
|
||||
nlohmann::json tab_options() const;
|
||||
|
||||
// Inline setting editor descriptor for a SettingAction id. Returns the JSON the palette
|
||||
// renders: {id, opt_key, type, title, breadcrumb, category, group, unit, tooltip, editable,
|
||||
// control ("toggle|number|dropdown|combo|text|color"), cardinality ("scalar"|"vector"),
|
||||
// value|values, index_labels[], enum_options[], min|max}. Empty object for a non-setting id.
|
||||
nlohmann::json setting_descriptor(const std::string& id) const;
|
||||
// Apply an edit submitted by the palette. `value` is the control's JSON payload (scalar, or an
|
||||
// array for vector settings). Writes the value(s) into the global preset config and marks the
|
||||
// preset dirty, exactly like a sidebar edit. Returns false on a bad id/type/value.
|
||||
bool apply_setting(const std::string& id, const nlohmann::json& value);
|
||||
|
||||
private:
|
||||
void seed_state(AppAction& a) const; // favourite/stats from config
|
||||
AppAction* find(const std::string& id);
|
||||
|
||||
// (Re)materialise the current visible config settings as SettingActions from the live
|
||||
// searcher (respecting printer-tech + user-mode + visibility filtering), removing stale ones.
|
||||
// Called at the top of snapshot() so the palette always reflects the current configs.
|
||||
void materialize_setting_actions();
|
||||
|
||||
// Loader callbacks (marshalled to the UI thread) land here. refresh_source rebuilds
|
||||
// one plugin's whole action set; refresh_capability touches a single capability.
|
||||
void refresh_source(const std::string& plugin_key, ActionChange change);
|
||||
|
||||
@@ -198,7 +198,8 @@ void KBShortcutsDialog::fill_shortcuts()
|
||||
// Switch table page
|
||||
{ ctrl + L("Tab"), L("Switch table page")},
|
||||
// Open speed dial
|
||||
{ ctrl + "K", L("Open speed dial") },
|
||||
{ "Space", L("Open speed dial") },
|
||||
{ alt + "1..9,0", L("Run a Speed Dial favourite") },
|
||||
//DEL
|
||||
#ifdef __APPLE__
|
||||
{"fn+⌫", L("Delete Selected")},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "MainFrame.hpp"
|
||||
|
||||
#include <wx/panel.h>
|
||||
#include <wx/textentry.h>
|
||||
#include <wx/notebook.h>
|
||||
#include <wx/listbook.h>
|
||||
#include <wx/simplebook.h>
|
||||
@@ -701,8 +702,18 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_
|
||||
}
|
||||
return;}
|
||||
#endif
|
||||
// Orca: open the speed dial from any page. CmdDown() = Ctrl on Win/Linux, Cmd on macOS.
|
||||
if (evt.CmdDown() && evt.GetKeyCode() == 'K') { wxGetApp().open_speed_dial(); return; }
|
||||
// Orca: open the speed dial from any page with a bare Space. Only when no modifier is held (so
|
||||
// editing shortcuts like Ctrl+Shift+Space in the canvas still reach it) and while no text field
|
||||
// is focused, so typing a space into the search box or a parameter value isn't hijacked.
|
||||
if (!evt.CmdDown() && !evt.ShiftDown() && !evt.AltDown() && evt.GetKeyCode() == WXK_SPACE) {
|
||||
wxWindow* focus = wxWindow::FindFocus();
|
||||
if (focus && dynamic_cast<wxTextEntryBase*>(focus)) {
|
||||
evt.Skip(); // typing in a text field - let the space reach it
|
||||
return;
|
||||
}
|
||||
wxGetApp().open_speed_dial();
|
||||
return;
|
||||
}
|
||||
if (evt.CmdDown() && evt.GetKeyCode() == 'R') { if (m_slice_enable) { wxGetApp().plater()->update(true, true); wxPostEvent(m_plater, SimpleEvent(EVT_GLTOOLBAR_SLICE_PLATE)); this->m_tabpanel->SelectPageByName(TAB_ID_PREVIEW); } return; }
|
||||
if (evt.CmdDown() && evt.ShiftDown() && evt.GetKeyCode() == 'G') {
|
||||
m_plater->apply_background_progress();
|
||||
@@ -3350,7 +3361,7 @@ void MainFrame::init_menubar_as_editor()
|
||||
"", nullptr, []() { return true; }, this, 1);
|
||||
parent_menu->AppendSeparator();
|
||||
append_menu_item(
|
||||
parent_menu, wxID_ANY, _L("Open speed dial...") + sep + ctrl_t + "K", "",
|
||||
parent_menu, wxID_ANY, _L("Open speed dial...") + sep + "Space", "",
|
||||
[](wxCommandEvent &) { wxGetApp().open_speed_dial(); },
|
||||
"", nullptr, []() { return true; }, this);
|
||||
//parent_menu->Insert(1, preference_item);
|
||||
@@ -3378,7 +3389,7 @@ void MainFrame::init_menubar_as_editor()
|
||||
top_menu->AppendSeparator();
|
||||
|
||||
append_menu_item(
|
||||
top_menu, wxID_ANY, _L("Open speed dial...") + "\t" + ctrl + "K", "",
|
||||
top_menu, wxID_ANY, _L("Open speed dial...") + "\t" + "Space", "",
|
||||
[](wxCommandEvent &) { wxGetApp().open_speed_dial(); },
|
||||
"", nullptr, []() { return true; }, this);
|
||||
top_menu->AppendSeparator();
|
||||
@@ -3522,7 +3533,7 @@ void MainFrame::init_menubar_as_editor()
|
||||
// On Mac, the Apple menu ignores non-standard custom items, so add Preset Bundle to the File menu
|
||||
fileMenu->AppendSeparator();
|
||||
append_menu_item(
|
||||
fileMenu, wxID_ANY, _L("Open speed dial...") + sep + ctrl_t + "K", "",
|
||||
fileMenu, wxID_ANY, _L("Open speed dial...") + sep + "Space", "",
|
||||
[](wxCommandEvent&) { wxGetApp().open_speed_dial(); },
|
||||
"", nullptr, []() { return true; }, this);
|
||||
append_menu_item(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -148,6 +148,10 @@ public:
|
||||
void show_dialog(Preset::Type type, wxWindow *parent, TextInput *input, wxWindow *ssearch_btn);
|
||||
void dlg_sys_color_changed();
|
||||
void dlg_msw_rescale();
|
||||
|
||||
// The full gated option set built by init() (after visibility/mode/printer-tech filtering).
|
||||
// Used by the Speed Dial to materialise config settings as first-class actions.
|
||||
const std::vector<Option>& all_options() const { return options; }
|
||||
};
|
||||
|
||||
//------------------------------------------
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
#include <libslic3r/Preset.hpp>
|
||||
|
||||
#include <boost/nowide/convert.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <wx/display.h>
|
||||
@@ -127,9 +129,14 @@ void SpeedDialWebDialog::handle_web_command(const nlohmann::json& payload)
|
||||
if (command == "request_actions") {
|
||||
m_page_ready = true;
|
||||
send_actions();
|
||||
} else if (command == "toggle_favourite")
|
||||
wxGetApp().action_registry().set_favourite(payload.value("id", ""), payload.value("fav", false));
|
||||
else if (command == "reorder_favourites") {
|
||||
} else if (command == "toggle_favourite") {
|
||||
// set_favourite() refuses once the bar hits kFavLimit; tell the page so it can undo the
|
||||
// star and show a "favourites are full" hint instead of silently losing the pin.
|
||||
const std::string fav_id = payload.value("id", "");
|
||||
const bool ok = wxGetApp().action_registry().set_favourite(fav_id, payload.value("fav", false));
|
||||
if (!ok)
|
||||
call_web_handler({{"command", "favourite_full"}, {"limit", (int) ActionRegistry::kFavLimit}, {"id", fav_id}});
|
||||
} else if (command == "reorder_favourites") {
|
||||
std::vector<std::string> ids;
|
||||
if (payload.contains("ids") && payload["ids"].is_array())
|
||||
for (const auto& id : payload["ids"])
|
||||
@@ -138,21 +145,6 @@ void SpeedDialWebDialog::handle_web_command(const nlohmann::json& payload)
|
||||
wxGetApp().action_registry().reorder_favourites(ids);
|
||||
} else if (command == "run_action")
|
||||
run_action(payload.value("id", ""), payload.value("title", ""), payload.value("param", ""));
|
||||
else if (command == "go_to_setting") {
|
||||
// "Go to setting..." second phase: the page hands back the option it matched.
|
||||
const std::string opt_key = payload.value("opt_key", "");
|
||||
if (!opt_key.empty()) {
|
||||
const int type = json_int_or(payload, "type", int(Preset::TYPE_INVALID));
|
||||
const std::string label = payload.value("label", "");
|
||||
const std::string group = payload.value("group", "");
|
||||
const std::string cat = payload.value("category", "");
|
||||
// Track it in the palette's recent-settings list before jumping (persisted).
|
||||
wxGetApp().action_registry().record_setting_recent(opt_key, type, label, cat, group);
|
||||
Hide();
|
||||
wxGetApp().sidebar().jump_to_option(opt_key, Preset::Type(type), from_u8(cat).ToStdWstring());
|
||||
}
|
||||
} else if (command == "search_settings")
|
||||
search_settings(payload.value("q", ""));
|
||||
else if (command == "search_tabs")
|
||||
search_tabs();
|
||||
else if (command == "go_to_tab") {
|
||||
@@ -163,6 +155,27 @@ void SpeedDialWebDialog::handle_web_command(const nlohmann::json& payload)
|
||||
if (wxGetApp().mainframe)
|
||||
wxGetApp().mainframe->select_tab(from_u8(tab_id));
|
||||
}
|
||||
} else if (command == "setting_descriptor") {
|
||||
// Inline editor: hand the page the descriptor for the setting it's editing.
|
||||
const std::string id = payload.value("id", "");
|
||||
call_web_handler(
|
||||
{{"command", "setting_descriptor"}, {"descriptor", wxGetApp().action_registry().setting_descriptor(id)}});
|
||||
} else if (command == "set_setting") {
|
||||
// Inline editor submit. Apply the value; on success close the dialog.
|
||||
const std::string id = payload.value("id", "");
|
||||
const nlohmann::json value = payload.contains("value") ? payload["value"] : nlohmann::json(nullptr);
|
||||
if (id.empty() || !wxGetApp().action_registry().apply_setting(id, value)) {
|
||||
call_web_handler({{"command", "apply_failed"}, {"id", id}});
|
||||
return;
|
||||
}
|
||||
Hide();
|
||||
} else if (command == "open_setting_in_sidebar") {
|
||||
// Non-inline-editable setting (points, plugin-backed, float-or-percent): jump the sidebar.
|
||||
Hide();
|
||||
const std::string opt_key = payload.value("opt_key", "");
|
||||
const std::string category = payload.value("category", "");
|
||||
if (!opt_key.empty())
|
||||
wxGetApp().sidebar().jump_to_option(opt_key, Preset::Type(payload.value("type", int(Preset::TYPE_INVALID))), boost::nowide::widen(category));
|
||||
} else if (command == "resize")
|
||||
resize_to_content(json_int_or(payload, "height", 0));
|
||||
}
|
||||
@@ -179,18 +192,6 @@ void SpeedDialWebDialog::search_tabs()
|
||||
});
|
||||
}
|
||||
|
||||
void SpeedDialWebDialog::search_settings(const std::string& query)
|
||||
{
|
||||
// Round-trip is async because the webview delivers script messages synchronously on the
|
||||
// GTK/macOS stack; defer the (cheap) search and push the result back to the page.
|
||||
wxGetApp().CallAfter([this, alive = m_alive, query]() {
|
||||
if (!alive->load(std::memory_order_acquire))
|
||||
return;
|
||||
auto results = wxGetApp().action_registry().settings_search(query);
|
||||
call_web_handler({{"command", "settings_results"}, {"results", std::move(results)}});
|
||||
});
|
||||
}
|
||||
|
||||
void SpeedDialWebDialog::resize_to_content(int height)
|
||||
{
|
||||
if (height <= 0)
|
||||
|
||||
@@ -22,7 +22,6 @@ private:
|
||||
void resize_to_content(int height);
|
||||
void run_action(const std::string& id, const std::string& title, const std::string& param = "");
|
||||
void send_actions();
|
||||
void search_settings(const std::string& query);
|
||||
void search_tabs();
|
||||
|
||||
bool m_page_ready{false};
|
||||
|
||||
Reference in New Issue
Block a user