From a5b065ec58bb47c74283bffe48f67a8459168a6d Mon Sep 17 00:00:00 2001 From: Yury Rodzikau Date: Fri, 11 Sep 2026 10:41:26 +0200 Subject: [PATCH 01/17] fix(svelte-client): add overflow-safe layout to DialogContent, DialogFooter and DialogTitle to keep long 3MF names and buttons inside dialogs On the Build Library page, selecting a build opened the details dialog with the 3MF file name and the "Close"/"Select" buttons running past the dialog border. DialogContent is a single-column CSS grid whose implicit auto column grows to the min-content width of its widest child. A file name without spaces is one unbreakable word, so the column grew beyond the 500px dialog box and the right-aligned footer moved with it. The fix is applied to the shared shadcn-svelte dialog components, so every dialog in the Svelte client gets it. feat(dialog-content): cap the content column and wrap long words - Add grid-cols-[minmax(0,1fr)]: the column can no longer grow wider than the dialog, so header, body and footer stay inside the border. - Add wrap-anywhere (overflow-wrap: anywhere). The property is inherited, so long names, paths and module content wrap in every dialog. Unlike wrap-break-word it also lowers the min-content width, which the grid column needs in order to shrink. Button captions keep whitespace-nowrap and stay on one line. feat(dialog-footer): wrap footer buttons - Add sm:flex-wrap. Buttons are shrink-0 and whitespace-nowrap, so several or long buttons (e.g. multiple - - - - - - - - {#if pointsLoaded} - {#if pointColumns.includes('laseron')} + +
+
+ + + + + + + + + {#if pointsLoaded} + {#if pointColumns.includes('laseron')} + + {/if} {/if} - +
+ + + {#if labelCaption || labelIcon || loadingLayer || loadingPoints} +
+ {#if labelCaption || labelIcon} +
+ {#if labelIcon}{/if} + {labelCaption} +
+ {/if} + {#if loadingLayer || loadingPoints} +
+ + Loading layer data +
+ {/if} +
{/if}
- {#if labelCaption} -
{labelCaption}
- {/if} - {#if layerCount > 0} {#if layerJumpOpen} @@ -1287,7 +1505,7 @@ type="number" inputmode="numeric" min="0" - max={layerCount} + max={layerCount - 1} step="1" bind:value={layerJumpValue} onkeydown={onLayerJumpKeyDown} @@ -1301,7 +1519,7 @@ type="button" class="layerview-layer-info layerview-layer-info-button" onclick={openLayerJump} - title="Click to jump to a layer" + title={sliderFixed ? 'Layer' : 'Click to jump to a layer'} aria-label={`Layer ${sliderValue} of ${layerCount}. Click to jump to a layer`} > Layer {sliderValue} / {layerCount} @@ -1429,6 +1647,14 @@ {/if} + + {#if hoverInfo.visible && !propertiesMode} +
{hoverInfo.text}
+ {/if} + {#if propertiesMode && hoverPoint} {#if hoverPointScreen} @@ -1586,19 +1812,32 @@ {/if} - + {#if layerCount > 0}
+ {#if !sliderFixed} + + {/if} + {#if !sliderFixed} + + {/if}
{/if} @@ -1700,13 +1939,24 @@ fill: var(--destructive, #dc2626); text-decoration: line-through; } - .layerview-toolbar { + .layerview-top-left { position: absolute; top: 8px; left: 8px; + /* Leave room for the layer-info badge in the top-right corner */ + max-width: calc(100% - 140px); display: flex; + flex-direction: column; + align-items: flex-start; gap: 4px; z-index: 10; + pointer-events: none; + } + .layerview-toolbar { + display: flex; + flex-wrap: wrap; + gap: 4px; + pointer-events: auto; } .layerview-btn { display: inline-flex; @@ -1737,23 +1987,38 @@ background-color: var(--primary, #2563eb); box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.65); } + .layerview-btn:disabled { + opacity: 0.4; + cursor: default; + } + .layerview-step { + width: 24px; + height: 24px; + padding: 0; + } + .layerview-status { + display: flex; + flex-wrap: wrap; + gap: 4px; + max-width: 100%; + } .layerview-label { - position: absolute; - /* Below the toolbar (top 8px + 56px buttons). */ - top: 72px; - left: 8px; - max-width: calc(100% - 56px); + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 100%; padding: 4px 10px; border-radius: 4px; background: rgba(0, 0, 0, 0.65); color: white; font-size: 11px; font-variant-numeric: tabular-nums; + white-space: nowrap; + } + .layerview-label span { overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; - pointer-events: none; - z-index: 10; } .layerview-layer-info { position: absolute; @@ -1797,6 +2062,8 @@ bottom: 8px; width: 64px; height: 64px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.35); pointer-events: none; z-index: 9; } @@ -2024,6 +2291,18 @@ opacity: 0.4; cursor: default; } + .layerview-hover-info { + position: absolute; + z-index: 20; + padding: 5px 8px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.75); + color: white; + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: pre-line; + pointer-events: none; + } .layerview-point-marker { position: absolute; width: 14px; @@ -2086,14 +2365,17 @@ top: 44px; bottom: 16px; display: flex; + flex-direction: column; align-items: center; justify-content: center; + gap: 6px; z-index: 10; } .layerview-slider { writing-mode: vertical-lr; direction: rtl; - height: 100%; + flex: 1; + min-height: 0; width: 20px; accent-color: var(--primary, #2563eb); } From c7d0c43ab0e8c53c65ff84536294be6404084178 Mon Sep 17 00:00:00 2001 From: Yury Rodzikau Date: Fri, 18 Sep 2026 14:29:00 +0200 Subject: [PATCH 07/17] =?UTF-8?q?feat(ui-core,=20client-core,=20svelte-cli?= =?UTF-8?q?ent,=20vue2-client):=20machine-driven=20modal=20dialogs=20that?= =?UTF-8?q?=20the=20user=20cannot=20close,=20for=20the=20"Pausing=20Build?= =?UTF-8?q?=E2=80=A6"=20/=20"Canceling=20Build=E2=80=A6"=20wait=20screens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State machines cannot open dialogs and UI event handlers cannot set machine parameters, so a dialog could only be opened by a click and closed by the user. The pause/resume/cancel build controls need dialogs that main opens while it finishes the current layer, that cannot be dismissed, and that close by themselves once the machine is ready. This adds that generically; the ScanlabOCM machine uses it for "Pausing Build…" and "Canceling Build…". feat(ui-core): dialog attributes sync:active and closable - accepts `active` / `sync:active` and `closable` (default "1"), stored as CUIExpression in CUIDialog, validated at startup, passed through addDialog_Unsafe (signature extended). - /api/frontend emits "closable" for every dialog and "active" only for dialogs with an active expression, so event-driven dialogs stay untouched. Legacy /api/ui/state emits "closable" too. New keys AMC_API_KEY_UI_DIALOGACTIVE / AMC_API_KEY_UI_DIALOGCLOSABLE. - The server is authoritative: a dialog is open while the bound parameter is true and re-asserted on every frontend poll. feat(client-core): follow server-driven dialog state - AMCApplicationDialog initialises `closable` and `serverDriven` in the constructor (Vue 2 reactivity). - retrieveFrontendState() indexes dialog modules into frontendLookup and calls new _syncServerDrivenDialogs(), which opens a dialog via showDialog() when "active" turns true and clears dialogIsActive when it turns false. Dialogs without "active" keep the client-action driven behaviour. feat(svelte-client): non-closable dialogs - Dialog.Content gets showCloseButton, escapeKeydownBehavior and interactOutsideBehavior from dialog.closable; setDialogOpen() ignores close requests for closable="0"; dialogIsClosable() reads through the poll tick so the property mutation re-renders. feat(vue2-client): non-closable dialogs - v-dialog gets :persistent and :no-click-animation for closable === false. Backward compatible: dialogs without the new attributes behave as before. Signed-off-by: Yury Rodzikau --- Client/core/common/AMCApplication.js | 38 ++++++++++++++++++++++++ Client/core/common/AMCDialog.js | 7 +++++ Client/svelte/src/routes/+page.svelte | 18 ++++++++++- Client/vue2/src/App.vue | 4 ++- Implementation/API/amc_api_constants.hpp | 2 ++ Implementation/UI/amc_ui_dialog.cpp | 14 +++++++-- Implementation/UI/amc_ui_dialog.hpp | 19 ++++++++++-- Implementation/UI/amc_ui_handler.cpp | 22 ++++++++++++-- Implementation/UI/amc_ui_handler.hpp | 2 +- 9 files changed, 115 insertions(+), 11 deletions(-) diff --git a/Client/core/common/AMCApplication.js b/Client/core/common/AMCApplication.js index 2b3144cd0..2d6281768 100644 --- a/Client/core/common/AMCApplication.js +++ b/Client/core/common/AMCApplication.js @@ -1156,6 +1156,10 @@ export default class AMCApplication extends Common.AMCObject { applyPageVisibility(this.AppContent.PageMap, data.pages); applyPageVisibility(this.AppContent.CustomPageMap, data.custompages); applyPageVisibility(this.AppContent.DialogMap, data.dialogs); + + // Server-driven dialogs follow their "active" flag; patches update the dialog objects in place. + if (data.dialogs) + this._syncServerDrivenDialogs(data.dialogs); } // Leaves a page that became hidden and closes dialogs that became hidden. @@ -1245,6 +1249,37 @@ export default class AMCApplication extends Common.AMCObject { } } + // Applies the server-side open state of dialogs that carry an "active" flag (dialogs + // with sync:active in the machine configuration). The server is authoritative: such a + // dialog is opened while the flag is true, even if a client action closed it in between, + // and closed as soon as the flag turns false. Dialogs without the flag are untouched. + _syncServerDrivenDialogs(dialogsJSON) { + let changed = false; + + for (let dialogJSON of dialogsJSON) { + if (typeof dialogJSON.active !== "boolean") + continue; + + let dialog = this.AppContent.DialogMap.get(dialogJSON.name); + if (!dialog) + continue; + + dialog.serverDriven = true; + dialog.closable = (dialogJSON.closable !== false); + + if (dialogJSON.active && !dialog.dialogIsActive && (dialog.visible !== false)) { + // showDialog already refreshes the content items. + this.showDialog(dialog.name); + } else if (!dialogJSON.active && dialog.dialogIsActive) { + dialog.dialogIsActive = false; + changed = true; + } + } + + if (changed) + this.updateContentItems(); + } + // Recursively index a v2 module (and its submodules) by UUID. _indexFrontendModule(mod, containerUUIDs) { if (!mod || !mod.uuid) @@ -2108,6 +2143,9 @@ export default class AMCApplication extends Common.AMCObject { } } + // Only one dialog is open at a time. A server-driven dialog (see + // _syncServerDrivenDialogs) re-opens on the next frontend poll while its flag is + // set, so it wins over dialogs opened by client actions. showDialog(dialog) { this.closeAllDialogs(); diff --git a/Client/core/common/AMCDialog.js b/Client/core/common/AMCDialog.js index fdc622384..03ee82ad6 100644 --- a/Client/core/common/AMCDialog.js +++ b/Client/core/common/AMCDialog.js @@ -41,6 +41,13 @@ export default class AMCApplicationDialog extends AMCApplicationPage { // dialog JSON); capture it so the client can render the dialog header. this.title = dialogJSON.title || ""; this.dialogIsActive = false; + + // "closable" (default true): a non-closable dialog has no close button and ignores + // Escape and outside clicks. "active" is only sent for server-driven dialogs; their + // open state follows the server on every frontend poll instead of client actions. + // Both are initialised here so Vue 2 tracks them. + this.closable = (dialogJSON.closable !== false); + this.serverDriven = (typeof dialogJSON.active === "boolean"); } // A dialog is never the application's active page, so the inherited page-based diff --git a/Client/svelte/src/routes/+page.svelte b/Client/svelte/src/routes/+page.svelte index 0c21e7727..e43f428ca 100644 --- a/Client/svelte/src/routes/+page.svelte +++ b/Client/svelte/src/routes/+page.svelte @@ -260,10 +260,20 @@ if (open) { app.showDialog(dialog.name); } else { + // A non-closable dialog is only closed by the server (see + // AMCApplication._syncServerDrivenDialogs) or by a client action. + if (dialog.closable === false) return; dialog.dialogIsActive = false; bumpTick(); } } + + // Read through a poll-tick dependency: closable is a plain property mutation on a + // dialog object with stable identity, so it would not re-trigger on its own. + function dialogIsClosable (dialog: any): boolean { + poll.v; + return dialog.closable !== false; + } @@ -431,11 +441,17 @@ {#each dialogs as dialog (dialog.name)} + {@const closable = dialogIsClosable(dialog)} setDialogOpen(dialog, open)} > - + {#if dialog.title} {dialog.title} diff --git a/Client/vue2/src/App.vue b/Client/vue2/src/App.vue index 0aa4af1a9..35a16bfd3 100644 --- a/Client/vue2/src/App.vue +++ b/Client/vue2/src/App.vue @@ -109,11 +109,13 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.