diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index b9cd57e6d..2c2ec74b5 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -170,6 +170,10 @@ jobs: fi echo 'S7 QML authority boundary: PASS · presentation submits intents through SmartSessionController and device mutators are not QML-invokable' + - name: Studio operator workspace layout and PTP authority contract + shell: bash + run: python3 apps/arstack_studio/tests/check_operator_workspace.py + - name: Native binary profile authority regression shell: bash run: | @@ -308,6 +312,13 @@ jobs: status=$? set -e cat /tmp/arstack-studio-launch.log + # A process that stays alive can still have broken QML bindings. + # Headless Linux lacks Qt SVG decode support (non-fatal presentation + # warnings), but JavaScript/QML binding failures are release blockers. + if grep -Eq 'TypeError:|ReferenceError:|Cannot assign to non-existent property|is not a type' /tmp/arstack-studio-launch.log; then + echo 'ARStack Studio has QML runtime binding/type failures.' + exit 1 + fi if [ "$status" -ne 124 ]; then echo "ARStack Studio exited before smoke timeout (status=$status)." exit "$status" @@ -322,6 +333,13 @@ jobs: status=$? set -e cat /tmp/arstack-studio-hidpi.log + # A process that stays alive can still have broken QML bindings. + # Headless Linux lacks Qt SVG decode support (non-fatal presentation + # warnings), but JavaScript/QML binding failures are release blockers. + if grep -Eq 'TypeError:|ReferenceError:|Cannot assign to non-existent property|is not a type' /tmp/arstack-studio-hidpi.log; then + echo 'ARStack Studio has QML runtime binding/type failures.' + exit 1 + fi if [ "$status" -ne 124 ]; then echo "ARStack Studio high-DPI launch exited early (status=$status)." exit "$status" diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index 6f080900e..7a6e86daa 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -85,3 +85,67 @@ jobs: Start-Sleep -Milliseconds 250 Invoke-LifecycleCycle 2 Write-Host 'Windows lifecycle gate: PASS · normal close -> process exit -> lock release -> immediate reopen -> clean exit' + + - name: Studio UI geometry acceptance and Windows screenshots + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + # The GitHub runner's virtual desktop clamps physical window sizes. + # Exercise *exact* logical viewports using Windows-built Qt Quick + # offscreen software rendering. These PNGs are layout evidence, + # NOT an assertion of real on-monitor Windows color/hover fidelity. + $env:QT_QPA_PLATFORM = 'offscreen' + $env:QT_QUICK_BACKEND = 'software' + $candidate = Get-ChildItem -Path (Join-Path $PWD 'build-arstack-studio') -Recurse -File | + Where-Object { $_.Name -ieq 'arstack_studio.exe' -or $_.Name -ieq 'ARStackStudio.exe' } | + Select-Object -First 1 + if ($null -eq $candidate) { throw 'Studio GUI executable not found' } + $exe = $candidate.FullName + New-Item -ItemType Directory -Path 'studio-ui-evidence' -Force | Out-Null + Remove-Item Env:ARSTACK_STUDIO_QA_DIR -ErrorAction SilentlyContinue + Remove-Item Env:ARSTACK_STUDIO_QA_NATIVE_ONLY -ErrorAction SilentlyContinue + foreach ($scale in @('1', '1.5', '1.75')) { + $env:QT_SCALE_FACTOR = $scale + $suffix = 'dpi-' + [string][int]([double]::Parse($scale, [System.Globalization.CultureInfo]::InvariantCulture) * 100) + $logdir = Join-Path (Join-Path $PWD 'studio-ui-evidence') $suffix + New-Item -ItemType Directory -Path $logdir -Force | Out-Null + $stdout = Join-Path $logdir 'geometry-stdout.txt' + $stderr = Join-Path $logdir 'geometry-stderr.txt' + $probe = Start-Process -FilePath $exe -ArgumentList '--check-operator-ui' -Wait -PassThru ` + -RedirectStandardOutput $stdout -RedirectStandardError $stderr + Get-Content $stdout -ErrorAction SilentlyContinue + Get-Content $stderr -ErrorAction SilentlyContinue + if ($probe.ExitCode -ne 0) { + throw "Deterministic Qt Quick viewport geometry failed at DPI=$scale, exit=$($probe.ExitCode)" + } + } + # Offscreen grabWindow() is not a reliable image source. Capture + # one supported native Windows viewport separately at 100% DPI. + Remove-Item Env:QT_QPA_PLATFORM -ErrorAction SilentlyContinue + Remove-Item Env:QT_QUICK_BACKEND -ErrorAction SilentlyContinue + $env:QT_SCALE_FACTOR = '1' + $env:ARSTACK_STUDIO_QA_NATIVE_ONLY = '1' + $env:ARSTACK_STUDIO_QA_DIR = Join-Path $PWD 'studio-ui-evidence\native-windows' + New-Item -ItemType Directory -Path $env:ARSTACK_STUDIO_QA_DIR -Force | Out-Null + $stdout = Join-Path $env:ARSTACK_STUDIO_QA_DIR 'native-stdout.txt' + $stderr = Join-Path $env:ARSTACK_STUDIO_QA_DIR 'native-stderr.txt' + $probe = Start-Process -FilePath $exe -ArgumentList '--check-operator-ui' -Wait -PassThru ` + -RedirectStandardOutput $stdout -RedirectStandardError $stderr + Get-Content $stdout -ErrorAction SilentlyContinue + Get-Content $stderr -ErrorAction SilentlyContinue + if ($probe.ExitCode -ne 0) { + throw "Native Windows screenshot acceptance failed, exit=$($probe.ExitCode)" + } + $png = Join-Path $env:ARSTACK_STUDIO_QA_DIR 'studio-960x620.png' + if (-not (Test-Path $png) -or (Get-Item $png).Length -lt 10000) { + throw 'Native Windows 960x620 screenshot was missing or empty' + } + Write-Host 'Windows Qt deterministic 3-viewports x 3-DPI geometry + native 960x620 screenshot: PASS' + - name: Upload desktop layout evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: arstack-studio-windows-layout-evidence + path: studio-ui-evidence/ + if-no-files-found: ignore + retention-days: 7 diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 4a9fa7112..83ca7bb7e 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -99,6 +99,10 @@ qt_add_qml_module(arstack_studio qml/PtpExpertPanel.qml qml/StudioDarkComboBox.qml qml/StudioDarkCheckBox.qml + qml/StudioActionMenu.qml + qml/StudioMenuDivider.qml + qml/StudioPtpSwitch.qml + qml/StudioTimingTray.qml qml/DeviceExpertPanel.qml qml/FirmwarePanel.qml qml/WaveformExpertPanel.qml @@ -109,24 +113,31 @@ qt_add_qml_module(arstack_studio RESOURCES assets/InterVariable.ttf assets/INTER_LICENSE.txt - assets/LUCIDE_LICENSE.txt - assets/lucide/x.svg - assets/lucide/external-link.svg - assets/lucide/chevron-up.svg - assets/lucide/chevron-down.svg - assets/lucide/radio-tower.svg - assets/lucide/scale.svg - assets/lucide/circle-off.svg - assets/lucide/activity.svg - assets/lucide/circle-check.svg - assets/lucide/settings-2.svg - assets/lucide/upload.svg - assets/lucide/square.svg - assets/lucide/play.svg - assets/lucide/panels-top-left.svg - assets/lucide/folder-open.svg - assets/lucide/scan-search.svg - assets/lucide/clock-3.svg + assets/MATERIAL_ICONS_LICENSE.txt + assets/material/close.svg + assets/material/open_in_new.svg + assets/material/keyboard_arrow_up.svg + assets/material/keyboard_arrow_down.svg + assets/material/tune.svg + assets/material/block.svg + assets/material/monitor_heart.svg + assets/material/check_circle.svg + assets/material/settings.svg + assets/material/file_upload.svg + assets/material/stop.svg + assets/material/play_arrow.svg + assets/material/dashboard.svg + assets/material/folder_open.svg + assets/material/search.svg + assets/material/schedule.svg + assets/material/content_copy.svg + assets/material/sync.svg + assets/material/done_all.svg + assets/material/balance.svg + assets/material/refresh.svg + assets/material/device_hub.svg + assets/material/help_outline.svg + assets/material/smv_injector.svg ) qt_add_resources(arstack_studio "reference_templates" diff --git a/apps/arstack_studio/assets/MATERIAL_ICONS_LICENSE.txt b/apps/arstack_studio/assets/MATERIAL_ICONS_LICENSE.txt new file mode 100644 index 000000000..3a3cdacf3 --- /dev/null +++ b/apps/arstack_studio/assets/MATERIAL_ICONS_LICENSE.txt @@ -0,0 +1,206 @@ +Google Material Icons +Upstream: https://github.com/google/material-design-icons +License: Apache License 2.0. Paths are embedded as project SVG resources. + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/balance.svg b/apps/arstack_studio/assets/material/balance.svg new file mode 100644 index 000000000..4c75e57d5 --- /dev/null +++ b/apps/arstack_studio/assets/material/balance.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/block.svg b/apps/arstack_studio/assets/material/block.svg new file mode 100644 index 000000000..67398f975 --- /dev/null +++ b/apps/arstack_studio/assets/material/block.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/check_circle.svg b/apps/arstack_studio/assets/material/check_circle.svg new file mode 100644 index 000000000..00d29c79f --- /dev/null +++ b/apps/arstack_studio/assets/material/check_circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/close.svg b/apps/arstack_studio/assets/material/close.svg new file mode 100644 index 000000000..ddedbfb23 --- /dev/null +++ b/apps/arstack_studio/assets/material/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/content_copy.svg b/apps/arstack_studio/assets/material/content_copy.svg new file mode 100644 index 000000000..d63d6294e --- /dev/null +++ b/apps/arstack_studio/assets/material/content_copy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/dashboard.svg b/apps/arstack_studio/assets/material/dashboard.svg new file mode 100644 index 000000000..da8e31e2b --- /dev/null +++ b/apps/arstack_studio/assets/material/dashboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/device_hub.svg b/apps/arstack_studio/assets/material/device_hub.svg new file mode 100644 index 000000000..2be3ff75e --- /dev/null +++ b/apps/arstack_studio/assets/material/device_hub.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/done_all.svg b/apps/arstack_studio/assets/material/done_all.svg new file mode 100644 index 000000000..3cc0d238a --- /dev/null +++ b/apps/arstack_studio/assets/material/done_all.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/file_upload.svg b/apps/arstack_studio/assets/material/file_upload.svg new file mode 100644 index 000000000..f5074356c --- /dev/null +++ b/apps/arstack_studio/assets/material/file_upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/folder_open.svg b/apps/arstack_studio/assets/material/folder_open.svg new file mode 100644 index 000000000..0fcd40589 --- /dev/null +++ b/apps/arstack_studio/assets/material/folder_open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/help_outline.svg b/apps/arstack_studio/assets/material/help_outline.svg new file mode 100644 index 000000000..763bbc3dd --- /dev/null +++ b/apps/arstack_studio/assets/material/help_outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/keyboard_arrow_down.svg b/apps/arstack_studio/assets/material/keyboard_arrow_down.svg new file mode 100644 index 000000000..435865d74 --- /dev/null +++ b/apps/arstack_studio/assets/material/keyboard_arrow_down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/keyboard_arrow_up.svg b/apps/arstack_studio/assets/material/keyboard_arrow_up.svg new file mode 100644 index 000000000..cb874fc07 --- /dev/null +++ b/apps/arstack_studio/assets/material/keyboard_arrow_up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/monitor_heart.svg b/apps/arstack_studio/assets/material/monitor_heart.svg new file mode 100644 index 000000000..ef138cd9c --- /dev/null +++ b/apps/arstack_studio/assets/material/monitor_heart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/open_in_new.svg b/apps/arstack_studio/assets/material/open_in_new.svg new file mode 100644 index 000000000..fccef9cde --- /dev/null +++ b/apps/arstack_studio/assets/material/open_in_new.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/play_arrow.svg b/apps/arstack_studio/assets/material/play_arrow.svg new file mode 100644 index 000000000..49aa93429 --- /dev/null +++ b/apps/arstack_studio/assets/material/play_arrow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/refresh.svg b/apps/arstack_studio/assets/material/refresh.svg new file mode 100644 index 000000000..646978692 --- /dev/null +++ b/apps/arstack_studio/assets/material/refresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/schedule.svg b/apps/arstack_studio/assets/material/schedule.svg new file mode 100644 index 000000000..0980a7b48 --- /dev/null +++ b/apps/arstack_studio/assets/material/schedule.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/search.svg b/apps/arstack_studio/assets/material/search.svg new file mode 100644 index 000000000..f75f071ab --- /dev/null +++ b/apps/arstack_studio/assets/material/search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/settings.svg b/apps/arstack_studio/assets/material/settings.svg new file mode 100644 index 000000000..4ae879efd --- /dev/null +++ b/apps/arstack_studio/assets/material/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/smv_injector.svg b/apps/arstack_studio/assets/material/smv_injector.svg new file mode 100644 index 000000000..4ae58d19d --- /dev/null +++ b/apps/arstack_studio/assets/material/smv_injector.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/stop.svg b/apps/arstack_studio/assets/material/stop.svg new file mode 100644 index 000000000..104f9a3fd --- /dev/null +++ b/apps/arstack_studio/assets/material/stop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/sync.svg b/apps/arstack_studio/assets/material/sync.svg new file mode 100644 index 000000000..67cd25010 --- /dev/null +++ b/apps/arstack_studio/assets/material/sync.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/tune.svg b/apps/arstack_studio/assets/material/tune.svg new file mode 100644 index 000000000..4598f18d9 --- /dev/null +++ b/apps/arstack_studio/assets/material/tune.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index 6f56597a3..0b22527d1 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -16,6 +16,10 @@ Item { function openEngineeringFile() { profileInspector.openEngineeringFile() } function showRecovery() { expertTabs.currentIndex = 1 } + function showTiming() { expertTabs.currentIndex = 3 } + // The Main tray always shares the Advanced PTP profile and verified + // guided START state machine; never create an independent START sequence. + function startPtpRole(role) { return timingPanel.startRole(role) } Component.onCompleted: { // Recovery is the only advanced page an unconfigured board should need. @@ -159,6 +163,7 @@ Item { monoFont: hub.monoFont } PtpExpertPanel { + id: timingPanel theme: hub.theme controller: hub.controller device: hub.device diff --git a/apps/arstack_studio/qml/DockFrame.qml b/apps/arstack_studio/qml/DockFrame.qml index bc492d874..9fad0df28 100644 --- a/apps/arstack_studio/qml/DockFrame.qml +++ b/apps/arstack_studio/qml/DockFrame.qml @@ -70,7 +70,7 @@ Rectangle { DarkToolButton { visible: frame.detachable - iconSource: Qt.resolvedUrl("../assets/lucide/external-link.svg") + iconSource: Qt.resolvedUrl("../assets/material/open_in_new.svg") iconSize: 15 theme: frame.theme uiFont: frame.uiFont @@ -80,7 +80,7 @@ Rectangle { } DarkToolButton { visible: frame.closable - iconSource: Qt.resolvedUrl("../assets/lucide/x.svg") + iconSource: Qt.resolvedUrl("../assets/material/close.svg") iconSize: 16 theme: frame.theme uiFont: frame.uiFont diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index fdaef5634..9c4e3b919 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import QtCore import ARStack.Studio 1.0 ApplicationWindow { @@ -26,11 +27,94 @@ ApplicationWindow { palette.highlightedText: studioTheme.text StudioTheme { id: studioTheme } + Settings { + id: scenarioSettings + category: "offline-injection-lab" + property string lastScenario: "" + } + Timer { + id: saveScenarioTimer + interval: 400 + onTriggered: scenarioSettings.lastScenario = JSON.stringify(root.scenarioSnapshot()) + } + function scenarioSnapshot() { + function values(model) { + var rows = [] + for (var i = 0; i < 4; ++i) { + const r = model.get(i) + rows.push([r.magnitude, r.phase, r.enabled, Number(r.quality)]) + } + return rows + } + return { version: 1, hz: signalFrequency, current: values(currentModel), + voltage: values(voltageModel), linked: phaseLink } + } + function markScenarioDirty() { + if (restoringScenario) return + saveScenarioTimer.restart() + if (!workflowBar.session || !workflowBar.session.liveControlReady) + pendingApply = true + } + function restoreLocalScenario() { + const saved = scenarioSettings.lastScenario + if (!saved || saved.length > 8192) return + try { + const data = JSON.parse(saved) + if (data.version !== 1 || typeof data.hz !== "number" + || !Number.isFinite(data.hz) || !validFrequency(data.hz) + || !Array.isArray(data.current) || !Array.isArray(data.voltage) + || data.current.length !== 4 || data.voltage.length !== 4) + throw new Error("Invalid scenario format") + function validate(rows, group) { + for (var i = 0; i < 4; ++i) { + const row = rows[i] + if (!Array.isArray(row) || row.length !== 4 + || typeof row[0] !== "number" || !Number.isFinite(row[0]) + || (data.hz === 0 ? Math.abs(row[0]) : row[0]) > maximumMagnitude(group) + || (data.hz !== 0 && row[0] < 0) + || typeof row[1] !== "number" || !Number.isFinite(row[1]) + || !validPhase(row[1]) || typeof row[2] !== "boolean" + || typeof row[3] !== "number" || !Number.isInteger(row[3]) + || row[3] < 0 || row[3] > 4294967295) + throw new Error("Invalid channel value") + } + } + validate(data.current, 0) + validate(data.voltage, 1) + restoringScenario = true + function assign(model, rows) { + for (var i = 0; i < 4; ++i) { + model.setProperty(i, "magnitude", rows[i][0]) + model.setProperty(i, "phase", rows[i][1]) + model.setProperty(i, "enabled", rows[i][2]) + model.setProperty(i, "quality", rows[i][3]) + } + } + assign(currentModel, data.current) + assign(voltageModel, data.voltage) + signalFrequency = data.hz + previousAcFrequency = data.hz > 0 ? data.hz : 50 + frequencyField.text = data.hz.toFixed(3) + phaseLink = data.linked === true + selectSignal(activeGroup, activeRow) + refreshPreview() + restoringScenario = false + pendingApply = true + } catch (e) { + restoringScenario = false + scenarioSettings.lastScenario = "" + showMessage("Invalid local experiment; safe 4I+4V defaults restored.", true) + } + } + Component.onCompleted: restoreLocalScenario() property string uiFont: interFont.status === FontLoader.Ready ? interFont.name : "Inter" property string monoFont: uiFont property bool phaseLink: false property bool profileDirty: false + property bool pendingApply: true + property bool restoringScenario: false + property bool offlinePreviewRunning: false property real currentScale: 1000.0 property real voltageScale: 100.0 property real signalFrequency: 50.0 @@ -68,6 +152,12 @@ ApplicationWindow { configurationWindow.hide() detachedPhasorWindow.hide() detachedWaveformWindow.hide() + // The debounced persistence timer can still be pending when the user + // closes immediately after editing. Flush the local draft once, while + // ListModels and Settings remain alive; no device command is sent. + saveScenarioTimer.stop() + if (!root.restoringScenario) + scenarioSettings.lastScenario = JSON.stringify(root.scenarioSnapshot()) // C++ owns process termination and bounded worker retirement. Do not // race it with a second QML quit path. } @@ -95,6 +185,10 @@ ApplicationWindow { function onDeviceMessage(message) { root.showMessage(message, false) } + function onDeviceVerifiedChanged() { + root.pendingApply = true + root.offlinePreviewRunning = false + } } Timer { @@ -164,6 +258,15 @@ ApplicationWindow { configurationWindow.requestActivate() } + function openTimingSettings() { + profilePanel.showTiming() + openConfiguration() + } + + function startPtpRole(role) { + return profilePanel.startPtpRole(role) + } + function openDiagnostics() { diagnosticsDialog.open() } @@ -302,7 +405,8 @@ ApplicationWindow { } selectSignal(group, row) - if (workflowBar.session && workflowBar.session.liveControlReady) { + markScenarioDirty() + if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) { if (phaseLink && row < 3) sendLinkedGroup(group) else sendSignal(group, row) } @@ -310,11 +414,27 @@ ApplicationWindow { } function applyAllSignals() { - if (!workflowBar.session || !workflowBar.session.liveControlReady) return - workflowBar.session.requestSetFrequency(signalFrequency) - for (var group = 0; group < 2; ++group) applyGroupSignals(group) - workflowBar.session.requestSetCtSaturation(ctSaturationEnabled, ctDcOffsetPercent, - ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) + if (!workflowBar.session || !workflowBar.session.liveControlReady) return false + var ok = workflowBar.session.requestSetFrequency(signalFrequency) + for (var group = 0; group < 2; ++group) + for (var row = 0; row < 4; ++row) ok = sendSignal(group, row) && ok + ok = workflowBar.session.requestSetCtSaturation(ctSaturationEnabled, ctDcOffsetPercent, + ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) && ok + pendingApply = !ok + showMessage(ok ? "Local draft commands queued. Verify physical SV on wire; SETs are not atomic." + : "One or more draft commands could not be queued; retry after checking the board.", !ok) + return ok + } + function requestApplyDraft() { + if (!workflowBar.session || !workflowBar.session.liveControlReady) { + showMessage("Local draft saved; connect a verified ESP32-P4 to apply it.", false) + return false + } + if (workflowBar.injectionRunning) { + showMessage("Stop the publisher before applying an offline draft.", true) + return false + } + return applyAllSignals() } function setFrequencyValue(value) { @@ -324,7 +444,8 @@ ApplicationWindow { frequencyField.text = value.toFixed(3) frequencyField.invalidInput = false refreshPreview() - if (workflowBar.session && workflowBar.session.liveControlReady) + markScenarioDirty() + if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) workflowBar.session.requestSetFrequency(value) return true } @@ -348,7 +469,8 @@ ApplicationWindow { } ctSaturationEnabled = enabled refreshPreview() - if (workflowBar.session && workflowBar.session.liveControlReady) + markScenarioDirty() + if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) workflowBar.session.requestSetCtSaturation(enabled, ctDcOffsetPercent, ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) showMessage(enabled @@ -365,11 +487,12 @@ ApplicationWindow { if (!validMagnitude(group, value)) return for (var i = 0; i < 3; ++i) model.setProperty(i, "magnitude", value) selectSignal(group, row) - if (workflowBar.session && workflowBar.session.liveControlReady && !sendLinkedGroup(group)) { + markScenarioDirty() + if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply && !sendLinkedGroup(group)) { showMessage("ABC update was not accepted; inspect device diagnostics.", true) return } - showMessage("ABC magnitude queued from " + model.get(row).signalId, false) + showMessage((pendingApply ? "Local experiment: " : "Device queue: ") + "ABC magnitude queued from " + model.get(row).signalId, false) } function equalGroupAngles(group, row) { @@ -379,11 +502,12 @@ ApplicationWindow { if (!validPhase(value)) return for (var i = 0; i < 3; ++i) model.setProperty(i, "phase", value) selectSignal(group, row) - if (workflowBar.session && workflowBar.session.liveControlReady && !sendLinkedGroup(group)) { + markScenarioDirty() + if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply && !sendLinkedGroup(group)) { showMessage("ABC update was not accepted; inspect device diagnostics.", true) return } - showMessage("ABC angles queued from " + model.get(row).signalId, false) + showMessage((pendingApply ? "Local experiment: " : "Device queue: ") + "ABC angles queued from " + model.get(row).signalId, false) } function balanceGroupAngles(group, row) { @@ -396,11 +520,12 @@ ApplicationWindow { model.setProperty(1, "phase", normalizedAngle(base - 120)) model.setProperty(2, "phase", normalizedAngle(base + 120)) selectSignal(group, row) - if (workflowBar.session && workflowBar.session.liveControlReady && !sendLinkedGroup(group)) { + markScenarioDirty() + if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply && !sendLinkedGroup(group)) { showMessage("ABC update was not accepted; inspect device diagnostics.", true) return } - showMessage("Balanced ABC angles queued from " + model.get(row).signalId, false) + showMessage((pendingApply ? "Local experiment: " : "Device queue: ") + "Balanced ABC angles queued from " + model.get(row).signalId, false) } function balanced() { @@ -415,7 +540,8 @@ ApplicationWindow { } selectSignal(activeGroup, activeRow) refreshPreview() - if (workflowBar.session && workflowBar.session.liveControlReady) applyAllSignals() + markScenarioDirty() + if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) applyAllSignals() } function zeroAll() { @@ -425,7 +551,8 @@ ApplicationWindow { } selectSignal(activeGroup, activeRow) refreshPreview() - if (workflowBar.session && workflowBar.session.liveControlReady) + markScenarioDirty() + if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) workflowBar.session.requestZero() } @@ -433,7 +560,8 @@ ApplicationWindow { var unsignedValue = Number(value) >>> 0 groupModel(activeGroup).setProperty(activeRow, "quality", unsignedValue) activeQuality = unsignedValue - if (workflowBar.session && workflowBar.session.liveControlReady) + markScenarioDirty() + if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) workflowBar.session.requestSetQuality(activeSignal, unsignedValue) } @@ -605,6 +733,7 @@ ApplicationWindow { activeMagnitude: root.activeMagnitude activePhase: root.activePhase signalFrequency: root.signalFrequency + previewRunning: root.offlinePreviewRunning && !device.deviceVerified ctSaturationEnabled: root.ctSaturationEnabled ctDcOffsetPercent: root.ctDcOffsetPercent ctHarmonicPercent: root.ctHarmonicPercent @@ -647,6 +776,7 @@ ApplicationWindow { activeMagnitude: root.activeMagnitude activePhase: root.activePhase signalFrequency: root.signalFrequency + previewRunning: root.offlinePreviewRunning && !device.deviceVerified ctSaturationEnabled: root.ctSaturationEnabled ctDcOffsetPercent: root.ctDcOffsetPercent ctHarmonicPercent: root.ctHarmonicPercent @@ -663,6 +793,7 @@ ApplicationWindow { WorkflowBar { id: workflowBar + objectName: "studioWorkflowBar" Layout.fillWidth: true Layout.preferredHeight: 54 Layout.minimumHeight: 54 @@ -713,6 +844,7 @@ ApplicationWindow { } SurfacePanel { + objectName: "studioInjectionSurface" SplitView.fillWidth: true SplitView.fillHeight: true SplitView.minimumWidth: root.compactLayout ? 500 : 550 @@ -731,6 +863,17 @@ ApplicationWindow { Label { text: "4I + 4V Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: root.compactLayout ? 17 : 18; font.weight: Font.DemiBold } } Item { Layout.fillWidth: true } + CalmButton { + theme: studioTheme + uiFont: root.uiFont + iconSource: Qt.resolvedUrl("../assets/material/file_upload.svg") + text: !device.deviceVerified ? "Local draft" + : root.pendingApply ? "Apply to board" : "SET queued" + implicitWidth: 106 + tone: device.deviceVerified && root.pendingApply ? "accent" : "normal" + toolTipText: "Local setpoints are separate from physical output. Board last values are not read back." + onClicked: root.requestApplyDraft() + } } Rectangle { @@ -740,6 +883,25 @@ ApplicationWindow { color: "#0b1219" border.width: 0 + StudioActionMenu { + id: frequencyMenu + objectName: "studioFrequencyMenu" + theme: studioTheme + uiFont: root.uiFont + x: 80 + y: 45 + title: "Frequency · shared across all channels" + Action { text: "DC · 0 Hz"; icon.source: Qt.resolvedUrl("../assets/material/block.svg"); onTriggered: root.setWaveformMode("DC") } + Action { text: "50 Hz · nominal"; icon.source: Qt.resolvedUrl("../assets/material/schedule.svg"); onTriggered: root.setFrequencyValue(50) } + Action { text: "60 Hz · nominal"; icon.source: Qt.resolvedUrl("../assets/material/schedule.svg"); onTriggered: root.setFrequencyValue(60) } + StudioMenuDivider { theme: studioTheme } + Action { + text: "Restore previous AC" + icon.source: Qt.resolvedUrl("../assets/material/refresh.svg") + enabled: root.previousAcFrequency > 0 + onTriggered: root.setWaveformMode("AC") + } + } RowLayout { anchors.fill: parent anchors.leftMargin: 11 @@ -756,6 +918,14 @@ ApplicationWindow { } NumericField { id: frequencyField + ToolTip.visible: hovered && !activeFocus + ToolTip.delay: 650 + ToolTip.text: "Right-click for DC / 50 Hz / 60 Hz and AC restoration" + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.RightButton + onClicked: frequencyMenu.popup() + } theme: studioTheme monoFont: root.monoFont compact: root.compactLayout @@ -771,7 +941,8 @@ ApplicationWindow { root.signalFrequency = value if (value > 0) root.previousAcFrequency = value root.refreshPreview() - if (workflowBar.session && workflowBar.session.liveControlReady) + root.markScenarioDirty() + if (workflowBar.session && workflowBar.session.liveControlReady && !root.pendingApply) workflowBar.session.requestSetFrequency(value) } else invalidInput = true } @@ -795,7 +966,10 @@ ApplicationWindow { enabled: root.signalFrequency > 0 checked: root.phaseLink text: "3-phase link" - onToggled: root.phaseLink = checked + onToggled: { + root.phaseLink = checked + root.markScenarioDirty() + } font.family: root.uiFont font.pixelSize: 9 } @@ -808,43 +982,60 @@ ApplicationWindow { Layout.fillHeight: true spacing: 9 SignalMatrix { - id: currentMatrix + id: voltageMatrix + objectName: "studioVoltageMatrix" Layout.fillWidth: true Layout.fillHeight: true theme: studioTheme controller: root device: device session: workflowBar.session - sourceModel: currentModel + sourceModel: voltageModel uiFont: root.uiFont monoFont: root.monoFont - groupIndex: 0 - titleText: "Current" - symbolText: "I" - unitText: root.signalFrequency === 0 ? "A DC" : "A RMS" + groupIndex: 1 + titleText: "Voltage" + symbolText: "U" + unitText: root.signalFrequency === 0 ? "V DC" : "V RMS" compact: root.compactLayout } SignalMatrix { - id: voltageMatrix + id: currentMatrix + objectName: "studioCurrentMatrix" Layout.fillWidth: true Layout.fillHeight: true theme: studioTheme controller: root device: device session: workflowBar.session - sourceModel: voltageModel + sourceModel: currentModel uiFont: root.uiFont monoFont: root.monoFont - groupIndex: 1 - titleText: "Voltage" - symbolText: "U" - unitText: root.signalFrequency === 0 ? "V DC" : "V RMS" + groupIndex: 0 + titleText: "Current" + symbolText: "I" + unitText: root.signalFrequency === 0 ? "A DC" : "A RMS" compact: root.compactLayout } + } + StudioTimingTray { + objectName: "studioTimingTray" + Layout.fillWidth: true + Layout.preferredHeight: root.height < 700 ? 112 : 132 + Layout.minimumHeight: root.height < 700 ? 112 : 132 + theme: studioTheme + controller: root + device: device + session: workflowBar.session + uiFont: root.uiFont + monoFont: root.monoFont + compact: root.compactLayout + } Rectangle { Layout.fillWidth: true; height: 1; color: studioTheme.lineSoft } RowLayout { + objectName: "studioQualityRow" Layout.fillWidth: true spacing: 8 Rectangle { width: 6; height: 6; radius: 3; color: studioTheme.accent } @@ -923,6 +1114,7 @@ ApplicationWindow { activeMagnitude: root.activeMagnitude activePhase: root.activePhase signalFrequency: root.signalFrequency + previewRunning: root.offlinePreviewRunning && !device.deviceVerified ctSaturationEnabled: root.ctSaturationEnabled ctDcOffsetPercent: root.ctDcOffsetPercent ctHarmonicPercent: root.ctHarmonicPercent @@ -961,6 +1153,7 @@ ApplicationWindow { activeMagnitude: root.activeMagnitude activePhase: root.activePhase signalFrequency: root.signalFrequency + previewRunning: root.offlinePreviewRunning && !device.deviceVerified ctSaturationEnabled: root.ctSaturationEnabled ctDcOffsetPercent: root.ctDcOffsetPercent ctHarmonicPercent: root.ctHarmonicPercent diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index 31f809305..0ed98d7f5 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -27,6 +27,7 @@ Rectangle { } function conciseState() { + if (!device.deviceVerified && controller.offlinePreviewRunning) return "Offline preview" if (session.state === "RUNNING") return "Running" if (session.state === "READY") return "Ready" if (session.state === "WAITING FOR DEVICE") return "Offline" @@ -61,7 +62,7 @@ Rectangle { anchors.centerIn: parent width: 15 height: 15 - source: Qt.resolvedUrl("../assets/lucide/radio-tower.svg") + source: Qt.resolvedUrl("../assets/material/smv_injector.svg") sourceSize.width: 30 sourceSize.height: 30 } @@ -84,12 +85,21 @@ Rectangle { RibbonAction { theme: bar.theme uiFont: bar.uiFont - text: "Start" - tone: bar.session.startReady ? "success" : "neutral" - iconSource: Qt.resolvedUrl("../assets/lucide/play.svg") - enabled: !bar.workflow.injectionRunning && !bar.session.updatingFirmware && !bar.session.updateNeedsBootloaderHelp - toolTipText: bar.workflow.startReason() - onClicked: bar.workflow.requestStart() + text: !bar.device.deviceVerified ? "Preview" : "Start" + tone: !bar.device.deviceVerified ? "accent" + : bar.session.startReady ? "success" : "neutral" + iconSource: Qt.resolvedUrl("../assets/material/play_arrow.svg") + enabled: !bar.workflow.injectionRunning && !bar.controller.offlinePreviewRunning + && !bar.session.updatingFirmware && !bar.session.updateNeedsBootloaderHelp + toolTipText: !bar.device.deviceVerified + ? "Animate local setpoint preview only; no SV frames are transmitted." + : bar.workflow.startReason() + onClicked: { + if (!bar.device.deviceVerified) { + bar.controller.offlinePreviewRunning = true + bar.controller.showMessage("Offline waveform preview · no network SV output.", false) + } else bar.workflow.requestStart() + } } RibbonAction { @@ -97,10 +107,14 @@ Rectangle { uiFont: bar.uiFont text: "Stop" tone: bar.workflow.injectionRunning ? "danger" : "neutral" - iconSource: Qt.resolvedUrl("../assets/lucide/square.svg") - enabled: bar.workflow.injectionRunning && !bar.session.updatingFirmware - toolTipText: "Stop Sampled Values output" - onClicked: bar.workflow.requestStop() + iconSource: Qt.resolvedUrl("../assets/material/stop.svg") + enabled: (bar.workflow.injectionRunning || bar.controller.offlinePreviewRunning) + && !bar.session.updatingFirmware + toolTipText: bar.device.deviceVerified ? "Stop Sampled Values output" : "Stop local preview only" + onClicked: { + if (!bar.device.deviceVerified) bar.controller.offlinePreviewRunning = false + else bar.workflow.requestStop() + } } Rectangle { width: 1; height: 24; color: bar.theme.lineSoft } @@ -109,7 +123,7 @@ Rectangle { theme: bar.theme uiFont: bar.uiFont text: bar.compact ? "SCL" : "Open SCL" - iconSource: Qt.resolvedUrl("../assets/lucide/folder-open.svg") + iconSource: Qt.resolvedUrl("../assets/material/folder_open.svg") enabled: bar.session.engineeringEditable toolTipText: "Open IEC 61850 engineering configuration" onClicked: bar.workflow.openEngineeringDialog() @@ -120,7 +134,7 @@ Rectangle { theme: bar.theme uiFont: bar.uiFont text: "4I+4V" - iconSource: Qt.resolvedUrl("../assets/lucide/panels-top-left.svg") + iconSource: Qt.resolvedUrl("../assets/material/dashboard.svg") enabled: bar.session.engineeringEditable toolTipText: "Use the built-in 4I + 4V / 4000 samples/s profile" onClicked: bar.workflow.useBuiltInProfile() @@ -150,6 +164,7 @@ Rectangle { theme: bar.theme uiFont: bar.uiFont text: "Phasor" + iconSource: Qt.resolvedUrl("../assets/material/tune.svg") checkable: true checked: bar.controller.phasorDockVisible || bar.controller.phasorDetached tone: checked ? "accent" : "neutral" @@ -164,6 +179,7 @@ Rectangle { theme: bar.theme uiFont: bar.uiFont text: "Wave" + iconSource: Qt.resolvedUrl("../assets/material/monitor_heart.svg") visible: !bar.compact checkable: true checked: bar.controller.waveformDockVisible || bar.controller.waveformDetached @@ -180,7 +196,7 @@ Rectangle { uiFont: bar.uiFont text: bar.compact ? "" : "Advanced" iconOnly: bar.compact - iconSource: Qt.resolvedUrl("../assets/lucide/settings-2.svg") + iconSource: Qt.resolvedUrl("../assets/material/settings.svg") tone: "neutral" toolTipText: bar.session.firmwareUpdateAvailable ? "Advanced controls · optional firmware update available" diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index ebbc7ef52..bef8d9b70 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -44,12 +44,18 @@ SurfacePanel { return panel.session && panel.session.requestConfigurePtp(panel.ptpProfile()) } + function startRole(role) { + if (!panel.session || !panel.device.ptpAvailable || !panel.device.deviceVerified + || panel.device.ptpRunning || panel.startPending || panel.session.updatingFirmware) + return false + const accepted = panel.session.requestStartPtpConfigured(role, panel.ptpProfile()) + if (!accepted && panel.controller) + panel.controller.showMessage("PTP start blocked. Check timing diagnostics.", true) + return Boolean(accepted) + } + function startSelectedMode() { - if (!panel.session || !panel.device.ptpAvailable) return - var role = panel.roleValues[modeCombo.currentIndex] - if (!panel.session.requestStartPtpConfigured(role, panel.ptpProfile())) { - if (panel.controller) panel.controller.showMessage("PTP start blocked. See the Timing status for details.", true) - } + return panel.startRole(panel.roleValues[modeCombo.currentIndex]) } readonly property string activeRole: panel.device.ptpRole @@ -114,52 +120,23 @@ SurfacePanel { } } Item { Layout.fillWidth: true } - Switch { + StudioPtpSwitch { id: ptpPower + theme: panel.theme + uiFont: panel.uiFont text: "PTP" checked: Boolean(panel.device.ptpRunning) enabled: panel.session && panel.device.ptpAvailable && !panel.startPending && !panel.session.updatingFirmware - font.family: panel.uiFont - font.pixelSize: 11 ToolTip.visible: hovered - ToolTip.text: "Start the selected lab PTP role, or stop PTP. State follows verified board telemetry." + ToolTip.text: "Start the selected lab PTP role or stop it. State follows verified board telemetry." onClicked: { - // A Switch flips locally before asynchronous firmware ACK. - // Restore its binding to the sole device-owned state first; - // rejected START/STOP must never advertise the wrong status. const wantsRunning = checked checked = Qt.binding(function() { return Boolean(panel.device.ptpRunning) }) if (wantsRunning) panel.startSelectedMode() else if (panel.session && !panel.session.requestStopPtp() && panel.controller) panel.controller.showMessage("PTP stop was not accepted. Copy diagnostics.", true) } - indicator: Rectangle { - implicitWidth: 46 - implicitHeight: 24 - x: ptpPower.leftPadding - y: (ptpPower.height - height) / 2 - radius: 12 - color: ptpPower.checked ? "#195b44" : panel.theme.surface2 - border.width: 1 - border.color: ptpPower.checked ? panel.theme.green : panel.theme.line - Rectangle { - width: 16 - height: 16 - y: 4 - x: ptpPower.checked ? parent.width - width - 4 : 4 - radius: 8 - color: ptpPower.checked ? panel.theme.green : panel.theme.textSoft - Behavior on x { NumberAnimation { duration: 120 } } - } - } - contentItem: Text { - text: ptpPower.text - color: ptpPower.enabled ? panel.theme.text : panel.theme.muted - font: ptpPower.font - leftPadding: ptpPower.indicator.width + ptpPower.spacing - verticalAlignment: Text.AlignVCenter - } } StateBadge { theme: panel.theme diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index 1b789b0d0..8b49bd621 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -124,33 +124,70 @@ Rectangle { property real pendingMagnitude: mag property real pendingPhase: angle - Menu { + // The same placement/visibility semantics for mouse and keyboard. + // Clamp horizontally to the row rather than allowing a 250px + // popup to extend underneath the adjacent matrix. + function openFieldMenu(field, actionMenu) { + const at = signalRow.mapFromItem(field, 0, field.height) + actionMenu.popup(Math.max(6, Math.min(at.x, signalRow.width - actionMenu.width)), at.y) + } + + // Action-backed menu uses a single globally styled delegate. + // Rows/callbacks stay local so keyboard navigation and model + // ownership remain unchanged. + StudioActionMenu { id: magnitudeMenu - background: Rectangle { color: matrix.theme.raised; radius: 8; border.color: matrix.theme.line } - MenuItem { - text: "Equal Value · A/B/C = " + signalRow.sid + theme: matrix.theme + uiFont: matrix.uiFont + title: "Magnitude · " + signalRow.sid + Action { + text: "Zero · 0" + icon.source: Qt.resolvedUrl("../assets/material/block.svg") + onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "magnitude", 0) + } + Action { + text: "50% nominal (demo)" + icon.source: Qt.resolvedUrl("../assets/material/tune.svg") + onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, + "magnitude", matrix.groupIndex === 0 ? 0.5 : 28.87) + } + Action { + text: "100% nominal (demo)" + icon.source: Qt.resolvedUrl("../assets/material/check_circle.svg") + onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, + "magnitude", matrix.groupIndex === 0 ? 1.0 : 57.74) + } + StudioMenuDivider { theme: matrix.theme } + Action { + text: "Equal magnitudes A/B/C" + icon.source: Qt.resolvedUrl("../assets/material/done_all.svg") enabled: signalRow.rowIndex < 3 onTriggered: matrix.controller.equalGroupMagnitudes(matrix.groupIndex, signalRow.rowIndex) - contentItem: Label { text: parent.text; color: matrix.theme.text; font.family: matrix.uiFont; font.pixelSize: 12 } - background: Rectangle { color: parent.highlighted ? matrix.theme.accentSoft : "transparent" } } } - Menu { + StudioActionMenu { id: phaseMenu - background: Rectangle { color: matrix.theme.raised; radius: 8; border.color: matrix.theme.line } - MenuItem { - text: "Equal Angle · A/B/C = " + signalRow.sid + theme: matrix.theme + uiFont: matrix.uiFont + title: "Angle · " + signalRow.sid + Action { + text: "Zero angle · 0°" + icon.source: Qt.resolvedUrl("../assets/material/block.svg") + enabled: matrix.controller.signalFrequency > 0 + onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "phase", 0) + } + StudioMenuDivider { theme: matrix.theme } + Action { + text: "Equal angles A/B/C" + icon.source: Qt.resolvedUrl("../assets/material/done_all.svg") enabled: signalRow.rowIndex < 3 onTriggered: matrix.controller.equalGroupAngles(matrix.groupIndex, signalRow.rowIndex) - contentItem: Label { text: parent.text; color: matrix.theme.text; font.family: matrix.uiFont; font.pixelSize: 12 } - background: Rectangle { color: parent.highlighted ? matrix.theme.accentSoft : "transparent" } } - MenuItem { - text: "Balance Angle · 120° from " + signalRow.sid + Action { + text: "Balance angles · 120°" + icon.source: Qt.resolvedUrl("../assets/material/balance.svg") enabled: signalRow.rowIndex < 3 onTriggered: matrix.controller.balanceGroupAngles(matrix.groupIndex, signalRow.rowIndex) - contentItem: Label { text: parent.text; color: matrix.theme.text; font.family: matrix.uiFont; font.pixelSize: 12 } - background: Rectangle { color: parent.highlighted ? matrix.theme.accentSoft : "transparent" } } } @@ -207,7 +244,8 @@ Rectangle { matrix.sourceModel.setProperty(signalRow.rowIndex, "enabled", checked) matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) matrix.controller.refreshPreview() - if (matrix.session && matrix.session.liveControlReady) + matrix.controller.markScenarioDirty() + if (matrix.session && matrix.session.liveControlReady && !matrix.controller.pendingApply) matrix.session.requestSetEnabled(signalRow.sid, checked) } } @@ -231,12 +269,12 @@ Rectangle { id: magnitudeField ToolTip.visible: hovered && !activeFocus ToolTip.delay: 650 - ToolTip.text: "Right-click: Equal Value (set phases A/B/C to this magnitude)" + ToolTip.text: "Right-click or Shift+F10: magnitude presets and Equal ABC" MouseArea { anchors.fill: parent z: 5 acceptedButtons: Qt.RightButton - onClicked: magnitudeMenu.popup() + onClicked: signalRow.openFieldMenu(magnitudeField, magnitudeMenu) } Layout.fillWidth: true theme: matrix.theme @@ -275,7 +313,11 @@ Rectangle { } } Keys.onPressed: function(event) { - if ([Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right].indexOf(event.key) >= 0) { + if (event.key === Qt.Key_Menu || + (event.key === Qt.Key_F10 && (event.modifiers & Qt.ShiftModifier))) { + signalRow.openFieldMenu(magnitudeField, magnitudeMenu) + event.accepted = true + } else if ([Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right].indexOf(event.key) >= 0) { 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) { @@ -289,12 +331,12 @@ Rectangle { id: phaseField ToolTip.visible: hovered && !activeFocus ToolTip.delay: 650 - ToolTip.text: "Right-click: Equal Angle or Balance phases A/B/C by 120°" + ToolTip.text: "Right-click or Shift+F10: Equal/Balance ABC angles" MouseArea { anchors.fill: parent z: 5 acceptedButtons: Qt.RightButton - onClicked: phaseMenu.popup() + onClicked: signalRow.openFieldMenu(phaseField, phaseMenu) } enabled: matrix.controller.signalFrequency > 0 Layout.preferredWidth: matrix.compact ? 84 : 100 @@ -334,7 +376,11 @@ Rectangle { } } Keys.onPressed: function(event) { - if ([Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right].indexOf(event.key) >= 0) { + if (event.key === Qt.Key_Menu || + (event.key === Qt.Key_F10 && (event.modifiers & Qt.ShiftModifier))) { + signalRow.openFieldMenu(phaseField, phaseMenu) + event.accepted = true + } else if ([Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right].indexOf(event.key) >= 0) { 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) { diff --git a/apps/arstack_studio/qml/SignalPreview.qml b/apps/arstack_studio/qml/SignalPreview.qml index 8e8a33910..342998400 100644 --- a/apps/arstack_studio/qml/SignalPreview.qml +++ b/apps/arstack_studio/qml/SignalPreview.qml @@ -22,6 +22,8 @@ SurfacePanel { property real ctClipPercent: 60 property bool showHeader: true property string viewMode: "both" // both, phasor, waveform + property bool previewRunning: false + property real previewCursorMs: 0 color: preview.showHeader ? preview.theme.surface : "transparent" border.width: preview.showHeader ? 1 : 0 @@ -164,6 +166,17 @@ SurfacePanel { } } + // Only an animated local cursor: never mistaken for live SV capture. + Timer { + interval: 80 + repeat: true + running: preview.previewRunning && preview.viewMode !== "phasor" + onTriggered: { + preview.previewCursorMs = (preview.previewCursorMs + 2) % 40 + waveformCanvas.requestPaint() + } + } + function paintWaveform(canvas) { var ctx = canvas.getContext("2d") ctx.reset() @@ -176,8 +189,9 @@ SurfacePanel { var plotHeight = canvas.height - top - bottom var gap = preview.compact ? 15 : 18 var laneHeight = (plotHeight - gap) / 2 - var currentY = top + laneHeight / 2 - var voltageY = top + laneHeight + gap + laneHeight / 2 + // One physical order across table, phasor and waveform: U then I. + var voltageY = top + laneHeight / 2 + var currentY = top + laneHeight + gap + laneHeight / 2 var gridColor = preview.theme ? preview.theme.plotGrid : "#22303d" var strongGridColor = preview.theme ? preview.theme.plotGridStrong : "#334353" @@ -199,7 +213,7 @@ SurfacePanel { ctx.strokeStyle = strongGridColor for (var lane = 0; lane < 2; ++lane) { - var baseline = lane === 0 ? currentY : voltageY + var baseline = lane === 0 ? voltageY : currentY ctx.beginPath() ctx.moveTo(left, baseline) ctx.lineTo(left + plotWidth, baseline) @@ -210,10 +224,10 @@ SurfacePanel { ctx.font = "600 8px '" + preview.uiFont + "'" ctx.textAlign = "right" ctx.textBaseline = "middle" - ctx.fillText("CURRENT", left - 7, top + 11) - ctx.fillText("A", left - 7, top + 23) - ctx.fillText("VOLTAGE", left - 7, top + laneHeight + gap + 11) - ctx.fillText("V", left - 7, top + laneHeight + gap + 23) + ctx.fillText("VOLTAGE", left - 7, top + 11) + ctx.fillText("V", left - 7, top + 23) + ctx.fillText("CURRENT", left - 7, top + laneHeight + gap + 11) + ctx.fillText("A", left - 7, top + laneHeight + gap + 23) ctx.font = "500 8px '" + preview.uiFont + "'" ctx.textAlign = "center" @@ -281,8 +295,19 @@ SurfacePanel { } } - drawWaveGroup(preview.currentModel, currentY, true) drawWaveGroup(preview.voltageModel, voltageY, false) + drawWaveGroup(preview.currentModel, currentY, true) + if (preview.previewRunning) { + const cursorX = left + (preview.previewCursorMs / 40) * plotWidth + ctx.strokeStyle = preview.theme ? preview.theme.accent : "#67a5ee" + ctx.lineWidth = 1.1 + ctx.globalAlpha = 0.78 + ctx.beginPath() + ctx.moveTo(cursorX, top) + ctx.lineTo(cursorX, top + plotHeight) + ctx.stroke() + ctx.globalAlpha = 1 + } } ColumnLayout { @@ -336,10 +361,10 @@ SurfacePanel { radius: 7 color: preview.theme.plotSurface border.width: 1 - border.color: currentPhasorHover.hovered ? "#35506b" : preview.theme.lineSoft + border.color: voltagePhasorHover.hovered ? "#35506b" : preview.theme.lineSoft Behavior on border.color { ColorAnimation { duration: 100 } } - HoverHandler { id: currentPhasorHover } + HoverHandler { id: voltagePhasorHover } ColumnLayout { anchors.fill: parent @@ -351,7 +376,7 @@ SurfacePanel { spacing: 6 Rectangle { width: 3; height: 13; radius: 2; color: preview.theme.accent } Label { - text: "CURRENT PHASOR" + text: "VOLTAGE PHASOR" color: preview.theme.textSoft font.family: preview.uiFont font.pixelSize: 9 @@ -360,7 +385,7 @@ SurfacePanel { } Item { Layout.fillWidth: true } Label { - text: preview.signalFrequency === 0 ? "A DC" : "A RMS" + text: preview.signalFrequency === 0 ? "V DC" : "V RMS" color: preview.theme.muted font.family: preview.uiFont font.pixelSize: 8 @@ -368,28 +393,26 @@ SurfacePanel { } Canvas { - id: currentPhasorCanvas + id: voltagePhasorCanvas Layout.fillWidth: true Layout.fillHeight: true Layout.minimumHeight: 100 onWidthChanged: requestPaint() onHeightChanged: requestPaint() - onPaint: preview.paintPhasor(currentPhasorCanvas, preview.currentModel) + onPaint: preview.paintPhasor(voltagePhasorCanvas, preview.voltageModel) } } - } - - Rectangle { + } Rectangle { Layout.fillWidth: true Layout.fillHeight: true Layout.minimumWidth: 150 radius: 7 color: preview.theme.plotSurface border.width: 1 - border.color: voltagePhasorHover.hovered ? "#35506b" : preview.theme.lineSoft + border.color: currentPhasorHover.hovered ? "#35506b" : preview.theme.lineSoft Behavior on border.color { ColorAnimation { duration: 100 } } - HoverHandler { id: voltagePhasorHover } + HoverHandler { id: currentPhasorHover } ColumnLayout { anchors.fill: parent @@ -401,7 +424,7 @@ SurfacePanel { spacing: 6 Rectangle { width: 3; height: 13; radius: 2; color: preview.theme.accent } Label { - text: "VOLTAGE PHASOR" + text: "CURRENT PHASOR" color: preview.theme.textSoft font.family: preview.uiFont font.pixelSize: 9 @@ -410,7 +433,7 @@ SurfacePanel { } Item { Layout.fillWidth: true } Label { - text: preview.signalFrequency === 0 ? "V DC" : "V RMS" + text: preview.signalFrequency === 0 ? "A DC" : "A RMS" color: preview.theme.muted font.family: preview.uiFont font.pixelSize: 8 @@ -418,16 +441,18 @@ SurfacePanel { } Canvas { - id: voltagePhasorCanvas + id: currentPhasorCanvas Layout.fillWidth: true Layout.fillHeight: true Layout.minimumHeight: 100 onWidthChanged: requestPaint() onHeightChanged: requestPaint() - onPaint: preview.paintPhasor(voltagePhasorCanvas, preview.voltageModel) + onPaint: preview.paintPhasor(currentPhasorCanvas, preview.currentModel) } } } + + } Rectangle { diff --git a/apps/arstack_studio/qml/StudioActionMenu.qml b/apps/arstack_studio/qml/StudioActionMenu.qml new file mode 100644 index 000000000..62c4dc0df --- /dev/null +++ b/apps/arstack_studio/qml/StudioActionMenu.qml @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Compact Material-action popup shared by magnitude, angle and frequency. +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Menu { + id: actionMenu + property var theme + property string uiFont: "Inter" + popupType: Popup.Item + implicitWidth: 232 + topPadding: 5 + bottomPadding: 5 + leftPadding: 5 + rightPadding: 5 + spacing: 0 + font.family: uiFont + font.pixelSize: 11 + delegate: MenuItem { + id: actionItem + width: actionMenu.width - actionMenu.leftPadding - actionMenu.rightPadding + implicitHeight: actionMenu.theme.menuItemHeight + leftPadding: 9 + rightPadding: 10 + font.family: actionMenu.uiFont + font.pixelSize: 11 + contentItem: RowLayout { + spacing: 9 + Image { + visible: actionItem.icon.source.toString().length > 0 + source: actionItem.icon.source + sourceSize.width: 28 + sourceSize.height: 28 + Layout.preferredWidth: visible ? 17 : 0 + Layout.preferredHeight: 17 + opacity: actionItem.enabled ? 0.95 : 0.35 + } + Text { + text: actionItem.text + font: actionItem.font + color: !actionItem.enabled ? actionMenu.theme.muted2 + : actionItem.highlighted ? actionMenu.theme.text : actionMenu.theme.textSoft + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + Layout.fillWidth: true + } + } + background: Rectangle { + radius: actionMenu.theme.controlRadius + color: actionItem.highlighted ? actionMenu.theme.menuHover : "transparent" + border.width: actionItem.activeFocus ? 1 : 0 + border.color: actionMenu.theme.accent + } + } + background: Rectangle { + color: actionMenu.theme.menuSurface + radius: actionMenu.theme.menuRadius + border.width: 1 + border.color: actionMenu.theme.line + } +} diff --git a/apps/arstack_studio/qml/StudioMenuDivider.qml b/apps/arstack_studio/qml/StudioMenuDivider.qml new file mode 100644 index 000000000..c673ae18c --- /dev/null +++ b/apps/arstack_studio/qml/StudioMenuDivider.qml @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls + +MenuSeparator { + id: separator + property var theme + topPadding: 3 + bottomPadding: 3 + leftPadding: 10 + rightPadding: 10 + contentItem: Rectangle { + implicitHeight: 1 + color: separator.theme.line + } +} diff --git a/apps/arstack_studio/qml/StudioPtpSwitch.qml b/apps/arstack_studio/qml/StudioPtpSwitch.qml new file mode 100644 index 000000000..9b476072e --- /dev/null +++ b/apps/arstack_studio/qml/StudioPtpSwitch.qml @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Presentation-only PTP switch; checked is owned by device telemetry at call site. +import QtQuick +import QtQuick.Controls + +Switch { + id: control + property var theme + property string uiFont: "Inter" + text: "PTP" + hoverEnabled: true + font.family: uiFont + font.pixelSize: 11 + spacing: 9 + indicator: Rectangle { + implicitWidth: 46 + implicitHeight: 24 + x: control.leftPadding + y: (control.height - height) / 2 + radius: 12 + color: control.checked ? control.theme.greenSoft : control.theme.surface2 + border.width: 1 + border.color: control.checked ? control.theme.green : control.theme.line + 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: 120 } } + } + } + contentItem: Text { + text: control.text + font: control.font + color: control.enabled ? control.theme.text : control.theme.muted + leftPadding: control.indicator.width + control.spacing + verticalAlignment: Text.AlignVCenter + } +} diff --git a/apps/arstack_studio/qml/StudioTheme.qml b/apps/arstack_studio/qml/StudioTheme.qml index d9921adfe..1cb937802 100644 --- a/apps/arstack_studio/qml/StudioTheme.qml +++ b/apps/arstack_studio/qml/StudioTheme.qml @@ -45,4 +45,13 @@ QtObject { readonly property int controlHeight: 34 readonly property int panelRadius: 8 readonly property int controlRadius: 7 + // Shared Material-inspired desktop surface/interaction tokens. + readonly property color menuSurface: "#18222e" + readonly property color menuHover: "#253b51" + readonly property int menuRadius: 11 + readonly property int menuItemHeight: 33 + readonly property int spaceXs: 4 + readonly property int spaceSm: 8 + readonly property int spaceMd: 12 + readonly property int spaceLg: 16 } diff --git a/apps/arstack_studio/qml/StudioTimingTray.qml b/apps/arstack_studio/qml/StudioTimingTray.qml new file mode 100644 index 000000000..0da9537dc --- /dev/null +++ b/apps/arstack_studio/qml/StudioTimingTray.qml @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Main-window timing workflow. Shares the existing Advanced PTP profile and +// SmartSessionController/DeviceController authority; never invents lock state. +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtCore + +Rectangle { + id: tray + property var theme + property var controller + property var device + property var session + property string uiFont: "Inter" + property string monoFont: "Inter" + property bool compact: false + property int selectedRoleIndex: offlineSettings.lastRole + // Offline controls edit only a lab plan, never the real PTP subsystem. + Settings { + id: offlineSettings + category: "offline-ptp-lab" + property int lastRole: 0 + property int lastSynch: 0 + property bool previewPtp: false + } + readonly property bool boardOnline: Boolean(device && device.deviceVerified) + readonly property bool offlinePtpPreview: !boardOnline && offlineSettings.previewPtp + + readonly property var roleValues: ["SOURCE", "RECEIVER", "MONITOR"] + readonly property bool busy: Boolean(device && device.ptpStarting) + readonly property bool ptpReady: Boolean(session && device && device.deviceVerified + && device.ptpAvailable && !session.updatingFirmware) + readonly property string stateText: !boardOnline + ? (offlinePtpPreview ? "PTP PLAN · OFFLINE" : "OFFLINE LAB") + : !device.ptpAvailable ? "PTP UNAVAILABLE" + : busy ? (device.ptpStatus.indexOf("unverified") !== -1 ? "UNVERIFIED" : "PENDING") + : !device.ptpRunning ? "PTP OFF" + : device.ptpRole + " RUNNING" + readonly property color stateColor: !boardOnline ? theme.amber + : busy ? theme.amber + : device && device.ptpRunning ? theme.green : theme.muted + + function roleIndex(role) { + if (role === "RECEIVER") return 1 + if (role === "MONITOR") return 2 + return 0 + } + + function syncIndex(mode) { + if (mode === "FORCE_0") return 1 + if (mode === "FORCE_1_LOCAL") return 2 + if (mode === "FORCE_2_GLOBAL") return 3 + return 0 + } + + color: theme.panelAlt + radius: theme.panelRadius + border.width: 1 + border.color: theme.line + + ColumnLayout { + anchors.fill: parent + anchors.margins: tray.compact ? 9 : 11 + spacing: tray.compact ? 5 : 7 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Rectangle { width: 3; height: 14; radius: 2; color: tray.theme.accent } + Label { + text: "TIME SYNC / SV SIMULATION" + color: tray.theme.textSoft + font.family: tray.uiFont + font.pixelSize: 10 + font.weight: Font.DemiBold + font.letterSpacing: 0.45 + } + Item { Layout.fillWidth: true } + StateBadge { + theme: tray.theme + monoFont: tray.monoFont + state: tray.stateText + stateColor: tray.stateColor + } + } + + RowLayout { + Layout.fillWidth: true + spacing: tray.compact ? 7 : 10 + StudioPtpSwitch { + id: powerSwitch + objectName: "studioPtpSwitch" + theme: tray.theme + uiFont: tray.uiFont + checked: tray.boardOnline ? Boolean(tray.device.ptpRunning) : offlineSettings.previewPtp + enabled: !tray.busy && (!tray.boardOnline || tray.ptpReady) + ToolTip.visible: hovered + ToolTip.text: "Start/stop PTP with verified board feedback. Does not establish SV sample-phase lock." + onClicked: { + // Switch flips locally before asynchronous firmware evidence. + // Restore binding immediately, including on rejected requests. + const wantsRunning = checked + checked = Qt.binding(function() { + return tray.boardOnline ? Boolean(tray.device.ptpRunning) : offlineSettings.previewPtp + }) + if (!tray.boardOnline) { + offlineSettings.previewPtp = wantsRunning + if (tray.controller) + tray.controller.showMessage("Offline PTP plan only — no network packets or clock discipline.", false) + } else if (wantsRunning) { + if (tray.controller) tray.controller.startPtpRole(tray.roleValues[tray.selectedRoleIndex]) + } else if (tray.session && !tray.session.requestStopPtp() && tray.controller) { + tray.controller.showMessage("PTP stop was not accepted; inspect diagnostics.", true) + } + } + } + StudioDarkComboBox { + id: roleCombo + objectName: "studioPtpRole" + theme: tray.theme + uiFont: tray.uiFont + Layout.fillWidth: true + Layout.minimumWidth: 118 + model: ["Source", "Follower", "Monitor"] + currentIndex: tray.device && tray.device.ptpRunning + ? tray.roleIndex(tray.device.ptpRole) : tray.selectedRoleIndex + enabled: !tray.busy && (!tray.boardOnline + || (tray.ptpReady && !tray.device.ptpRunning)) + onActivated: { + tray.selectedRoleIndex = currentIndex + offlineSettings.lastRole = currentIndex + currentIndex = Qt.binding(function() { + return tray.device && tray.device.ptpRunning + ? tray.roleIndex(tray.device.ptpRole) : tray.selectedRoleIndex + }) + } + ToolTip.visible: hovered + ToolTip.text: "Source transmits PTP; Follower discovers an external GM; Monitor receives without disciplining." + } + Label { + text: "smpSynch" + color: tray.theme.muted + font.family: tray.uiFont + font.pixelSize: 9 + } + StudioDarkComboBox { + id: syncCombo + objectName: "studioSmpSynchMode" + theme: tray.theme + uiFont: tray.uiFont + Layout.fillWidth: true + Layout.minimumWidth: 100 + model: ["AUTO", "0 · No sync", "1 · Local (sim)", "2 · Global (sim)"] + currentIndex: tray.boardOnline + ? tray.syncIndex(tray.device.smpSynchMode) : offlineSettings.lastSynch + enabled: !tray.boardOnline || (tray.session && !tray.session.updatingFirmware) + ToolTip.visible: hovered + ToolTip.text: "1/2 change SV metadata only; they do not synchronize the sample clock." + onActivated: { + const requested = currentIndex + offlineSettings.lastSynch = requested + if (!tray.boardOnline) { + currentIndex = Qt.binding(function() { + return tray.boardOnline ? tray.syncIndex(tray.device.smpSynchMode) + : offlineSettings.lastSynch + }) + if (tray.controller) + tray.controller.showMessage("Offline smpSynch plan saved; no SV flag or clock changed.", false) + return + } + const accepted = tray.session && tray.session.requestSmpSynch(["AUTO", "0", "1", "2"][requested]) + currentIndex = Qt.binding(function() { + return tray.boardOnline ? tray.syncIndex(tray.device.smpSynchMode) : offlineSettings.lastSynch + }) + if (!accepted && tray.controller) + tray.controller.showMessage("SV smpSynch override rejected; inspect diagnostics.", true) + } + } + CalmButton { + theme: tray.theme + uiFont: tray.uiFont + text: "Details" + iconSource: Qt.resolvedUrl("../assets/material/settings.svg") + objectName: "studioTimingDetails" + implicitWidth: 66 + toolTipText: "PTP settings, timing evidence and diagnostics" + onClicked: if (tray.controller) tray.controller.openTimingSettings() + } + } + Label { + Layout.fillWidth: true + text: !tray.boardOnline + ? "OFFLINE PLAN ONLY · PTP and smpSynch selections are local; no PTP/SV packets sent. Board state wins at reconnect." + : tray.device.smpSynchSimulated + ? "SV smpSynch=" + tray.device.smpSynchValue + " · SIMULATED metadata — sample clock not proven locked." + : tray.device.smpSynchMeasured + ? "SV smpSynch=" + tray.device.smpSynchValue + " · board-reported evidence; sample phase needs independent verification." + : "SV smpSynch=" + tray.device.smpSynchValue + " · unqualified. PTP traffic alone does not prove SV synchronization." + color: tray.device && tray.device.smpSynchSimulated ? tray.theme.amber : tray.theme.muted + font.family: tray.uiFont + font.pixelSize: tray.compact ? 9 : 10 + elide: Text.ElideRight + ToolTip.visible: evidenceHover.hovered + ToolTip.text: text + HoverHandler { id: evidenceHover } + } + } +} diff --git a/apps/arstack_studio/qml/TelemetryDock.qml b/apps/arstack_studio/qml/TelemetryDock.qml index 61cd0331e..2b874382c 100644 --- a/apps/arstack_studio/qml/TelemetryDock.qml +++ b/apps/arstack_studio/qml/TelemetryDock.qml @@ -84,8 +84,8 @@ Rectangle { theme: telemetry.theme uiFont: telemetry.uiFont iconSource: telemetry.expanded - ? Qt.resolvedUrl("../assets/lucide/chevron-down.svg") - : Qt.resolvedUrl("../assets/lucide/chevron-up.svg") + ? Qt.resolvedUrl("../assets/material/keyboard_arrow_down.svg") + : Qt.resolvedUrl("../assets/material/keyboard_arrow_up.svg") onClicked: telemetry.expanded = !telemetry.expanded ToolTip.visible: hovered ToolTip.text: telemetry.expanded ? "Hide monitor details" : "Show monitor details" diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 032056bd6..9865562ad 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -112,8 +112,12 @@ SurfacePanel { Connections { target: smartSession function onReadyForLiveApply() { + // The current board SHOW response does not contain per-channel + // amplitude/phase/quality. Never silently claim readback or + // overwrite a board with a restored/offline local draft. controller.profileDirty = false - controller.applyAllSignals() + controller.pendingApply = true + controller.showMessage("Board ready. Review your local values, then Apply to board.", false) } function onStateChanged() { if (ribbon.firmwareGateActive) @@ -244,6 +248,10 @@ SurfacePanel { controller.showMessage(ribbon.startReason(), false) return } + if (controller.pendingApply) { + controller.showMessage("Apply the local scenario before starting physical SMV output.", true) + return + } if (!smartSession.requestStart()) controller.showMessage(smartSession.statusText, true) } diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index b74c177f0..13518af84 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -20,12 +20,17 @@ #include #include #include +#include #include +#include +#include #include #include #include #include +#include +#include #include #ifndef ARSTACK_STUDIO_VERSION @@ -40,6 +45,73 @@ bool hasArgument(const int argc, char* argv[], const std::string_view wanted) { return false; } + +struct UiBounds { + QRectF area; + bool visible{false}; +}; + +[[nodiscard]] UiBounds findUiBounds(QQuickWindow* window, const char* name) { + auto* item = window->findChild(QString::fromLatin1(name)); + if (item == nullptr || item->width() < 1 || item->height() < 1) return {}; + return {QRectF{item->mapToScene(QPointF{0, 0}), QSizeF{item->width(), item->height()}}, + item->isVisible()}; +} + +[[nodiscard]] bool checkOperatorViewport(QQuickWindow* window, const QSize size) { + constexpr qreal tolerance = 2.0; + const QRectF viewport{QPointF{0, 0}, QSizeF{size}}; + const auto bar = findUiBounds(window, "studioWorkflowBar"); + const auto surface = findUiBounds(window, "studioInjectionSurface"); + const auto voltage = findUiBounds(window, "studioVoltageMatrix"); + const auto current = findUiBounds(window, "studioCurrentMatrix"); + const auto tray = findUiBounds(window, "studioTimingTray"); + const auto quality = findUiBounds(window, "studioQualityRow"); + const std::array, 6> areas{{ + {"toolbar", bar}, {"injection workspace", surface}, + {"voltage matrix", voltage}, {"current matrix", current}, + {"PTP timing tray", tray}, {"quality editor", quality} + }}; + for (const auto& [label, item] : areas) { + if (!item.visible || item.area.width() < 25 || item.area.height() < 15 || + !viewport.adjusted(-tolerance, -tolerance, tolerance, tolerance) + .contains(item.area.adjusted(tolerance, tolerance, + -tolerance, -tolerance))) { + qCritical().noquote() << "Studio UI viewport violation" << size << label + << "bounds=" << item.area << "viewport=" << viewport; + return false; + } + } + if (!(voltage.area.right() <= current.area.left() + tolerance + && tray.area.top() >= voltage.area.bottom() - tolerance + && quality.area.top() >= tray.area.bottom() - tolerance + && quality.area.bottom() <= surface.area.bottom() + tolerance)) { + qCritical().noquote() << "Studio UI panel order/overlap violation" << size + << voltage.area << current.area << tray.area << quality.area; + return false; + } + const std::array controls{{ + "studioPtpSwitch", "studioPtpRole", "studioSmpSynchMode", "studioTimingDetails" + }}; + qreal previousRight = tray.area.left(); + for (const char* name : controls) { + const auto control = findUiBounds(window, name); + if (!control.visible || control.area.width() < 35 || + control.area.left() + tolerance < previousRight || + !tray.area.adjusted(-tolerance, -tolerance, tolerance, tolerance) + .contains(control.area.adjusted(tolerance, tolerance, + -tolerance, -tolerance))) { + qCritical().noquote() << "Studio timing controls clipped or overlapped" + << size << name << control.area << tray.area; + return false; + } + previousRight = control.area.right(); + } + qInfo().noquote() << "Studio viewport geometry: PASS" << size + << "toolbar=" << bar.area << "PTP=" << tray.area; + return true; +} + class PrimaryWindowCloseFilter final : public QObject { public: explicit PrimaryWindowCloseFilter(QCoreApplication* app) @@ -436,6 +508,7 @@ int main(int argc, char* argv[]) { } const bool lifecycleCheck = hasArgument(argc, argv, "--check-app-lifecycle"); + const bool operatorUiCheck = hasArgument(argc, argv, "--check-operator-ui"); QGuiApplication app(argc, argv); app.setQuitOnLastWindowClosed(true); @@ -521,6 +594,87 @@ int main(int argc, char* argv[]) { }, Qt::DirectConnection); + + if (operatorUiCheck) { + if (mainWindow == nullptr) { + qCritical().noquote() << "Studio UI geometry test: no main window"; + return 19; + } + // Exercise desktop, supported minimum, and extended desktop layout on + // the real rendering engine. This branch never touches a serial port. + constexpr std::array sizes{{QSize{1240, 760}, QSize{960, 620}, + QSize{1440, 900}}}; + // Virtual Qt platforms can resize to arbitrary logical viewports, + // but cannot support grabWindow. One native 960x620 pass captures + // actual Windows pixels separately without requesting an oversized + // desktop window. + const bool nativeCapture = !qEnvironmentVariableIsEmpty("ARSTACK_STUDIO_QA_NATIVE_ONLY"); + const int caseCount = nativeCapture ? 1 : static_cast(sizes.size()); + const auto caseSize = [sizes, nativeCapture](const int step) { + return nativeCapture ? sizes[1] : sizes.at(static_cast(step)); + }; + const QString screenshots = qEnvironmentVariable("ARSTACK_STUDIO_QA_DIR"); + if (!screenshots.isEmpty() && !QDir{}.mkpath(screenshots)) { + qCritical().noquote() << "Cannot create GUI evidence directory" << screenshots; + return 20; + } + auto step = std::make_shared(0); + auto resizeRetries = std::make_shared(0); + auto* uiTimer = new QTimer(&app); + uiTimer->setInterval(400); + mainWindow->resize(caseSize(0)); + QObject::connect(uiTimer, &QTimer::timeout, &app, + [&, step, resizeRetries, uiTimer, caseSize, caseCount, screenshots] { + const QSize target = caseSize(*step); + if (mainWindow->size() != target) { + // CI's virtual Windows desktop can clamp large native + // windows. Diagnose that instead of spinning 15 seconds. + if (++*resizeRetries > 5) { + qCritical().noquote() << "Studio UI cannot reach target viewport:" + << "requested=" << target << "actual=" << mainWindow->size() + << "platform=" << QGuiApplication::platformName(); + uiTimer->stop(); + QCoreApplication::exit(24); + return; + } + mainWindow->resize(target); + return; + } + *resizeRetries = 0; + if (!checkOperatorViewport(mainWindow, target)) { + uiTimer->stop(); + QCoreApplication::exit(21); + return; + } + if (!screenshots.isEmpty()) { + const QString path = QDir{screenshots}.filePath( + QStringLiteral("studio-%1x%2.png").arg(target.width()).arg(target.height())); + const QImage frame = mainWindow->grabWindow(); + if (frame.isNull() || !frame.save(path)) { + qCritical().noquote() << "GUI evidence capture failed:" << path; + uiTimer->stop(); + QCoreApplication::exit(22); + return; + } + } + ++*step; + if (*step == caseCount) { + uiTimer->stop(); + qInfo().noquote() << "Studio operator layout acceptance: PASS"; + QCoreApplication::exit(0); + return; + } + mainWindow->resize(caseSize(*step)); + }); + uiTimer->start(); + QTimer::singleShot(15000, &app, [&app, uiTimer] { + if (uiTimer->isActive()) { + qCritical().noquote() << "Studio UI geometry test timed out"; + QCoreApplication::exit(23); + } + }); + } + if (lifecycleCheck) { if (mainWindow == nullptr) { qCritical().noquote() << "Application lifecycle regression: main window was not created."; diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py new file mode 100644 index 000000000..68fe8e9b6 --- /dev/null +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Fast structural gates for the native operator GUI (before the Qt build). + +These assert UI and state-ownership intent; headless QML launch/Windows +interactive review remain separate acceptance steps. +""" +from pathlib import Path + +STUDIO = Path(__file__).resolve().parents[1] +QML = STUDIO / "qml" + + +def source(name: str) -> str: + return (QML / name).read_text(encoding="utf-8") + + +def require(condition: bool, message: str) -> None: + if not condition: + raise SystemExit(f"Studio workspace contract failed: {message}") + + +def ordered(data: str, first: str, second: str, reason: str) -> None: + require(first in data and second in data and data.index(first) < data.index(second), reason) + + +def main() -> None: + main_qml = source("Main.qml") + preview = source("SignalPreview.qml") + tray = source("StudioTimingTray.qml") + expert = source("PtpExpertPanel.qml") + config = source("ConfigurationHub.qml") + matrix = source("SignalMatrix.qml") + menu = source("StudioActionMenu.qml") + cmake = (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8") + + ordered(main_qml, "id: voltageMatrix", "id: currentMatrix", + "voltage must precede current in the injection table") + ordered(preview, "id: voltagePhasorCanvas", "id: currentPhasorCanvas", + "voltage must precede current in the phasor view") + ordered(preview, "drawWaveGroup(preview.voltageModel, voltageY, false)", + "drawWaveGroup(preview.currentModel, currentY, true)", + "voltage waveform must be above current") + require("lane === 0 ? voltageY : currentY" in preview, + "waveform baselines must follow voltage/current lane order") + require("StudioTimingTray {" in main_qml and "id: frequencyMenu" in main_qml, + "main window must expose timing and frequency context controls") + require("function startPtpRole(role) { return timingPanel.startRole(role) }" in config + and "return profilePanel.startPtpRole(role)" in main_qml + and "requestStartPtpConfigured(role, panel.ptpProfile())" in expert, + "main PTP must reuse the existing verified Advanced profile") + require("checked = Qt.binding(function()" in tray + and "tray.session.requestStopPtp()" in tray + and "tray.session.requestSmpSynch([\"AUTO\", \"0\", \"1\", \"2\"][requested])" in tray + and "if (!tray.boardOnline)" in tray, + "main PTP/smpSynch must restore device-owned state after user intent") + require("SIMULATED metadata" in tray and "sample phase needs independent verification" in tray, + "SV simulation must never masquerade as independently proven lock") + require(matrix.count("Qt.Key_Menu") == 2 and matrix.count("Qt.Key_F10") == 2 + and matrix.count("signalRow.openFieldMenu(") == 4, + "both magnitude and angle menus must support mouse and keyboard") + require("1.75" in (STUDIO.parent.parent / ".github/workflows/arstack-studio-windows-lifecycle.yml").read_text(encoding="utf-8"), + "reuse Windows executable for 100%, 150% and 175% DPI acceptance") + workflow = source("WorkflowBar.qml") + ribbon = source("ModernRibbon.qml") + cmake_icons = (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8") + require("scenarioSnapshot()" in main_qml and "restoreLocalScenario()" in main_qml + and "requestApplyDraft()" in main_qml and "offlinePreviewRunning" in main_qml, + "offline-first scenario needs persistence, explicit Apply and non-wire preview") + require('saveScenarioTimer.stop()' in main_qml + and 'scenarioSettings.lastScenario = JSON.stringify(root.scenarioSnapshot())' in main_qml, + "closing immediately after an edit must flush the local draft before teardown") + require('typeof data.hz !== "number"' in main_qml + and 'typeof row[0] !== "number"' in main_qml + and 'typeof row[1] !== "number"' in main_qml + and 'typeof row[3] !== "number"' in main_qml, + "saved scenario restoration must reject JSON null/string coercion and nonfinite values") + require("controller.pendingApply = true" in workflow + and "Apply the local scenario before starting" in workflow + and "controller.applyAllSignals()" not in workflow, + "reconnect must not silently transmit an offline draft") + require("offline-ptp-lab" in tray and "no PTP/SV packets sent" in tray, + "PTP/smpSynch offline controls must be plans only") + require("assets/material/smv_injector.svg" in cmake_icons + and "assets/MATERIAL_ICONS_LICENSE.txt" in cmake_icons + and "../assets/material/smv_injector.svg" in ribbon, + "user-provided injector mark and licensed Material icon resources required") + require("MAGNITUDE ·" not in matrix and "ANGLE ·" not in matrix + and "Equal magnitudes A/B/C" in matrix + and "Balance angles · 120°" in matrix, + "context actions must stay compact, descriptive and grouped") + require(matrix.count("StudioActionMenu {") == 2 + and "id: magnitudeMenu" in matrix and "id: phaseMenu" in matrix + and "popupType: Popup.Item" in menu, + "QML-styled wide context menus must replace platform-native renderers") + for component in ("StudioActionMenu", "StudioMenuDivider", "StudioPtpSwitch", "StudioTimingTray"): + require(f"qml/{component}.qml" in cmake, + f"{component} must be registered in the Qt QML module") + + require("--check-operator-ui" in (STUDIO / "src/main.cpp").read_text(encoding="utf-8") + and "studio-ui-evidence" in (STUDIO.parent.parent / ".github/workflows/arstack-studio-windows-lifecycle.yml").read_text(encoding="utf-8"), + "Windows geometry/evidence gate must run from existing lifecycle build") + print("Studio operator workspace contract: PASS") + + +if __name__ == "__main__": + main()