From 9b73ccee470eab0e5f59ccadfca13f2259ac8975 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:00:57 +0700 Subject: [PATCH 1/6] feat(studio): Remove 8 per-channel checkboxes and reclaim column width --- apps/arstack_studio/qml/SignalMatrix.qml | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index ac323221d..20477ffd8 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -84,7 +84,6 @@ Rectangle { Layout.leftMargin: 9 Layout.rightMargin: 9 spacing: 6 - Item { Layout.preferredWidth: 25 } Label { text: "CH" Layout.preferredWidth: 46 @@ -126,7 +125,6 @@ Rectangle { property string sid: model.signalId property real mag: model.magnitude property real angle: model.phase - property bool channelEnabled: model.enabled property real signalQuality: model.quality property color phaseColor: matrix.phaseColorFor(rowIndex) property bool selected: matrix.controller.activeSignal === signalRow.sid @@ -271,23 +269,6 @@ Rectangle { anchors.rightMargin: 9 spacing: 6 - StudioDarkCheckBox { - theme: matrix.theme - uiFont: matrix.uiFont - Layout.preferredWidth: 25 - Layout.alignment: Qt.AlignVCenter - checked: signalRow.channelEnabled - ToolTip.visible: false - onToggled: { - matrix.sourceModel.setProperty(signalRow.rowIndex, "enabled", checked) - matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) - matrix.controller.refreshPreview() - matrix.controller.markScenarioDirty() - if (matrix.session && matrix.session.liveControlReady && !matrix.controller.pendingApply) - matrix.session.requestSetEnabled(signalRow.sid, checked) - } - } - RowLayout { Layout.preferredWidth: 46 Layout.alignment: Qt.AlignVCenter From c1caa1ee82f960eb9f9e8c01fa04980e741fff17 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:01:00 +0700 Subject: [PATCH 2/6] feat(studio): Migrate legacy disabled rows and avoid link replay --- apps/arstack_studio/qml/Main.qml | 57 ++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 34aa44c19..d36223f43 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -83,12 +83,20 @@ ApplicationWindow { validate(data.current, 0) validate(data.voltage, 1) restoringScenario = true + // Legacy presets can contain disabled output rows. This simple + // matrix has no channel enable controls by design. At application + // startup ONLY (never during live RUNNING), migrate disabled + // outputs to enabled channels with zero magnitude, preserving + // the previous zero-output effect without hidden OFF flags. + var migratedDisabled = 0 function assign(model, rows) { for (var i = 0; i < 4; ++i) { - model.setProperty(i, "magnitude", rows[i][0]) + const wasDisabled = rows[i][2] === false + model.setProperty(i, "magnitude", wasDisabled ? 0 : rows[i][0]) model.setProperty(i, "phase", rows[i][1]) - model.setProperty(i, "enabled", rows[i][2]) + model.setProperty(i, "enabled", true) model.setProperty(i, "quality", rows[i][3]) + if (wasDisabled) migratedDisabled += 1 } } assign(currentModel, data.current) @@ -101,6 +109,13 @@ ApplicationWindow { refreshPreview() restoringScenario = false pendingApply = true + if (migratedDisabled > 0) { + // Persist the migration to avoid redoing it on next launch. + // Explicitly notify the operator; no serial output is sent. + scenarioSettings.lastScenario = JSON.stringify(scenarioSnapshot()) + showMessage("Legacy preset: " + migratedDisabled + + " disabled channel(s) restored as enabled at 0 magnitude. Review before START.", true) + } } catch (e) { restoringScenario = false scenarioSettings.lastScenario = "" @@ -516,6 +531,18 @@ ApplicationWindow { } // START always serializes the same ListModels that draw the table, // including all eight current phase angles and enabled flags. + // Never silently START with an invisible disabled channel. The + // legacy restore path migrates at boot; an unexpected future state + // must be corrected explicitly instead of serialized unnoticed. + for (var group = 0; group < 2; ++group) { + var rows = groupModel(group) + for (var row = 0; row < 4; ++row) { + if (rows.get(row).enabled !== true) { + showMessage("Hidden disabled channel detected. Restore the scenario before START.", true) + return null + } + } + } var draft = scenarioSnapshot() draft.currentScale = currentScale draft.voltageScale = voltageScale @@ -1062,32 +1089,18 @@ ApplicationWindow { CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "50"; implicitWidth: 44; onClicked: root.setFrequencyValue(50) } CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "60"; implicitWidth: 44; onClicked: root.setFrequencyValue(60) } Rectangle { width: 1; height: 24; color: studioTheme.lineSoft } - StudioDarkCheckBox { + StudioLinkSwitch { theme: studioTheme uiFont: root.uiFont - enabled: root.signalFrequency > 0 + enabled: root.signalFrequency > 0 && !workflowBar.startQueued checked: root.phaseLink - text: "3-phase link" onToggled: { + // Link is an editing preference, not a wire + // setpoint. No 8-channel live replay or + // pendingApply on a presentation-only switch. root.phaseLink = checked - // Linking phases changes future edit behavior only; - // never silently rewrite existing setpoints. - root.markScenarioDirty() - if (workflowBar.injectionRunning && workflowBar.session - && workflowBar.session.liveControlReady) { - // Persist *all eight current table values* on - // live link changes; the displayed draft - // must never silently diverge from TX. - var ok = true - for (var group = 0; group < 2; ++group) - for (var row = 0; row < 4; ++row) - ok = root.sendSignal(group, row) && ok - if (ok) root.pendingApply = false - else root.showMessage("Not all live phases were sent. Stop/Start to apply the table.", true) - } + root.saveScenarioTimer.restart() } - font.family: root.uiFont - font.pixelSize: 9 } Item { Layout.fillWidth: true } } From 5fed504936b44c3dca71bb6571e5fcc5486e4a7b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:01:03 +0700 Subject: [PATCH 3/6] feat(studio): Register modern link switch QML --- apps/arstack_studio/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 3edb631d8..4fa8163e1 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -115,6 +115,7 @@ qt_add_qml_module(arstack_studio qml/StudioActionMenu.qml qml/StudioMenuDivider.qml qml/StudioPtpSwitch.qml + qml/StudioLinkSwitch.qml qml/StudioTimingTray.qml qml/DeviceExpertPanel.qml qml/SvCapturePanel.qml From 3777841274a8b455d13591cc92019ecb9ecd0ff0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:01:05 +0700 Subject: [PATCH 4/6] feat(studio): Assert zero-checkbox / migration / no-replay operator contract --- .../tests/check_operator_workspace.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index db6b56f68..82c0ef82c 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -156,6 +156,27 @@ def main() -> None: and 'scenarioStage_ = ScenarioStage::awaiting_start_ack;' in device_cpp, "START requires signed millidegree setpoints, firmware commit ACK and readback") matrix = source("SignalMatrix.qml") + link_toggle = source("StudioLinkSwitch.qml") + require("StudioDarkCheckBox {" not in matrix + and "requestSetEnabled(" not in matrix + and "property bool channelEnabled:" not in matrix + and "Item { Layout.preferredWidth: 25 }" not in matrix, + "all eight injection table channel checkboxes and their header spacer must be gone") + require("StudioLinkSwitch {" in main_qml + and "Switch {" in link_toggle + and "Behavior on x" in link_toggle + and "Accessible.name" in link_toggle + and "root.saveScenarioTimer.restart()" in main_qml + and "Not all live phases were sent." not in main_qml, + "3-phase link must be an accessible animated switch that sends no setpoints") + require("wasDisabled ? 0 : rows[i][0]" in main_qml + and 'model.setProperty(i, "enabled", true)' in main_qml + and "migratedDisabled > 0" in main_qml + and "Review before START." in main_qml + and "Hidden disabled channel detected." in main_qml, + "legacy disabled-channel presets must migrate visibly and START cannot hide OFF channels") + require("qml/StudioLinkSwitch.qml" in (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8"), + "link control must be registered with the native QML module") require("Layout.fillHeight: true" in matrix and "Layout.minimumHeight: 34" in matrix and "Item { Layout.fillHeight: true }" not in matrix, From 2beb4e592cddaa06f277cf1722e4afe4dfb9939c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:01:08 +0700 Subject: [PATCH 5/6] feat(studio): add accessible animated three-phase editing switch --- apps/arstack_studio/qml/StudioLinkSwitch.qml | 56 ++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 apps/arstack_studio/qml/StudioLinkSwitch.qml diff --git a/apps/arstack_studio/qml/StudioLinkSwitch.qml b/apps/arstack_studio/qml/StudioLinkSwitch.qml new file mode 100644 index 000000000..251f0d8e3 --- /dev/null +++ b/apps/arstack_studio/qml/StudioLinkSwitch.qml @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Only changes how subsequent A/B/C setpoint edits are linked. +// Actual 4I+4V TX remains owned by Main.qml's verified scenario transaction. +import QtQuick +import QtQuick.Controls + +Switch { + id: control + property var theme + property string uiFont: "Inter" + text: "3-phase link" + font.family: uiFont + font.pixelSize: 10 + font.weight: Font.Medium + hoverEnabled: true + focusPolicy: Qt.StrongFocus + spacing: 8 + Accessible.name: "3-phase link" + Accessible.description: "Link subsequent changes to phases A, B and C" + ToolTip.visible: hovered + ToolTip.delay: 900 + ToolTip.text: checked ? "Editing A, B or C also updates the linked phases" + : "Edit phases A, B and C independently" + indicator: Rectangle { + implicitWidth: 44 + implicitHeight: 24 + x: control.leftPadding + y: (control.height - height) / 2 + radius: 12 + color: !control.enabled ? control.theme.surface2 + : control.checked ? control.theme.greenSoft : control.theme.surface2 + border.width: control.visualFocus ? 2 : 1 + border.color: control.visualFocus ? control.theme.green + : control.checked ? control.theme.green : control.theme.line + opacity: control.enabled ? 1.0 : 0.48 + Behavior on color { ColorAnimation { duration: 150 } } + Rectangle { + width: 16 + height: 16 + radius: 8 + y: 4 + x: control.checked ? parent.width - width - 4 : 4 + color: control.checked ? control.theme.green : control.theme.textSoft + Behavior on x { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: 140 } } + } + } + contentItem: Text { + text: control.text + font: control.font + color: control.enabled ? control.theme.text : control.theme.muted + opacity: control.enabled ? 1.0 : 0.65 + leftPadding: control.indicator.width + control.spacing + verticalAlignment: Text.AlignVCenter + } +} From b3632b182bd31840da5500d367dddb98a8427c56 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:10:03 +0700 Subject: [PATCH 6/6] test(studio): update obsolete live-link replay gate for intent-only switch --- apps/arstack_studio/tests/check_operator_workspace.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 82c0ef82c..3a065fd5d 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -138,10 +138,10 @@ def main() -> None: "checkbox must keep one stable custom indicator on hover") require("anchors.centerIn: parent" in calm, "button caption needs horizontal centering") - require("root.sendSignal(group, row)" in main_qml + require("function sendSignal(group, row)" in main_qml and "Layout.fillHeight: false" in main_qml and "Layout.preferredHeight: 196" in main_qml, - "tables and live sent phases must use explicit bounded layout") + "tables and live sent phases must use explicit bounded layout without link-toggle replay") require('property bool ptpDefaultStartAttempted: false' in main_qml and 'root.startPtpRole("SOURCE")' in main_qml and "device.deviceVerified" in main_qml,