From 5efcd2eaf558130cf54a003e383cbd84e87f6472 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 17:59:53 +0700 Subject: [PATCH 1/9] fix(layout): correct SplitView stretch ownership; narrow setpoint rail and give Phasor/Waveform balanced heights --- apps/arstack_studio/qml/Main.qml | 38 ++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index c8a46b6e8..dcfd46f11 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -575,8 +575,9 @@ ApplicationWindow { return ok } function startScenarioSnapshot() { - // The table editors debounce keyboard edits for live preview. Flush - // them synchronously before creating the ordered board START payload. + // Table editor values are committed by Enter/focus departure, not by + // debounce while the engineer is halfway through a number. Flush + // synchronously before building the atomic START scenario.usly before creating the ordered board START payload. if (!currentMatrix.flushPendingEdits() || !voltageMatrix.flushPendingEdits()) { showMessage("Invalid signal input: correct highlighted field before Start.", true) return null @@ -1063,11 +1064,14 @@ ApplicationWindow { SurfacePanel { objectName: "studioInjectionSurface" - SplitView.fillWidth: true + // Injection setpoints are a FIXED-WIDTH editing rail. + // Old fillWidth:true made this the horizontal stretch owner + // and starved charts despite its preferredWidth. + SplitView.fillWidth: false SplitView.fillHeight: true - SplitView.minimumWidth: root.compactLayout ? 500 : 540 - SplitView.preferredWidth: root.compactLayout ? 600 : 690 - SplitView.maximumWidth: 750 + SplitView.minimumWidth: 425 + SplitView.preferredWidth: 470 + SplitView.maximumWidth: 515 theme: studioTheme ColumnLayout { @@ -1296,9 +1300,10 @@ ApplicationWindow { SplitView { visible: (root.phasorDockVisible && !root.phasorDetached) || (root.waveformDockVisible && !root.waveformDetached) - SplitView.preferredWidth: root.compactLayout ? 450 : 470 - SplitView.minimumWidth: root.compactLayout ? 300 : 340 - SplitView.maximumWidth: 680 + // The visual-analysis rail owns unused horizontal space. + SplitView.fillWidth: true + SplitView.preferredWidth: 640 + SplitView.minimumWidth: 385 SplitView.fillHeight: true orientation: Qt.Vertical handle: Rectangle { @@ -1317,10 +1322,11 @@ ApplicationWindow { DockFrame { visible: root.phasorDockVisible && !root.phasorDetached SplitView.fillWidth: true - SplitView.minimumHeight: 170 - SplitView.preferredHeight: 225 - SplitView.maximumHeight: 265 - SplitView.fillHeight: false + SplitView.minimumHeight: 190 + SplitView.preferredHeight: 300 + // Phasor takes leftover vertical space rather than + // being capped at 265 px regardless of window size. + SplitView.fillHeight: true theme: studioTheme titleText: "Phasor" statusText: "" @@ -1357,9 +1363,9 @@ ApplicationWindow { DockFrame { visible: root.waveformDockVisible && !root.waveformDetached SplitView.fillWidth: true - SplitView.minimumHeight: 170 - SplitView.preferredHeight: 328 - SplitView.fillHeight: true + SplitView.minimumHeight: 190 + SplitView.preferredHeight: 300 + SplitView.fillHeight: false theme: studioTheme titleText: "Waveform" statusText: root.signalFrequency.toFixed(3) + " Hz" From 2b01532f0391a5bf19027b5d861f9e8b7aa8c1d5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 18:01:20 +0700 Subject: [PATCH 2/9] fix(editor): commit numeric values once on Enter/focus loss, normalize comma decimal, reject invalid without partial live SET --- apps/arstack_studio/qml/SignalMatrix.qml | 139 +++++++++++------------ 1 file changed, 65 insertions(+), 74 deletions(-) diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index 20477ffd8..c5b4fbc26 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -30,6 +30,8 @@ Rectangle { // Commit any last keystroke synchronously before constructing an ordered // START scenario. The 90ms edit debounce must never make the board // receive old angles while the table already displays new ones. + // No 90ms live SET while typing: a partial number must never reach + // ESP32-P4 or re-render the phasor before operator confirmation. function flushPendingEdits() { for (var i = 0; i < rowRepeater.count; ++i) { const row = rowRepeater.itemAt(i) @@ -130,27 +132,52 @@ Rectangle { property bool selected: matrix.controller.activeSignal === signalRow.sid property alias magnitudeEditor: magnitudeField property alias phaseEditor: phaseField - property real pendingMagnitude: mag - property real pendingPhase: angle + property bool magnitudeDirty: false + property bool phaseDirty: false - function flushPendingEdits() { - if (magnitudeApplyTimer.running || magnitudeField.activeFocus) { - const magValue = matrix.controller.parseOperatorNumber(magnitudeField.text) - if (!matrix.controller.validMagnitude(matrix.groupIndex, magValue)) - return false - magnitudeApplyTimer.stop() - if (!matrix.controller.editSignal(matrix.groupIndex, rowIndex, "magnitude", magValue)) - return false + function commitMagnitude() { + if (!magnitudeDirty) return true + const value = matrix.controller.parseOperatorNumber(magnitudeField.text) + magnitudeDirty = false + if (!matrix.controller.validMagnitude(matrix.groupIndex, value)) { + magnitudeField.invalidInput = false + magnitudeField.text = mag.toFixed(3) + matrix.controller.showMessage( + sid + ": invalid magnitude. Restored " + mag.toFixed(3) + + ". Use dot or comma as decimal separator.", true) + return false } - if (phaseApplyTimer.running || phaseField.activeFocus) { - const phaseValue = matrix.controller.parseOperatorNumber(phaseField.text) - if (!matrix.controller.validPhase(phaseValue)) - return false - phaseApplyTimer.stop() - if (!matrix.controller.editSignal(matrix.groupIndex, rowIndex, "phase", phaseValue)) - return false + // Normalize 0,5 and 0.5 to the SAME displayed 0.500. + magnitudeField.invalidInput = false + magnitudeField.text = value.toFixed(3) + if (Math.abs(value - mag) < 0.000000001) return true + return matrix.controller.editSignal( + matrix.groupIndex, rowIndex, "magnitude", value) + } + + function commitPhase() { + if (!phaseDirty) return true + const value = matrix.controller.parseOperatorNumber(phaseField.text) + phaseDirty = false + if (!matrix.controller.validPhase(value)) { + phaseField.invalidInput = false + phaseField.text = angle.toFixed(2) + matrix.controller.showMessage( + sid + ": invalid angle. Restored " + angle.toFixed(2) + + "°. Use dot or comma as decimal separator.", true) + return false } - return true + phaseField.invalidInput = false + phaseField.text = value.toFixed(2) + if (Math.abs(value - angle) < 0.000000001) return true + return matrix.controller.editSignal( + matrix.groupIndex, rowIndex, "phase", value) + } + + function flushPendingEdits() { + // Also cover START while a field still owns focus. + if (!commitMagnitude()) return false + return commitPhase() } // Align with the field edge, not the left side of the table. @@ -237,18 +264,8 @@ Rectangle { HoverHandler { id: rowHover } - Timer { - id: magnitudeApplyTimer - interval: 90 - repeat: false - onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "magnitude", signalRow.pendingMagnitude) - } - Timer { - id: phaseApplyTimer - interval: 90 - repeat: false - onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "phase", signalRow.pendingPhase) - } + // Numeric drafts are held in the editor until confirmation. + // No background timer sends incomplete values to the board. onMagChanged: if (!magnitudeField.activeFocus) magnitudeField.text = mag.toFixed(3) onAngleChanged: if (!phaseField.activeFocus) phaseField.text = angle.toFixed(2) @@ -306,31 +323,16 @@ Rectangle { if (activeFocus) { matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) text = signalRow.mag.toFixed(3) + signalRow.magnitudeDirty = false selectAll() - } + } else signalRow.commitMagnitude() } onTextEdited: { - var value = matrix.controller.parseOperatorNumber(text) - if (matrix.controller.validMagnitude(matrix.groupIndex, value)) { - invalidInput = false - signalRow.pendingMagnitude = value - magnitudeApplyTimer.restart() - } else invalidInput = true - } - onEditingFinished: { - var value = matrix.controller.parseOperatorNumber(text) - if (!matrix.controller.validMagnitude(matrix.groupIndex, value)) { - magnitudeApplyTimer.stop() - text = signalRow.mag.toFixed(3) - invalidInput = false - matrix.controller.showMessage(signalRow.sid + " value is outside the supported range.", true) - } else { - signalRow.pendingMagnitude = value - magnitudeApplyTimer.stop() - matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "magnitude", value) - text = value.toFixed(3) - } + signalRow.magnitudeDirty = true + invalidInput = !matrix.controller.validMagnitude( + matrix.groupIndex, matrix.controller.parseOperatorNumber(text)) } + onEditingFinished: signalRow.commitMagnitude() Keys.onPressed: function(event) { if (event.key === Qt.Key_Menu || (event.key === Qt.Key_F10 && (event.modifiers & Qt.ShiftModifier))) { @@ -340,7 +342,9 @@ Rectangle { matrix.controller.navigate(matrix.groupIndex, signalRow.rowIndex, 0, event.key) event.accepted = true } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { - matrix.controller.focusCell(matrix.groupIndex, signalRow.rowIndex + 1, 0) + if (signalRow.commitMagnitude()) + matrix.controller.focusCell(matrix.groupIndex, signalRow.rowIndex + 1, 0) + else selectAll() event.accepted = true } } @@ -369,31 +373,16 @@ Rectangle { if (activeFocus) { matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) text = signalRow.angle.toFixed(2) + signalRow.phaseDirty = false selectAll() - } + } else signalRow.commitPhase() } onTextEdited: { - var value = matrix.controller.parseOperatorNumber(text) - if (matrix.controller.validPhase(value)) { - invalidInput = false - signalRow.pendingPhase = value - phaseApplyTimer.restart() - } else invalidInput = true - } - onEditingFinished: { - var value = matrix.controller.parseOperatorNumber(text) - if (!matrix.controller.validPhase(value)) { - phaseApplyTimer.stop() - text = signalRow.angle.toFixed(2) - invalidInput = false - matrix.controller.showMessage(signalRow.sid + " phase is outside the supported range.", true) - } else { - signalRow.pendingPhase = value - phaseApplyTimer.stop() - matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "phase", value) - text = value.toFixed(2) - } + signalRow.phaseDirty = true + invalidInput = !matrix.controller.validPhase( + matrix.controller.parseOperatorNumber(text)) } + onEditingFinished: signalRow.commitPhase() Keys.onPressed: function(event) { if (event.key === Qt.Key_Menu || (event.key === Qt.Key_F10 && (event.modifiers & Qt.ShiftModifier))) { @@ -403,7 +392,9 @@ Rectangle { matrix.controller.navigate(matrix.groupIndex, signalRow.rowIndex, 1, event.key) event.accepted = true } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { - matrix.controller.focusCell(matrix.groupIndex, signalRow.rowIndex + 1, 1) + if (signalRow.commitPhase()) + matrix.controller.focusCell(matrix.groupIndex, signalRow.rowIndex + 1, 1) + else selectAll() event.accepted = true } } From 0c46784a7b82e8f5671771a00f70e413115b00f4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 18:02:24 +0700 Subject: [PATCH 3/9] fix(editor): blank-table clicks release numeric focus and commit without stealing editor/button interactions --- apps/arstack_studio/qml/SignalMatrix.qml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index c5b4fbc26..9a37c106a 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -49,6 +49,16 @@ Rectangle { editor.selectAll() } + // Click on any empty part of this panel explicitly releases a numeric + // editor's focus. TextField.editingFinished then commits exactly once. + // Positioned UNDER actual controls, so buttons, text selection and menus + // retain their regular mouse behavior. + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + onClicked: matrix.forceActiveFocus() + } + ColumnLayout { anchors.fill: parent spacing: 0 From 82d43a85fde505b529e303a61c2f5e3f91b82752 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 18:02:52 +0700 Subject: [PATCH 4/9] test(ui): enforce narrow SplitView stretch owner, equalized plots, Enter/blur numeric commit and decimal comma --- .../tests/check_operator_workspace.py | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index aa459e8ae..7c2f0e715 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -196,12 +196,20 @@ def main() -> None: and 'startWatchdog' not in source("WorkflowBar.qml"), "SV STOP must remain actionable while the acknowledged scenario transaction is pending") require("function flushPendingEdits()" in matrix - and "magnitudeApplyTimer.stop()" in matrix - and "phaseApplyTimer.stop()" in matrix + and "function commitMagnitude()" in matrix + and "function commitPhase()" in matrix + and "onEditingFinished: signalRow.commitMagnitude()" in matrix + and "onEditingFinished: signalRow.commitPhase()" in matrix + and "onActiveFocusChanged:" in matrix + and "if (signalRow.commitMagnitude())" in matrix + and "if (signalRow.commitPhase())" in matrix + and "onClicked: matrix.forceActiveFocus()" in matrix + and "magnitudeApplyTimer" not in matrix + and "phaseApplyTimer" not in matrix and "currentMatrix.flushPendingEdits()" in main_qml and "voltageMatrix.flushPendingEdits()" in main_qml and "if (!scenario) return" in source("WorkflowBar.qml"), - "START must commit last visible I/V angle before ordered scenario serialization") + "Table edits commit ONLY on Enter/focus-loss/blank-click/START, never as 90ms partial SET") require("Layout.fillHeight: true" in main_qml and "Layout.maximumHeight: root.compactLayout ? 352 : 384" not in main_qml and main_qml.index("id: voltageMatrix") < main_qml.index("id: currentMatrix"), @@ -544,8 +552,12 @@ def main() -> None: ribbon_layout = source("ModernRibbon.qml") start_flow = source("WorkflowBar.qml") require("width: 1150" in responsive_main - and "SplitView.maximumWidth: 750" in responsive_main - and "SplitView.preferredWidth: root.compactLayout ? 450 : 470" in responsive_main + and "SplitView.fillWidth: false" in responsive_main + and "SplitView.preferredWidth: 470" in responsive_main + and "SplitView.maximumWidth: 515" in responsive_main + and "SplitView.preferredWidth: 640" in responsive_main + and "SplitView.preferredHeight: 300" in responsive_main + and "SplitView.maximumHeight: 265" not in responsive_main and "electric_bolt_run.svg" in ribbon_layout and "electric_bolt_stop.svg" in ribbon_layout and "assets/material/electric_bolt_run.svg" in @@ -565,6 +577,17 @@ def main() -> None: and "smartSession.requestStartWithScenario(scenario)" in start_flow, "First START click must be bounded, single-device, cancelable, and use verified scenario START") + # Shared parser accepts 0.5 and 0,5 as the SAME engineering value. + # Reject malformed multi-separator strings rather than guessing a + # potentially dangerous physical voltage/current magnitude. + require('normalized = normalized.replace(",", ".")' in responsive_main + and 'return Number(normalized)' in responsive_main + and 'Math.abs(value - mag) < 0.000000001' in matrix + and 'Math.abs(value - angle) < 0.000000001' in matrix + and 'magnitudeDirty = false' in matrix + and 'phaseDirty = false' in matrix, + "Numeric input must normalize decimal comma, suppress duplicate SET and restore invalid drafts") + toast_layout = source("Main.qml") toast_view = source("StatusToast.qml") require("function explainFailure(message)" in toast_layout From 91f2a1b13ee183b226addabcb6b7a5dee7ae7187 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 18:03:55 +0700 Subject: [PATCH 5/9] fix(editor): passive workspace tap commits focused value on click outside cells without stealing normal clicks --- apps/arstack_studio/qml/Main.qml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index dcfd46f11..2437261a7 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -1006,10 +1006,31 @@ ApplicationWindow { } ColumnLayout { + id: workbenchColumn anchors.fill: parent anchors.margins: 8 spacing: 8 + // Passive tap observer: clicking outside the currently focused numeric + // cell (including blank workspace/plots) commits via focus loss. + // Do not intercept ordinary buttons or text selection. + TapHandler { + acceptedButtons: Qt.LeftButton + onTapped: function(point) { + const editor = root.activeFocusItem + if (!editor || editor.suffixText === undefined || !editor.activeFocus) + return + const at = editor.mapFromItem( + workbenchColumn, point.position.x, point.position.y) + if (at.x >= 0 && at.x <= editor.width && + at.y >= 0 && at.y <= editor.height) + return // a click inside the editing cell must never commit + Qt.callLater(function() { + if (editor.activeFocus) root.forceActiveFocus() + }) + } + } + WorkflowBar { id: workflowBar objectName: "studioWorkflowBar" From 93b7ccf7d3edfbe7fc3055f7f373866a5d51a796 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 18:06:26 +0700 Subject: [PATCH 6/9] test(layout): replace obsolete tiny-phasor max265 assertion with equal-height plot contract --- apps/arstack_studio/tests/check_operator_workspace.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 7c2f0e715..84f964f38 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -385,9 +385,12 @@ def main() -> None: assert "text: \"Generated setpoint · not independent on-wire proof\"" not in preview assert "No ESP32 board detected · Preview mode only" in main_qml require("Layout.preferredHeight: 57" in main_qml - and "SplitView.maximumHeight: 265" in main_qml + and main_qml.count("SplitView.preferredHeight: 300") == 2 + and main_qml.count("SplitView.minimumHeight: 190") == 2 + and "SplitView.maximumHeight: 265" not in main_qml + and "SplitView.fillHeight: true" in main_qml and "SplitView.fillHeight: false" in main_qml, - "compact timing strip and bounded phasor panel must avoid dead space") + "compact timing strip plus balanced, user-resizable Phasor/Waveform panes") require("TIME SYNC / SV SIMULATION" not in tray and "StateBadge {" not in tray, "device/PTP status must not be duplicated in the control tray") From 260652d22b47a0e8e84a8b11d4c05a4e5e749fef Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 18:07:22 +0700 Subject: [PATCH 7/9] chore(studio): document atomic START snapshot semantics after commit-on-focus change --- apps/arstack_studio/qml/Main.qml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 2437261a7..477d35314 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -575,9 +575,8 @@ ApplicationWindow { return ok } function startScenarioSnapshot() { - // Table editor values are committed by Enter/focus departure, not by - // debounce while the engineer is halfway through a number. Flush - // synchronously before building the atomic START scenario.usly before creating the ordered board START payload. + // Explicitly commit an editor still in focus BEFORE snapshotting the + // eight visible setpoints. Never START with a partially typed value. if (!currentMatrix.flushPendingEdits() || !voltageMatrix.flushPendingEdits()) { showMessage("Invalid signal input: correct highlighted field before Start.", true) return null From 189f0867489c3969531e3b727343b15265e37c48 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 18:14:12 +0700 Subject: [PATCH 8/9] fix(focus): click-away routes focus to QQuickItem workbench, never ApplicationWindow --- apps/arstack_studio/qml/Main.qml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 477d35314..07de4f92d 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -1025,7 +1025,8 @@ ApplicationWindow { at.y >= 0 && at.y <= editor.height) return // a click inside the editing cell must never commit Qt.callLater(function() { - if (editor.activeFocus) root.forceActiveFocus() + // Window is not a QQuickItem; focus the workspace Item. + if (editor.activeFocus) workbenchColumn.forceActiveFocus() }) } } From 6c42fbdb6fe7e726088fac21f6f2a65c761fb23b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 18:14:27 +0700 Subject: [PATCH 9/9] test(focus): regression guard ensures workspace Item, not Window, receives click-away focus --- apps/arstack_studio/tests/check_operator_workspace.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 84f964f38..5ed907361 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -204,6 +204,8 @@ def main() -> None: and "if (signalRow.commitMagnitude())" in matrix and "if (signalRow.commitPhase())" in matrix and "onClicked: matrix.forceActiveFocus()" in matrix + and "workbenchColumn.forceActiveFocus()" in main_qml + and "root.forceActiveFocus()" not in main_qml and "magnitudeApplyTimer" not in matrix and "phaseApplyTimer" not in matrix and "currentMatrix.flushPendingEdits()" in main_qml