Compare commits
4 Commits
main
..
7e76cffc88
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e76cffc88 | |||
| f24dd89e8e | |||
| bbe7baea5b | |||
| b338eedb13 |
@@ -5,9 +5,6 @@ __pycache__/
|
||||
*.pyd
|
||||
*.so
|
||||
*.a
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Virtual environment
|
||||
.venv/
|
||||
@@ -17,11 +14,9 @@ env/
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
.qtcreator/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
docs
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -40,21 +35,3 @@ dist/
|
||||
*.spec
|
||||
*.icns
|
||||
*.ico
|
||||
|
||||
# Runtime scratch data
|
||||
tmp/
|
||||
|
||||
# Superpowers brainstorming visual companion
|
||||
.superpowers/
|
||||
.llm-wiki/
|
||||
.agents/
|
||||
.pi/
|
||||
|
||||
# Planning / personal docs (root level)
|
||||
CLAUDE.md
|
||||
LEARNINGS.md
|
||||
MIGRATION.md
|
||||
AGENTS.md
|
||||
CONVENTIONS.md
|
||||
specs/
|
||||
graphify-out/
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import type QmlProjectModel 1.0
|
||||
|
||||
QmlProject {
|
||||
name: "ISC"
|
||||
version: "1.0"
|
||||
mainFile: "src/pygui/ISC/Main.qml"
|
||||
|
||||
dependencies {
|
||||
module: "QtQuick"
|
||||
minimumVersion: "6.0.0"
|
||||
}
|
||||
|
||||
importPaths: [
|
||||
"src/pygui"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
|
||||
// ===== Home Workspace Shell =====
|
||||
Rectangle {
|
||||
id: root
|
||||
anchors.fill: parent
|
||||
color: Theme.pageBackground
|
||||
clip: true
|
||||
border.color: Theme.outerFrameBorder
|
||||
border.width: Theme.borderStrong
|
||||
|
||||
// ===== Navigation Model =====
|
||||
// Primary navigation shown in the left rail.
|
||||
property var sideActions: ["DETECT WAFER", "READ MEMORY", "OPEN CSV IN EXCEL", "ERASE MEMORY", "IMPORT DATA", "STORED DATA"]
|
||||
|
||||
// ===== Footer Tab Model =====
|
||||
// Footer tabs drive the active workspace section.
|
||||
property var bottomTabs: ["Status", "Graph", "Data", "Wafer Map", "Compare", "Split", "Settings", "About"]
|
||||
|
||||
// ===== View State =====
|
||||
property int selectedTabIndex: 0
|
||||
property int selectedSideActionIndex: -1 // nothing active on startup
|
||||
|
||||
// ===== Main Two-Column Layout =====
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
// ===== Left Action Rail =====
|
||||
// Left control rail.
|
||||
Rectangle {
|
||||
id: sideRail
|
||||
Layout.preferredWidth: Theme.sideRailWidth
|
||||
Layout.fillHeight: true
|
||||
color: Theme.sideRailBackground
|
||||
border.color: Theme.workspaceBorder
|
||||
border.width: Theme.borderThin
|
||||
|
||||
readonly property int actionCount: root.sideActions.length
|
||||
readonly property real computedButtonHeight: Math.min(Theme.sideButtonHeight, (height - (Theme.panelPadding * 2) - (Theme.sideRailSpacing * Math.max(0, actionCount - 1))) / Math.max(1, actionCount))
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.panelPadding
|
||||
spacing: Theme.sideRailSpacing
|
||||
|
||||
Repeater {
|
||||
model: root.sideActions
|
||||
|
||||
Button {
|
||||
id: control
|
||||
text: modelData
|
||||
property bool isActive: index === root.selectedSideActionIndex
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Math.max(Theme.sideButtonMinHeight, sideRail.computedButtonHeight)
|
||||
hoverEnabled: true
|
||||
onClicked: {
|
||||
root.selectedSideActionIndex = index
|
||||
root.selectedTabIndex = 0 // always jump to Status tab
|
||||
if (index === 0) deviceController.detectWafer()
|
||||
else if (index === 1) deviceController.readMemoryAsync()
|
||||
else if (index === 2) deviceController.openCsvFile()
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: {
|
||||
if (control.down) {
|
||||
return Theme.buttonPressed;
|
||||
}
|
||||
if (control.isActive) {
|
||||
return Theme.sideActiveBackground;
|
||||
}
|
||||
return "transparent";
|
||||
}
|
||||
border.color: control.isActive ? Theme.cardBorder : "transparent"
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: 3
|
||||
visible: control.isActive
|
||||
color: Theme.primaryAccent
|
||||
radius: Theme.radiusXs
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Text {
|
||||
text: control.text
|
||||
color: control.isActive ? Theme.headingColor : Theme.bodyColor
|
||||
font.bold: control.isActive
|
||||
font.pixelSize: 18
|
||||
wrapMode: Text.WordWrap
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main workspace and footer navigation.
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: Theme.workspaceBackground
|
||||
border.color: Theme.workspaceBorder
|
||||
border.width: Theme.borderThin
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.mainAreaPadding
|
||||
spacing: Theme.rightPaneGap
|
||||
|
||||
// ===== Active Tab Content Area =====
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.minimumHeight: 220
|
||||
color: Theme.responseBackground
|
||||
border.color: Theme.responseBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusMd
|
||||
|
||||
StackLayout {
|
||||
anchors.fill: parent
|
||||
currentIndex: root.selectedTabIndex
|
||||
|
||||
// ===== Tab Content Routing =====
|
||||
Repeater {
|
||||
model: root.bottomTabs
|
||||
|
||||
Item {
|
||||
property string tabName: modelData
|
||||
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
active: parent.tabName === "Settings"
|
||||
source: parent.tabName === "Settings" ? "Tabs/SettingsTab.qml" : ""
|
||||
}
|
||||
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
active: parent.tabName === "Status"
|
||||
source: parent.tabName === "Status" ? "Tabs/StatusTab.qml" : ""
|
||||
}
|
||||
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
active: parent.tabName === "Data"
|
||||
source: parent.tabName === "Data" ? "Tabs/DataTab.qml" : ""
|
||||
}
|
||||
|
||||
Label {
|
||||
anchors.centerIn: parent
|
||||
visible: parent.tabName !== "Settings" && parent.tabName !== "Status" && parent.tabName !== "Data"
|
||||
text: parent.tabName + " content"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: 20
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the tab strip evenly distributed across the footer.
|
||||
// ===== Footer Tab Strip =====
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Theme.tabBarHeight + (Theme.tabBarPadding * 2)
|
||||
color: Theme.tabBarBackground
|
||||
border.color: Theme.workspaceBorder
|
||||
border.width: Theme.borderThin
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.tabBarPadding
|
||||
spacing: Theme.tabSpacing
|
||||
|
||||
Repeater {
|
||||
model: root.bottomTabs
|
||||
|
||||
Button {
|
||||
id: botTabBtn
|
||||
property bool isActive: index === root.selectedTabIndex
|
||||
|
||||
text: modelData
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Theme.tabBarHeight
|
||||
Layout.minimumWidth: Theme.tabButtonMinWidth
|
||||
onClicked: root.selectedTabIndex = index
|
||||
hoverEnabled: true
|
||||
|
||||
background: Rectangle {
|
||||
color: botTabBtn.isActive ? Theme.tabActiveBackground : (botTabBtn.hovered ? Theme.tabHoverBackground : Theme.tabBackground)
|
||||
border.color: Theme.tabBorder
|
||||
border.width: 1
|
||||
radius: Theme.tabRadius
|
||||
}
|
||||
|
||||
contentItem: Text {
|
||||
text: botTabBtn.text
|
||||
color: botTabBtn.isActive ? Theme.tabActiveText : Theme.tabText
|
||||
font.pixelSize: Theme.tabFontSize
|
||||
font.bold: botTabBtn.isActive
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import ISC
|
||||
// ===== App Window Shell =====
|
||||
Window {
|
||||
// ===== Window Dimensions =====
|
||||
width: 1920
|
||||
height: 1080
|
||||
width: 1400
|
||||
height: 820
|
||||
minimumWidth: 1100
|
||||
minimumHeight: 700
|
||||
visible: true
|
||||
@@ -0,0 +1,153 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
|
||||
// ===== Data Tab =====
|
||||
// Displays parsed temperature data in a scrollable table.
|
||||
// Column 0 = Row index, remaining columns = Sensor1, Sensor2, ...
|
||||
Item {
|
||||
id: root
|
||||
anchors.fill: parent
|
||||
|
||||
readonly property int rowCount: deviceController.dataRowCount
|
||||
|
||||
// Re-layout columns whenever the model is reset
|
||||
Connections {
|
||||
target: deviceController
|
||||
function onParsedDataReady(result) {
|
||||
if (result && result.success) {
|
||||
dataTable.forceLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Empty State =====
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: 16
|
||||
visible: root.rowCount === 0
|
||||
|
||||
Label {
|
||||
text: "No Data"
|
||||
font.pixelSize: 24
|
||||
font.bold: true
|
||||
color: Theme.headingColor
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "Read and parse wafer data to see temperature readings here."
|
||||
font.pixelSize: 14
|
||||
color: Theme.bodyColor
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Data Table =====
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.panelPadding
|
||||
spacing: Theme.rightPaneGap
|
||||
visible: root.rowCount > 0
|
||||
|
||||
// --- Header row ---
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.rightPaneGap
|
||||
|
||||
Label {
|
||||
text: "Temperature Data"
|
||||
font.pixelSize: 18
|
||||
font.bold: true
|
||||
color: Theme.headingColor
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Label {
|
||||
text: deviceController.dataRowCount + " rows × " +
|
||||
deviceController.dataColCount + " sensors"
|
||||
font.pixelSize: 13
|
||||
color: Theme.bodyColor
|
||||
}
|
||||
}
|
||||
|
||||
// --- Table container ---
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: Theme.panelBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: Theme.borderThin
|
||||
radius: Theme.radiusSm
|
||||
clip: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 1
|
||||
spacing: 0
|
||||
|
||||
// Column header strip
|
||||
HorizontalHeaderView {
|
||||
id: headerView
|
||||
Layout.fillWidth: true
|
||||
syncView: dataTable
|
||||
clip: true
|
||||
|
||||
delegate: Rectangle {
|
||||
implicitHeight: 28
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
// 'display' is the Qt.DisplayRole value from headerData()
|
||||
text: display !== undefined ? display : ""
|
||||
color: Theme.headingColor
|
||||
font.pixelSize: 11
|
||||
font.bold: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Data rows
|
||||
TableView {
|
||||
id: dataTable
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
model: deviceController.dataModel
|
||||
clip: true
|
||||
reuseItems: true
|
||||
|
||||
columnWidthProvider: function(col) {
|
||||
if (col === 0) return 52 // row index column
|
||||
const sensorCols = Math.max(1, dataTable.columns - 1)
|
||||
const available = dataTable.width - 52
|
||||
return Math.max(52, Math.min(90, available / sensorCols))
|
||||
}
|
||||
|
||||
rowHeightProvider: function() { return 24 }
|
||||
|
||||
delegate: Rectangle {
|
||||
color: row % 2 === 0 ? Theme.panelBackground : Theme.cardBackground
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: display !== undefined ? display : ""
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: 11
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
ScrollBar.horizontal: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import Qt.labs.qmlmodels
|
||||
import ISC
|
||||
@@ -25,11 +24,9 @@ Dialog {
|
||||
property int selectedRow: -1
|
||||
property int metadataEditRow: -1
|
||||
readonly property string masterTypeFieldText: {
|
||||
if (metadataEditRow < 0)
|
||||
return "";
|
||||
if (metadataEditRow < 0) return "";
|
||||
const rec = tableModel[metadataEditRow];
|
||||
if (!rec)
|
||||
return "";
|
||||
if (!rec) return "";
|
||||
return rec.masterType ?? "";
|
||||
}
|
||||
|
||||
@@ -45,7 +42,7 @@ Dialog {
|
||||
chamberField.text = rec.chamber ?? "";
|
||||
if (!chamberField.text && settingsModel.chamberId)
|
||||
chamberField.text = settingsModel.chamberId;
|
||||
notesField.text = rec.notes ?? "";
|
||||
notesField.text = rec.notes ?? "";
|
||||
filePathLabel.text = rec.fileName ?? "";
|
||||
metadataEditDialog.open();
|
||||
}
|
||||
@@ -79,7 +76,7 @@ Dialog {
|
||||
readonly property var normalizedRows: {
|
||||
// Touch settingsModel.masters so QML tracks this dependency.
|
||||
const _ = settingsModel.masters;
|
||||
return (tableModel || []).map(function (row) {
|
||||
return (tableModel || []).map(function(row) {
|
||||
row = row || {};
|
||||
// settingsModel is the authoritative source; fall back to sidecar masterType.
|
||||
const masterType = root.masterFamilyForPath(row.fileName ?? "") || row.masterType || "";
|
||||
@@ -116,7 +113,7 @@ Dialog {
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
font.bold: true
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.pixelSize: 13
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,12 +129,6 @@ Dialog {
|
||||
color: Theme.fieldBackground
|
||||
border.width: dialogTextInput.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||
border.color: dialogTextInput.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
Behavior on border.color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +163,7 @@ Dialog {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 16
|
||||
text: root.title
|
||||
font.pixelSize: Theme.fontLg
|
||||
font.pixelSize: 15
|
||||
font.bold: true
|
||||
color: Theme.headingColor
|
||||
}
|
||||
@@ -182,17 +173,10 @@ Dialog {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
text: "✕"
|
||||
implicitWidth: 32
|
||||
implicitHeight: 32
|
||||
onClicked: root.close()
|
||||
contentItem: IconImage {
|
||||
source: "../icons/x.svg"
|
||||
width: 14; height: 14
|
||||
sourceSize.width: 14
|
||||
sourceSize.height: 14
|
||||
color: Theme.buttonNeutralText
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +221,7 @@ Dialog {
|
||||
text: file_browser.currentDirectory
|
||||
color: Theme.fieldText
|
||||
elide: Text.ElideMiddle
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.pixelSize: 13
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,7 +242,7 @@ Dialog {
|
||||
anchors.centerIn: parent
|
||||
text: root.headerTitles[index] ?? ""
|
||||
font.bold: true
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.pixelSize: 12
|
||||
color: Theme.headingColor
|
||||
}
|
||||
}
|
||||
@@ -345,7 +329,7 @@ Dialog {
|
||||
const mt = root.normalizedRows[row]?.masterType ?? "";
|
||||
return mt || "—";
|
||||
}
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.pixelSize: 14
|
||||
font.bold: true
|
||||
color: {
|
||||
const mt = root.normalizedRows[row]?.masterType ?? "";
|
||||
@@ -362,7 +346,7 @@ Dialog {
|
||||
sourceComponent: Button {
|
||||
hoverEnabled: true
|
||||
text: "Edit…"
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.pixelSize: 12
|
||||
onClicked: root.openMetadataEditor(row)
|
||||
|
||||
background: Rectangle {
|
||||
@@ -377,7 +361,7 @@ Dialog {
|
||||
color: Theme.buttonNeutralText
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.pixelSize: 12
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -404,7 +388,7 @@ Dialog {
|
||||
return "";
|
||||
}
|
||||
elide: Text.ElideRight
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.pixelSize: 13
|
||||
color: Theme.bodyColor
|
||||
}
|
||||
}
|
||||
@@ -425,12 +409,12 @@ Dialog {
|
||||
return record.notes ?? "";
|
||||
}
|
||||
elide: Text.ElideRight
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.pixelSize: 13
|
||||
color: Theme.bodyColor
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: column !== 6
|
||||
onClicked: {
|
||||
@@ -472,7 +456,7 @@ Dialog {
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
text: root.selectedPath
|
||||
elide: Text.ElideLeft
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.pixelSize: 13
|
||||
color: root.selectedPath ? Theme.fieldText : Theme.fieldPlaceholder
|
||||
}
|
||||
}
|
||||
@@ -520,7 +504,7 @@ Dialog {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 16
|
||||
text: metadataEditDialog.title
|
||||
font.pixelSize: Theme.fontLg
|
||||
font.pixelSize: 15
|
||||
font.bold: true
|
||||
color: Theme.headingColor
|
||||
}
|
||||
@@ -546,7 +530,7 @@ Dialog {
|
||||
maximumLineCount: 3
|
||||
elide: Text.ElideRight
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
Label {
|
||||
@@ -1,10 +1,8 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Dialogs
|
||||
import ISC
|
||||
import ISC.Tabs.components
|
||||
|
||||
Item {
|
||||
id: root
|
||||
@@ -29,7 +27,8 @@ Item {
|
||||
}
|
||||
|
||||
// ===== Settings Data Helpers =====
|
||||
// ListModel for master families defined inline in the ColumnLayout below
|
||||
readonly property var masterFamilies: ["A", "B", "C", "D", "E", "F", "P", "X", "Z"]
|
||||
|
||||
function masterPath(family) {
|
||||
const table = settingsModel.masters;
|
||||
return table && table[family] ? table[family] : "";
|
||||
@@ -59,7 +58,7 @@ Item {
|
||||
label: Label {
|
||||
text: settingsGroup.title
|
||||
color: Theme.panelTitleText
|
||||
font.pixelSize: Theme.fontLg
|
||||
font.pixelSize: 16
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
@@ -76,12 +75,6 @@ Item {
|
||||
color: Theme.fieldBackground
|
||||
border.width: textInput.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||
border.color: textInput.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
Behavior on border.color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,37 +107,25 @@ Item {
|
||||
x: toggle.leftPadding
|
||||
y: parent.height / 2 - height / 2
|
||||
radius: Theme.radiusXs
|
||||
color: toggle.checked ? Theme.primaryAccent : "transparent"
|
||||
color: toggle.checked ? Theme.primaryAccent : Theme.fieldBackground
|
||||
border.width: Theme.borderThin
|
||||
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
Behavior on border.color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
|
||||
IconImage {
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
source: "icons/check.svg"
|
||||
width: 12; height: 12
|
||||
sourceSize.width: 12
|
||||
sourceSize.height: 12
|
||||
color: Theme.panelBackground
|
||||
opacity: toggle.checked ? 1.0 : 0.0
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
width: 9
|
||||
height: 9
|
||||
radius: Theme.radiusXs
|
||||
visible: toggle.checked
|
||||
color: Theme.fieldBackground
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Text {
|
||||
text: toggle.text
|
||||
color: Theme.headingColor
|
||||
color: Theme.checkboxText
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
leftPadding: toggle.indicator.width + toggle.spacing
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +155,6 @@ Item {
|
||||
anchors.top: settingsScroll.top
|
||||
anchors.right: settingsScroll.right
|
||||
anchors.bottom: settingsScroll.bottom
|
||||
anchors.leftMargin: 6
|
||||
policy: ScrollBar.AsNeeded
|
||||
|
||||
contentItem: Rectangle {
|
||||
@@ -190,19 +170,16 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Gap reserved between the centered content and the right-edge scrollbar.
|
||||
readonly property int scrollGap: 100
|
||||
|
||||
ColumnLayout {
|
||||
id: container
|
||||
width: Math.min(settingsScroll.availableWidth - settingsScroll.scrollGap, Theme.settingsPanelMaxWidth)
|
||||
width: Math.min(settingsScroll.availableWidth, Theme.settingsPanelMaxWidth)
|
||||
x: (settingsScroll.availableWidth - width) / 2
|
||||
spacing: Theme.settingsSectionSpacing
|
||||
|
||||
// ===== Page Title =====
|
||||
Label {
|
||||
text: "Settings"
|
||||
font.pixelSize: Theme.font3xl
|
||||
font.pixelSize: 30
|
||||
font.bold: true
|
||||
color: Theme.headingColor
|
||||
}
|
||||
@@ -244,7 +221,7 @@ Item {
|
||||
Layout.preferredWidth: 90
|
||||
Layout.preferredHeight: Theme.settingsButtonHeight
|
||||
onClicked: {
|
||||
settingsModel.ChamberId = chamberField.text;
|
||||
settingsModel.setChamberId(chamberField.text);
|
||||
settingsModel.saveSettings();
|
||||
}
|
||||
}
|
||||
@@ -260,24 +237,11 @@ Item {
|
||||
anchors.fill: parent
|
||||
spacing: Theme.settingsGridSpacing
|
||||
|
||||
ListModel {
|
||||
id: masterModel
|
||||
ListElement { letter: "A" }
|
||||
ListElement { letter: "B" }
|
||||
ListElement { letter: "C" }
|
||||
ListElement { letter: "D" }
|
||||
ListElement { letter: "E" }
|
||||
ListElement { letter: "F" }
|
||||
ListElement { letter: "P" }
|
||||
ListElement { letter: "X" }
|
||||
ListElement { letter: "Z" }
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: masterModel
|
||||
model: root.masterFamilies
|
||||
|
||||
RowLayout {
|
||||
property string family: model.letter
|
||||
property string family: modelData
|
||||
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.settingsRowSpacing
|
||||
@@ -328,7 +292,7 @@ Item {
|
||||
SettingsToggle {
|
||||
text: "Reverse Z Wafer"
|
||||
checked: settingsModel.reverseZWafer
|
||||
onToggled: settingsModel.reverseZWafer = checked
|
||||
onToggled: settingsModel.setReverseZWafer(checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -366,7 +330,7 @@ Item {
|
||||
width: 22
|
||||
height: 22
|
||||
radius: height / 2
|
||||
color: Theme.toggleThumb
|
||||
color: "white"
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
x: Theme.isDarkMode ? parent.width - width - 3 : 3
|
||||
|
||||
@@ -393,7 +357,7 @@ Item {
|
||||
Label {
|
||||
text: Theme.isDarkMode ? "Dark" : "Light"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.pixelSize: 13
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
Behavior on color {
|
||||
@@ -452,9 +416,12 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
Label {
|
||||
anchors.centerIn: parent
|
||||
spacing: 4
|
||||
text: "↓ Scroll for more"
|
||||
font.pixelSize: 13
|
||||
font.bold: true
|
||||
color: Theme.bodyColor
|
||||
opacity: (settingsScroll.ScrollBar.vertical.position <= 0 && settingsScroll.contentHeight > settingsScroll.height) ? (scrollHintTimer.running ? 1 : 0) : 0
|
||||
|
||||
Behavior on opacity {
|
||||
@@ -462,22 +429,6 @@ Item {
|
||||
duration: 400
|
||||
}
|
||||
}
|
||||
|
||||
IconImage {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
source: "icons/chevron-down.svg"
|
||||
width: 14; height: 14
|
||||
sourceSize.width: 14
|
||||
sourceSize.height: 14
|
||||
color: Theme.bodyColor
|
||||
}
|
||||
Label {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Scroll for more"
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
color: Theme.bodyColor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls
|
||||
import ISC
|
||||
|
||||
// ===== Status Tab =====
|
||||
// Blank on startup. Fills in after Detect Wafer fires from the left rail.
|
||||
ColumnLayout {
|
||||
id: root
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.panelPadding
|
||||
spacing: Theme.rightPaneGap
|
||||
|
||||
property alias waferFamilyCode: waferInfoFamily.text
|
||||
property alias waferSerialNumber: waferSerial.text
|
||||
property alias waferSensorCount: waferSensors.text
|
||||
property alias waferRuntime: waferRuntime.text
|
||||
property alias waferCycles: waferCycles.text
|
||||
property bool waferDetected: false
|
||||
|
||||
// Data summary after parse
|
||||
property int dataRows: 0
|
||||
property int dataCols: 0
|
||||
property string csvPath: ""
|
||||
property bool dataParsed: false
|
||||
|
||||
// ===== Empty state — nothing shown until detect fires =====
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
visible: !root.waferDetected && !deviceController.operationInProgress
|
||||
}
|
||||
|
||||
// ===== Results area (shown once detect/operation runs) =====
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
visible: root.waferDetected || deviceController.operationInProgress
|
||||
spacing: Theme.rightPaneGap
|
||||
|
||||
// --- Connection Status ---
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 86
|
||||
color: {
|
||||
if (deviceController.connectionStatus === "Connected") return Theme.statusSuccessColor + "22"
|
||||
if (deviceController.connectionStatus === "Disconnected") return Theme.statusErrorColor + "22"
|
||||
return Theme.cardBackground
|
||||
}
|
||||
border.color: {
|
||||
if (deviceController.connectionStatus === "Connected") return Theme.statusSuccessColor
|
||||
if (deviceController.connectionStatus === "Disconnected") return Theme.statusErrorColor
|
||||
return Theme.cardBorder
|
||||
}
|
||||
radius: Theme.radiusSm
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.panelPadding
|
||||
spacing: 4
|
||||
|
||||
Text {
|
||||
text: deviceController.connectionStatus
|
||||
color: {
|
||||
if (deviceController.connectionStatus === "Connected") return Theme.statusSuccessColor
|
||||
if (deviceController.connectionStatus === "Disconnected") return Theme.statusErrorColor
|
||||
return Theme.bodyColor
|
||||
}
|
||||
font.bold: true
|
||||
font.pixelSize: 16
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Port: " + (deviceController.selectedPort || "None selected")
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: 14
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Save dir: " + deviceController.saveDataDir
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: 12
|
||||
opacity: 0.7
|
||||
elide: Text.ElideMiddle
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Wafer Info ---
|
||||
GroupBox {
|
||||
title: "Wafer Information"
|
||||
visible: root.waferDetected
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.panelPadding
|
||||
spacing: 8
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.rightPaneGap
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
Text { text: "Family Code"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
||||
Text { id: waferInfoFamily; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
Text { text: "Serial Number"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
||||
Text { id: waferSerial; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
Text { text: "Sensor Count"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
||||
Text { id: waferSensors; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.rightPaneGap
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
Text { text: "Runtime"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
||||
Text { id: waferRuntime; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
Text { text: "Cycles"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
||||
Text { id: waferCycles; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Data Summary (shown after parse) ---
|
||||
GroupBox {
|
||||
title: "Data Summary"
|
||||
visible: root.dataParsed
|
||||
Layout.fillWidth: true
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.panelPadding
|
||||
spacing: Theme.rightPaneGap
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
Text { text: "Rows"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
||||
Text { text: String(root.dataRows); color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
Text { text: "Sensors"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
||||
Text { text: String(root.dataCols); color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
Text { text: "CSV"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
||||
Text { text: root.csvPath; color: Theme.bodyColor; font.pixelSize: 12; elide: Text.ElideMiddle }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Activity Log ---
|
||||
GroupBox {
|
||||
title: "Activity Log"
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
|
||||
TextArea {
|
||||
id: activityLog
|
||||
width: parent.width
|
||||
text: ""
|
||||
readOnly: true
|
||||
font.family: "monospace"
|
||||
font.pixelSize: 12
|
||||
wrapMode: TextArea.WordWrap
|
||||
leftPadding: 4
|
||||
rightPadding: 4
|
||||
color: Theme.bodyColor
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: deviceController
|
||||
function onActivityLogUpdated(newLog) {
|
||||
activityLog.text = newLog
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Signal Handlers ---
|
||||
Connections {
|
||||
target: deviceController
|
||||
function onDetectResult(result) {
|
||||
if (result && result.familyCode) {
|
||||
root.waferDetected = true
|
||||
waferInfoFamily.text = result.familyCode
|
||||
waferSerial.text = result.serialNumber
|
||||
waferSensors.text = String(result.sensorCount)
|
||||
waferRuntime.text = result.runtime + "s"
|
||||
waferCycles.text = String(result.cycleCount)
|
||||
}
|
||||
// On failure, keep the last displayed wafer info and do not change waferDetected.
|
||||
}
|
||||
|
||||
function onStatusRestored() {
|
||||
// Show restored wafer info if available
|
||||
var info = deviceController.lastWaferInfo
|
||||
if (info && info.length > 0) {
|
||||
root.waferDetected = true
|
||||
waferInfoFamily.text = info[0] || "—"
|
||||
waferSerial.text = info[1] || "—"
|
||||
waferSensors.text = String(info[2] || 0)
|
||||
waferRuntime.text = (info[3] || 0) + "s"
|
||||
waferCycles.text = String(info[4] || 0)
|
||||
}
|
||||
// Show data summary if data was previously parsed
|
||||
if (deviceController.dataRowCount > 0) {
|
||||
root.dataParsed = true
|
||||
root.dataRows = deviceController.dataRowCount
|
||||
root.dataCols = deviceController.dataColCount
|
||||
root.csvPath = "" // CSV path not persisted as full path, just show it was parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: deviceController
|
||||
function onReadResult(result) {
|
||||
root.dataParsed = false
|
||||
root.dataRows = 0
|
||||
root.dataCols = 0
|
||||
root.csvPath = ""
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: deviceController
|
||||
function onParsedDataReady(result) {
|
||||
if (result && result.success) {
|
||||
root.dataParsed = true
|
||||
root.dataRows = result.rows
|
||||
root.dataCols = result.cols
|
||||
root.csvPath = result.csv_path || ""
|
||||
} else {
|
||||
root.dataParsed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# ===== ISC Tabs Module =====
|
||||
module ISC.Tabs
|
||||
|
||||
# ===== Tab Components =====
|
||||
SettingsTab 1.0 SettingsTab.qml
|
||||
@@ -0,0 +1,129 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
|
||||
// =============================================================================
|
||||
// Theme — single source of truth for colors and sizing
|
||||
// =============================================================================
|
||||
// Sections:
|
||||
// 1. Mode flag
|
||||
// 2. Tone palette (raw tones; everything else derives from these)
|
||||
// 3. Surfaces (page / card / panel / workspace backgrounds)
|
||||
// 4. Borders
|
||||
// 5. Text (headings, body, panel titles)
|
||||
// 6. Controls (fields, buttons, checkbox)
|
||||
// 7. Tracks (pill toggle + scrollbar)
|
||||
// 8. Tabs (footer tab bar)
|
||||
// 9. Side rail
|
||||
// 10. Status (success / warning / error)
|
||||
// 11. Geometry (radius / border width / spacing / sizing)
|
||||
// =============================================================================
|
||||
|
||||
QtObject {
|
||||
// ── 1. Mode ──────────────────────────────────────────────────────────────
|
||||
property bool isDarkMode: false
|
||||
|
||||
// ── 2. Tone palette (base values; consume via semantic tokens below) ─────
|
||||
readonly property color tone100: isDarkMode ? "#111111" : "#FAFAFA"
|
||||
readonly property color tone200: isDarkMode ? "#1A1A1A" : "#F2F2F2"
|
||||
readonly property color tone300: isDarkMode ? "#242424" : "#E8E8E8"
|
||||
readonly property color toneText: isDarkMode ? "#F2F2F2" : "#111111"
|
||||
readonly property color toneMute: isDarkMode ? "#A8A8A8" : "#8A8A8A"
|
||||
readonly property color toneBorder: isDarkMode ? "#2B2B2B" : "#D8D8D8"
|
||||
|
||||
// ── 3. Surfaces ──────────────────────────────────────────────────────────
|
||||
readonly property color pageBackground: tone100
|
||||
readonly property color cardBackground: tone200
|
||||
readonly property color panelBackground: tone200
|
||||
readonly property color workspaceBackground: tone200
|
||||
readonly property color responseBackground: tone200
|
||||
readonly property color subtleSectionBackground: tone300
|
||||
|
||||
// ── 4. Borders ───────────────────────────────────────────────────────────
|
||||
readonly property color cardBorder: toneBorder
|
||||
readonly property color responseBorder: toneBorder
|
||||
readonly property color workspaceBorder: toneBorder
|
||||
readonly property color innerFrameBorder: toneBorder
|
||||
readonly property color outerFrameBorder: isDarkMode ? "#343434" : "#CECECE"
|
||||
readonly property color softBorder: isDarkMode ? "#222222" : "#E2E2E2"
|
||||
|
||||
// ── 5. Text ──────────────────────────────────────────────────────────────
|
||||
readonly property color headingColor: toneText
|
||||
readonly property color bodyColor: toneMute
|
||||
readonly property color subheadingColor: toneMute
|
||||
readonly property color panelTitleText: toneMute
|
||||
readonly property color disabledText: toneMute
|
||||
|
||||
// ── 6. Controls ──────────────────────────────────────────────────────────
|
||||
// Fields
|
||||
readonly property color fieldBackground: tone100
|
||||
readonly property color fieldText: toneText
|
||||
readonly property color fieldPlaceholder: toneMute
|
||||
readonly property color fieldBorder: toneBorder
|
||||
readonly property color fieldBorderFocus: toneText
|
||||
// Neutral buttons
|
||||
readonly property color buttonNeutralBackground: tone300
|
||||
readonly property color buttonNeutralHover: isDarkMode ? "#303030" : "#DDDDDD"
|
||||
readonly property color buttonNeutralPressed: tone100
|
||||
readonly property color buttonNeutralText: toneText
|
||||
readonly property color buttonPressed: tone300
|
||||
readonly property color primaryAccent: toneText
|
||||
// Checkbox
|
||||
readonly property color checkboxText: toneText
|
||||
|
||||
// ── 7. Tracks (pill toggle, scrollbar thumb) ─────────────────────────────
|
||||
readonly property color trackBackground: isDarkMode ? "#25252B" : "#D1D5DB"
|
||||
|
||||
// ── 8. Tabs (footer tab bar) ─────────────────────────────────────────────
|
||||
readonly property color tabBarBackground: tone200
|
||||
readonly property color tabBackground: "transparent"
|
||||
readonly property color tabActiveBackground: "transparent"
|
||||
readonly property color tabHoverBackground: tone300
|
||||
readonly property color tabBorder: "transparent"
|
||||
readonly property color tabText: toneMute
|
||||
readonly property color tabActiveText: toneText
|
||||
|
||||
// ── 9. Side rail ─────────────────────────────────────────────────────────
|
||||
readonly property color sideRailBackground: tone200
|
||||
readonly property color sideActiveBackground: tone300
|
||||
|
||||
// ── 10. Status ───────────────────────────────────────────────────────────
|
||||
readonly property color statusSuccessColor: isDarkMode ? "#63D471" : "#2E9E44"
|
||||
readonly property color statusWarningColor: isDarkMode ? "#F5C15C" : "#C88A18"
|
||||
readonly property color statusErrorColor: isDarkMode ? "#FF6B6B" : "#D64545"
|
||||
|
||||
// ── 11. Geometry ─────────────────────────────────────────────────────────
|
||||
// Radius
|
||||
readonly property int radiusXs: 4 // fields, tight elements
|
||||
readonly property int radiusSm: 6 // buttons
|
||||
readonly property int radiusMd: 10 // cards, group boxes
|
||||
readonly property int radiusLg: 14 // large panels / dialogs
|
||||
// Border width
|
||||
readonly property int borderThin: 1
|
||||
readonly property int borderStrong: 2
|
||||
// Main panel layout
|
||||
readonly property int mainAreaPadding: 10
|
||||
readonly property int rightPaneGap: 6
|
||||
readonly property int panelPadding: 12
|
||||
// Side rail layout
|
||||
readonly property int sideRailWidth: 190
|
||||
readonly property int sideRailSpacing: 14
|
||||
readonly property int sideButtonHeight: 146
|
||||
readonly property int sideButtonMinHeight: 88
|
||||
// Tab bar layout
|
||||
readonly property int tabBarHeight: 34
|
||||
readonly property int tabBarPadding: 6
|
||||
readonly property int tabSpacing: 2
|
||||
readonly property int tabRadius: 2
|
||||
readonly property int tabFontSize: 12
|
||||
readonly property int tabButtonMinWidth: 80
|
||||
// Settings page layout
|
||||
readonly property int settingsPanelMaxWidth: 760
|
||||
readonly property int settingsOuterMargin: 40
|
||||
readonly property int settingsSectionSpacing: 20
|
||||
readonly property int settingsRowSpacing: 16
|
||||
readonly property int settingsGridSpacing: 12
|
||||
readonly property int settingsButtonHeight: 40
|
||||
readonly property int settingsActionWidth: 200
|
||||
readonly property int settingsFieldMinWidth: 160
|
||||
}
|
||||
@@ -4,7 +4,6 @@ module ISC
|
||||
# ===== Root Components =====
|
||||
Main 1.0 Main.qml
|
||||
HomePage 1.0 HomePage.qml
|
||||
AboutDialog 1.0 AboutDialog.qml
|
||||
|
||||
# ===== Singleton Theme =====
|
||||
singleton Theme 1.0 Theme.qml
|
||||
@@ -1,23 +0,0 @@
|
||||
.PHONY: install test lint fix typecheck run clean
|
||||
|
||||
install:
|
||||
uv sync
|
||||
|
||||
test: lint
|
||||
uv run pytest tests/
|
||||
|
||||
lint:
|
||||
uv run ruff check src/ tests/
|
||||
|
||||
fix:
|
||||
uv run ruff check src/ tests/ --fix
|
||||
|
||||
typecheck:
|
||||
uv run mypy src/
|
||||
|
||||
run:
|
||||
uv run python -m pygui
|
||||
|
||||
clean:
|
||||
rm -rf .pytest_cache .ruff_cache .mypy_cache
|
||||
find . -type d -name "__pycache__" -exec rm -rf {} +
|
||||
@@ -2,164 +2,48 @@
|
||||
|
||||
Small PySide6 + Qt Quick application scaffold for the `ISenseCloud` desktop UI shell.
|
||||
|
||||
## Key App Features
|
||||
|
||||
The `ISenseCloud` application provides a real-time monitor and analysis dashboard for silicon wafer temperature profiles:
|
||||
|
||||
- **Real-Time Data Streaming & Coalescing**: Decodes binary frame streams from thermal sensors over serial/COM connections at ~20 Hz with low CPU overhead.
|
||||
- **RBF Heatmap Interpolation**: A Radial Basis Function (RBF) interpolation engine paints a smooth 2D temperature gradient across the wafer surface based on physical sensor locations.
|
||||
- **Interactive Review & Playback**: A `FramePlayer` component supports variable-speed playback (play, pause, step, seek) of recorded runs.
|
||||
- **Interactive Trend Charts**: Renders multi-series temperature trends using a custom QtQuick painted item (`GraphQuickItem`) with automatic scaling, custom legends, and grid layouts.
|
||||
- **Tabular Data Inspection**: Direct inspection of tabular dataset grids for frame-by-frame diagnostic checkups.
|
||||
- **Custom Safety Limits & Thresholds**: Configurable alarm thresholds and set-points with automatic alerts when temperatures diverge from normal boundaries.
|
||||
|
||||
### App Showcase
|
||||
|
||||

|
||||
*Figure 1: Main Status Dashboard displaying successful connection, active serial communications, and automated logging terminal.*
|
||||
|
||||
> [!NOTE]
|
||||
> **Take Screenshots for Visual Placeholders:**
|
||||
>
|
||||
> - **Wafer Heatmap Tab**: Take a screenshot of the Wafer Map tab showing the radial heatmap interpolation, save it to `docs/design/wafer_heatmap_tab.png` and add it here.
|
||||
> - **Temperature Trend Graph**: Take a screenshot of the Graph tab plotting multi-sensor trend lines, save it to `docs/design/temp_trend_tab.png` and add it here.
|
||||
|
||||
## Key Architectural & Design Decisions
|
||||
|
||||
During the refactoring from the flat-layout prototype (`SettingTab` branch) to the unified package application (`main`), the following structural changes were made:
|
||||
|
||||
1. **Package Layout Restructuring**: Reorganized flat imports into a clean `src/` layout under `pygui.*` sub-packages (`controllers/`, `models/`, `data/`, `visualization/`, `wafer/`) for better module isolation.
|
||||
2. **QML Component Decoupling**: Monolithic pages were refactored into reusable component blocks (e.g., `ReadoutPanel.qml`, `WaferMapView.qml`, `TransportBar.qml` under `src/pygui/ISC/Tabs/components/`), easing tab maintenance.
|
||||
3. **Decoupled Controller Architecture**: Introduced QML-bound controllers (`DeviceController`, `SessionController`) acting as bridges between UI triggers and backend model states (`FramePlayer`, `SessionModel`), keeping UI files strictly visual.
|
||||
4. **Robust Slot Error Boundaries**: Decorated QML-callable slots with a custom `@slot_error_boundary` wrapper to intercept runtime errors gracefully without terminating the Qt environment.
|
||||
5. **Modernized Rendering Engine**: Replaced outdated contour algorithms with Radial Basis Function (RBF) interpolation (`rbf_heatmap.py`) to render high-fidelity, real-time thermal profiles.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.11+ (tested with Python 3.14 in this workspace)
|
||||
- Python 3.10+ (tested with Python 3.14 in this workspace)
|
||||
- macOS, Linux, or Windows with GUI support
|
||||
|
||||
## Setup
|
||||
|
||||
From the project root:
|
||||
|
||||
### Option A: Using `uv` (Recommended)
|
||||
|
||||
If you have [uv](https://docs.astral.sh/uv/) installed:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Option B: Using `pip`
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate # or .venv\Scripts\activate on Windows
|
||||
source .venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install -e . # install the `pygui` package (src/ layout) in editable mode
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
### Launch PySide6 QML App
|
||||
|
||||
```bash
|
||||
make run
|
||||
source .venv/bin/activate
|
||||
python main.py
|
||||
```
|
||||
|
||||
This launches the Qt window and loads the `ISC` QML module from `src/pygui/ISC/Main.qml`.
|
||||
|
||||
## Development
|
||||
|
||||
The project uses `uv` for dependency management and tooling, `Ruff` for linting and formatting, `Mypy` for static type checking, and `pytest` for unit tests.
|
||||
|
||||
A `Makefile` is provided to simplify common development and verification tasks:
|
||||
|
||||
| Command | Description |
|
||||
| --------- | ------------- |
|
||||
| `make install` | Sync dependencies and set up the local `.venv` using `uv` |
|
||||
| `make run` | Launch the ISenseCloud application |
|
||||
| `make test` | Run the `pytest` test suite |
|
||||
| `make lint` | Check code style and quality with the `Ruff` linter |
|
||||
| `make fix` | Automatically fix fixable `Ruff` style violations |
|
||||
| `make typecheck` | Run the `Mypy` static type checker |
|
||||
| `make clean` | Clean build and tool caches (`.pytest_cache`, `.ruff_cache`, `.mypy_cache`, `__pycache__`) |
|
||||
|
||||
### Ruff Configuration
|
||||
|
||||
Ruff is configured in `pyproject.toml` to enforce:
|
||||
|
||||
- **E/W**: Pycodestyle errors and warnings
|
||||
- **F**: Pyflakes linter rules
|
||||
- **I**: Import sorting (isort parity)
|
||||
- **N**: PEP 8 naming conventions
|
||||
This launches the Qt window and loads the `ISC` QML module from `ISC/Main.qml`.
|
||||
|
||||
## Project Structure
|
||||
|
||||
The application lives under a `src/` layout as the `pygui` package:
|
||||
|
||||
- `src/pygui/__main__.py`: PySide6 bootstrap; creates the Qt app and loads QML module `ISC/Main`. Entry point for `python -m pygui`.
|
||||
- `src/pygui/backend/`: Qt-facing models, controllers, and visualizers organized into subdirectories:
|
||||
- `controllers/`: QML-facing controllers (Session & Device controllers).
|
||||
- `data/`: local settings, file browser, and CSV recorder utilities.
|
||||
- `models/`: data models (Session, Thresholds, Frame Player, Frame representation).
|
||||
- `visualization/`: QML wafer map item integration.
|
||||
- `wafer/`: layout definitions, coordinate mappings, and files parser.
|
||||
- `src/pygui/serialcomm/`: serial port transport, device service scanning, and protocol data-parser layer.
|
||||
- `src/pygui/ISC/`: the `ISC` QML module (UI).
|
||||
- `Main.qml`: top-level window definition.
|
||||
- `HomePage.qml`: main UI layout (left action rail, workspace panel, footer tabs).
|
||||
- `Theme.qml`: shared theme constants and dark/light mode tokens.
|
||||
- `qmldir`: QML module registration.
|
||||
- `tests/`: pytest suite.
|
||||
- `packaging/`: PyInstaller spec (`isc.spec`) and app icons.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ QML Layer (src/pygui/ISC/) │
|
||||
│ HomePage.qml ─ WaferMapTab.qml ─ DataTab.qml │
|
||||
│ StatusTab.qml ─ SettingsTab.qml ─ components/* │
|
||||
└─────────────────────────┬──────────────────────────────────┘
|
||||
│ Q_PROPERTY / @Slot / Signal
|
||||
┌─────────────────────────▼──────────────────────────────────┐
|
||||
│ Controller Layer (backend/controllers/) │
|
||||
│ DeviceController ─ SessionController │
|
||||
│ (serial ops, status) (streaming, playback) │
|
||||
└─────────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────▼──────────────────────────────────┐
|
||||
│ Model Layer (backend/models/) │
|
||||
│ FramePlayer ─ SessionModel ─ ReplayStatsTracker │
|
||||
│ ThresholdClassifier ─ SensorEditor ─ ClusterAverage │
|
||||
│ TemperatureTableModel │
|
||||
└─────────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────▼──────────────────────────────────┐
|
||||
│ Serial / Data Layer (serialcomm/ + data/) │
|
||||
│ SerialPort ─ DeviceService ─ StreamReader ─ data_parser │
|
||||
│ LocalSettings ─ FileBrowser ─ CsvRecorder │
|
||||
└─────────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────▼──────────────────────────────────┐
|
||||
│ Visualization (backend/visualization/) │
|
||||
│ WaferMapItem ─ GraphView ─ TrendChartItem │
|
||||
│ RBFHeatmap ─ WaferMapView (QML wrapper) │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
- `main.py`: PySide6 bootstrap; creates the Qt app and loads QML module `ISC/Main`.
|
||||
- `ISC/Main.qml`: top-level window definition.
|
||||
- `ISC/HomePage.qml`: main UI layout (left action rail, workspace panel, footer tabs).
|
||||
- `ISC/Theme.qml`: shared theme constants and dark/light mode tokens.
|
||||
- `ISC/qmldir`: QML module registration.
|
||||
|
||||
## Window Configuration
|
||||
|
||||
Window dimensions and constraints are defined in `src/pygui/ISC/Main.qml`:
|
||||
Window dimensions and constraints are defined in `ISC/Main.qml`:
|
||||
|
||||
- **Default size**: 1400 × 820 pixels
|
||||
- **Minimum size**: 1100 × 700 pixels
|
||||
- **Title bar**: "ISenseCloud"
|
||||
|
||||
To adjust the window, edit the `Window` block in `src/pygui/ISC/Main.qml`:
|
||||
To adjust the window, edit the `Window` block in `ISC/Main.qml`:
|
||||
|
||||
```qml
|
||||
Window {
|
||||
@@ -174,52 +58,16 @@ Window {
|
||||
|
||||
## Customization
|
||||
|
||||
- Toggle dark/light mode in `src/pygui/ISC/Theme.qml` via `isDarkMode`.
|
||||
- Update sidebar and footer labels in `src/pygui/ISC/HomePage.qml` through `sideActions` and `bottomTabs`.
|
||||
- Toggle dark/light mode in `ISC/Theme.qml` via `isDarkMode`.
|
||||
- Update sidebar and footer labels in `ISC/HomePage.qml` through `sideActions` and `bottomTabs`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If the app does not start, verify the virtual environment is active and dependencies are installed:
|
||||
|
||||
*Using `uv`:*
|
||||
- If the app does not start, verify the venv is active and dependencies are installed:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
*Using `pip`:*
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate # or .venv\Scripts\activate on Windows
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
- If no window appears, ensure you are running in a desktop session with GUI access.
|
||||
|
||||
- **Windows COM Port Connection Issues:**
|
||||
- Standard Windows serial drivers don't support custom baud rates (e.g. `888888`), throwing an expected `OSError(22)` warning before falling back to `115200` baud.
|
||||
|
||||
### Step-by-Step Windows Simulator Connection Guide
|
||||
|
||||
#### Step 1: Create the Virtual Serial Port Bridge
|
||||
|
||||
Open **HHD Virtual Serial Port Tools**. Under the **Local Bridges** panel on the left, click the green **`+`** (Add) button to create a new port pair. Configure it to bridge **`COM5 ↔ COM6`**.
|
||||
|
||||

|
||||
|
||||
#### Step 2: Configure and Start the Wafer Simulator
|
||||
|
||||
Launch the **Wafer Simulator Control Panel** (`wafer_sim_gui.py`):
|
||||
|
||||
1. Set the **Serial Port** to **`COM5`**.
|
||||
2. **Uncheck** the `Auto-create Virtual Port (com0com)` checkbox.
|
||||
3. Choose your desired **Wafer Type** (e.g., `aepwafer`) and **Family Code** (e.g., `A`).
|
||||
4. Click **Start Simulator**. Once the client connects, you will see `Streaming (D2)` status and active data transfer logs.
|
||||
|
||||

|
||||
|
||||
#### Step 3: Run the UI and Connect
|
||||
|
||||
Launch the `pygui` client application. On the left navigation rail, click **DETECT WAFER**. The application will scan all active COM ports, automatically detect the virtual wafer simulator on **`COM6`**, and update the status indicator to **Connected** (green).
|
||||
|
||||

|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# This will be the name of the output files
|
||||
name: aepwafer
|
||||
wafers: ["A", "E", "P"]
|
||||
|
||||
# round or square wafer
|
||||
shape: round
|
||||
|
||||
# diameter/edge size of the wafer
|
||||
size: 300
|
||||
|
||||
# large or small markers and labels
|
||||
marker_size: large
|
||||
font_size: large
|
||||
|
||||
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||
# for that axis are negative, or else we'll be drawing outside the image.
|
||||
x_origin: left
|
||||
y_origin: bottom
|
||||
|
||||
# sensor X and Y values, in mm relative to the origin
|
||||
X: [150, 204, 249, 280, 290, 280, 249, 204, 150, 97, 51, 21, 10, 21, 51, 97,
|
||||
150, 203, 241, 255, 241, 203, 150, 98, 59, 45, 59, 98, 171, 207, 228, 228,
|
||||
207, 171, 130, 94, 73, 73, 94, 130, 150, 186, 200, 186, 150, 115, 100, 115]
|
||||
Y: [290, 280, 249, 204, 150, 97, 51, 21, 10, 21, 51, 97, 150, 204, 249, 280,
|
||||
255, 241, 203, 150, 98, 59, 45, 59, 98, 150, 203, 241, 228, 207, 171, 130,
|
||||
94, 73, 73, 94, 130, 171, 207, 228, 200, 186, 150, 115, 100, 115, 150, 186]
|
||||
|
||||
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||
start_sn: 1
|
||||
|
||||
# Normally, positive X is to the right, and positive Y is up.
|
||||
# These settings can be used to reverse one or both of these.
|
||||
reverse_x: false
|
||||
reverse_y: false
|
||||
|
||||
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||
# start at start_sn. Directions are up, down, left, and right, and values
|
||||
# are multiples of the marker size.
|
||||
label_exceptions: {
|
||||
4: { top: [0, 0] },
|
||||
5: { top: [0, 0] },
|
||||
6: { top: [0, 0] },
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
# This will be the name of the output files
|
||||
name: bcdwafer
|
||||
wafers: ["B", "C", "D"]
|
||||
|
||||
# round or square wafer
|
||||
shape: round
|
||||
|
||||
# diameter/edge size of the wafer
|
||||
size: 300
|
||||
|
||||
# large or small markers and labels
|
||||
marker_size: large
|
||||
font_size: large
|
||||
|
||||
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||
# for that axis are negative, or else we'll be drawing outside the image.
|
||||
x_origin: center
|
||||
y_origin: center
|
||||
|
||||
# sensor X and Y values, in mm relative to the origin
|
||||
X: [0, 72.50, 125.57, 145.00, 125.57, 72.50, 0, -72.50, -125.57, -145.00,
|
||||
-125.57, -72.50, 0, 47.50, 82.27, 95.00, 82.27, 47.50, 0, -47.50, -82.27,
|
||||
-95.00, -82.27, -47.50, 38.97, 22.50, -38.97, -22.50, 0]
|
||||
Y: [145.00, 125.57, 72.50, 0, -72.50, -125.57, -145.00, -125.57, -72.50, 0,
|
||||
72.50, 125.57, 95.00, 82.27, 47.50, 0, -47.50, -82.27, -95.00, -82.27,
|
||||
-47.50, 0, 47.50, 82.27, 22.50, -38.97, -22.50, 38.97, 0]
|
||||
|
||||
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||
start_sn: 1
|
||||
|
||||
# Normally, positive X is to the right, and positive Y is up.
|
||||
# These settings can be used to reverse one or both of these.
|
||||
reverse_x: false
|
||||
reverse_y: false
|
||||
|
||||
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||
# start at start_sn. Directions are up, down, left, and right, and values
|
||||
# are multiples of the marker size.
|
||||
label_exceptions: {
|
||||
2: { left: [1, 0] },
|
||||
3: { left: [1, 0] },
|
||||
4: { left: [1, 0] },
|
||||
5: { left: [1, 0] },
|
||||
6: { left: [1, 0] },
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
# This will be the name of the output files
|
||||
name: fwafer
|
||||
wafers: ["F"]
|
||||
|
||||
# round or square wafer
|
||||
shape: round
|
||||
|
||||
# diameter/edge size of the wafer
|
||||
size: 200
|
||||
|
||||
# large or small markers and labels
|
||||
marker_size: large
|
||||
font_size: large
|
||||
|
||||
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||
# for that axis are negative, or else we'll be drawing outside the image.
|
||||
x_origin: center
|
||||
y_origin: center
|
||||
|
||||
# sensor X and Y values, in mm relative to the origin
|
||||
X: [0.0, 47.5, 82.3, 95.0, 82.3, 47.5, 0.0, -47.5, -82.3, -95.0, -82.3, -47.5,
|
||||
-55.0, -14.8, 39.7, 55.0, 14.8, -39.7, -14.7, 25.5, 14.7, -25.5]
|
||||
Y: [95.0, 82.3, 47.5, 0.0, -47.5, -82.3, -95.0, -82.3, -47.5, 0.0, 47.5, 82.3,
|
||||
13.3, 54.4, 40.0, -13.3, -54.4, -40.0, 25.5, 14.7, -25.5, -14.7]
|
||||
|
||||
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||
start_sn: 1
|
||||
|
||||
# Normally, positive X is to the right, and positive Y is up.
|
||||
# These settings can be used to reverse one or both of these.
|
||||
reverse_x: false
|
||||
reverse_y: false
|
||||
|
||||
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||
# start at start_sn. Directions are up, down, left, and right, and values
|
||||
# are multiples of the marker size.
|
||||
label_exceptions: {
|
||||
2: { left: [1, 0] },
|
||||
3: { left: [1, 0] },
|
||||
4: { left: [1, 0] },
|
||||
5: { left: [1, 0] },
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
# This will be the name of the output files
|
||||
name: xwafer
|
||||
wafers: ["X"]
|
||||
|
||||
# round or square wafer
|
||||
shape: square
|
||||
|
||||
# diameter/edge size of the wafer
|
||||
size: 310
|
||||
|
||||
# large or small markers and labels
|
||||
marker_size: small
|
||||
font_size: small
|
||||
|
||||
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||
# for that axis are negative, or else we'll be drawing outside the image.
|
||||
x_origin: left
|
||||
y_origin: bottom
|
||||
|
||||
# sensor X and Y values, in mm relative to the origin
|
||||
X: [ 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00,
|
||||
13.00, 23.00, 46.43, 89.86, 133.29, 176.72, 220.15, 263.58, 287.01, 297.01,
|
||||
307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01,
|
||||
307.01, 307.01, 307.01, 297.01, 287.01, 263.58, 220.15, 176.72, 133.29,
|
||||
89.86, 46.43, 23.00, 13.00, 46.43, 46.43, 46.43, 46.43, 46.43, 46.43,
|
||||
89.86, 133.29, 176.72, 220.15, 263.58, 263.58, 263.58, 263.58, 263.58,
|
||||
263.58, 220.15, 176.72, 133.29, 89.86, 89.86, 89.86, 89.86, 89.86,
|
||||
133.29, 176.72, 220.15, 220.15, 220.15, 220.15, 176.72, 133.29, 133.29,
|
||||
133.29, 176.72, 176.72]
|
||||
Y: [ 3.00, 13.07, 23.07, 46.50, 89.93, 133.36, 176.79, 220.22, 263.65, 287.08,
|
||||
297.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08,
|
||||
307.08, 307.08, 307.08, 307.08, 297.08, 287.08, 263.65, 220.22, 176.79,
|
||||
133.36, 89.93, 46.50, 23.07, 13.07, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00,
|
||||
3.00, 3.00, 3.00, 3.00, 3.00, 46.50, 89.93, 133.36, 176.79, 220.22, 263.65,
|
||||
263.65, 263.65, 263.65, 263.65, 263.65, 220.22, 176.79, 133.36, 89.93,
|
||||
46.50, 46.50, 46.50, 46.50, 46.50, 89.93, 133.36, 176.79, 220.22, 220.22,
|
||||
220.22, 220.22, 176.79, 133.36, 89.93, 89.93, 89.93, 133.36, 176.79,
|
||||
176.79, 133.36 ]
|
||||
|
||||
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||
start_sn: 1
|
||||
|
||||
# Normally, positive X is to the right, and positive Y is up.
|
||||
# These settings can be used to reverse one or both of these.
|
||||
reverse_x: false
|
||||
reverse_y: false
|
||||
|
||||
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||
# start at start_sn. Directions are up, down, left, and right, and values
|
||||
# are multiples of the marker size.
|
||||
label_exceptions: {
|
||||
21: { left: [0, 0] },
|
||||
22: { left: [0, 0] },
|
||||
23: { left: [0, 0] },
|
||||
24: { left: [0, 0] },
|
||||
25: { left: [0, 0] },
|
||||
26: { left: [0, 0] },
|
||||
27: { left: [0, 0] },
|
||||
28: { left: [0, 0] },
|
||||
29: { left: [0, 0] },
|
||||
30: { left: [0, 0] },
|
||||
31: { left: [0, 0] },
|
||||
32: { left: [0, 0] },
|
||||
33: { left: [0, 0] },
|
||||
34: { left: [0, 0] },
|
||||
35: { left: [0, 0] },
|
||||
36: { left: [0, 0] }
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
# This will be the name of the output files
|
||||
name: zwafer
|
||||
wafers: ["Z"]
|
||||
|
||||
# round or square wafer
|
||||
shape: round
|
||||
|
||||
# diameter/size of the wafer
|
||||
size: 300
|
||||
|
||||
# large or small markers and labels
|
||||
marker_size: small
|
||||
font_size: small
|
||||
|
||||
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||
# be top, bottom, or center. Using right or top probably also needs
|
||||
# reverse_x/reverse_y to be set as well.
|
||||
x_origin: center
|
||||
y_origin: center
|
||||
|
||||
# sensor X and Y values, in mm relative to the wafer center
|
||||
X: [0.00, -1.17, 8.92, 1.17, -8.92, 22.52, 29.35, -22.52, -29.35, 45.05, 73.37,
|
||||
58.71, 9.66, -45.05, -73.37, -58.71, -9.66, 79.85, 89.06, 46.10, -23.86,
|
||||
-79.85, -89.06, -46.10, 23.86, 66.96, 109.06, 87.27, 14.36, -66.96,
|
||||
-109.06, -87.27, -14.36, 121.24, 135.23, 70.00, -36.23, -121.24, -135.23,
|
||||
-70.00, 36.23, 124.71, 139.09, 72.00, -37.27, -124.71, -139.09, -72.00,
|
||||
37.27, 89.49, 127.31, 145.74, 141.99, 116.62, 73.50, 19.19, -38.05, -89.49,
|
||||
-127.31, -145.74, -141.99, -116.62, -73.50, -19.19, 38.05]
|
||||
Y: [0.00, -8.92, -1.17, 8.92, 1.17, -29.35, 22.52, 29.35, -22.52, -58.71,
|
||||
-9.66, 45.05, 73.37, 58.71, 9.66, -45.05, -73.37, -46.10, 23.86, 79.85,
|
||||
89.06, 46.10, -23.86, -79.85, -89.06, -87.27, -14.36, 66.96, 109.06, 87.27,
|
||||
14.36, -66.96, -109.06, -70.00, 36.23, 121.24, 135.23, 70.00, -36.23,
|
||||
-121.24, -135.23, -72.00, 37.27, 124.71, 139.09, 72.00, -37.27, -124.71,
|
||||
-139.09, -116.62, -73.50, -19.19, 38.05, 89.49, 127.31, 145.74, 141.99,
|
||||
116.62, 73.50, 19.19, -38.05, -89.49, -127.31, -145.74, -141.99]
|
||||
|
||||
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||
start_sn: 0
|
||||
|
||||
# If one or both axes are reversed, this can be used to fix it
|
||||
# without having to edit all of the coordinates.
|
||||
# These should be false for most normal wafers.
|
||||
reverse_x: false
|
||||
reverse_y: false
|
||||
|
||||
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||
# start at start_sn. Directions are up, down, left, and right, and values
|
||||
# are multiples of the marker size.
|
||||
label_exceptions: {
|
||||
33: { left: [0, 0] },
|
||||
34: { left: [0, 0] },
|
||||
41: { top: [0, 0] },
|
||||
42: { bottom: [0, 0] },
|
||||
45: { top: [1, 0] },
|
||||
46: { bottom: [0.5, 0] },
|
||||
49: { left: [0, 0] },
|
||||
50: { bottom: [-1.75, -0.5] },
|
||||
51: { top: [-1, 0] },
|
||||
52: { top: [-1, 0] },
|
||||
53: { bottom: [0, 0] },
|
||||
54: { left: [0, -0.75] },
|
||||
55: { right: [0, 0.5] },
|
||||
58: { bottom: [-0.75, 0] },
|
||||
60: { top: [0, 0] },
|
||||
62: { right: [0, 1] },
|
||||
63: { right: [0, 0] },
|
||||
64: { left: [0, 0.25] }
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
# This will be the name of the output files
|
||||
name: zwafer_rev
|
||||
wafers: [] # the reversed version doesn't represent any real wafer
|
||||
|
||||
# round or square wafer
|
||||
shape: round
|
||||
|
||||
# diameter/size of the wafer
|
||||
size: 300
|
||||
|
||||
# large or small markers and labels
|
||||
marker_size: small
|
||||
font_size: small
|
||||
|
||||
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||
# be top, bottom, or center. Using right or top probably also needs
|
||||
# reverse_x/reverse_y to be set as well.
|
||||
x_origin: center
|
||||
y_origin: center
|
||||
|
||||
# sensor X and Y values, in mm relative to the wafer center
|
||||
X: [0.00, -1.17, 8.92, 1.17, -8.92, 22.52, 29.35, -22.52, -29.35, 45.05, 73.37,
|
||||
58.71, 9.66, -45.05, -73.37, -58.71, -9.66, 79.85, 89.06, 46.10, -23.86,
|
||||
-79.85, -89.06, -46.10, 23.86, 66.96, 109.06, 87.27, 14.36, -66.96,
|
||||
-109.06, -87.27, -14.36, 121.24, 135.23, 70.00, -36.23, -121.24, -135.23,
|
||||
-70.00, 36.23, 124.71, 139.09, 72.00, -37.27, -124.71, -139.09, -72.00,
|
||||
37.27, 89.49, 127.31, 145.74, 141.99, 116.62, 73.50, 19.19, -38.05, -89.49,
|
||||
-127.31, -145.74, -141.99, -116.62, -73.50, -19.19, 38.05]
|
||||
Y: [0.00, -8.92, -1.17, 8.92, 1.17, -29.35, 22.52, 29.35, -22.52, -58.71,
|
||||
-9.66, 45.05, 73.37, 58.71, 9.66, -45.05, -73.37, -46.10, 23.86, 79.85,
|
||||
89.06, 46.10, -23.86, -79.85, -89.06, -87.27, -14.36, 66.96, 109.06, 87.27,
|
||||
14.36, -66.96, -109.06, -70.00, 36.23, 121.24, 135.23, 70.00, -36.23,
|
||||
-121.24, -135.23, -72.00, 37.27, 124.71, 139.09, 72.00, -37.27, -124.71,
|
||||
-139.09, -116.62, -73.50, -19.19, 38.05, 89.49, 127.31, 145.74, 141.99,
|
||||
116.62, 73.50, 19.19, -38.05, -89.49, -127.31, -145.74, -141.99]
|
||||
|
||||
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||
start_sn: 0
|
||||
|
||||
# If one or both axes are reversed, this can be used to fix it
|
||||
# without having to edit all of the coordinates.
|
||||
# These should be false for most normal wafers.
|
||||
reverse_x: true
|
||||
reverse_y: true
|
||||
|
||||
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||
# start at start_sn. Directions are up, down, left, and right, and values
|
||||
# are multiples of the marker size.
|
||||
label_exceptions: {
|
||||
37: { left: [0, 0] },
|
||||
38: { left: [0, 0] },
|
||||
39: { left: [0, 0] },
|
||||
41: { top: [1.5, 0] },
|
||||
42: { bottom: [1, 0] },
|
||||
43: { left: [0, -1] },
|
||||
45: { bottom: [-1, -1] },
|
||||
46: { bottom: [0, -1] },
|
||||
47: { right: [0, 1] },
|
||||
48: { left: [0, 0] },
|
||||
50: { bottom: [-1, 0] },
|
||||
52: { top: [0, 0] },
|
||||
54: { right: [0.5, 1] },
|
||||
56: { left: [0, 0.5] },
|
||||
57: { left: [0, 0] },
|
||||
58: { top: [0.5, 0] },
|
||||
59: { left: [0, 0] },
|
||||
60: { top: [-1, 0] },
|
||||
61: { left: [0, 0] },
|
||||
62: { left: [0, -1] },
|
||||
63: { right: [0, 1] },
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# ===== Backend Package Marker =====
|
||||
@@ -2,8 +2,8 @@ import secrets
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
|
||||
# ===== Crypto Utilities =====
|
||||
@@ -19,14 +19,14 @@ class CSVFileMetadata:
|
||||
"""Return the first character of the wafer string, or empty if none"""
|
||||
return self.wafer[0] if self.wafer else ""
|
||||
|
||||
def get_date(self) -> datetime | None:
|
||||
"""Parsifes the date string. Returns None if parsing fails"""
|
||||
def get_date(self) -> datetime:
|
||||
"""Parsifes the date string. Returns current time if parsing fails"""
|
||||
date_format = "%Y-%m-%d %H:%M:%S"
|
||||
try:
|
||||
return datetime.strptime(self.date, date_format)
|
||||
except (ValueError, TypeError):
|
||||
# If format is wrong or date is None, return None
|
||||
return None
|
||||
# If format is wrong or date is None, return current time
|
||||
return datetime.now()
|
||||
|
||||
# ===== Formatting =====
|
||||
@staticmethod
|
||||
@@ -48,5 +48,4 @@ class CSVFileMetadata:
|
||||
|
||||
def string_date_format(self) -> str:
|
||||
"""Helper to ensure the date is always a string for JSON."""
|
||||
dt = self.get_date()
|
||||
return self.format_date(dt) if dt else self.date
|
||||
return self.format_date(self.get_date())
|
||||
@@ -55,7 +55,7 @@ class TemperatureTableModel(QAbstractTableModel):
|
||||
# Column 0 = row index, columns 1..N = sensor values
|
||||
return self._col_count + 1
|
||||
|
||||
def data(self, index: Any, role: int = ...) -> Any: # type: ignore[assignment]
|
||||
def data(self, index: Any, role: int = ...) -> Any:
|
||||
if not index.isValid():
|
||||
return None
|
||||
|
||||
@@ -73,7 +73,7 @@ class TemperatureTableModel(QAbstractTableModel):
|
||||
return None
|
||||
|
||||
def headerData(
|
||||
self, section: int, orientation: Qt.Orientation, role: int = ... # type: ignore[assignment]
|
||||
self, section: int, orientation: int, role: int = ...
|
||||
) -> Any:
|
||||
if role != Qt.ItemDataRole.DisplayRole:
|
||||
return None
|
||||
@@ -1,31 +1,31 @@
|
||||
"""QML-exposed controller for wafer device communication."""
|
||||
"""QML-exposed controller for wafer device communication.
|
||||
|
||||
Bridges DeviceService (serialcomm) to QML via @Slot methods and signals.
|
||||
All hardware operations run synchronously on the main thread (matching
|
||||
C# Form1.cs per-operation open/close pattern). For long operations
|
||||
(erase ~15s, read up to 120s) the caller should show a loading state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
|
||||
from PySide6.QtCore import QObject, Property, Qt, Signal, Slot
|
||||
|
||||
from pygui.backend.data.local_settings import LocalSettings
|
||||
from pygui.backend.models.data_model import TemperatureTableModel
|
||||
from pygui.backend.utils import slot_error_boundary
|
||||
from pygui.backend.visualization.graph_view import GraphView
|
||||
from pygui.serialcomm.data_parser import (
|
||||
convert_to_debug_temperatures,
|
||||
from backend.data_model import TemperatureTableModel
|
||||
from backend.graph_view import GraphView
|
||||
from backend.local_settings import LocalSettings
|
||||
from serialcomm.data_parser import (
|
||||
convert_to_temperatures,
|
||||
parse_binary_data,
|
||||
remove_trailing_zeros,
|
||||
save_debug_csv,
|
||||
save_to_csv,
|
||||
)
|
||||
from pygui.serialcomm.serial_port import WaferInfo
|
||||
from serialcomm.device_service import DeviceService
|
||||
from serialcomm.serial_port import WaferInfo
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,15 +38,11 @@ class DeviceController(QObject):
|
||||
|
||||
# ---- public signals ----
|
||||
portsUpdated = Signal(list)
|
||||
connectionStatusChanged = Signal()
|
||||
operationInProgressChanged = Signal()
|
||||
waferDetectedChanged = Signal()
|
||||
selectedPortChanged = Signal()
|
||||
detectResult = Signal(object) # WaferInfo dict or None
|
||||
readResult = Signal(dict) # {"success": bool, "bytes": int} or {"error": str}
|
||||
eraseResult = Signal(dict) # {"success": bool}
|
||||
debugResult = Signal(dict) # {"success": bool, "sensor_bytes": int, "debug_bytes": int}
|
||||
parsedDataReady = Signal(dict) # {"success": bool, "rows": int, "cols": int, "data": ..., "csv_path": str}
|
||||
readResult = Signal(object) # {"success": bool, "bytes": int} or {"error": str}
|
||||
eraseResult = Signal(object) # {"success": bool}
|
||||
debugResult = Signal(object) # {"success": bool, "sensor_bytes": int, "debug_bytes": int}
|
||||
parsedDataReady = Signal(object) # {"success": bool, "rows": int, "cols": int, "data": ..., "csv_path": str}
|
||||
statusRestored = Signal() # Emitted when status is restored from previous session
|
||||
logMessage = Signal(str)
|
||||
activityLogUpdated = Signal(str)
|
||||
@@ -54,52 +50,46 @@ class DeviceController(QObject):
|
||||
# ---- private signals (marshal worker-thread results back to main thread) ----
|
||||
_detectFinished = Signal(object, object) # (port_or_None, WaferInfo_or_None)
|
||||
_readFinished = Signal(str, str, object) # (port, family_code, bytes_or_None)
|
||||
_eraseFinished = Signal(bool) # (success)
|
||||
_debugFinished = Signal(dict) # (result_dict)
|
||||
|
||||
def __init__(self, settings: LocalSettings, data_dir: str, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._settings = settings
|
||||
self._data_dir = data_dir
|
||||
from pygui.serialcomm.device_service import DeviceService
|
||||
self._service = DeviceService(settings)
|
||||
|
||||
# Always start fresh — never restore connection status or logs from a prior session
|
||||
self._connection_status = "Disconnected"
|
||||
# Initialize status from persisted settings (with defaults for new installations)
|
||||
self._connection_status = getattr(settings, 'connection_status', "Disconnected")
|
||||
self._operation_in_progress = False
|
||||
self._activity_log: list[str] = []
|
||||
self._activity_log: list[str] = getattr(settings, 'activity_log', [])
|
||||
self._raw_bytes: Optional[bytes] = None
|
||||
self._save_data_dir: str = getattr(settings, 'save_data_dir', "") or str(Path(self._data_dir) / "csv")
|
||||
# TODO P1.2: accept ctor param license_lookup: Callable[[str], str]
|
||||
# (default lambda s: "") and add:
|
||||
# def _wafer_license_level(self) -> str:
|
||||
# return self._license_lookup(self._last_wafer_info.get("serialNumber", ""))
|
||||
# plus @Slot(result=bool) waferLicensed() for QML (used by P2.2).
|
||||
# THINKING: single shared guard — per-call-site checks are the
|
||||
# sibling-caller bug (fix read, forget erase). Empty serial → ""
|
||||
# → denied = safe default. Called by P2.1.
|
||||
# See docs/pending/license-gating-plan.md §1.2.
|
||||
self._last_wafer_info: dict[str, Any] = {}
|
||||
self._wafer_detected: bool = False
|
||||
self._last_csv_path: str = ""
|
||||
self._selected_port: str = ""
|
||||
self._data_row_count: int = 0
|
||||
self._data_col_count: int = 0
|
||||
# Default save dir lives next to the settings file (~/Documents/isc_data/csv)
|
||||
from pathlib import Path
|
||||
self._save_data_dir: str = getattr(settings, 'save_data_dir', str(Path.home() / "Documents" / "isc_data" / "csv"))
|
||||
self._last_wafer_info: dict[str, Any] = getattr(settings, 'last_wafer_info', {})
|
||||
self._last_csv_path: str = getattr(settings, 'last_csv_path', "")
|
||||
self._selected_port: str = getattr(settings, 'selected_port', "")
|
||||
self._data_row_count: int = getattr(settings, 'data_row_count', 0)
|
||||
self._data_col_count: int = getattr(settings, 'data_col_count', 0)
|
||||
self._data_model = TemperatureTableModel(self)
|
||||
self._graph_view = GraphView(self)
|
||||
|
||||
# Reset connection status on fresh start
|
||||
self._connection_status = "Disconnected"
|
||||
# If we have persisted activity log, emit it to QML
|
||||
if self._activity_log:
|
||||
self.activityLogUpdated.emit("\n".join(self._activity_log))
|
||||
|
||||
# Log status restoration and emit signal for UI updates
|
||||
if self._connection_status != "Disconnected" or self._selected_port or self._last_wafer_info:
|
||||
self._append_log("Restored previous session status")
|
||||
if self._selected_port:
|
||||
self._append_log(f"Last selected port: {self._selected_port}")
|
||||
if self._last_wafer_info:
|
||||
info = self._last_wafer_info
|
||||
self._append_log(f"Last wafer: {info.get('serialNumber', 'Unknown')} ({info.get('familyCode', 'Unknown')})")
|
||||
self.statusRestored.emit()
|
||||
|
||||
# Marshal worker-thread results onto the Qt main thread.
|
||||
self._detectFinished.connect(self._handle_detect_finished, Qt.ConnectionType.QueuedConnection)
|
||||
self._readFinished.connect(self._handle_read_finished, Qt.ConnectionType.QueuedConnection)
|
||||
self._eraseFinished.connect(self._handle_erase_finished, Qt.ConnectionType.QueuedConnection)
|
||||
self._debugFinished.connect(self._handle_debug_finished, Qt.ConnectionType.QueuedConnection)
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.addHandler(QmlActivityLogHandler(self))
|
||||
root_logger.setLevel(logging.INFO)
|
||||
self._detectFinished.connect(self._handle_detect_finished, Qt.QueuedConnection)
|
||||
self._readFinished.connect(self._handle_read_finished, Qt.QueuedConnection)
|
||||
|
||||
# ---- Properties ----
|
||||
@Property(list, notify=portsUpdated)
|
||||
@@ -107,89 +97,38 @@ class DeviceController(QObject):
|
||||
"""Currently available serial port names."""
|
||||
return self._service.enumerate_ports()
|
||||
|
||||
def _set_connection_status(self, status: str) -> None:
|
||||
"""Update connection status and notify QML."""
|
||||
if self._connection_status != status:
|
||||
self._connection_status = status
|
||||
self.connectionStatusChanged.emit()
|
||||
|
||||
@Property(str, notify=connectionStatusChanged)
|
||||
@Property(str, notify=portsUpdated)
|
||||
def connectionStatus(self) -> str:
|
||||
"""Current connection status string for display."""
|
||||
return self._connection_status
|
||||
|
||||
@Property(bool, notify=operationInProgressChanged)
|
||||
@Property(bool, notify=portsUpdated)
|
||||
def operationInProgress(self) -> bool:
|
||||
"""Whether a hardware operation is currently running."""
|
||||
return self._operation_in_progress
|
||||
|
||||
@Property(str, notify=activityLogUpdated)
|
||||
def activityLog(self) -> str:
|
||||
"""Full activity log text; survives QML tab reloads."""
|
||||
return "\n".join(self._activity_log)
|
||||
|
||||
@Property(str, notify=activityLogUpdated)
|
||||
def saveDataDir(self) -> str:
|
||||
"""Directory where parsed CSV data files are saved."""
|
||||
return self._save_data_dir
|
||||
|
||||
@Slot(str)
|
||||
@slot_error_boundary
|
||||
def setSaveDataDir(self, path: str) -> None:
|
||||
"""Set the directory for saving parsed CSV data files."""
|
||||
self._save_data_dir = path
|
||||
self._append_log(f"Save data dir set to: {path}")
|
||||
self._save_status()
|
||||
|
||||
@Slot(result=str)
|
||||
@slot_error_boundary
|
||||
def exportDir(self) -> str:
|
||||
"""Ensure and return the Export subfolder under saveDataDir."""
|
||||
path = Path(self._save_data_dir) / "Export"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return str(path)
|
||||
|
||||
@Slot(str)
|
||||
@slot_error_boundary
|
||||
def openCsvFile(self, file_path: str) -> None:
|
||||
"""Open a CSV file in the system default spreadsheet application."""
|
||||
|
||||
if not file_path:
|
||||
self._append_log("No file path provided to openCsvFile")
|
||||
return
|
||||
|
||||
try:
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
self._append_log(f"File not found: {file_path}")
|
||||
return
|
||||
|
||||
# Platform-appropriate file opener
|
||||
if sys.platform == "darwin":
|
||||
subprocess.Popen(["open", str(path)])
|
||||
elif sys.platform == "linux":
|
||||
subprocess.Popen(["xdg-open", str(path)])
|
||||
else: # Windows
|
||||
os.startfile(str(path))
|
||||
|
||||
self._append_log(f"Opened file: {file_path}")
|
||||
except Exception as exc:
|
||||
log.warning("Failed to open file %s: %s", file_path, exc)
|
||||
self._append_log(f"Error opening file: {exc}")
|
||||
|
||||
@Property(str, notify=selectedPortChanged)
|
||||
@Property(str, notify=portsUpdated)
|
||||
def selectedPort(self) -> str:
|
||||
"""Currently selected serial port shared across the UI."""
|
||||
return self._selected_port
|
||||
|
||||
@Slot(str)
|
||||
@slot_error_boundary
|
||||
def setSelectedPort(self, port: str) -> None:
|
||||
"""Set the active serial port from the port selector."""
|
||||
if self._selected_port != port:
|
||||
self._selected_port = port
|
||||
self.selectedPortChanged.emit()
|
||||
self._save_status()
|
||||
self._selected_port = port
|
||||
self._save_status()
|
||||
|
||||
@Property(int, notify=parsedDataReady)
|
||||
def dataRowCount(self) -> int:
|
||||
@@ -208,33 +147,15 @@ class DeviceController(QObject):
|
||||
Returns [familyCode, serialNumber, sensorCount, mfgDateHex, runtime, cycleCount].
|
||||
"""
|
||||
info = self._last_wafer_info
|
||||
family_code = info.get("familyCode", "")
|
||||
sensor_count = info.get("sensorCount", 0)
|
||||
from pygui.serialcomm.data_parser import csv_column_count
|
||||
mapped_count = csv_column_count(family_code)
|
||||
if mapped_count > 0:
|
||||
sensor_count = mapped_count
|
||||
|
||||
return [
|
||||
family_code,
|
||||
info.get("familyCode", ""),
|
||||
info.get("serialNumber", ""),
|
||||
sensor_count,
|
||||
info.get("sensorCount", 0),
|
||||
info.get("mfgDateHex", ""),
|
||||
info.get("runtime", 0),
|
||||
info.get("cycleCount", 0),
|
||||
]
|
||||
|
||||
@Property(bool, notify=waferDetectedChanged)
|
||||
def waferDetected(self) -> bool:
|
||||
"""Whether a wafer is currently detected and ready for read/erase."""
|
||||
return self._wafer_detected
|
||||
|
||||
def _set_wafer_detected(self, detected: bool) -> None:
|
||||
if self._wafer_detected != detected:
|
||||
self._wafer_detected = detected
|
||||
self.waferDetectedChanged.emit()
|
||||
|
||||
|
||||
@Property(object, notify=detectResult)
|
||||
def dataModel(self) -> TemperatureTableModel:
|
||||
"""QAbstractTableModel for the parsed temperature data table."""
|
||||
@@ -246,7 +167,6 @@ class DeviceController(QObject):
|
||||
return self._graph_view
|
||||
|
||||
@Slot(result=object)
|
||||
@slot_error_boundary
|
||||
def getChartData(self) -> dict[str, Any]:
|
||||
"""Extract chart-ready data from the current model.
|
||||
|
||||
@@ -284,57 +204,28 @@ class DeviceController(QObject):
|
||||
self._activity_log = self._activity_log[-200:]
|
||||
self.activityLogUpdated.emit("\n".join(self._activity_log))
|
||||
|
||||
@Slot()
|
||||
@slot_error_boundary
|
||||
def clearActivityLog(self) -> None:
|
||||
"""Clear the activity log."""
|
||||
self._activity_log.clear()
|
||||
self.activityLogUpdated.emit("")
|
||||
self._save_status()
|
||||
|
||||
@Slot()
|
||||
@slot_error_boundary
|
||||
def clearSession(self) -> None:
|
||||
"""Clear the current session state, allowing a clean detect."""
|
||||
self._set_connection_status("Disconnected")
|
||||
self._selected_port = ""
|
||||
self._last_wafer_info = {}
|
||||
self._set_wafer_detected(False)
|
||||
self._data_row_count = 0
|
||||
self._data_col_count = 0
|
||||
self._raw_bytes = None
|
||||
self._operation_in_progress = False
|
||||
self._data_model.reset()
|
||||
self._graph_view.resetChart()
|
||||
self._activity_log.clear()
|
||||
self.detectResult.emit(None)
|
||||
self.parsedDataReady.emit({"success": False})
|
||||
self.portsUpdated.emit(self.availablePorts)
|
||||
self.activityLogUpdated.emit("")
|
||||
self._save_status()
|
||||
|
||||
|
||||
def _set_operation_progress(self, in_progress: bool) -> None:
|
||||
"""Set operation progress state and notify QML."""
|
||||
if self._operation_in_progress != in_progress:
|
||||
self._operation_in_progress = in_progress
|
||||
self.operationInProgressChanged.emit()
|
||||
"""Set operation progress state and notify QML.
|
||||
|
||||
portsUpdated is the shared notify signal for availablePorts,
|
||||
connectionStatus, and operationInProgress — emitting it forces
|
||||
QML to re-evaluate all three bindings.
|
||||
"""
|
||||
self._operation_in_progress = in_progress
|
||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||
|
||||
# ---- Public Slots ----
|
||||
|
||||
@Slot()
|
||||
@slot_error_boundary
|
||||
def refreshPorts(self) -> None:
|
||||
"""Scan for available serial ports and emit updated list."""
|
||||
self._service.clear_port_scan_cache()
|
||||
ports = self._service.enumerate_ports()
|
||||
self._set_connection_status("Disconnected")
|
||||
self._connection_status = "Disconnected"
|
||||
self.portsUpdated.emit(ports)
|
||||
self._append_log(f"Scanned {len(ports)} serial port(s)")
|
||||
self._save_status()
|
||||
|
||||
@Slot()
|
||||
@slot_error_boundary
|
||||
def detectWafer(self) -> None:
|
||||
"""Scan all serial ports for a wafer (runs in background thread).
|
||||
|
||||
@@ -346,21 +237,8 @@ class DeviceController(QObject):
|
||||
self._append_log("Already busy — ignoring detect request")
|
||||
return
|
||||
|
||||
# Clear previous session data & logs for a fresh session
|
||||
self._selected_port = ""
|
||||
self._last_wafer_info = {}
|
||||
self._data_row_count = 0
|
||||
self._data_col_count = 0
|
||||
self._raw_bytes = None
|
||||
self._data_model.reset()
|
||||
self._graph_view.resetChart()
|
||||
self._activity_log.clear()
|
||||
self.detectResult.emit(None)
|
||||
self.selectedPortChanged.emit()
|
||||
self.parsedDataReady.emit({"success": False})
|
||||
|
||||
self._set_operation_progress(True)
|
||||
self._set_connection_status("Detecting...")
|
||||
self._connection_status = "Detecting..."
|
||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||
self._append_log("Scanning all ports for wafer ...")
|
||||
|
||||
@@ -377,58 +255,40 @@ class DeviceController(QObject):
|
||||
self._detectFinished.emit(port, info)
|
||||
|
||||
@Slot(object, object)
|
||||
@slot_error_boundary
|
||||
def _handle_detect_finished(
|
||||
self, port: Optional[str], info: Optional[WaferInfo]
|
||||
) -> None:
|
||||
"""Main-thread completion handler for detectWafer."""
|
||||
try:
|
||||
if info is not None:
|
||||
# Build the dict (can raise) before declaring success, so a
|
||||
# bad payload can't leave status="Connected" while
|
||||
# waferDetected stays false and Read/Erase stay greyed.
|
||||
wafer_dict = self._wafer_info_to_dict(info)
|
||||
self._selected_port = port or ""
|
||||
self._last_wafer_info = wafer_dict
|
||||
self._set_wafer_detected(True)
|
||||
self._set_connection_status("Connected")
|
||||
self.detectResult.emit(wafer_dict)
|
||||
self.selectedPortChanged.emit()
|
||||
self._save_status()
|
||||
else:
|
||||
self._selected_port = ""
|
||||
self._set_connection_status("Disconnected")
|
||||
self._append_log("No wafer detected on any port")
|
||||
self._last_wafer_info = {}
|
||||
self._set_wafer_detected(False)
|
||||
self.detectResult.emit(None)
|
||||
self.selectedPortChanged.emit()
|
||||
finally:
|
||||
self._set_operation_progress(False)
|
||||
if info is not None:
|
||||
self._selected_port = port or ""
|
||||
self._connection_status = "Connected"
|
||||
self._append_log(
|
||||
f"Detected: {info.serial_number} (family={info.family_code}, "
|
||||
f"port={port}, runtime={info.runtime}s, cycles={info.cycle_count})"
|
||||
)
|
||||
wafer_dict = self._wafer_info_to_dict(info)
|
||||
self._last_wafer_info = wafer_dict
|
||||
self.detectResult.emit(wafer_dict)
|
||||
self._save_status()
|
||||
else:
|
||||
self._selected_port = ""
|
||||
self._connection_status = "Disconnected"
|
||||
self._append_log("No wafer detected on any port")
|
||||
self._last_wafer_info = {}
|
||||
self.detectResult.emit(None)
|
||||
self._set_operation_progress(False)
|
||||
|
||||
@Slot()
|
||||
@slot_error_boundary
|
||||
def readMemoryAsync(self) -> None:
|
||||
"""Read wafer memory in a background thread.
|
||||
|
||||
Uses the family code and port from the last detect.
|
||||
Emits readResult on completion; QML prompts for the save directory
|
||||
before calling parseAndSaveData.
|
||||
Emits readResult on completion, then auto-chains to parseAndSaveData.
|
||||
"""
|
||||
if self._operation_in_progress:
|
||||
self._append_log("Already busy — ignoring read request")
|
||||
return
|
||||
|
||||
# TODO P2.1: license guard —
|
||||
# if self._wafer_license_level() == "":
|
||||
# self._append_log("Wafer not licensed — load its license .bin (About dialog)")
|
||||
# self.readResult.emit({"error": "Wafer not licensed"})
|
||||
# return
|
||||
# THINKING: this slot is the trust boundary; the QML enabled
|
||||
# binding (P2.2) is UX only. Reuses existing readResult error
|
||||
# shape → zero new signals. Depends on P1.2. Same guard goes in
|
||||
# eraseMemory (emit eraseResult there). See plan §2.1.
|
||||
|
||||
if not self._selected_port:
|
||||
self._append_log("No wafer detected — run Detect Wafer first")
|
||||
self.readResult.emit({"error": "No port — detect wafer first"})
|
||||
@@ -438,7 +298,7 @@ class DeviceController(QObject):
|
||||
family_code = self._last_wafer_info.get("familyCode", "")
|
||||
|
||||
self._set_operation_progress(True)
|
||||
self._set_connection_status("Reading...")
|
||||
self._connection_status = "Reading..."
|
||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||
self._append_log(
|
||||
f"Reading memory on {port} (family={family_code or 'auto'}) ..."
|
||||
@@ -460,21 +320,22 @@ class DeviceController(QObject):
|
||||
self._readFinished.emit(port, family_code, data)
|
||||
|
||||
@Slot(str, str, object)
|
||||
@slot_error_boundary
|
||||
def _handle_read_finished(
|
||||
self, port: str, family_code: str, data: Optional[bytes]
|
||||
) -> None:
|
||||
"""Main-thread completion handler for readMemoryAsync."""
|
||||
if data is not None:
|
||||
self._set_connection_status("Connected")
|
||||
self._connection_status = "Connected"
|
||||
self._append_log(f"Read {len(data)} bytes")
|
||||
self._raw_bytes = data
|
||||
self.readResult.emit({"success": True, "bytes": len(data)})
|
||||
self._set_operation_progress(False)
|
||||
self._append_log("Memory read complete - choose save directory to save CSV")
|
||||
# Auto-chain: read → parse → save (matches C# behavior).
|
||||
# Parse runs on main thread — it's CPU-bound but bounded (~1s).
|
||||
self.parseAndSaveData(family_code, port)
|
||||
self._save_status()
|
||||
return
|
||||
self._set_connection_status("Disconnected")
|
||||
self._connection_status = "Disconnected"
|
||||
self._append_log("Read returned no data")
|
||||
self._raw_bytes = None
|
||||
self.readResult.emit({"error": "Read returned no data"})
|
||||
@@ -482,132 +343,67 @@ class DeviceController(QObject):
|
||||
self._save_status()
|
||||
|
||||
@Slot(str)
|
||||
@slot_error_boundary
|
||||
def eraseMemory(self, port: str) -> None:
|
||||
"""Send p1 erase command."""
|
||||
if self._operation_in_progress:
|
||||
self._append_log("Already busy — ignoring erase request")
|
||||
return
|
||||
|
||||
# TODO P2.1: same license guard as readMemoryAsync, but emit
|
||||
# self.eraseResult.emit({"success": False}) on denial. See plan §2.1.
|
||||
self._set_operation_progress(True)
|
||||
self._set_connection_status("Erasing...")
|
||||
self._connection_status = "Erasing..."
|
||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||
self._append_log(f"Erase command sent to {port} (wafer takes ~15s) ...")
|
||||
|
||||
threading.Thread(
|
||||
target=self._erase_worker,
|
||||
args=(port,),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def _erase_worker(self, port: str) -> None:
|
||||
"""Background thread worker for eraseMemory."""
|
||||
try:
|
||||
ok = self._service.erase_wafer(port)
|
||||
except Exception as exc:
|
||||
log.exception("Erase worker crashed: %s", exc)
|
||||
ok = False
|
||||
self._eraseFinished.emit(ok)
|
||||
|
||||
@Slot(bool)
|
||||
@slot_error_boundary
|
||||
def _handle_erase_finished(self, ok: bool) -> None:
|
||||
"""Main thread completion handler for eraseMemory."""
|
||||
ok = self._service.erase_wafer(port)
|
||||
if ok:
|
||||
self._set_connection_status("Connected")
|
||||
self._connection_status = "Connected"
|
||||
self._append_log("Erase command accepted — wait ~15 seconds")
|
||||
else:
|
||||
self._set_connection_status("Disconnected")
|
||||
self._connection_status = "Disconnected"
|
||||
self._append_log("Erase command failed")
|
||||
self.eraseResult.emit({"success": ok})
|
||||
self._set_operation_progress(False)
|
||||
|
||||
@Slot(str)
|
||||
@slot_error_boundary
|
||||
def readDebug(self, port: str) -> None:
|
||||
"""Read both D1 (sensor) and F1 (debug/cold-junction) data."""
|
||||
if self._operation_in_progress:
|
||||
self._append_log("Already busy — ignoring debug read request")
|
||||
return
|
||||
"""Read both D1 (sensor) and F1 (debug/cold-junction) data.
|
||||
|
||||
Emits debugResult with counts on success.
|
||||
"""
|
||||
self._set_operation_progress(True)
|
||||
self._set_connection_status("Reading debug...")
|
||||
self._connection_status = "Reading debug..."
|
||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||
self._append_log(f"Reading debug data on {port} ...")
|
||||
|
||||
threading.Thread(
|
||||
target=self._debug_worker,
|
||||
args=(port,),
|
||||
daemon=True,
|
||||
).start()
|
||||
# Step 1: D1 sensor data
|
||||
sensor_data = self._service.read_wafer_data(port, family_code="")
|
||||
if sensor_data is None:
|
||||
self._connection_status = "Disconnected"
|
||||
self._append_log("D1 read failed — aborting debug read")
|
||||
self.debugResult.emit({"error": "D1 read failed"})
|
||||
self._set_operation_progress(False)
|
||||
return
|
||||
|
||||
def _debug_worker(self, port: str) -> None:
|
||||
"""Background thread worker for readDebug."""
|
||||
try:
|
||||
# Step 1: D1 sensor data
|
||||
sensor_data = self._service.read_wafer_data(port, family_code="")
|
||||
if sensor_data is None:
|
||||
self._debugFinished.emit({"error": "D1 read failed"})
|
||||
return
|
||||
self._append_log(f"D1 read: {len(sensor_data)} bytes")
|
||||
|
||||
# Step 2: F1 debug data
|
||||
debug_data = self._service.read_wafer_data(port, family_code="", cmd="F1")
|
||||
if debug_data is None:
|
||||
self._debugFinished.emit({"error": "F1 read failed"})
|
||||
return
|
||||
# Step 2: F1 debug data
|
||||
debug_data = self._service.read_wafer_data(port, family_code="", cmd="F1")
|
||||
if debug_data is None:
|
||||
self._connection_status = "Disconnected"
|
||||
self._append_log("F1 read failed")
|
||||
self.debugResult.emit({"error": "F1 read failed"})
|
||||
self._set_operation_progress(False)
|
||||
return
|
||||
|
||||
fc = (
|
||||
self._last_wafer_info.get("familyCode", "")
|
||||
if self._last_wafer_info
|
||||
else ""
|
||||
)
|
||||
sensor_hex = parse_binary_data(sensor_data, fc)
|
||||
debug_hex = parse_binary_data(debug_data, fc)
|
||||
if not sensor_hex or not debug_hex:
|
||||
self._debugFinished.emit({"error": "Debug binary parse failed"})
|
||||
return
|
||||
|
||||
temp_data = convert_to_temperatures(sensor_hex, fc)
|
||||
debug_temp_data = convert_to_debug_temperatures(debug_hex)
|
||||
csv_path = save_debug_csv(temp_data, debug_temp_data, self._save_data_dir)
|
||||
if csv_path is None:
|
||||
self._debugFinished.emit({"error": "Debug CSV save failed"})
|
||||
return
|
||||
|
||||
self._debugFinished.emit({
|
||||
"success": True,
|
||||
"sensor_bytes": len(sensor_data),
|
||||
"debug_bytes": len(debug_data),
|
||||
"csv_path": csv_path,
|
||||
})
|
||||
except Exception as exc:
|
||||
log.exception("Debug worker crashed: %s", exc)
|
||||
self._debugFinished.emit({"error": str(exc)})
|
||||
|
||||
@Slot(dict)
|
||||
@slot_error_boundary
|
||||
def _handle_debug_finished(self, result: dict) -> None:
|
||||
"""Main thread completion handler for readDebug."""
|
||||
if result.get("success"):
|
||||
self._set_connection_status("Connected")
|
||||
sensor_bytes = result.get("sensor_bytes", 0)
|
||||
debug_bytes = result.get("debug_bytes", 0)
|
||||
csv_path = result.get("csv_path", "")
|
||||
msg = f"Debug read complete: {sensor_bytes} sensor bytes, {debug_bytes} debug bytes"
|
||||
if csv_path:
|
||||
msg += f", CSV written to {csv_path}"
|
||||
self._append_log(msg)
|
||||
else:
|
||||
self._set_connection_status("Disconnected")
|
||||
err_msg = result.get("error", "Debug read failed")
|
||||
self._append_log(err_msg)
|
||||
|
||||
self.debugResult.emit(result)
|
||||
self._connection_status = "Connected"
|
||||
self._append_log(
|
||||
f"Debug read complete: {len(sensor_data)} sensor bytes, "
|
||||
f"{len(debug_data)} debug bytes"
|
||||
)
|
||||
self.debugResult.emit({
|
||||
"success": True,
|
||||
"sensor_bytes": len(sensor_data),
|
||||
"debug_bytes": len(debug_data),
|
||||
})
|
||||
self._set_operation_progress(False)
|
||||
|
||||
@Slot(str, str)
|
||||
@slot_error_boundary
|
||||
def parseAndSaveData(self, family_code: str = "", port: str = "") -> None:
|
||||
"""Parse raw bytes into temperatures and save to CSV.
|
||||
|
||||
@@ -623,9 +419,12 @@ class DeviceController(QObject):
|
||||
return
|
||||
|
||||
if not self._save_data_dir:
|
||||
self._save_data_dir = str(Path(self._data_dir) / "csv")
|
||||
self._append_log(f"Auto-set save directory to: {self._save_data_dir}")
|
||||
self._save_status()
|
||||
self._append_log("No save data directory set")
|
||||
self.parsedDataReady.emit({
|
||||
"success": False,
|
||||
"error": "No save data directory set",
|
||||
})
|
||||
return
|
||||
|
||||
fc = family_code or (
|
||||
self._last_wafer_info.get("familyCode", "")
|
||||
@@ -672,7 +471,6 @@ class DeviceController(QObject):
|
||||
return
|
||||
|
||||
self._append_log(f"Saved CSV: {csv_path}")
|
||||
self._last_csv_path = csv_path
|
||||
|
||||
# Load data into the QAbstractTableModel
|
||||
self._data_model.load_data(temp_data, cols)
|
||||
@@ -706,36 +504,18 @@ class DeviceController(QObject):
|
||||
self._settings.last_csv_path = self._last_csv_path
|
||||
|
||||
# Save to disk
|
||||
LocalSettings.save_settings(self._data_dir, self._settings)
|
||||
LocalSettings.save_settings(LocalSettings._settings_path(self._data_dir), self._settings)
|
||||
|
||||
# ---- Helpers ----
|
||||
|
||||
@staticmethod
|
||||
def _wafer_info_to_dict(info: WaferInfo) -> dict[str, Any]:
|
||||
"""Convert WaferInfo dataclass to JSON-serialisable dict for QML."""
|
||||
from pygui.serialcomm.data_parser import csv_column_count
|
||||
sensor_count = info.sensor_count
|
||||
mapped_count = csv_column_count(info.family_code)
|
||||
if mapped_count > 0:
|
||||
sensor_count = mapped_count
|
||||
|
||||
return {
|
||||
"familyCode": info.family_code,
|
||||
"serialNumber": info.serial_number,
|
||||
"sensorCount": sensor_count,
|
||||
"sensorCount": info.sensor_count,
|
||||
"mfgDateHex": info.mfg_date_hex,
|
||||
"runtime": info.runtime,
|
||||
"cycleCount": info.cycle_count,
|
||||
}
|
||||
|
||||
class QmlActivityLogHandler(logging.Handler):
|
||||
def __init__(self, controller: DeviceController) -> None:
|
||||
super().__init__()
|
||||
self._controller = controller
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
message = record.getMessage()
|
||||
if "found wafer on" in message:
|
||||
return
|
||||
level = record.levelname
|
||||
msg = f"{level}: {message}"
|
||||
self._controller._append_log(msg)
|
||||
@@ -5,12 +5,11 @@ import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import Property, QObject, Signal, Slot
|
||||
from PySide6.QtCore import QObject, Property, QStandardPaths, Signal, Slot
|
||||
from PySide6.QtWidgets import QFileDialog, QMessageBox
|
||||
|
||||
from pygui.backend.data.constants import default_data_dir
|
||||
from pygui.backend.data.csv_file_metadata import CSVFileMetadata
|
||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||
from backend.csv_file_metadata import CSVFileMetadata
|
||||
from backend.zwafer_parser import ZWaferParser
|
||||
|
||||
|
||||
# ===== File Browser Model =====
|
||||
@@ -28,7 +27,7 @@ class FileBrowser(QObject):
|
||||
self._refresh_files(show_empty_message=False)
|
||||
|
||||
# ===== Exposed Properties =====
|
||||
@Property("QVariantList", notify=filesChanged) # type: ignore[arg-type]
|
||||
@Property("QVariantList", notify=filesChanged)
|
||||
def files(self) -> list[dict[str, object]]:
|
||||
return [dict(row) for row in self._files]
|
||||
|
||||
@@ -37,22 +36,12 @@ class FileBrowser(QObject):
|
||||
return str(self._current_directory)
|
||||
|
||||
# ===== Directory Selection =====
|
||||
@Slot(str)
|
||||
def setCurrentDirectory(self, path: str) -> None:
|
||||
"""Point the browser at `path` without opening a native dialog.
|
||||
|
||||
Used to keep this directory in sync with DeviceController.saveDataDir
|
||||
when the user picks a save folder elsewhere in the app.
|
||||
"""
|
||||
self._set_current_directory(Path(path))
|
||||
self._refresh_files(show_empty_message=False)
|
||||
|
||||
@Slot()
|
||||
def chooseDirectory(self) -> None:
|
||||
selected_dir = QFileDialog.getExistingDirectory(
|
||||
None,
|
||||
"Select CSV Folder",
|
||||
str(self._current_directory),
|
||||
self.currentDirectory,
|
||||
)
|
||||
if not selected_dir:
|
||||
return
|
||||
@@ -81,16 +70,17 @@ class FileBrowser(QObject):
|
||||
self._show_error(f"CSV file was not found:\n{file_path}")
|
||||
return
|
||||
|
||||
# ponytail: row is absent when saving metadata for a CSV just read from
|
||||
# the wafer (not in the browser listing) — still write the sidecar.
|
||||
row = self._find_row(file_path)
|
||||
if row is not None:
|
||||
row["wafer"] = wafer
|
||||
row["date"] = date
|
||||
row["chamber"] = chamber
|
||||
row["notes"] = notes
|
||||
row["selected"] = selected
|
||||
row["masterType"] = master_type
|
||||
if row is None:
|
||||
self._show_error("The selected CSV row is no longer available.")
|
||||
return
|
||||
|
||||
row["wafer"] = wafer
|
||||
row["date"] = date
|
||||
row["chamber"] = chamber
|
||||
row["notes"] = notes
|
||||
row["selected"] = selected
|
||||
row["masterType"] = master_type
|
||||
|
||||
payload = {
|
||||
"wafer": wafer,
|
||||
@@ -128,63 +118,31 @@ class FileBrowser(QObject):
|
||||
return
|
||||
|
||||
for csv_path in sorted(self._current_directory.glob("*.csv")):
|
||||
# Recorded-live CSVs are named "live_<serial>_<timestamp>.csv" by
|
||||
# SessionController.startRecording (see WaferMapTab's Record button),
|
||||
# distinct from the "<serial>-<timestamp>.csv" convention used by
|
||||
# DeviceController.parseAndSaveData for read-memory dumps.
|
||||
is_recording = csv_path.stem.lower().startswith("live_")
|
||||
try:
|
||||
metadata = self._load_metadata(csv_path)
|
||||
wafer_type = metadata.get_wafer_type().upper() or (csv_path.stem[0].upper() if csv_path.stem else "")
|
||||
date_str = metadata.string_date_format()
|
||||
serial = metadata.wafer[1:] if len(metadata.wafer) > 1 else ""
|
||||
if not serial:
|
||||
stem = csv_path.stem
|
||||
serial = stem[1:] if len(stem) > 1 else ""
|
||||
if "-" in serial:
|
||||
serial = serial.split("-")[0]
|
||||
if "_" in serial:
|
||||
serial = serial.split("_")[0]
|
||||
time_str = date_str[11:] if len(date_str) > 10 else ""
|
||||
self._files.append(
|
||||
{
|
||||
"selected": selected_state.get(str(csv_path), False),
|
||||
"baseName": csv_path.stem,
|
||||
"wafer": metadata.wafer,
|
||||
"waferType": wafer_type,
|
||||
"serialNumber": serial,
|
||||
"date": date_str,
|
||||
"timeStr": time_str,
|
||||
"date": metadata.string_date_format(),
|
||||
"chamber": metadata.chamber,
|
||||
"notes": metadata.notes,
|
||||
"masterType": master_state.get(str(csv_path), "") or metadata.master_type,
|
||||
"fileName": str(csv_path),
|
||||
"highlight": wafer_type in {"A", "B", "C"},
|
||||
"isRecording": is_recording,
|
||||
"highlight": metadata.get_wafer_type() in {"A", "B", "C"},
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
stem = csv_path.stem
|
||||
serial_fallback = stem[1:] if len(stem) > 1 else ""
|
||||
if "-" in serial_fallback:
|
||||
serial_fallback = serial_fallback.split("-")[0]
|
||||
if "_" in serial_fallback:
|
||||
serial_fallback = serial_fallback.split("_")[0]
|
||||
self._files.append(
|
||||
{
|
||||
"selected": selected_state.get(str(csv_path), False),
|
||||
"baseName": stem,
|
||||
"wafer": stem,
|
||||
"waferType": stem[0].upper() if stem else "",
|
||||
"serialNumber": serial_fallback,
|
||||
"wafer": csv_path.stem,
|
||||
"date": "",
|
||||
"timeStr": "",
|
||||
"chamber": "",
|
||||
"notes": "Unable to parse metadata",
|
||||
"masterType": master_state.get(str(csv_path), ""),
|
||||
"fileName": str(csv_path),
|
||||
"highlight": False,
|
||||
"isRecording": is_recording,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -210,19 +168,25 @@ class FileBrowser(QObject):
|
||||
# Fall back to CSV/header parsing if sidecar is malformed.
|
||||
pass
|
||||
|
||||
meta = self._metadata_from_filename(csv_path)
|
||||
parser_data, _ = self._parser.parse(str(csv_path))
|
||||
if parser_data is not None:
|
||||
if parser_data.serial:
|
||||
meta.wafer = parser_data.serial
|
||||
wafer = parser_data.serial or ""
|
||||
date_text = ""
|
||||
if parser_data.date != datetime.min:
|
||||
meta.date = CSVFileMetadata.format_date(parser_data.date)
|
||||
date_text = CSVFileMetadata.format_date(parser_data.date)
|
||||
return CSVFileMetadata(
|
||||
wafer=wafer,
|
||||
date=date_text,
|
||||
chamber="",
|
||||
notes="",
|
||||
filename=str(csv_path),
|
||||
)
|
||||
|
||||
return meta
|
||||
return self._metadata_from_filename(csv_path)
|
||||
|
||||
def _metadata_from_filename(self, csv_path: Path) -> CSVFileMetadata:
|
||||
match = re.match(
|
||||
r"^(?P<wafer>[^-]+)-(?P<date>\d{8})(?:_(?P<time>\d{6}))?$",
|
||||
r"^(?P<wafer>[^-]+)-(?P<date>\d{8})(?:_(?P<run>\d+))?$",
|
||||
csv_path.stem,
|
||||
)
|
||||
if not match:
|
||||
@@ -230,9 +194,7 @@ class FileBrowser(QObject):
|
||||
|
||||
date_text = ""
|
||||
try:
|
||||
date_part = match.group("date")
|
||||
time_part = match.group("time") or "000000"
|
||||
parsed = datetime.strptime(date_part + time_part, "%Y%m%d%H%M%S")
|
||||
parsed = datetime.strptime(match.group("date"), "%Y%m%d")
|
||||
date_text = CSVFileMetadata.format_date(parsed)
|
||||
except ValueError:
|
||||
date_text = ""
|
||||
@@ -245,7 +207,11 @@ class FileBrowser(QObject):
|
||||
|
||||
# ===== Internal Helpers =====
|
||||
def _resolve_default_directory(self) -> Path:
|
||||
return default_data_dir()
|
||||
documents_dir = QStandardPaths.writableLocation(
|
||||
QStandardPaths.DocumentsLocation
|
||||
)
|
||||
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
|
||||
return base_dir / "isc_data"
|
||||
|
||||
def _set_current_directory(self, directory: Path) -> None:
|
||||
normalized = Path(directory)
|
||||
@@ -270,7 +236,7 @@ class FileBrowser(QObject):
|
||||
def showInfo(self, message: str) -> None:
|
||||
self._show_info(message)
|
||||
|
||||
@Slot(str, result=dict)
|
||||
@Slot(str, result="QVariantMap")
|
||||
def parseCsvMetadata(self, file_path: str) -> dict:
|
||||
from pathlib import Path as _Path
|
||||
|
||||
@@ -9,13 +9,16 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import pyqtgraph as pg
|
||||
from pyqtgraph import PlotWidget
|
||||
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
|
||||
from PySide6.QtCore import QObject, Property, Signal, Slot
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Import pyqtgraph after Qt is initialized
|
||||
import pyqtgraph as pg
|
||||
from pyqtgraph import PlotWidget
|
||||
|
||||
|
||||
class GraphView(QObject):
|
||||
"""QML-exposed controller for a pyqtgraph line chart.
|
||||
|
||||
@@ -23,9 +26,6 @@ class GraphView(QObject):
|
||||
each sensor as a separate line series.
|
||||
"""
|
||||
|
||||
# Set lazily in updateTrend (guarded by hasattr); declared for mypy.
|
||||
_trend_line: Any
|
||||
|
||||
# ---- signals ----
|
||||
dataReady = Signal(object) # {"success": bool, "sensory_names": list, "series": list}
|
||||
|
||||
@@ -167,71 +167,3 @@ class GraphView(QObject):
|
||||
self._plot_window = None
|
||||
self._plot_widget = None
|
||||
self._series = []
|
||||
|
||||
@Slot(str)
|
||||
def updateTrend(self, avgs_json: str) -> None:
|
||||
"""Plot the running avg temp series."""
|
||||
import json
|
||||
if not self._plot_widget:
|
||||
return
|
||||
try:
|
||||
avgs = json.loads(avgs_json)
|
||||
except (json.JSONDecodeError, TypeError) as exc:
|
||||
log.error("Failed to parse trend data: %s", exc)
|
||||
return
|
||||
|
||||
if hasattr(self,'_trend_line') and self._trend_line is not None:
|
||||
self._plot_widget.removeItem(self._trend_line)
|
||||
|
||||
if not avgs:
|
||||
return
|
||||
|
||||
x = list(range(len(avgs)))
|
||||
pen = pg.mkPen("#5B9DF5", width=2)
|
||||
self._trend_line = self._plot_widget.plot(x, avgs, name="Average", pen=pen)
|
||||
|
||||
# Y-axis range with buffer
|
||||
y_min, y_max = min(avgs), max(avgs)
|
||||
buf = max((y_max - y_min) * 0.1, 1.0) if y_max > y_min else 5.0
|
||||
self._plot_widget.setYRange(y_min - buf, y_max + buf)
|
||||
|
||||
@Slot(str,str,str)
|
||||
def updateComparison(self, series_a_json: str, series_b_json: str,path_json: str) -> None:
|
||||
"""Overlay two runs with DTW warping path connections.
|
||||
|
||||
Args:
|
||||
series_a_json: JSON list of floats -- Run Aaverage temps.
|
||||
series_b_json: JSON list of floats -- Run B average temps.
|
||||
path_json: JSON list of [index_a, index_b] paris.
|
||||
"""
|
||||
import json
|
||||
if not self._plot_widget:
|
||||
return
|
||||
|
||||
self._plot_widget.clear()
|
||||
|
||||
try:
|
||||
a = json.loads(series_a_json)
|
||||
b = json.loads(series_b_json)
|
||||
path = json.loads(path_json)
|
||||
except (json.JSONDecodeError, TypeError) as exc:
|
||||
log.error("Failed to parse comparison data: %s", exc)
|
||||
return
|
||||
|
||||
pen_a = pg.mkPen("#5B9DF5", width=2) #blue
|
||||
pen_b = pg.mkPen("#22C55E", width=2) #green
|
||||
|
||||
self._plot_widget.plot(list(range(len(a))), a, name="Run A", pen=pen_a)
|
||||
self._plot_widget.plot(list(range(len(b))), b, name="Run B", pen=pen_b)
|
||||
|
||||
# Draw every 20th warping link as vertical dotted lines
|
||||
step = max(1, len(path)//20)
|
||||
for i in range (0,len(path),step):
|
||||
index_a, index_b = path[i]
|
||||
if index_a < len(a) and index_b < len(b):
|
||||
line = pg.InfiniteLine(pos=index_a, angle=90, pen=pg.mkPen("#666666", width=1,
|
||||
style=Qt.PenStyle.DashLine))
|
||||
self._plot_widget.addItem(line)
|
||||
|
||||
self._plot_widget.setTitle("DTW Comparison")
|
||||
self._plot_widget.setLabel("left", "Temperature", units="°C")
|
||||
@@ -1,10 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ===== Settings Persistence Model =====
|
||||
class LocalSettings:
|
||||
@@ -13,8 +10,8 @@ class LocalSettings:
|
||||
# Configuration settings
|
||||
self.chamber_id = ""
|
||||
self.reverse_z_wafer = False
|
||||
self.master: dict[str, str] = {}
|
||||
self.wafer_data_size: dict[str, int] = {}
|
||||
self.master = {} # Dict[str, str]
|
||||
self.wafer_data_size = {} # Dict[str, int]
|
||||
self.debug = False
|
||||
self.wafer_read_retries = 8
|
||||
# Timeout in msec for reading from the wafer (default 2 min)
|
||||
@@ -54,8 +51,7 @@ class LocalSettings:
|
||||
if hasattr(settings, key):
|
||||
setattr(settings, key, value)
|
||||
except Exception as e:
|
||||
log = logging.getLogger(__name__)
|
||||
log.warning("Error reading setting file: %s",e)
|
||||
print(f"Error reading setting file: {e}")
|
||||
|
||||
return settings
|
||||
|
||||
@@ -70,7 +66,7 @@ class LocalSettings:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=4)
|
||||
except Exception as e:
|
||||
log.exception("Error saving settings: %s",e)
|
||||
print(f"Error saving settings: {e}")
|
||||
|
||||
# ===== Master File Helpers =====
|
||||
@classmethod
|
||||
@@ -2,26 +2,17 @@ from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import Property, QDateTime, QObject, Signal, Slot
|
||||
from PySide6.QtCore import QObject, Property, QDateTime, QStandardPaths, Signal, Slot
|
||||
|
||||
from backend.local_settings import LocalSettings
|
||||
|
||||
from pygui.backend.data.local_settings import LocalSettings
|
||||
|
||||
MASTER_FAMILIES = ("A", "B", "C", "D", "E", "F", "P", "X", "Z")
|
||||
|
||||
|
||||
class LocalSettingsModel(QObject):
|
||||
# Draft attrs are seeded via setattr() in __init__ from _new_defaults();
|
||||
# declared here so mypy knows their types.
|
||||
_chamber_id: str
|
||||
_reverse_z_wafer: bool
|
||||
_debug_mode: bool
|
||||
_wafer_read_timeout: int
|
||||
_wafer_detect_timeout: int
|
||||
_wafer_retries: int
|
||||
_masters: dict[str, str]
|
||||
|
||||
chamberIdChanged = Signal()
|
||||
reverseZWaferChanged = Signal()
|
||||
debugModeChanged = Signal()
|
||||
@@ -35,35 +26,18 @@ class LocalSettingsModel(QObject):
|
||||
saveStatusChanged = Signal()
|
||||
lastSavedAtChanged = Signal()
|
||||
|
||||
# Draft attr (this class) <-> LocalSettings attr (the shared, persisted
|
||||
# instance also held by DeviceController) <-> coercer applied on both
|
||||
# load and revert/reset. One table drives load/save/revert/reset instead
|
||||
# of four hand-written field-by-field copies.
|
||||
_FIELD_MAP: tuple[tuple[str, str, Callable[[Any], Any]], ...] = (
|
||||
("_chamber_id", "chamber_id", str),
|
||||
("_reverse_z_wafer", "reverse_z_wafer", bool),
|
||||
("_debug_mode", "debug", bool),
|
||||
("_wafer_read_timeout", "wafer_read_timeout", int),
|
||||
("_wafer_detect_timeout", "wafer_detect_timeout", int),
|
||||
("_wafer_retries", "wafer_read_retries", int),
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: LocalSettings,
|
||||
data_dir: Path | str,
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
# Shared with DeviceController — saving here must mutate this same
|
||||
# instance, not a throwaway copy, or the device layer keeps using
|
||||
# stale config until restart.
|
||||
self._settings = settings
|
||||
self._data_dir = Path(data_dir)
|
||||
self._data_dir = self._resolve_data_dir()
|
||||
self._defaults = self._new_defaults()
|
||||
|
||||
for draft_attr, default_value in self._defaults.items():
|
||||
setattr(self, draft_attr, deepcopy(default_value))
|
||||
self._chamber_id = self._defaults["chamberId"]
|
||||
self._reverse_z_wafer = self._defaults["reverseZWafer"]
|
||||
self._debug_mode = self._defaults["debugMode"]
|
||||
self._wafer_read_timeout = self._defaults["waferReadTimeout"]
|
||||
self._wafer_detect_timeout = self._defaults["waferDetectTimeout"]
|
||||
self._wafer_retries = self._defaults["waferRetries"]
|
||||
self._masters = deepcopy(self._defaults["masters"])
|
||||
|
||||
self._is_dirty = False
|
||||
self._is_valid = True
|
||||
@@ -73,21 +47,34 @@ class LocalSettingsModel(QObject):
|
||||
self._saved_snapshot = self._snapshot()
|
||||
self._recompute_derived()
|
||||
|
||||
def _resolve_data_dir(self) -> Path:
|
||||
documents_dir = QStandardPaths.writableLocation(
|
||||
QStandardPaths.DocumentsLocation
|
||||
)
|
||||
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
|
||||
return base_dir / "isc_data"
|
||||
|
||||
def _new_defaults(self) -> dict[str, Any]:
|
||||
return {
|
||||
"_chamber_id": "2",
|
||||
"_reverse_z_wafer": False,
|
||||
"_debug_mode": False,
|
||||
"_wafer_read_timeout": 120000,
|
||||
"_wafer_detect_timeout": 5000,
|
||||
"_wafer_retries": 8,
|
||||
"_masters": {family: "" for family in MASTER_FAMILIES},
|
||||
"chamberId": "2",
|
||||
"reverseZWafer": False,
|
||||
"debugMode": False,
|
||||
"waferReadTimeout": 120000,
|
||||
"waferDetectTimeout": 5000,
|
||||
"waferRetries": 8,
|
||||
"masters": {family: "" for family in MASTER_FAMILIES},
|
||||
}
|
||||
|
||||
def _snapshot(self) -> dict[str, Any]:
|
||||
snap = {attr: getattr(self, attr) for attr, _, _ in self._FIELD_MAP}
|
||||
snap["_masters"] = deepcopy(self._masters)
|
||||
return snap
|
||||
return {
|
||||
"chamberId": self._chamber_id,
|
||||
"reverseZWafer": self._reverse_z_wafer,
|
||||
"debugMode": self._debug_mode,
|
||||
"waferReadTimeout": self._wafer_read_timeout,
|
||||
"waferDetectTimeout": self._wafer_detect_timeout,
|
||||
"waferRetries": self._wafer_retries,
|
||||
"masters": deepcopy(self._masters),
|
||||
}
|
||||
|
||||
def _set_is_dirty(self, value: bool) -> None:
|
||||
if self._is_dirty != value:
|
||||
@@ -150,19 +137,22 @@ class LocalSettingsModel(QObject):
|
||||
self.waferRetriesChanged.emit()
|
||||
self.mastersChanged.emit()
|
||||
|
||||
def _normalized_masters(self, source: dict[str, str]) -> dict[str, str]:
|
||||
masters = {family: "" for family in MASTER_FAMILIES}
|
||||
for family, value in source.items():
|
||||
normalized = str(family).strip().upper()
|
||||
if normalized in masters:
|
||||
masters[normalized] = str(value).strip()
|
||||
return masters
|
||||
def _to_local_settings(self) -> LocalSettings:
|
||||
settings = LocalSettings()
|
||||
settings.chamber_id = self._chamber_id
|
||||
settings.reverse_z_wafer = self._reverse_z_wafer
|
||||
settings.debug = self._debug_mode
|
||||
settings.wafer_read_timeout = self._wafer_read_timeout
|
||||
settings.wafer_detect_timeout = self._wafer_detect_timeout
|
||||
settings.wafer_read_retries = self._wafer_retries
|
||||
settings.master = deepcopy(self._masters)
|
||||
return settings
|
||||
|
||||
@Property(str, notify=chamberIdChanged)
|
||||
def chamberId(self) -> str:
|
||||
return self._chamber_id
|
||||
|
||||
@chamberId.setter # type: ignore[no-redef]
|
||||
@chamberId.setter
|
||||
def chamberId(self, value: str) -> None:
|
||||
next_value = str(value).strip()
|
||||
if self._chamber_id == next_value:
|
||||
@@ -175,7 +165,7 @@ class LocalSettingsModel(QObject):
|
||||
def reverseZWafer(self) -> bool:
|
||||
return self._reverse_z_wafer
|
||||
|
||||
@reverseZWafer.setter # type: ignore[no-redef]
|
||||
@reverseZWafer.setter
|
||||
def reverseZWafer(self, value: bool) -> None:
|
||||
if self._reverse_z_wafer == value:
|
||||
return
|
||||
@@ -187,7 +177,7 @@ class LocalSettingsModel(QObject):
|
||||
def debugMode(self) -> bool:
|
||||
return self._debug_mode
|
||||
|
||||
@debugMode.setter # type: ignore[no-redef]
|
||||
@debugMode.setter
|
||||
def debugMode(self, value: bool) -> None:
|
||||
if self._debug_mode == value:
|
||||
return
|
||||
@@ -199,7 +189,7 @@ class LocalSettingsModel(QObject):
|
||||
def waferReadTimeout(self) -> int:
|
||||
return self._wafer_read_timeout
|
||||
|
||||
@waferReadTimeout.setter # type: ignore[no-redef]
|
||||
@waferReadTimeout.setter
|
||||
def waferReadTimeout(self, value: int) -> None:
|
||||
if self._wafer_read_timeout == value:
|
||||
return
|
||||
@@ -211,7 +201,7 @@ class LocalSettingsModel(QObject):
|
||||
def waferDetectTimeout(self) -> int:
|
||||
return self._wafer_detect_timeout
|
||||
|
||||
@waferDetectTimeout.setter # type: ignore[no-redef]
|
||||
@waferDetectTimeout.setter
|
||||
def waferDetectTimeout(self, value: int) -> None:
|
||||
if self._wafer_detect_timeout == value:
|
||||
return
|
||||
@@ -223,7 +213,7 @@ class LocalSettingsModel(QObject):
|
||||
def waferRetries(self) -> int:
|
||||
return self._wafer_retries
|
||||
|
||||
@waferRetries.setter # type: ignore[no-redef]
|
||||
@waferRetries.setter
|
||||
def waferRetries(self, value: int) -> None:
|
||||
if self._wafer_retries == value:
|
||||
return
|
||||
@@ -231,7 +221,7 @@ class LocalSettingsModel(QObject):
|
||||
self.waferRetriesChanged.emit()
|
||||
self._recompute_derived()
|
||||
|
||||
@Property(dict, notify=mastersChanged)
|
||||
@Property("QVariantMap", notify=mastersChanged)
|
||||
def masters(self) -> dict[str, str]:
|
||||
return dict(self._masters)
|
||||
|
||||
@@ -261,11 +251,22 @@ class LocalSettingsModel(QObject):
|
||||
|
||||
@Slot()
|
||||
def loadSettings(self) -> None:
|
||||
"""Seed draft fields from the shared LocalSettings instance."""
|
||||
for draft_attr, settings_attr, coerce in self._FIELD_MAP:
|
||||
setattr(self, draft_attr, coerce(getattr(self._settings, settings_attr)))
|
||||
self._chamber_id = self._chamber_id.strip() or str(self._defaults["_chamber_id"])
|
||||
self._masters = self._normalized_masters(self._settings.master)
|
||||
loaded = LocalSettings.read_settings(str(self._data_dir))
|
||||
self._chamber_id = str(loaded.chamber_id).strip() or str(
|
||||
self._defaults["chamberId"]
|
||||
)
|
||||
self._reverse_z_wafer = bool(loaded.reverse_z_wafer)
|
||||
self._debug_mode = bool(loaded.debug)
|
||||
self._wafer_read_timeout = int(loaded.wafer_read_timeout)
|
||||
self._wafer_detect_timeout = int(loaded.wafer_detect_timeout)
|
||||
self._wafer_retries = int(loaded.wafer_read_retries)
|
||||
|
||||
masters = {family: "" for family in MASTER_FAMILIES}
|
||||
for family, value in loaded.master.items():
|
||||
normalized = str(family).strip().upper()
|
||||
if normalized in masters:
|
||||
masters[normalized] = str(value).strip()
|
||||
self._masters = masters
|
||||
|
||||
self._saved_snapshot = self._snapshot()
|
||||
self._emit_all_changed()
|
||||
@@ -274,18 +275,13 @@ class LocalSettingsModel(QObject):
|
||||
|
||||
@Slot()
|
||||
def saveSettings(self) -> None:
|
||||
"""Write draft fields onto the shared LocalSettings instance and persist it."""
|
||||
self._recompute_derived()
|
||||
if not self._is_valid:
|
||||
self._set_save_status("error: invalid settings")
|
||||
return
|
||||
|
||||
try:
|
||||
for draft_attr, settings_attr, _coerce in self._FIELD_MAP:
|
||||
setattr(self._settings, settings_attr, getattr(self, draft_attr))
|
||||
self._settings.master = deepcopy(self._masters)
|
||||
LocalSettings.save_settings(str(self._data_dir), self._settings)
|
||||
|
||||
LocalSettings.save_settings(str(self._data_dir), self._to_local_settings())
|
||||
self._saved_snapshot = self._snapshot()
|
||||
self._recompute_derived()
|
||||
self._set_save_status("Saved")
|
||||
@@ -297,9 +293,13 @@ class LocalSettingsModel(QObject):
|
||||
@Slot()
|
||||
def revertChanges(self) -> None:
|
||||
snap = self._saved_snapshot
|
||||
for draft_attr, _settings_attr, coerce in self._FIELD_MAP:
|
||||
setattr(self, draft_attr, coerce(snap[draft_attr]))
|
||||
self._masters = deepcopy(snap["_masters"])
|
||||
self._chamber_id = str(snap["chamberId"])
|
||||
self._reverse_z_wafer = bool(snap["reverseZWafer"])
|
||||
self._debug_mode = bool(snap["debugMode"])
|
||||
self._wafer_read_timeout = int(snap["waferReadTimeout"])
|
||||
self._wafer_detect_timeout = int(snap["waferDetectTimeout"])
|
||||
self._wafer_retries = int(snap["waferRetries"])
|
||||
self._masters = deepcopy(snap["masters"])
|
||||
|
||||
self._emit_all_changed()
|
||||
self._recompute_derived()
|
||||
@@ -307,9 +307,13 @@ class LocalSettingsModel(QObject):
|
||||
|
||||
@Slot()
|
||||
def resetDefaults(self) -> None:
|
||||
for draft_attr, _settings_attr, coerce in self._FIELD_MAP:
|
||||
setattr(self, draft_attr, coerce(self._defaults[draft_attr]))
|
||||
self._masters = deepcopy(self._defaults["_masters"])
|
||||
self._chamber_id = str(self._defaults["chamberId"])
|
||||
self._reverse_z_wafer = bool(self._defaults["reverseZWafer"])
|
||||
self._debug_mode = bool(self._defaults["debugMode"])
|
||||
self._wafer_read_timeout = int(self._defaults["waferReadTimeout"])
|
||||
self._wafer_detect_timeout = int(self._defaults["waferDetectTimeout"])
|
||||
self._wafer_retries = int(self._defaults["waferRetries"])
|
||||
self._masters = deepcopy(self._defaults["masters"])
|
||||
|
||||
self._emit_all_changed()
|
||||
self._recompute_derived()
|
||||
@@ -330,3 +334,27 @@ class LocalSettingsModel(QObject):
|
||||
@Slot(str)
|
||||
def clearMaster(self, family: str) -> None:
|
||||
self.setMaster(family, "")
|
||||
|
||||
@Slot(str)
|
||||
def setChamberId(self, value: str) -> None:
|
||||
self.chamberId = value
|
||||
|
||||
@Slot(bool)
|
||||
def setReverseZWafer(self, value: bool) -> None:
|
||||
self.reverseZWafer = value
|
||||
|
||||
@Slot(bool)
|
||||
def setDebugMode(self, value: bool) -> None:
|
||||
self.debugMode = value
|
||||
|
||||
@Slot(int)
|
||||
def setWaferReadTimeout(self, value: int) -> None:
|
||||
self.waferReadTimeout = value
|
||||
|
||||
@Slot(int)
|
||||
def setWaferDetectTimeout(self, value: int) -> None:
|
||||
self.waferDetectTimeout = value
|
||||
|
||||
@Slot(int)
|
||||
def setWaferRetries(self, value: int) -> None:
|
||||
self.waferRetries = value
|
||||
@@ -1,8 +1,8 @@
|
||||
from typing import List, Optional, Tuple
|
||||
from typing import List, Tuple, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from pygui.backend.attic.contour_models import ContourLine, ContourSegment
|
||||
from backend.contour_models import ContourLine, ContourSegment
|
||||
|
||||
|
||||
# ===== Contour Generation =====
|
||||
@@ -9,9 +9,6 @@ class Sensor:
|
||||
label: str
|
||||
x: float
|
||||
y: float
|
||||
side: str = "right"
|
||||
offset_x: float = 0.0
|
||||
offset_y: float = 0.0
|
||||
|
||||
|
||||
# ===== Data Row =====
|
||||
@@ -1,8 +1,8 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
from typing import Tuple, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from pygui.backend.wafer.zwafer_models import Sensor, ZWaferData
|
||||
from backend.zwafer_models import ZWaferData, Sensor
|
||||
|
||||
|
||||
# ===== Z-Wafer CSV Parser =====
|
||||
@@ -32,13 +32,7 @@ class ZWaferParser:
|
||||
|
||||
# ===== Header Parsing =====
|
||||
def _process_header(self, file_obj) -> ZWaferData:
|
||||
"""Parse header and sensor layout from open file object.
|
||||
Handles two CSV row orderings:
|
||||
1. Label/X/Y rows BEFORE 'data' (fixture test format)
|
||||
2. 'data' row BEFORE Label/X/Y (some real CSV files)
|
||||
The C# parser handles both by collecting sensor layout rows
|
||||
regardless of whether 'data' has been seen.
|
||||
"""
|
||||
"""Parse header and sensor layout from open file object."""
|
||||
wafer_data = ZWaferData()
|
||||
labels: Optional[list] = None
|
||||
x_coords: Optional[list] = None
|
||||
@@ -53,25 +47,26 @@ class ZWaferParser:
|
||||
parts = [p.strip() for p in line.split(",")]
|
||||
first_part = parts[0].lower()
|
||||
|
||||
# Collect sensor layout rows (Label, X (mm), Y (mm))
|
||||
# regardless of whether 'data' has been seen.
|
||||
# This matches the C# parser behavior.
|
||||
if first_part == "label":
|
||||
labels = parts[1:]
|
||||
elif first_part == "x (mm)":
|
||||
x_coords = parts[1:]
|
||||
elif first_part == "y (mm)":
|
||||
y_coords = parts[1:]
|
||||
# Parse metadata (key=value pairs) — only when not a sensor layout row
|
||||
elif first_part != "data":
|
||||
self._parse_header_line(wafer_data, parts)
|
||||
|
||||
# Detect end of header section
|
||||
if first_part == "data":
|
||||
wafer_data.csv_headers = labels or []
|
||||
self._build_sensor_layout(wafer_data, labels, x_coords, y_coords)
|
||||
return wafer_data
|
||||
|
||||
# Detect switch from metadata to sensor layout
|
||||
# Parse metadata (key=value pairs)
|
||||
if not self._parse_header_line(wafer_data, parts):
|
||||
# If not metadata, it's part of sensor layout
|
||||
label = parts[0].lower()
|
||||
values = parts[1:]
|
||||
|
||||
if label == "label":
|
||||
labels = values
|
||||
elif label == "x (mm)":
|
||||
x_coords = values
|
||||
elif label == "y (mm)":
|
||||
y_coords = values
|
||||
|
||||
return wafer_data # Incomplete file — return partial data
|
||||
|
||||
# ===== Metadata Parsing =====
|
||||
@@ -104,6 +99,9 @@ class ZWaferParser:
|
||||
elif key.lower() == "wafer id":
|
||||
wafer_data.serial = value
|
||||
|
||||
if found_kv and wafer_data.date == datetime.min:
|
||||
wafer_data.date = datetime.min
|
||||
|
||||
return found_kv
|
||||
|
||||
# ===== Sensor Layout Parsing =====
|
||||
@@ -115,7 +113,7 @@ class ZWaferParser:
|
||||
y_coords: Optional[list],
|
||||
) -> None:
|
||||
"""Build sensor list from layout arrays."""
|
||||
if not (labels and x_coords and y_coords):
|
||||
if not all([labels, x_coords, y_coords]):
|
||||
raise ValueError("Sensor layout section is incomplete or missing.")
|
||||
|
||||
if len(labels) != len(x_coords) or len(labels) != len(y_coords):
|
||||
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,45 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtQml import QQmlApplicationEngine
|
||||
from PySide6.QtQuickControls2 import QQuickStyle
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from backend.device_controller import DeviceController
|
||||
from backend.local_settings import LocalSettings
|
||||
from backend.local_settings_model import LocalSettingsModel
|
||||
from backend.file_browser import FileBrowser
|
||||
|
||||
# ===== Application Entry Point =====
|
||||
if __name__ == "__main__":
|
||||
# ===== UI Style Setup =====
|
||||
# Use a non-native controls style so our custom QML button backgrounds are supported.
|
||||
QQuickStyle.setStyle("Basic")
|
||||
|
||||
# ===== Qt Bootstrap =====
|
||||
# Minimal Qt bootstrap: load the ISC QML module and exit if it fails.
|
||||
app = QApplication(sys.argv)
|
||||
engine = QQmlApplicationEngine()
|
||||
|
||||
# ===== Shared Models =====
|
||||
settings_model = LocalSettingsModel()
|
||||
select_file_dialog_model = FileBrowser()
|
||||
settings_model.loadSettings()
|
||||
engine.rootContext().setContextProperty("settingsModel", settings_model)
|
||||
engine.rootContext().setContextProperty("file_browser", select_file_dialog_model)
|
||||
|
||||
# ===== Device Controller (serial comm) =====
|
||||
data_dir = str(settings_model._data_dir)
|
||||
raw_settings = LocalSettings.read_settings(data_dir)
|
||||
device_controller = DeviceController(raw_settings, data_dir)
|
||||
engine.rootContext().setContextProperty("deviceController", device_controller)
|
||||
|
||||
# ===== QML Startup =====
|
||||
engine.addImportPath(Path(__file__).parent)
|
||||
engine.loadFromModule("ISC", "Main")
|
||||
|
||||
# ===== Exit Handling =====
|
||||
if not engine.rootObjects():
|
||||
sys.exit(-1)
|
||||
|
||||
sys.exit(app.exec())
|
||||
@@ -1,130 +1,16 @@
|
||||
# ===== Build System =====
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
# ===== Project Metadata =====
|
||||
[project]
|
||||
name = "pygui"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"PySide6>=6.6.0",
|
||||
"pyqtgraph>=0.13.0",
|
||||
"numpy>=1.24.0",
|
||||
"pyserial>=3.5",
|
||||
"pandas>=2.0.0",
|
||||
"matplotlib>=3.7.0",
|
||||
"scipy>=1.17.1",
|
||||
"pyyaml>=6.0.3",
|
||||
"dtw-python",
|
||||
"cryptography>=41.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest"]
|
||||
|
||||
# ===== Console Entry Point =====
|
||||
[project.scripts]
|
||||
isc = "pygui.__main__:main"
|
||||
|
||||
# ===== Package Discovery (src layout) =====
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
# Ship the QML module alongside the Python package.
|
||||
pygui = ["ISC/**/*.qml", "ISC/**/qmldir"]
|
||||
|
||||
# ===== PySide Build Inputs =====
|
||||
[tool.pyside6-project]
|
||||
files = [
|
||||
"src/pygui/ISC/HomePage.qml",
|
||||
"src/pygui/ISC/Main.qml",
|
||||
"src/pygui/ISC/Theme.qml",
|
||||
"src/pygui/ISC/qmldir",
|
||||
"src/pygui/ISC/Tabs/DataTab.qml",
|
||||
"src/pygui/ISC/Tabs/SelectFileDialog.qml",
|
||||
"src/pygui/ISC/Tabs/SettingsTab.qml",
|
||||
"src/pygui/ISC/Tabs/StatusTab.qml",
|
||||
"src/pygui/ISC/Tabs/qmldir",
|
||||
"src/pygui/__main__.py",
|
||||
"src/pygui/backend/contour_models.py",
|
||||
"src/pygui/backend/crypto/crypto_helper.py",
|
||||
"src/pygui/backend/csv_file_metadata.py",
|
||||
"src/pygui/backend/data_model.py",
|
||||
"src/pygui/backend/data_segment.py",
|
||||
"src/pygui/backend/device_controller.py",
|
||||
"src/pygui/backend/file_browser.py",
|
||||
"src/pygui/backend/frame.py",
|
||||
"src/pygui/backend/graph_view.py",
|
||||
"src/pygui/backend/local_settings.py",
|
||||
"src/pygui/backend/local_settings_model.py",
|
||||
"src/pygui/backend/marching_squares.py",
|
||||
"src/pygui/backend/zwafer_models.py",
|
||||
"src/pygui/backend/zwafer_parser.py",
|
||||
"src/pygui/backend/frame_stats.py",
|
||||
"src/pygui/backend/threshold_classifier.py",
|
||||
"src/pygui/backend/stability_detector.py",
|
||||
"src/pygui/backend/csv_recorder.py",
|
||||
"src/pygui/backend/frame_player.py",
|
||||
"src/pygui/backend/stream_reader.py",
|
||||
"src/pygui/backend/session_model.py",
|
||||
"src/pygui/backend/session_controller.py",
|
||||
"src/pygui/backend/wafer_layouts.py",
|
||||
"src/pygui/backend/rbf_heatmap.py",
|
||||
"src/pygui/backend/wafer_map_item.py",
|
||||
"src/pygui/serialcomm/__init__.py",
|
||||
"src/pygui/serialcomm/data_parser.py",
|
||||
"src/pygui/serialcomm/device_service.py",
|
||||
"src/pygui/serialcomm/serial_port.py",
|
||||
"src/pygui/ISC/Tabs/WaferMapTab.qml",
|
||||
"src/pygui/ISC/Tabs/components/WaferMapView.qml",
|
||||
"src/pygui/ISC/Tabs/components/SourcePanel.qml",
|
||||
"src/pygui/ISC/Tabs/components/ReadoutPanel.qml",
|
||||
"src/pygui/ISC/Tabs/components/TransportBar.qml",
|
||||
"src/pygui/ISC/Tabs/components/ReplaceSensorDialog.qml",
|
||||
"src/pygui/ISC/Tabs/components/qmldir",
|
||||
]
|
||||
files = ["ISC/HomePage.qml", "ISC/Main.qml", "ISC/Tabs/DataTab.qml", "ISC/Tabs/GraphTab.qml", "ISC/Tabs/SelectFileDialog.qml", "ISC/Tabs/SettingsTab.qml", "ISC/Tabs/StatusTab.qml", "ISC/Tabs/qmldir", "ISC/Theme.qml", "ISC/qmldir", "backend/data_model.py", "backend/device_controller.py", "backend/graph_view.py", "backend/file_browser.py", "backend/local_settings.py", "backend/local_settings_model.py", "main.py", "serialcomm/__init__.py", "serialcomm/data_parser.py", "serialcomm/serial_port.py", "serialcomm/device_service.py"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=9.0.3",
|
||||
"ruff",
|
||||
"mypy",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'win32'",
|
||||
"sys_platform == 'linux'",
|
||||
]
|
||||
|
||||
# ===== Ruff Linter and Formatter =====
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 125
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # pyflakes
|
||||
"I", # isort (import sorting)
|
||||
"N", # pep8-naming
|
||||
]
|
||||
ignore = [
|
||||
"N802", # Function name should be lowercase (standard for Qt Slots and Properties)
|
||||
"N815", # Class attribute should not be mixedCase (standard for Qt Properties and Signals)
|
||||
"E702", # Multiple statements on one line (semicolon, standard for inline Qt property setters with self.update())
|
||||
]
|
||||
|
||||
# ===== Mypy Static Type Checker =====
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
check_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
ignore_missing_imports = true
|
||||
|
||||
|
||||
@@ -3,7 +3,3 @@ PySide6>=6.6.0
|
||||
pyqtgraph>=0.13.0
|
||||
numpy>=1.24.0
|
||||
pyserial>=3.5
|
||||
pandas>=2.0.0
|
||||
matplotlib>=3.7.0
|
||||
scipy>=1.10.0
|
||||
pyyaml>=6.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Serial port communication layer for the temperature-sensing wafer."""
|
||||
|
||||
from pygui.serialcomm.device_service import DeviceService
|
||||
from pygui.serialcomm.serial_port import SerialPort, WaferInfo
|
||||
from serialcomm.device_service import DeviceService
|
||||
from serialcomm.serial_port import SerialPort, WaferInfo
|
||||
|
||||
__all__ = ["DeviceService", "SerialPort", "WaferInfo"]
|
||||
@@ -10,13 +10,16 @@ Mirrors the C# Form1.cs binary parsing pipeline:
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pygui.backend.wafer.family_spec import sensor_count_for
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Max DUTs (sensors) per row before overhead bytes are stripped
|
||||
# P wafer: 244 valid readings per 256-block (12 overhead bytes)
|
||||
# X wafer: 80 valid readings per 256-block (14 overhead bytes)
|
||||
MAXDUT_P = 244
|
||||
MAXDUT_X = 80
|
||||
|
||||
|
||||
def csv_column_count(family_code: str) -> int:
|
||||
"""Return the number of columns to display for a family code."""
|
||||
@@ -107,23 +110,6 @@ def _convert_aep(binary_bits: list[int]) -> float:
|
||||
return result
|
||||
|
||||
|
||||
def _convert_debug_temp(hex_str: str) -> float:
|
||||
"""Convert a 4-char hex string to a debug/cold-junction temperature.
|
||||
|
||||
Mirrors C#'s ConvertBinaryArrayToDebugTemp: bits 4-14 scaled by
|
||||
2**(i-8), sign bit at bit 0. No family-code branching — this formula
|
||||
applies regardless of wafer family, unlike _convert_hex_to_temp.
|
||||
"""
|
||||
bits = _hex_to_binary(hex_str)
|
||||
value = 0.0
|
||||
for i in range(4, 15):
|
||||
if bits[i]:
|
||||
value += 2.0 ** (i - 8)
|
||||
if bits[0]:
|
||||
value = -value
|
||||
return round(value, 2)
|
||||
|
||||
|
||||
def _convert_hex_to_temp(hex_str: str, family_code: str) -> float:
|
||||
"""Convert a singi hale 4-char hex string to a float temperature."""
|
||||
bits = _hex_to_binary(hex_str)
|
||||
@@ -170,27 +156,26 @@ def _parse_p_binary(data_bytes: bytes) -> list[list[str]]:
|
||||
"""Parse P-family (and A/B/C/D/E/F) binary data.
|
||||
|
||||
Each block of 256 readings has 244 valid + 12 overhead.
|
||||
Valid readings are chunked into rows of the family's sensor count (244).
|
||||
Valid readings are chunked into rows of MAXDUT_P (244).
|
||||
"""
|
||||
sensor_count = sensor_count_for("P")
|
||||
readings: list[str] = []
|
||||
|
||||
# Read 2 bytes at a time (UInt16 little-endian)
|
||||
# Each 256-word block has `sensor_count` valid readings (first N words), rest are overhead
|
||||
# Each 256-word block has MAXDUT_P valid readings (first N words), rest are overhead
|
||||
num_words = len(data_bytes) // 2
|
||||
for i in range(num_words):
|
||||
value = int.from_bytes(data_bytes[i * 2 : i * 2 + 2], byteorder="little")
|
||||
if i % 256 < sensor_count:
|
||||
if i % 256 < MAXDUT_P:
|
||||
readings.append(f"{value:04X}")
|
||||
|
||||
# Chunk into rows of sensor_count
|
||||
# Chunk into rows of MAXDUT_P
|
||||
result: list[list[str]] = []
|
||||
idx = 0
|
||||
while idx + sensor_count <= len(readings):
|
||||
result.append(readings[idx : idx + sensor_count])
|
||||
idx += sensor_count
|
||||
while idx + MAXDUT_P <= len(readings):
|
||||
result.append(readings[idx : idx + MAXDUT_P])
|
||||
idx += MAXDUT_P
|
||||
|
||||
log.info("Parsed P-family: %d rows × %d cols", len(result), sensor_count)
|
||||
log.info("Parsed P-family: %d rows × %d cols", len(result), MAXDUT_P)
|
||||
return result
|
||||
|
||||
|
||||
@@ -198,25 +183,24 @@ def _parse_x_binary(data_bytes: bytes) -> list[list[str]]:
|
||||
"""Parse X-family binary data.
|
||||
|
||||
Each block of 256 readings has 80 valid + 14 overhead.
|
||||
Valid readings are chunked into rows of the family's sensor count (80).
|
||||
Valid readings are chunked into rows of MAXDUT_X (80).
|
||||
"""
|
||||
sensor_count = sensor_count_for("X")
|
||||
readings: list[str] = []
|
||||
|
||||
num_words = len(data_bytes) // 2
|
||||
for i in range(num_words):
|
||||
value = int.from_bytes(data_bytes[i * 2 : i * 2 + 2], byteorder="little")
|
||||
if i % 256 < sensor_count:
|
||||
if i % 256 < MAXDUT_X:
|
||||
readings.append(f"{value:04X}")
|
||||
|
||||
# Chunk into rows of sensor_count
|
||||
# Chunk into rows of MAXDUT_X
|
||||
result: list[list[str]] = []
|
||||
idx = 0
|
||||
while idx + sensor_count <= len(readings):
|
||||
result.append(readings[idx : idx + sensor_count])
|
||||
idx += sensor_count
|
||||
while idx + MAXDUT_X <= len(readings):
|
||||
result.append(readings[idx : idx + MAXDUT_X])
|
||||
idx += MAXDUT_X
|
||||
|
||||
log.info("Parsed X-family: %d rows × %d cols", len(result), sensor_count)
|
||||
log.info("Parsed X-family: %d rows × %d cols", len(result), MAXDUT_X)
|
||||
return result
|
||||
|
||||
|
||||
@@ -242,15 +226,6 @@ def convert_to_temperatures(
|
||||
return temp_value
|
||||
|
||||
|
||||
def convert_to_debug_temperatures(hex_data: list[list[str]]) -> list[list[str]]:
|
||||
"""Convert debug/cold-junction hex values to temperature strings.
|
||||
|
||||
Same shape contract as convert_to_temperatures, but uses the
|
||||
family-independent debug formula (_convert_debug_temp).
|
||||
"""
|
||||
return [[str(_convert_debug_temp(h)) for h in row] for row in hex_data]
|
||||
|
||||
|
||||
def remove_trailing_zeros(data: list[list[str]]) -> None:
|
||||
"""Remove rows of all-zero values from the end of data (in-place).
|
||||
|
||||
@@ -264,7 +239,7 @@ def remove_trailing_zeros(data: list[list[str]]) -> None:
|
||||
fval = float(val)
|
||||
except ValueError:
|
||||
fval = 99.9
|
||||
if abs(fval) >= 0.01:
|
||||
if fval >= 0.01:
|
||||
is_all_zero = False
|
||||
break
|
||||
if is_all_zero:
|
||||
@@ -293,18 +268,13 @@ def save_to_csv(
|
||||
try:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
# Build filename: P00001-20260505_133045.csv
|
||||
from datetime import datetime
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{serial_number}-{timestamp}.csv"
|
||||
filepath = os.path.join(output_dir, filename)
|
||||
|
||||
# Header count = max of display column count and actual data width.
|
||||
# csv_column_count gives the display sensor count (e.g. 48 for P),
|
||||
# but the binary parser may yield more readings (244 for P). Use the
|
||||
# actual data width so no sensor values are silently dropped.
|
||||
display_cols = csv_column_count(family_code)
|
||||
data_width = max((len(r) for r in data), default=0)
|
||||
num_cols = max(display_cols, data_width)
|
||||
num_cols = csv_column_count(family_code)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
# Header row: Sensor1,Sensor2,...
|
||||
headers = [f"Sensor{j + 1}" for j in range(num_cols)]
|
||||
@@ -320,46 +290,3 @@ def save_to_csv(
|
||||
except Exception as exc:
|
||||
log.error("CSV save failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def save_debug_csv(
|
||||
temp_data: list[list[str]],
|
||||
debug_data: list[list[str]],
|
||||
output_dir: str,
|
||||
) -> Optional[str]:
|
||||
"""Save sensor + debug temperatures side-by-side to debug_info.csv.
|
||||
|
||||
Mirrors C#'s writeDebugCSV: alternating Sensor{j+1},Debug{j+1} columns,
|
||||
truncated to the shorter of the two row/column counts. Fixed filename
|
||||
(not timestamped), unlike save_to_csv.
|
||||
|
||||
Args:
|
||||
temp_data: 2D array of sensor temperature strings (D1, decoded via
|
||||
convert_to_temperatures).
|
||||
debug_data: 2D array of debug temperature strings (F1, decoded via
|
||||
convert_to_debug_temperatures).
|
||||
output_dir: Directory to save the CSV file.
|
||||
|
||||
Returns:
|
||||
Full file path on success, None on empty input or write failure.
|
||||
"""
|
||||
if not temp_data or not debug_data:
|
||||
log.error("Debug CSV save failed: temp or debug data is empty")
|
||||
return None
|
||||
try:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
num_cols = min(len(temp_data[0]), len(debug_data[0]))
|
||||
num_rows = min(len(temp_data), len(debug_data))
|
||||
filepath = os.path.join(output_dir, "debug_info.csv")
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
headers = [f"Sensor{j + 1},Debug{j + 1}" for j in range(num_cols)]
|
||||
f.write(",".join(headers) + "\n")
|
||||
for i in range(num_rows):
|
||||
row = [f"{temp_data[i][j]},{debug_data[i][j]}" for j in range(num_cols)]
|
||||
f.write(",".join(row) + "\n")
|
||||
|
||||
log.info("Saved debug CSV: %d rows × %d cols to %s", num_rows, num_cols, filepath)
|
||||
return filepath
|
||||
except Exception as exc:
|
||||
log.error("Debug CSV save failed: %s", exc)
|
||||
return None
|
||||
@@ -6,12 +6,13 @@ QML via @Slot methods on a QObject controller.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import serial.tools.list_ports
|
||||
|
||||
from pygui.backend.data.local_settings import LocalSettings
|
||||
from pygui.serialcomm.serial_port import SerialPort, WaferInfo
|
||||
from backend.local_settings import LocalSettings
|
||||
from serialcomm.serial_port import SerialPort, WaferInfo
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,14 +20,16 @@ log = logging.getLogger(__name__)
|
||||
class DeviceService:
|
||||
"""High-level wafer device communication."""
|
||||
|
||||
# Expected hex string lengths for known wafer families
|
||||
# P wafer: 393216 hex chars (196608 bytes)
|
||||
# X wafer: 1310720 hex chars (655360 bytes)
|
||||
EXPECTED_HEX_LENGTHS = {
|
||||
"P": 393216,
|
||||
"X": 1310720,
|
||||
}
|
||||
|
||||
def __init__(self, settings: LocalSettings) -> None:
|
||||
self._settings = settings
|
||||
self._cached_ports: list[str] = []
|
||||
self._last_port_scan_time = 0.0
|
||||
|
||||
def clear_port_scan_cache(self) -> None:
|
||||
"""Force the next scan to bypass the cache."""
|
||||
self._last_port_scan_time = 0.0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
@@ -34,65 +37,7 @@ class DeviceService:
|
||||
|
||||
def enumerate_ports(self) -> list[str]:
|
||||
"""Return list of available serial port names."""
|
||||
import time
|
||||
now = time.monotonic()
|
||||
if now - self._last_port_scan_time < 2.0:
|
||||
return self._cached_ports
|
||||
|
||||
hardware_ports = [p.device for p in serial.tools.list_ports.comports()]
|
||||
virtual_ports = []
|
||||
|
||||
# On macOS/Linux, also detect virtual loopback serial ports (PTYs) opened by socat
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
if platform.system() in ("Darwin", "Linux"):
|
||||
try:
|
||||
# Find PTYs opened by socat
|
||||
result = subprocess.run(
|
||||
["lsof", "-c", "socat"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.strip().split()
|
||||
if len(parts) >= 9:
|
||||
fd = parts[3]
|
||||
# Exclude standard FDs (0, 1, 2) which are typically the controlling terminal
|
||||
if fd.startswith(('0', '1', '2')):
|
||||
continue
|
||||
name = parts[-1]
|
||||
match = re.search(r'(/dev/(?:ttys\d+|pts/\d+))\b', name)
|
||||
if match:
|
||||
port = match.group(1)
|
||||
if port not in virtual_ports:
|
||||
virtual_ports.append(port)
|
||||
except Exception as e:
|
||||
log.debug("Error listing virtual ports via lsof: %s", e)
|
||||
|
||||
# Prioritize physical USB serial interfaces first -> generic hardware -> virtual ports
|
||||
# Virtual ports (PTYs detected via lsof) should NOT appear in hardware_ports,
|
||||
# so we filter them out explicitly.
|
||||
virtual_set = set(virtual_ports)
|
||||
ordered_ports = []
|
||||
for p in hardware_ports:
|
||||
if "usbserial" in p.lower() or "usb" in p.lower() or "ttyusb" in p.lower():
|
||||
if p not in ordered_ports:
|
||||
ordered_ports.append(p)
|
||||
for p in hardware_ports:
|
||||
if p not in ordered_ports and p not in virtual_set:
|
||||
ordered_ports.append(p)
|
||||
for p in virtual_ports:
|
||||
if p not in ordered_ports:
|
||||
ordered_ports.append(p)
|
||||
|
||||
self._cached_ports = ordered_ports
|
||||
self._last_port_scan_time = time.monotonic()
|
||||
return ordered_ports
|
||||
|
||||
return [p.device for p in serial.tools.list_ports.comports()]
|
||||
|
||||
def detect_wafer(self, port: str) -> Optional[WaferInfo]:
|
||||
"""Try to detect a wafer on the given port.
|
||||
@@ -201,3 +146,7 @@ class DeviceService:
|
||||
except Exception as exc:
|
||||
log.error("Erase failed on %s: %s", port, exc)
|
||||
return False
|
||||
|
||||
def get_expected_hex_length(self, family_code: str) -> int:
|
||||
"""Return expected hex string length for a wafer family."""
|
||||
return self._settings.get_wafer_data_size(family_code)
|
||||
@@ -46,16 +46,15 @@ class SerialPort:
|
||||
self._port_name = port_name
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Port opening (shared between scoped and long-lived use)
|
||||
# Context manager for scoped port access
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def open_port(port_name: str, timeout: float | None = None) -> pyserial.Serial:
|
||||
"""Open a serial port with baud-rate fallback.
|
||||
|
||||
Tries SerialPort.BAUDRATE first, falls back to 115200 on failure.
|
||||
The caller owns the lifecycle of the returned Serial object."""
|
||||
kwargs = dict(
|
||||
@contextmanager
|
||||
def _open(self, timeout: float | None = None) -> Iterator[pyserial.Serial]:
|
||||
"""Open the serial port, yield it, then close on exit."""
|
||||
port = pyserial.Serial(
|
||||
self._port_name,
|
||||
self.BAUDRATE,
|
||||
parity=pyserial.PARITY_NONE,
|
||||
bytesize=8,
|
||||
stopbits=pyserial.STOPBITS_ONE,
|
||||
@@ -64,26 +63,7 @@ class SerialPort:
|
||||
dsrdtr=False,
|
||||
)
|
||||
if timeout is not None:
|
||||
kwargs["timeout"] = timeout
|
||||
# Write timeout must never be None (= block forever): on macOS,
|
||||
# pseudo-ports like Bluetooth-Incoming-Port accept the open and then
|
||||
# hang the write, which froze detect in "Detecting..." permanently.
|
||||
kwargs["write_timeout"] = timeout if timeout is not None else 2.0
|
||||
|
||||
try:
|
||||
return pyserial.Serial(port_name, SerialPort.BAUDRATE, **kwargs)
|
||||
except Exception as exc:
|
||||
log.warning("Baud rate %d failed on %s, falling back to 115200: %s", SerialPort.BAUDRATE, port_name, exc)
|
||||
return pyserial.Serial(port_name, 115200, **kwargs)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Context manager for scoped port access
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@contextmanager
|
||||
def _open(self, timeout: float | None = None) -> Iterator[pyserial.Serial]:
|
||||
"""Open the serial port, yield it, then close on exit."""
|
||||
port = self.open_port(self._port_name, timeout=timeout)
|
||||
port.timeout = timeout
|
||||
try:
|
||||
yield port
|
||||
finally:
|
||||
@@ -200,6 +180,7 @@ class SerialPort:
|
||||
bytes 1016-1019 : Runtime (2 bytes hex → decimal seconds)
|
||||
bytes 1020-1023 : Cycle time (2 bytes hex → decimal cycles)
|
||||
"""
|
||||
raw = bytes.fromhex(hex_response)
|
||||
|
||||
family_code = self._hex_to_ascii(hex_response[0:2])
|
||||
serial_num = f"{family_code}{int(hex_response[2:6], 16):05d}"
|
||||
@@ -1,203 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Dialogs
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
|
||||
Popup {
|
||||
id: root
|
||||
modal: true
|
||||
dim: true
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
width: 600
|
||||
height: 500
|
||||
anchors.centerIn: Overlay.overlay
|
||||
|
||||
onOpened: licenseModel.refresh()
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusMd
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
FileDialog {
|
||||
id: licenseFileDialog
|
||||
title: "Load License"
|
||||
nameFilters: ["License files (*.bin)"]
|
||||
onAccepted: loadFailedLabel.visible = !licenseModel.loadLicenseFile(selectedFile.toString())
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 28
|
||||
spacing: 20
|
||||
|
||||
// ── Title ──
|
||||
Label {
|
||||
text: "About"
|
||||
font.pixelSize: Theme.font2xl
|
||||
font.bold: true
|
||||
color: Theme.headingColor
|
||||
}
|
||||
|
||||
// ── App info ──
|
||||
ColumnLayout {
|
||||
spacing: 6
|
||||
|
||||
Label {
|
||||
text: "ISenseCloud"
|
||||
font.pixelSize: Theme.fontXl
|
||||
font.bold: true
|
||||
color: Theme.headingColor
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "Version " + appVersion
|
||||
font.pixelSize: Theme.fontMd
|
||||
color: Theme.bodyColor
|
||||
}
|
||||
}
|
||||
|
||||
// ── Separator ──
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.cardBorder
|
||||
}
|
||||
|
||||
// ── License grid ──
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.minimumHeight: 150
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.panelBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 14
|
||||
spacing: 6
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
Label {
|
||||
text: "LICENSE"
|
||||
color: Theme.panelTitleText
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
font.letterSpacing: 0.5
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Label {
|
||||
id: loadFailedLabel
|
||||
visible: false
|
||||
text: "Invalid license file"
|
||||
color: Theme.statusWarningColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
}
|
||||
|
||||
// Grid header
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
Label { text: "Wafer SN"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 150 }
|
||||
Label { text: "Mfg Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 120 }
|
||||
Label { text: "Level"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 80 }
|
||||
Label { text: "License Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.fillWidth: true }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.softBorder
|
||||
}
|
||||
|
||||
ListView {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
clip: true
|
||||
visible: licenseModel.licenses.length > 0
|
||||
model: licenseModel.licenses
|
||||
delegate: RowLayout {
|
||||
width: ListView.view.width
|
||||
spacing: 8
|
||||
Label { text: modelData.serial; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 150 }
|
||||
Label { text: modelData.mfgDate; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 120 }
|
||||
Label { text: modelData.level; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 80 }
|
||||
Label { text: modelData.licDate; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.fillWidth: true }
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
visible: licenseModel.licenses.length === 0
|
||||
text: "No license loaded"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontMd
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Buttons ──
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
Button {
|
||||
text: "Load License"
|
||||
Layout.preferredWidth: 140
|
||||
Layout.preferredHeight: 36
|
||||
onClicked: licenseFileDialog.open()
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||
border.color: Theme.fieldBorder
|
||||
border.width: 1
|
||||
}
|
||||
contentItem: Label {
|
||||
text: parent.text
|
||||
color: Theme.buttonNeutralText
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.bold: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Button {
|
||||
text: "Close"
|
||||
Layout.preferredWidth: 100
|
||||
Layout.preferredHeight: 36
|
||||
onClicked: root.close()
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||
border.color: Theme.fieldBorder
|
||||
border.width: 1
|
||||
}
|
||||
contentItem: Label {
|
||||
text: parent.text
|
||||
color: Theme.buttonNeutralText
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.bold: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,664 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
import ISC.Tabs.components
|
||||
import QtQuick.Dialogs
|
||||
|
||||
// ===== Home Workspace Shell =====
|
||||
// Unified left rail: pill tab bar → context panel → status footer → utility buttons.
|
||||
// Workspace fills the remaining area with the active tab's content.
|
||||
Rectangle {
|
||||
id: root
|
||||
anchors.fill: parent
|
||||
color: Theme.pageBackground
|
||||
clip: true
|
||||
border.color: Theme.outerFrameBorder
|
||||
border.width: Theme.borderStrong
|
||||
|
||||
// ===== View State =====
|
||||
property int selectedTabIndex: 0
|
||||
onSelectedTabIndexChanged: {
|
||||
if (selectedTabIndex === 2) {
|
||||
file_browser.refreshFiles();
|
||||
streamController.unloadFile()
|
||||
} else if (streamController.mode === "live") {
|
||||
// Leaving the map tab mid-stream: stop and blank the live map.
|
||||
streamController.setMode("review")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
property bool memoryRead: false
|
||||
property bool waferDetected: false
|
||||
// TODO P2.2: add `property bool waferLicensed: false`; set it in
|
||||
// onDetectResult below via deviceController.waferLicensed() (slot from
|
||||
// P1.2 — the detectResult payload is opaque in QML, can't read serial
|
||||
// from it). Then READ/ERASE buttons get
|
||||
// `enabled: root.waferDetected && root.waferLicensed`.
|
||||
// See docs/pending/license-gating-plan.md §2.2.
|
||||
|
||||
Connections {
|
||||
target: deviceController
|
||||
function onDetectResult(result){
|
||||
// detectResult payload is Signal(object): a dict may cross into QML
|
||||
// as an opaque wrapper whose fields read as undefined, so only
|
||||
// test truthiness (None on failure, dict on success).
|
||||
root.waferDetected = !!result
|
||||
}
|
||||
function onParsedDataReady(result) {
|
||||
if (result.success && result.csv_path) {
|
||||
streamController.loadFile(result.csv_path)
|
||||
// TODO P3.1: only auto-jump to Map when unlocked:
|
||||
// `&& licenseModel.licenses.length > 0` — otherwise a
|
||||
// successful read walks straight into the locked tab.
|
||||
// See plan §3.1.
|
||||
if (root.selectedTabIndex === 0) {
|
||||
root.selectedTabIndex = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Connections {
|
||||
target: deviceController
|
||||
function onReadResult(result) {
|
||||
root.memoryRead = (result !== null &&
|
||||
result !== undefined &&
|
||||
result.success === true)
|
||||
if (root.memoryRead) {
|
||||
console.log("[P1.1] Memory read complete:", result.bytes, "bytes")
|
||||
} else {
|
||||
console.log("[P1.1] Read failed:", result.error || "unknown")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanFolderUrl(url) {
|
||||
return decodeURIComponent(String(url).replace(/^file:\/\//, ""))
|
||||
}
|
||||
|
||||
function _doDetect() {
|
||||
root.memoryRead = false
|
||||
root.waferDetected = false
|
||||
streamController.setMode("review")
|
||||
streamController.stopStream()
|
||||
deviceController.detectWafer()
|
||||
}
|
||||
|
||||
FolderDialog {
|
||||
id: saveDirDialog
|
||||
title: "Choose a folder to save Data"
|
||||
onAccepted: {
|
||||
var dir = root.cleanFolderUrl(selectedFolder)
|
||||
deviceController.setSaveDataDir(dir)
|
||||
file_browser.setCurrentDirectory(dir)
|
||||
root._doDetect()
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Split Dialog (Threshold Segmentation) =====
|
||||
SplitDialog {
|
||||
id: splitDialog
|
||||
}
|
||||
|
||||
// ===== Settings Popup =====
|
||||
Popup {
|
||||
id: settingsPopup
|
||||
modal: true
|
||||
dim: true
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
width: Math.min(root.width - 80, 800)
|
||||
height: Math.min(root.height - 80, 600)
|
||||
anchors.centerIn: Overlay.overlay
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusMd
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
contentItem: Rectangle {
|
||||
color: Theme.pageBackground
|
||||
radius: Theme.radiusMd
|
||||
clip: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 40
|
||||
color: Theme.panelBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusMd
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 14
|
||||
anchors.rightMargin: 4
|
||||
|
||||
Label {
|
||||
text: "Settings"
|
||||
font.pixelSize: Theme.fontLg
|
||||
font.bold: true
|
||||
color: Theme.headingColor
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "\u2715"
|
||||
flat: true
|
||||
Layout.preferredWidth: 32
|
||||
Layout.preferredHeight: 32
|
||||
onClicked: settingsPopup.close()
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusXs
|
||||
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||
}
|
||||
contentItem: Label {
|
||||
text: parent.text
|
||||
color: Theme.bodyColor
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.rightMargin: 6
|
||||
clip: true
|
||||
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
source: "Tabs/SettingsTab.qml"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== About Dialog =====
|
||||
AboutDialog {
|
||||
id: aboutDialog
|
||||
}
|
||||
|
||||
// ===== Main Layout: Rail + Workspace =====
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
// ── LEFT RAIL ──────────────────────────────────────────────────────
|
||||
Rectangle {
|
||||
id: rail
|
||||
Layout.preferredWidth: Theme.sideRailWidth
|
||||
Layout.fillHeight: true
|
||||
color: Theme.sideRailBackground
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.sideRailMargin
|
||||
spacing: Theme.sideRailSpacing
|
||||
|
||||
// ── BOX 1: Pill Tab Bar ─────────────────────────────────────
|
||||
Rectangle {
|
||||
id: pillBar
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Theme.sidePillHeight
|
||||
radius: 999
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
color: "transparent"
|
||||
|
||||
ListModel {
|
||||
id: pillModel
|
||||
ListElement {
|
||||
label: "STATUS"
|
||||
icon: "Tabs/icons/status.svg"
|
||||
expandW: 86
|
||||
desc: "Monitor real-time device connection, serial communication, and execute wafer actions."
|
||||
}
|
||||
ListElement {
|
||||
label: "DATA"
|
||||
icon: "Tabs/icons/data.svg"
|
||||
expandW: 66
|
||||
desc: "Manage, review, compare (DTW), and split historical sensor data records."
|
||||
}
|
||||
ListElement {
|
||||
label: "MAP"
|
||||
icon: "Tabs/icons/map.svg"
|
||||
expandW: 58
|
||||
desc: "Visualize spatial sensor readings on a 2D thin-plate spline RBF interpolated heatmap."
|
||||
}
|
||||
ListElement {
|
||||
label: "GRAPH"
|
||||
icon: "Tabs/icons/bar-chart.svg"
|
||||
expandW: 70
|
||||
desc: "Whole-run multi-sensor temperature chart for the currently loaded file."
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 3
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: pillModel
|
||||
delegate: Button {
|
||||
id: pillBtn
|
||||
checked: root.selectedTabIndex === index
|
||||
flat: true
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.preferredWidth: pillBtn.checked ? model.expandW : 44
|
||||
Behavior on Layout.preferredWidth {
|
||||
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: 50
|
||||
color: pillBtn.checked ? Theme.sidePanelBackground : "transparent"
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Row {
|
||||
id: pillContent
|
||||
spacing: pillBtn.checked ? 6 : 0
|
||||
width: pillIcon.width + (pillBtn.checked ? pillContent.spacing + pillLabel.width : 0)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
x: (parent.width - width) / 2
|
||||
Behavior on x {
|
||||
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
IconImage {
|
||||
id: pillIcon
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 16; height: 16
|
||||
source: model.icon
|
||||
sourceSize.width: 16; sourceSize.height: 16
|
||||
color: pillBtn.checked ? Theme.headingColor
|
||||
: pillBtn.hovered ? Qt.lighter(Theme.sideMutedText, 1.7)
|
||||
: Theme.sideMutedText
|
||||
opacity: pillBtn.checked ? 0.9 : pillBtn.hovered ? 0.7 : 0.4
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
id: pillLabel
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: pillBtn.checked ? implicitWidth : 0
|
||||
text: model.label
|
||||
color: pillBtn.checked ? Theme.headingColor : Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.0
|
||||
opacity: pillBtn.checked ? 1.0 : 0.0
|
||||
clip: true
|
||||
Behavior on width {
|
||||
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
|
||||
}
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard }
|
||||
}
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppToolTip {
|
||||
visible: pillBtn.hovered
|
||||
text: "<b>" + model.label + "</b>: " + model.desc
|
||||
}
|
||||
|
||||
onClicked: root.selectedTabIndex = index
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── BOX 1.5: Run Comparison (Data tab only) ────────────────
|
||||
Rectangle {
|
||||
id: runComparisonBox
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: runCompCol.implicitHeight + 28
|
||||
visible: root.selectedTabIndex === 1
|
||||
radius: Theme.sidePanelRadius
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
color: Theme.sidePanelBackground
|
||||
|
||||
readonly property var dataTab: dataTabLoader.item
|
||||
|
||||
component RunSlot: Rectangle {
|
||||
id: slotBox
|
||||
property string title
|
||||
property string filePath: ""
|
||||
property color accent: Theme.primaryAccent
|
||||
property bool active: false
|
||||
property bool locked: false
|
||||
property string placeholderText: "Click to select"
|
||||
signal cleared()
|
||||
signal armed()
|
||||
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 58
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.fieldBackground
|
||||
opacity: slotBox.locked ? 0.65 : 1.0
|
||||
border.width: slotBox.active ? 2 : 1
|
||||
border.color: slotBox.active ? accent : Theme.sideBorder
|
||||
|
||||
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: !slotBox.locked
|
||||
cursorShape: slotBox.locked ? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||
onClicked: slotBox.armed()
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
spacing: 8
|
||||
|
||||
Rectangle {
|
||||
width: 8; height: 8; radius: 4
|
||||
color: slotBox.accent
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
Label {
|
||||
text: slotBox.title
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.0
|
||||
color: slotBox.active ? slotBox.accent : Theme.sideMutedText
|
||||
}
|
||||
Label {
|
||||
text: slotBox.filePath !== ""
|
||||
? slotBox.filePath.split("/").pop()
|
||||
: (slotBox.locked ? slotBox.placeholderText : (slotBox.active ? "<- Select file below..." : "Click to select"))
|
||||
font.pixelSize: Theme.fontSm
|
||||
color: slotBox.filePath !== "" ? Theme.headingColor : Theme.bodyColor
|
||||
opacity: slotBox.filePath !== "" ? 1.0 : 0.6
|
||||
elide: Text.ElideMiddle
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
visible: slotBox.filePath !== "" && !slotBox.locked
|
||||
flat: true
|
||||
implicitWidth: 24; implicitHeight: 24
|
||||
onClicked: slotBox.cleared()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.flatButtonHover : "transparent"
|
||||
}
|
||||
contentItem: IconImage {
|
||||
source: "Tabs/icons/x.svg"
|
||||
width: 14; height: 14
|
||||
sourceSize.width: 14
|
||||
sourceSize.height: 14
|
||||
color: Theme.bodyColor
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: runCompCol
|
||||
anchors.fill: parent
|
||||
anchors.margins: 14
|
||||
spacing: 10
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Label {
|
||||
text: "RUN COMPARISON (DTW)"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Button {
|
||||
id: resetRunBtn
|
||||
flat: true
|
||||
implicitWidth: 32
|
||||
implicitHeight: 32
|
||||
visible: true
|
||||
onClicked: if (runComparisonBox.dataTab) runComparisonBox.dataTab.resetComparison()
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: resetRunBtn.hovered ? Theme.flatButtonHover : "transparent"
|
||||
}
|
||||
contentItem: IconImage {
|
||||
source: "Tabs/icons/rotate-ccw.svg"
|
||||
width: 18; height: 18
|
||||
sourceSize.width: 18
|
||||
sourceSize.height: 18
|
||||
color: Theme.bodyColor
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
AppToolTip {
|
||||
visible: resetRunBtn.hovered
|
||||
text: "Reset comparison"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PanelCheckBox {
|
||||
id: useMasterCheck
|
||||
Layout.fillWidth: true
|
||||
text: "Compare vs Master File"
|
||||
checked: runComparisonBox.dataTab ? runComparisonBox.dataTab.useMasterFile : false
|
||||
onToggled: {
|
||||
if (!runComparisonBox.dataTab) return
|
||||
runComparisonBox.dataTab.useMasterFile = checked
|
||||
if (checked) runComparisonBox.dataTab.activeBox = 2
|
||||
}
|
||||
}
|
||||
|
||||
RunSlot {
|
||||
readonly property bool useMaster: runComparisonBox.dataTab ? runComparisonBox.dataTab.useMasterFile : false
|
||||
title: useMaster ? "MASTER FILE" : "REFERENCE (RUN A)"
|
||||
accent: Theme.primaryAccent
|
||||
locked: useMaster
|
||||
placeholderText: useMaster
|
||||
? (runComparisonBox.dataTab.runBWaferType !== ""
|
||||
? "No master file for type " + runComparisonBox.dataTab.runBWaferType
|
||||
: "Select Run B to resolve master")
|
||||
: "Click to select"
|
||||
active: runComparisonBox.dataTab ? (!useMaster && runComparisonBox.dataTab.activeBox === 1) : false
|
||||
filePath: runComparisonBox.dataTab
|
||||
? (useMaster ? runComparisonBox.dataTab.masterFileForRunB : runComparisonBox.dataTab.compareFileA)
|
||||
: ""
|
||||
onArmed: if (runComparisonBox.dataTab && !useMaster) runComparisonBox.dataTab.activeBox = 1
|
||||
onCleared: {
|
||||
if (!runComparisonBox.dataTab) return
|
||||
runComparisonBox.dataTab.compareFileA = ""
|
||||
runComparisonBox.dataTab.activeBox = 1
|
||||
runComparisonBox.dataTab.clearResults()
|
||||
}
|
||||
}
|
||||
|
||||
RunSlot {
|
||||
title: "SESSION (RUN B)"
|
||||
accent: Theme.themeSkill
|
||||
active: runComparisonBox.dataTab ? runComparisonBox.dataTab.activeBox === 2 : false
|
||||
filePath: runComparisonBox.dataTab ? runComparisonBox.dataTab.compareFileB : ""
|
||||
onArmed: if (runComparisonBox.dataTab) runComparisonBox.dataTab.activeBox = 2
|
||||
onCleared: {
|
||||
if (!runComparisonBox.dataTab) return
|
||||
runComparisonBox.dataTab.compareFileB = ""
|
||||
runComparisonBox.dataTab.activeBox = 2
|
||||
runComparisonBox.dataTab.clearResults()
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: compareRunBtn
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 32
|
||||
readonly property var dt: runComparisonBox.dataTab
|
||||
readonly property string fileA: !!dt ? (dt.useMasterFile ? dt.masterFileForRunB : dt.compareFileA) : ""
|
||||
readonly property bool masterMissing: !!dt && dt.useMasterFile && dt.masterFileForRunB === ""
|
||||
readonly property bool sameFile: !!dt && fileA !== "" && fileA === dt.compareFileB
|
||||
readonly property string gateError: !!dt ? dt.familyGateError : ""
|
||||
enabled: !!dt && fileA !== "" && dt.compareFileB !== "" && !sameFile && !dt.comparing && !masterMissing && gateError === ""
|
||||
AppToolTip {
|
||||
visible: (compareRunBtn.sameFile || compareRunBtn.masterMissing || compareRunBtn.gateError !== "") && compareRunBtn.hovered
|
||||
text: compareRunBtn.masterMissing
|
||||
? "No master file registered for this wafer type (Settings → Master Files)"
|
||||
: compareRunBtn.gateError !== "" ? compareRunBtn.gateError
|
||||
: "Choose two different runs to compare"
|
||||
}
|
||||
onClicked: {
|
||||
dt.comparing = true
|
||||
dt.clearResults()
|
||||
streamController.compareFiles(fileA, dt.compareFileB)
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: compareRunBtn.enabled
|
||||
? (compareRunBtn.hovered ? Qt.lighter(Theme.primaryAccent, 1.1) : Theme.primaryAccent)
|
||||
: Theme.buttonNeutralBackground
|
||||
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
|
||||
}
|
||||
contentItem: Row {
|
||||
spacing: 8
|
||||
anchors.centerIn: parent
|
||||
Label {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: !compareRunBtn.dt ? "Select both runs"
|
||||
: compareRunBtn.dt.comparing
|
||||
? "Comparing…"
|
||||
: compareRunBtn.enabled
|
||||
? "Run DTW Comparison"
|
||||
: (compareRunBtn.masterMissing ? "No master for this type"
|
||||
: compareRunBtn.sameFile ? "Choose different runs"
|
||||
: compareRunBtn.gateError !== "" ? "Wafer family mismatch"
|
||||
: "Select both runs")
|
||||
color: compareRunBtn.enabled ? Theme.tone100 : Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
}
|
||||
BusyIndicator {
|
||||
running: !!compareRunBtn.dt && compareRunBtn.dt.comparing
|
||||
visible: !!compareRunBtn.dt && compareRunBtn.dt.comparing
|
||||
width: 14; height: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── BOX 2: Context-Sensitive Rail Panel ────────────────────
|
||||
StackLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.minimumHeight: 120
|
||||
currentIndex: root.selectedTabIndex === 0 ? 0 : 1
|
||||
|
||||
// ── Status tab: Hardware Actions + Log Actions ──────────
|
||||
StatusActionsPanel {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
|
||||
// ── Map tab: Source file browser ──────────────
|
||||
Rectangle {
|
||||
radius: Theme.sidePanelRadius
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
color: Theme.sidePanelBackground
|
||||
|
||||
SourcePanel {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 14
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── BOX 3: Hardware Status Footer ──────────────────────────
|
||||
ConnectionFooter {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
// ── BOX 4: Utility Buttons ─────────────────────────────────
|
||||
UtilityFooter {
|
||||
id: utilFooter
|
||||
Layout.fillWidth: true
|
||||
settingsPopup: settingsPopup
|
||||
aboutDialog: aboutDialog
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── WORKSPACE ─────────────────────────────────────────────────────
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: Theme.workspaceBackground
|
||||
|
||||
StackLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.mainAreaPadding
|
||||
currentIndex: root.selectedTabIndex
|
||||
|
||||
Loader {
|
||||
source: "Tabs/StatusTab.qml"
|
||||
active: StackLayout.index === root.selectedTabIndex
|
||||
}
|
||||
Loader {
|
||||
id: dataTabLoader
|
||||
source: "Tabs/DataTab.qml"
|
||||
active: StackLayout.index === root.selectedTabIndex
|
||||
}
|
||||
Loader {
|
||||
source: "Tabs/WaferMapTab.qml"
|
||||
// TODO P3.1: append `&& licenseModel.licenses.length > 0`
|
||||
// so the tab never instantiates while locked. See plan §3.1.
|
||||
active: StackLayout.index === root.selectedTabIndex
|
||||
}
|
||||
Loader {
|
||||
source: "Tabs/GraphTab.qml"
|
||||
active: StackLayout.index === root.selectedTabIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
import ISC.Tabs.components
|
||||
import ISC.Wafer
|
||||
|
||||
// ===== Data Tab (Compare Content Only) =====
|
||||
// Per docs/design/navigation-mock.html: the Data tab shows only the Compare
|
||||
// Runs content — the raw CSV table ("Inspect Table" mode in earlier drafts)
|
||||
// is intentionally not displayed in the application.
|
||||
// Layout: left column = aligned-curves chart with alignment scrubber + wafer
|
||||
// overlap map; right side panel = run selection dropdowns, overlap settings,
|
||||
// and DTW readout cards.
|
||||
Item {
|
||||
id: root
|
||||
anchors.fill: parent
|
||||
|
||||
// Properties for DTW Comparison
|
||||
property int activeBox: 1 // 1 for Reference (Run A), 2 for Session (Run B), 0 for inactive
|
||||
property string compareFileA: ""
|
||||
property string compareFileB: ""
|
||||
property bool useMasterFile: false // Run A comes from settingsModel.masters[runBWaferType] instead of manual selection
|
||||
|
||||
function waferTypeForFile(path) {
|
||||
if (!path) return ""
|
||||
// A file registered as a master carries that slot's family — this is
|
||||
// the only way a live recording gets a family (its filename/metadata
|
||||
// don't encode one reliably).
|
||||
for (var fam in settingsModel.masters)
|
||||
if (settingsModel.masters[fam] === path) return fam.toUpperCase()
|
||||
var rows = file_browser.files
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
if (rows[i].fileName === path) {
|
||||
if (rows[i].isRecording) return (rows[i].masterType || "").toUpperCase()
|
||||
return (rows[i].waferType || "").toUpperCase()
|
||||
}
|
||||
}
|
||||
var base = path.split("/").pop()
|
||||
if (base.toLowerCase().indexOf("live_") === 0) return ""
|
||||
return base ? base.charAt(0).toUpperCase() : ""
|
||||
}
|
||||
|
||||
// Master file must match Run B's wafer family — looking it up by that
|
||||
// family is itself the comparability gate (a mismatched-family master
|
||||
// simply won't exist under this key).
|
||||
readonly property string runBWaferType: root.waferTypeForFile(root.compareFileB)
|
||||
readonly property string masterFileForRunB: root.runBWaferType !== "" ? (settingsModel.masters[root.runBWaferType] || "") : ""
|
||||
readonly property string runAWaferType: root.useMasterFile ? root.runBWaferType : root.waferTypeForFile(root.compareFileA)
|
||||
// Family gate: both runs must resolve to the same wafer family. An empty
|
||||
// family means an unassigned live recording — comparable only after it is
|
||||
// registered as a master file (Settings → Master Files).
|
||||
readonly property string familyGateError: {
|
||||
var fileA = root.useMasterFile ? root.masterFileForRunB : root.compareFileA
|
||||
if (fileA === "" || root.compareFileB === "") return ""
|
||||
if (root.runAWaferType === "" || root.runBWaferType === "")
|
||||
return "Recording has no wafer family — register it as a master file (Settings → Master Files)"
|
||||
if (root.runAWaferType !== root.runBWaferType)
|
||||
return "Wafer Family Mismatch: " + root.runAWaferType + " vs " + root.runBWaferType
|
||||
return ""
|
||||
}
|
||||
property real warpingDistance: -1
|
||||
property int frameOffset: 0
|
||||
property real frameOffsetSeconds: 0
|
||||
property real maxSensorDeviation: -1
|
||||
property bool comparing: false
|
||||
property var trendDataA: []
|
||||
property var trendDataB: []
|
||||
property var timeDataA: []
|
||||
property var timeDataB: []
|
||||
property int frameCountA: 0
|
||||
property int frameCountB: 0
|
||||
property string errorMessage: ""
|
||||
property string mismatchMessage: ""
|
||||
|
||||
// Wafer overlap properties
|
||||
property var compareSensorLayout: []
|
||||
property var compareSensorDiff: []
|
||||
property string compareWaferShape: "round"
|
||||
property real compareWaferSize: 300.0
|
||||
|
||||
readonly property bool hasSeries: trendDataA.length > 1 && trendDataB.length > 1
|
||||
readonly property int seriesLen: Math.max(trendDataA.length, trendDataB.length)
|
||||
|
||||
function diffBands(diffs) {
|
||||
return diffs.map(d => d > 1.0 ? "high" : d < -1.0 ? "low" : "in_range")
|
||||
}
|
||||
|
||||
function shortName(path) {
|
||||
return path ? path.split("/").pop() : ""
|
||||
}
|
||||
|
||||
function scrubTimeLabel(idx) {
|
||||
var t = idx < root.timeDataA.length ? root.timeDataA[idx]
|
||||
: (idx < root.timeDataB.length ? root.timeDataB[idx] : null)
|
||||
return t !== null ? " (" + t.toFixed(1) + "s)" : ""
|
||||
}
|
||||
|
||||
function formatMinutes(secondsValue) {
|
||||
var mins = Math.floor(secondsValue / 60)
|
||||
var secs = secondsValue % 60
|
||||
return mins + ":" + (secs < 10 ? "0" : "") + secs.toFixed(1)
|
||||
}
|
||||
|
||||
function frameForTime(t) {
|
||||
var times = root.timeDataA.length > 0 ? root.timeDataA : root.timeDataB
|
||||
if (times.length === 0) return 0
|
||||
var closestIdx = 0
|
||||
var minDiff = Math.abs(times[0] - t)
|
||||
for (var i = 1; i < times.length; i++) {
|
||||
var diff = Math.abs(times[i] - t)
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff
|
||||
closestIdx = i
|
||||
}
|
||||
}
|
||||
return closestIdx
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
root.warpingDistance = -1
|
||||
root.frameOffset = 0
|
||||
root.frameOffsetSeconds = 0
|
||||
root.maxSensorDeviation = -1
|
||||
root.trendDataA = []
|
||||
root.trendDataB = []
|
||||
root.timeDataA = []
|
||||
root.timeDataB = []
|
||||
root.frameCountA = 0
|
||||
root.frameCountB = 0
|
||||
root.compareSensorLayout = []
|
||||
root.compareSensorDiff = []
|
||||
root.errorMessage = ""
|
||||
root.mismatchMessage = ""
|
||||
mismatchHideTimer.stop()
|
||||
}
|
||||
|
||||
function resetComparison() {
|
||||
root.compareFileA = ""
|
||||
root.compareFileB = ""
|
||||
root.useMasterFile = false
|
||||
root.comparing = false
|
||||
root.activeBox = 1
|
||||
clearResults()
|
||||
}
|
||||
|
||||
Component.onCompleted: file_browser.refreshFiles()
|
||||
|
||||
Timer {
|
||||
id: mismatchHideTimer
|
||||
interval: 6000
|
||||
onTriggered: root.mismatchMessage = ""
|
||||
}
|
||||
|
||||
// Clicking a file in the left rail's browser populates the armed run slot
|
||||
Connections {
|
||||
target: streamController
|
||||
function onLoadedFileChanged() {
|
||||
if (streamController.loadedFile && root.activeBox > 0) {
|
||||
if (root.activeBox === 1) {
|
||||
root.compareFileA = streamController.loadedFile
|
||||
root.activeBox = 2 // Auto-advance to box 2
|
||||
} else if (root.activeBox === 2) {
|
||||
root.compareFileB = streamController.loadedFile
|
||||
root.activeBox = 0 // Finished selecting
|
||||
}
|
||||
}
|
||||
}
|
||||
function onComparisonResult(result) {
|
||||
root.comparing = false
|
||||
if (result && result.success) {
|
||||
root.warpingDistance = result.distance
|
||||
root.frameOffset = result.frame_offset !== undefined ? result.frame_offset : 0
|
||||
root.frameOffsetSeconds = result.frame_offset_seconds !== undefined ? result.frame_offset_seconds : 0
|
||||
root.maxSensorDeviation = result.max_sensor_deviation !== undefined ? result.max_sensor_deviation : -1
|
||||
root.trendDataA = result.series_a || []
|
||||
root.trendDataB = result.series_b || []
|
||||
root.timeDataA = result.time_a || []
|
||||
root.timeDataB = result.time_b || []
|
||||
root.frameCountA = result.frame_count_a || 0
|
||||
root.frameCountB = result.frame_count_b || 0
|
||||
root.compareSensorLayout = result.sensor_layout || []
|
||||
root.compareSensorDiff = result.sensor_diff || []
|
||||
root.compareWaferShape = result.wafer_shape || "round"
|
||||
root.compareWaferSize = result.wafer_size || 300.0
|
||||
root.errorMessage = ""
|
||||
chartCard.frameIndex = Math.floor(root.seriesLen / 2)
|
||||
chartCard.focusScrubber()
|
||||
if (root.frameCountA !== root.frameCountB) {
|
||||
var timeA = root.timeDataA.length > 0 ? root.timeDataA[root.timeDataA.length - 1] : 0
|
||||
var timeB = root.timeDataB.length > 0 ? root.timeDataB[root.timeDataB.length - 1] : 0
|
||||
root.mismatchMessage = "Run A: " + root.frameCountA + " frames (" + timeA.toFixed(1) + "s) · "
|
||||
+ "Run B: " + root.frameCountB + " frames (" + timeB.toFixed(1) + "s). "
|
||||
+ "Deviation beyond the shorter run shows as \"none\"."
|
||||
mismatchHideTimer.restart()
|
||||
} else {
|
||||
root.mismatchMessage = ""
|
||||
mismatchHideTimer.stop()
|
||||
}
|
||||
} else {
|
||||
root.clearResults()
|
||||
root.errorMessage = (result && result.error) ? result.error : "Comparison failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main Layout ───────────────────────────────────────────────
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.panelPadding
|
||||
spacing: Theme.rightPaneGap
|
||||
|
||||
|
||||
|
||||
// ── Error Banner ───────────────────────────────────────────
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: errorRow.implicitHeight + 16
|
||||
visible: root.errorMessage !== ""
|
||||
color: Theme.errorSurface
|
||||
border.color: Theme.statusErrorColor
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
|
||||
RowLayout {
|
||||
id: errorRow
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
spacing: 8
|
||||
|
||||
IconImage {
|
||||
source: "icons/triangle-alert.svg"
|
||||
width: 16; height: 16
|
||||
sourceSize.width: 16
|
||||
sourceSize.height: 16
|
||||
color: Theme.errorTextSoft
|
||||
Layout.alignment: Qt.AlignTop
|
||||
}
|
||||
|
||||
Label {
|
||||
id: errorLabel
|
||||
text: root.errorMessage
|
||||
color: Theme.errorTextSoft
|
||||
font.pixelSize: Theme.fontSm
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Body: main column | side panel ─────────────────────────
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
spacing: 16
|
||||
|
||||
// --- Left Column: chart + scrubber, wafer overlap map ---
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.minimumWidth: 350
|
||||
spacing: 12
|
||||
|
||||
// Wafer Map Overlap View card
|
||||
OverlapMapCard {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.minimumHeight: 220
|
||||
sensorLayout: root.compareSensorLayout
|
||||
diffValues: streamController.getSensorDiffAt(Math.round(chartCard.frameIndex))
|
||||
diffBands: root.diffBands
|
||||
waferShape: root.compareWaferShape
|
||||
waferSize: root.compareWaferSize
|
||||
overlapEnabled: sidePanel.overlapEnabled
|
||||
blendAmount: sidePanel.blendAmount
|
||||
}
|
||||
|
||||
// Chart card with alignment scrubber
|
||||
ComparisonChartCard {
|
||||
id: chartCard
|
||||
Layout.fillWidth: true
|
||||
trendDataA: root.trendDataA
|
||||
trendDataB: root.trendDataB
|
||||
seriesLen: root.seriesLen
|
||||
hasSeries: root.hasSeries
|
||||
comparing: root.comparing
|
||||
}
|
||||
}
|
||||
|
||||
// --- Right Column: run selection, overlap settings, readout ---
|
||||
ComparisonSidePanel {
|
||||
id: sidePanel
|
||||
Layout.fillWidth: false
|
||||
Layout.preferredWidth: Theme.rightRailWidth
|
||||
Layout.minimumWidth: Theme.rightRailWidth
|
||||
Layout.maximumWidth: Theme.rightRailWidth
|
||||
Layout.fillHeight: true
|
||||
warpingDistance: root.warpingDistance
|
||||
frameOffset: root.frameOffset
|
||||
frameOffsetSeconds: root.frameOffsetSeconds
|
||||
maxSensorDeviation: root.maxSensorDeviation
|
||||
seriesLen: root.seriesLen
|
||||
hasSeries: root.hasSeries
|
||||
trendDataA: root.trendDataA
|
||||
trendDataB: root.trendDataB
|
||||
timeDataA: root.timeDataA
|
||||
timeDataB: root.timeDataB
|
||||
frameForTime: root.frameForTime
|
||||
frameIndex: chartCard.frameIndex
|
||||
onFrameIndexRequested: (idx) => chartCard.frameIndex = idx
|
||||
onScrubberFocusRequested: chartCard.focusScrubber()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Frame Mismatch Banner (amber, floats over content, auto-hides) ──
|
||||
Rectangle {
|
||||
id: mismatchBanner
|
||||
visible: root.mismatchMessage !== ""
|
||||
z: 100
|
||||
anchors.top: parent.top
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.topMargin: 12
|
||||
width: Math.min(parent.width - 48, 640)
|
||||
height: mismatchRow.implicitHeight + 16
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.statusWarningColor
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
|
||||
RowLayout {
|
||||
id: mismatchRow
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
spacing: 8
|
||||
|
||||
IconImage {
|
||||
source: "icons/triangle-alert.svg"
|
||||
width: 16; height: 16
|
||||
sourceSize.width: 16
|
||||
sourceSize.height: 16
|
||||
color: Theme.statusWarningColor
|
||||
Layout.alignment: Qt.AlignTop
|
||||
}
|
||||
|
||||
Label {
|
||||
text: root.mismatchMessage
|
||||
color: Theme.statusWarningColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Button {
|
||||
flat: true
|
||||
implicitWidth: 24
|
||||
implicitHeight: 24
|
||||
Layout.alignment: Qt.AlignTop
|
||||
onClicked: {
|
||||
root.mismatchMessage = ""
|
||||
mismatchHideTimer.stop()
|
||||
}
|
||||
background: Rectangle { color: "transparent" }
|
||||
contentItem: Label {
|
||||
text: "✕"
|
||||
color: Theme.statusWarningColor
|
||||
font.pixelSize: Theme.fontXs
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
import ISC.Tabs.components
|
||||
|
||||
// ===== Graph Tab =====
|
||||
// Whole-run multi-sensor line chart (C# parity: tabGraph/chartData). Static
|
||||
// snapshot recomputed whenever a file is loaded — not live-updating during
|
||||
// a stream. RunChart adds wheel zoom, drag pan, and a visible-range
|
||||
// min/max/avg strip. See specs/plans/2026-07-10-graph-tab.md and
|
||||
// docs/adr/0003-pyqtgraph-for-multi-sensor-charts.md.
|
||||
Item {
|
||||
id: root
|
||||
anchors.fill: parent
|
||||
|
||||
function refresh() {
|
||||
if (!streamController.loadedFile) {
|
||||
chart.seriesData = []
|
||||
chart.sensorNames = []
|
||||
return
|
||||
}
|
||||
chart.sensorNames = streamController.graphSensorNames
|
||||
? streamController.graphSensorNames.split(",") : []
|
||||
chart.seriesData = JSON.parse(streamController.graphSeriesJson || "[]")
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: streamController
|
||||
function onLoadedFileChanged() { root.refresh() }
|
||||
}
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.panelPadding
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusMd
|
||||
|
||||
RunChart {
|
||||
id: chart
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
title: "Sensor Temperature Over Time"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,639 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Dialogs
|
||||
import ISC
|
||||
|
||||
// ===== Status Tab — Bento Grid Dashboard =====
|
||||
// ROW 1: Connection Status (65%) | Cycles Completed (35%)
|
||||
// ROW 2: Wafer & Sensor + Directory (65%) | Total Runtime (35%)
|
||||
// ROW 3: Activity Log (100%, fills remaining)
|
||||
ColumnLayout {
|
||||
id: root
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.panelPadding
|
||||
spacing: 8
|
||||
|
||||
// ── Reusable Inline Components ──
|
||||
component StatCard : Rectangle {
|
||||
property alias value: valueText.text
|
||||
property alias label: labelText.text
|
||||
property bool active: root.waferDetected
|
||||
property int valSize: active ? Theme.font3xl : Theme.font2xl
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.panelPadding
|
||||
spacing: 4
|
||||
Text {
|
||||
id: labelText
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.5
|
||||
font.family: Theme.uiFontFamily
|
||||
}
|
||||
Text {
|
||||
id: valueText
|
||||
color: active ? Theme.headingColor : Theme.sideMutedText
|
||||
font.pixelSize: valSize
|
||||
font.weight: Font.Bold
|
||||
font.family: Theme.uiFontFamily
|
||||
Layout.fillHeight: true
|
||||
Layout.fillWidth: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
fontSizeMode: Text.HorizontalFit
|
||||
minimumPixelSize: Theme.fontMd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stacked stat block: small label over big value, centered in its grid
|
||||
// quadrant so the card's height is used instead of leaving dead space.
|
||||
component GridCell : ColumnLayout {
|
||||
property alias label: labelText.text
|
||||
property alias value: valueText.text
|
||||
property bool active: root.waferDetected
|
||||
|
||||
spacing: 2
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
Text {
|
||||
id: labelText
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.family: Theme.uiFontFamily
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
id: valueText
|
||||
color: active ? Theme.headingColor : Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontXl
|
||||
font.weight: Font.Bold
|
||||
font.family: Theme.uiFontFamily
|
||||
}
|
||||
Item { Layout.fillHeight: true }
|
||||
}
|
||||
|
||||
// ── Aliases for external wiring ──
|
||||
property alias waferFamilyCode: waferInfoFamilyCell.value
|
||||
property alias waferSerialNumber: waferSerialCell.value
|
||||
property alias waferSensorCount: waferSensorsCell.value
|
||||
property alias waferRuntime: runtimeCard.value
|
||||
property alias waferCycles: waferCyclesCell.value
|
||||
property bool waferDetected: {
|
||||
var info = deviceController.lastWaferInfo;
|
||||
return info && info.length > 0 && info[0] !== "";
|
||||
}
|
||||
|
||||
property int dataRows: 0
|
||||
property int dataCols: 0
|
||||
property string csvPath: ""
|
||||
property bool dataParsed: false
|
||||
|
||||
readonly property bool isConnected: deviceController.connectionStatus === "Connected"
|
||||
readonly property bool isBusy: deviceController.connectionStatus.endsWith("...")
|
||||
readonly property string portName: deviceController.selectedPort || "—"
|
||||
|
||||
function cleanFolderUrl(url) {
|
||||
return decodeURIComponent(String(url).replace(/^file:\/\//, ""));
|
||||
}
|
||||
function currentFamilyCode() {
|
||||
var info = deviceController.lastWaferInfo;
|
||||
return info && info.length > 0 ? (info[0] || "") : "";
|
||||
}
|
||||
function parseAndSavePendingRead() {
|
||||
deviceController.parseAndSaveData(root.currentFamilyCode(), deviceController.selectedPort || "");
|
||||
}
|
||||
|
||||
FolderDialog {
|
||||
id: saveDirDialog
|
||||
title: "Choose a folder to save."
|
||||
onAccepted: {
|
||||
var dir = root.cleanFolderUrl(selectedFolder);
|
||||
deviceController.setSaveDataDir(dir);
|
||||
file_browser.setCurrentDirectory(dir);
|
||||
root.parseAndSavePendingRead();
|
||||
}
|
||||
}
|
||||
|
||||
// Directory-only picker for the DIRECTORY card button — sets the save dir
|
||||
// without kicking off a parse/save of pending read data.
|
||||
FolderDialog {
|
||||
id: dirOnlyDialog
|
||||
title: "Choose a folder to save Data"
|
||||
onAccepted: {
|
||||
var dir = root.cleanFolderUrl(selectedFolder);
|
||||
deviceController.setSaveDataDir(dir);
|
||||
file_browser.setCurrentDirectory(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Post-read metadata editor (C# parity: EditCSVMetadataDialog after read).
|
||||
// Cancel keeps the CSV, skips the sidecar — matching C# behavior loosely.
|
||||
Dialog {
|
||||
id: editCsvDialog
|
||||
parent: Overlay.overlay
|
||||
modal: true
|
||||
title: "Edit CSV Metadata"
|
||||
width: 460
|
||||
anchors.centerIn: parent
|
||||
standardButtons: Dialog.Save | Dialog.Cancel
|
||||
onAccepted: file_browser.saveMetadata(root.csvPath, metaWafer.text, metaDate.text, metaChamber.text, metaNotes.text, false, "")
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
text: root.csvPath.split("/").pop()
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontXs
|
||||
elide: Text.ElideMiddle
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
Label { text: "Wafer" }
|
||||
TextField { id: metaWafer; Layout.fillWidth: true }
|
||||
Label { text: "Date" }
|
||||
TextField { id: metaDate; Layout.fillWidth: true }
|
||||
Label { text: "Chamber" }
|
||||
TextField { id: metaChamber; Layout.fillWidth: true }
|
||||
Label { text: "Notes" }
|
||||
TextField { id: metaNotes; Layout.fillWidth: true }
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// ROWS 1-2 (3-column bento):
|
||||
// [Connection Status] [Total Runtime ] [Wafer & Sensor]
|
||||
// [Directory (span 2) ] [ (rowspan) ]
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 188
|
||||
Layout.maximumHeight: 188
|
||||
columns: 3
|
||||
rowSpacing: 8
|
||||
columnSpacing: 8
|
||||
|
||||
// ── Connection Status: two cells wide, top-left ──
|
||||
Rectangle {
|
||||
Layout.columnSpan: 2
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: Theme.cardBackground
|
||||
border.color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.statusErrorColor)
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.panelPadding
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
text: "CONNECTION STATUS"
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.5
|
||||
font.family: Theme.uiFontFamily
|
||||
}
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 22
|
||||
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
spacing: 5
|
||||
|
||||
Repeater {
|
||||
model: 4
|
||||
Rectangle {
|
||||
width: 4; height: 4; radius: 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.sideMutedText)
|
||||
opacity: (root.isConnected || root.isBusy) ? 1.0 : 0.3
|
||||
|
||||
SequentialAnimation on opacity {
|
||||
running: root.isConnected || root.isBusy
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation { to: 0.3; duration: 1400 }
|
||||
NumberAnimation { to: 1.0; duration: 1400 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: statusPillText.implicitWidth + 16
|
||||
height: 20
|
||||
radius: 10
|
||||
color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.statusErrorColor)
|
||||
|
||||
Text {
|
||||
id: statusPillText
|
||||
anchors.centerIn: parent
|
||||
text: root.isConnected ? "ACTIVE" : (root.isBusy ? deviceController.connectionStatus.replace("...", "").toUpperCase() : "INACTIVE")
|
||||
color: Theme.statusBadgeText
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.weight: Font.Bold
|
||||
font.family: Theme.uiFontFamily
|
||||
font.letterSpacing: 0.6
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: 4
|
||||
Rectangle {
|
||||
width: 4; height: 4; radius: 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.sideMutedText)
|
||||
opacity: (root.isConnected || root.isBusy) ? 1.0 : 0.3
|
||||
|
||||
SequentialAnimation on opacity {
|
||||
running: root.isConnected || root.isBusy
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation { to: 0.3; duration: 1400 }
|
||||
NumberAnimation { to: 1.0; duration: 1400 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.portName
|
||||
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: Font.Bold
|
||||
font.family: Theme.uiFontFamily
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wafer & Sensor Data: far right, spans both rows ──
|
||||
Rectangle {
|
||||
Layout.rowSpan: 2
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.panelPadding
|
||||
spacing: 4
|
||||
|
||||
Text {
|
||||
text: "WAFER & SENSOR DATA"
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.weight: Font.Bold
|
||||
font.family: Theme.uiFontFamily
|
||||
font.letterSpacing: 0.6
|
||||
Layout.bottomMargin: 2
|
||||
}
|
||||
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
columns: 2
|
||||
rowSpacing: 6
|
||||
columnSpacing: 16
|
||||
|
||||
GridCell {
|
||||
id: waferInfoFamilyCell
|
||||
label: "Family Code"
|
||||
value: {
|
||||
var info = deviceController.lastWaferInfo;
|
||||
return (info && info.length > 0 && info[0]) ? info[0] : "—";
|
||||
}
|
||||
}
|
||||
GridCell {
|
||||
id: waferSerialCell
|
||||
label: "Serial"
|
||||
value: {
|
||||
var info = deviceController.lastWaferInfo;
|
||||
return (info && info.length > 1 && info[1]) ? info[1] : "—";
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.columnSpan: 2
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 1
|
||||
color: Theme.cardBorder
|
||||
opacity: 0.5
|
||||
}
|
||||
|
||||
GridCell {
|
||||
id: waferCyclesCell
|
||||
label: "Cycles Completed"
|
||||
value: {
|
||||
var info = deviceController.lastWaferInfo;
|
||||
return (info && info.length > 5 && info[5] !== undefined) ? String(info[5]) : "—";
|
||||
}
|
||||
active: root.waferDetected
|
||||
}
|
||||
GridCell {
|
||||
id: waferSensorsCell
|
||||
label: "Sensors"
|
||||
value: {
|
||||
var info = deviceController.lastWaferInfo;
|
||||
return (info && info.length > 2 && info[2]) ? String(info[2]) : "—";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Directory: bottom-left cell ──
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.panelPadding
|
||||
spacing: 6
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
text: "DIRECTORY"
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.5
|
||||
font.family: Theme.uiFontFamily
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: root.csvPath !== ""
|
||||
implicitWidth: sessionSavedLabel.implicitWidth + 12
|
||||
implicitHeight: 18
|
||||
radius: 4
|
||||
color: Theme.fieldBackground
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
|
||||
Text {
|
||||
id: sessionSavedLabel
|
||||
anchors.centerIn: parent
|
||||
text: "SESSION SAVED"
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.weight: Font.Bold
|
||||
font.family: Theme.uiFontFamily
|
||||
font.letterSpacing: 0.6
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
// Path pinned bottom-left
|
||||
Text {
|
||||
text: deviceController.saveDataDir || "Not configured"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.family: Theme.uiFontFamily
|
||||
font.italic: true
|
||||
opacity: 0.7
|
||||
elide: Text.ElideMiddle
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Button {
|
||||
id: browseBtn
|
||||
text: "Select Folder"
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.weight: Font.Medium
|
||||
font.family: Theme.uiFontFamily
|
||||
implicitHeight: 24
|
||||
hoverEnabled: true
|
||||
background: Rectangle {
|
||||
color: browseBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
}
|
||||
contentItem: Text {
|
||||
text: browseBtn.text
|
||||
color: Theme.bodyColor
|
||||
font: browseBtn.font
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
onClicked: dirOnlyDialog.open()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Total Runtime: bottom center ──
|
||||
StatCard {
|
||||
id: runtimeCard
|
||||
label: "TOTAL RUNTIME"
|
||||
value: {
|
||||
var info = deviceController.lastWaferInfo;
|
||||
if (!(info && info.length > 4 && info[4] !== undefined)) return "—";
|
||||
var secs = parseFloat(info[4]);
|
||||
if (isNaN(secs)) return info[4] + "s";
|
||||
var m = Math.floor(secs / 60);
|
||||
var s = Math.round(secs % 60);
|
||||
return secs + "s / " + m + ":" + (s < 10 ? "0" : "") + s + " min";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// ROW 3: Activity Log — ALWAYS visible (fills remaining height)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusMd
|
||||
clip: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 36
|
||||
color: Theme.subtleSectionBackground
|
||||
radius: Theme.radiusMd
|
||||
|
||||
// Cover bottom corners so only top-left and top-right are rounded
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: parent.radius
|
||||
color: parent.color
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Theme.panelPadding
|
||||
anchors.rightMargin: Theme.panelPadding
|
||||
spacing: 8
|
||||
|
||||
IconImage {
|
||||
source: "icons/file-text.svg"
|
||||
width: 14; height: 14
|
||||
sourceSize.width: 14
|
||||
sourceSize.height: 14
|
||||
color: Theme.sideMutedText
|
||||
visible: root.csvPath !== ""
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
Label {
|
||||
text: (root.csvPath !== "" ? root.csvPath.split("/").pop() : "ACTIVITY LOG").toUpperCase()
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
font.family: Theme.uiFontFamily
|
||||
elide: Text.ElideMiddle
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: 1
|
||||
color: Theme.cardBorder
|
||||
}
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.margins: Theme.panelPadding
|
||||
clip: true
|
||||
|
||||
TextArea {
|
||||
id: activityLog
|
||||
width: parent.width
|
||||
// Bound to backend so log survives tab switches (Loader
|
||||
// destroys this tab); clears only via Clear Log / restart.
|
||||
text: deviceController.activityLog || qsTr("Welcome — use the side rail to detect a wafer, or import data to review.")
|
||||
readOnly: true
|
||||
font.family: "monospace"
|
||||
font.pixelSize: Theme.fontSm
|
||||
wrapMode: TextArea.WordWrap
|
||||
color: Theme.bodyColor
|
||||
background: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// SIGNAL HANDLERS
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
Connections {
|
||||
target: deviceController
|
||||
function onLogMessage(message) {
|
||||
if (message == "CHOOSE_SAVE_DIR") {
|
||||
saveDirDialog.open();
|
||||
}
|
||||
}
|
||||
function onPortsUpdated() {}
|
||||
function onDetectResult(result) {
|
||||
if (!result) {
|
||||
root.dataParsed = false;
|
||||
root.dataRows = 0;
|
||||
root.dataCols = 0;
|
||||
root.csvPath = "";
|
||||
}
|
||||
}
|
||||
function onStatusRestored() {
|
||||
if (deviceController.dataRowCount > 0) {
|
||||
root.dataParsed = true;
|
||||
root.dataRows = deviceController.dataRowCount;
|
||||
root.dataCols = deviceController.dataColCount;
|
||||
root.csvPath = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: deviceController
|
||||
function onReadResult(result) {
|
||||
root.dataParsed = false;
|
||||
root.dataRows = 0;
|
||||
root.dataCols = 0;
|
||||
root.csvPath = "";
|
||||
if (result && result.success === true)
|
||||
saveDirDialog.open();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: deviceController
|
||||
function onParsedDataReady(result) {
|
||||
if (result && result.success) {
|
||||
root.dataParsed = true;
|
||||
root.dataRows = result.rows;
|
||||
root.dataCols = result.cols;
|
||||
root.csvPath = result.csv_path || "";
|
||||
if (root.csvPath) {
|
||||
metaWafer.text = result.serialNumber || "";
|
||||
metaDate.text = Qt.formatDateTime(new Date(), "yyyy-MM-dd hh:mm:ss");
|
||||
metaChamber.text = settingsModel.chamberId || "";
|
||||
metaNotes.text = "";
|
||||
editCsvDialog.open();
|
||||
}
|
||||
} else {
|
||||
root.dataParsed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
import ISC.Tabs.components
|
||||
import ISC.Wafer
|
||||
|
||||
Item {
|
||||
id: root
|
||||
anchors.fill: parent
|
||||
|
||||
// Wire streamController.trendDelta (Signal(str) carrying one new
|
||||
// [elapsed_s, value] pair per live frame) into the trend chart, which
|
||||
// accumulates its own bounded 60s window. trendReset clears it when a
|
||||
// new stream starts. Parsing happens on the Python TrendChartItem side
|
||||
// so malformed payloads are logged there instead of crashing the QML
|
||||
// binding.
|
||||
property string loadErrorMessage: ""
|
||||
Connections {
|
||||
target: streamController
|
||||
function onTrendDelta(deltaJson) {
|
||||
trendChart.appendDelta(deltaJson);
|
||||
}
|
||||
function onTrendReset() {
|
||||
trendChart.clearTrend();
|
||||
}
|
||||
function onLoadedFileChanged() {
|
||||
root.loadErrorMessage = "";
|
||||
}
|
||||
function onLoadFileError(message) {
|
||||
root.loadErrorMessage = message;
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
spacing: 16
|
||||
|
||||
// ── Toolbar ───────────────────────────────────────────────────────
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
// SET badge + avg temp
|
||||
Rectangle {
|
||||
visible: streamController.state === "set"
|
||||
height: 22
|
||||
radius: Theme.radiusSm
|
||||
color: "transparent"
|
||||
border.color: Theme.liveColor
|
||||
border.width: 1
|
||||
implicitWidth: setLabel.implicitWidth + 16
|
||||
Label {
|
||||
id: setLabel
|
||||
anchors.centerIn: parent
|
||||
text: "SET " + (streamController.stats.avg !== undefined ? streamController.stats.avg + " avg" : "")
|
||||
color: Theme.liveColor
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.weight: Font.Medium
|
||||
font.letterSpacing: 0.8
|
||||
}
|
||||
}
|
||||
|
||||
// Record indicator
|
||||
RowLayout {
|
||||
spacing: 5
|
||||
visible: streamController.recording
|
||||
Rectangle {
|
||||
width: 7
|
||||
height: 7
|
||||
radius: 4
|
||||
color: Theme.recordColor
|
||||
SequentialAnimation on opacity {
|
||||
running: streamController.recording
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation {
|
||||
to: 0.2
|
||||
duration: 600
|
||||
}
|
||||
NumberAnimation {
|
||||
to: 1.0
|
||||
duration: 600
|
||||
}
|
||||
}
|
||||
}
|
||||
Label {
|
||||
text: "REC"
|
||||
color: Theme.recordColor
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.weight: Font.Medium
|
||||
font.letterSpacing: 1.2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load error banner ───────────────────────────────────────────
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: errorRow.implicitHeight + 16
|
||||
visible: root.loadErrorMessage !== ""
|
||||
color: Theme.errorSurface
|
||||
border.color: Theme.statusErrorColor
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
|
||||
RowLayout {
|
||||
id: errorRow
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
spacing: 8
|
||||
|
||||
IconImage {
|
||||
source: "icons/triangle-alert.svg"
|
||||
width: 16; height: 16
|
||||
sourceSize.width: 16
|
||||
sourceSize.height: 16
|
||||
color: Theme.errorTextSoft
|
||||
Layout.alignment: Qt.AlignTop
|
||||
}
|
||||
|
||||
Label {
|
||||
text: root.loadErrorMessage
|
||||
color: Theme.errorTextSoft
|
||||
font.pixelSize: Theme.fontSm
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Body: 2 columns (wafer + trend + transport | readout) ──────────
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
spacing: 16
|
||||
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
spacing: 16
|
||||
|
||||
WaferMapView {
|
||||
id: waferView
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.preferredHeight: 400
|
||||
Layout.minimumHeight: 280
|
||||
blend: readoutPanel.heatmapBlend
|
||||
showLabels: readoutPanel.showLabels
|
||||
showExtremes: readoutPanel.showExtremes
|
||||
showThickness: readoutPanel.showThickness
|
||||
}
|
||||
Rectangle {
|
||||
id: trendPane
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: false
|
||||
Layout.preferredHeight: 220
|
||||
Layout.minimumHeight: 120
|
||||
visible: trendChart.hasData && streamController.mode === "live"
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusMd
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
spacing: 10
|
||||
|
||||
TrendChartItem {
|
||||
id: trendChart
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
visible: transportBar.hasContent
|
||||
implicitHeight: trendPane.visible ? 52 : 16
|
||||
}
|
||||
TransportBar {
|
||||
id: transportBar
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
// Readout panel — scrolls instead of clipping so the last card
|
||||
// (Thresholds) is always reachable even if content outgrows
|
||||
// the available height (e.g. once E-C delta rows appear).
|
||||
ScrollView {
|
||||
Layout.preferredWidth: Theme.rightRailWidth
|
||||
Layout.fillHeight: true
|
||||
clip: true
|
||||
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
|
||||
|
||||
ReadoutPanel {
|
||||
id: readoutPanel
|
||||
width: parent.width
|
||||
hasThicknessData: waferView.hasThickness
|
||||
onExportRequested: function(filePath, extra) {
|
||||
waferView.exportImage(filePath, extra);
|
||||
}
|
||||
onThicknessFileChosen: function(filePath) {
|
||||
var err = waferView.loadThickness(filePath);
|
||||
if (err !== "") {
|
||||
root.loadErrorMessage = err;
|
||||
readoutPanel.showThickness = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import ISC
|
||||
|
||||
// Single seam for tooltip chrome — every hover tip in the app instantiates
|
||||
// this instead of styling QtQuick.Controls ToolTip.background/contentItem
|
||||
// inline at each call site.
|
||||
ToolTip {
|
||||
id: root
|
||||
delay: 400
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.family: Theme.uiFontFamily
|
||||
|
||||
contentItem: Text {
|
||||
text: root.text
|
||||
color: Theme.headingColor
|
||||
font: root.font
|
||||
}
|
||||
background: Rectangle {
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusXs
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
|
||||
// Curve overlay chart card with the alignment timeline scrubber.
|
||||
PanelBox {
|
||||
id: card
|
||||
|
||||
required property var trendDataA
|
||||
required property var trendDataB
|
||||
required property int seriesLen
|
||||
required property bool hasSeries
|
||||
required property bool comparing
|
||||
|
||||
// Scrubber position — the internal Slider is private; consumers
|
||||
// read/write frameIndex and call focusScrubber().
|
||||
property alias frameIndex: scrubber.value
|
||||
|
||||
function focusScrubber() { scrubber.forceActiveFocus() }
|
||||
|
||||
implicitHeight: chartCol.implicitHeight + 24
|
||||
|
||||
ColumnLayout {
|
||||
id: chartCol
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
spacing: 8
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
SectionTitle {
|
||||
text: "CURVE OVERLAY"
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
Row {
|
||||
spacing: 16
|
||||
visible: card.hasSeries
|
||||
Row {
|
||||
spacing: 4
|
||||
Rectangle { width: 12; height: 3; radius: 1.5; color: Theme.primaryAccent; anchors.verticalCenter: parent.verticalCenter }
|
||||
Label { text: "Run A"; font.pixelSize: Theme.fontXs; color: Theme.bodyColor }
|
||||
|
||||
}
|
||||
Row {
|
||||
spacing: 4
|
||||
Rectangle { width: 12; height: 3; radius: 1.5; color: Theme.themeSkill; anchors.verticalCenter: parent.verticalCenter }
|
||||
Label { text: "Run B"; font.pixelSize: Theme.fontXs; color: Theme.bodyColor }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 200
|
||||
color: Theme.fieldBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
clip: true
|
||||
|
||||
Canvas {
|
||||
id: overlayCanvas
|
||||
anchors.fill: parent
|
||||
visible: card.hasSeries
|
||||
|
||||
readonly property real padLeft: 50
|
||||
readonly property real padRight: 16
|
||||
readonly property real padTop: 24
|
||||
readonly property real padBottom: 36
|
||||
|
||||
// Nice tick step (1/2/5 × 10^k) — same scheme as TrendChartItem,
|
||||
// so labels land on round numbers instead of raw data min/max.
|
||||
function niceStep(rawStep) {
|
||||
var mag = Math.pow(10, Math.floor(Math.log10(rawStep)))
|
||||
return [1, 2, 5, 10].map(function (s) { return s * mag })
|
||||
.find(function (s) { return s >= rawStep })
|
||||
}
|
||||
|
||||
function drawGrid(ctx, minV, maxV, yStep, dataLen) {
|
||||
var plotW = width - padLeft - padRight
|
||||
var plotH = height - padTop - padBottom
|
||||
var range = (maxV - minV) || 1
|
||||
|
||||
ctx.strokeStyle = Theme.chartGridLine
|
||||
ctx.lineWidth = 1
|
||||
ctx.fillStyle = Theme.chartAxisText
|
||||
ctx.font = "10px " + Theme.uiFontFamily
|
||||
ctx.textAlign = "right"
|
||||
|
||||
var ySteps = Math.max(1, Math.round(range / yStep))
|
||||
var yDec = Math.max(0, -Math.floor(Math.log10(yStep)))
|
||||
for (var yi = 0; yi <= ySteps; yi++) {
|
||||
var yFrac = yi / ySteps
|
||||
var yPx = padTop + plotH * yFrac
|
||||
var yVal = maxV - yFrac * range
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padLeft, yPx)
|
||||
ctx.lineTo(padLeft + plotW, yPx)
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillText(yVal.toFixed(yDec), padLeft - 6, yPx + 3)
|
||||
}
|
||||
|
||||
ctx.textAlign = "center"
|
||||
var xMax = dataLen - 1
|
||||
if (xMax > 0) {
|
||||
var xStep = niceStep(xMax / 5 || 1)
|
||||
for (var xIdx = 0; xIdx <= xMax; xIdx += xStep) {
|
||||
var xPx = padLeft + (xIdx / xMax) * plotW
|
||||
ctx.fillText(xIdx.toString(), xPx, height - 20)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(14, padTop + plotH / 2)
|
||||
ctx.rotate(-Math.PI / 2)
|
||||
ctx.textAlign = "center"
|
||||
ctx.font = "12px " + Theme.uiFontFamily
|
||||
ctx.fillText("Celcius (°C)", 0, 0)
|
||||
ctx.restore()
|
||||
|
||||
ctx.textAlign = "center"
|
||||
ctx.font = "12px " + Theme.uiFontFamily
|
||||
ctx.fillText("Frame", padLeft + plotW / 2, height - 4)
|
||||
}
|
||||
|
||||
function drawSeries(ctx, series, minV, range, color, dataLen) {
|
||||
// x is mapped against the shared frame domain (dataLen), not the
|
||||
// series' own length — otherwise a shorter run's line stretches to
|
||||
// fill the whole plot instead of stopping where its data ends.
|
||||
if (series.length < 2) return
|
||||
var plotW = width - padLeft - padRight
|
||||
var plotH = height - padTop - padBottom
|
||||
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
for (var i = 0; i < series.length; i++) {
|
||||
var x = padLeft + (i / (dataLen - 1)) * plotW
|
||||
var y = padTop + plotH - ((series[i] - minV) / range) * plotH
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
}
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function drawScrubMarker(ctx) {
|
||||
if (card.seriesLen < 2) return
|
||||
var plotW = width - padLeft - padRight
|
||||
var x = padLeft + (scrubber.value / (card.seriesLen - 1)) * plotW
|
||||
ctx.strokeStyle = Theme.primaryAccent
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.setLineDash([3, 3])
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, padTop)
|
||||
ctx.lineTo(x, height - padBottom)
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
}
|
||||
|
||||
onPaint: {
|
||||
var ctx = getContext("2d")
|
||||
ctx.reset()
|
||||
if (!card.hasSeries) return
|
||||
var all = card.trendDataA.concat(card.trendDataB)
|
||||
var minV = Math.min.apply(null, all)
|
||||
var maxV = Math.max.apply(null, all)
|
||||
var padding = (maxV - minV) * 0.05
|
||||
minV -= padding
|
||||
maxV += padding
|
||||
// Snap bounds to nice-step multiples -> stable round-number axis
|
||||
var yStep = niceStep((maxV - minV) / 5 || 1)
|
||||
minV = Math.floor(minV / yStep) * yStep
|
||||
maxV = Math.ceil(maxV / yStep) * yStep
|
||||
var range = (maxV - minV) || 1
|
||||
|
||||
drawGrid(ctx, minV, maxV, yStep, card.seriesLen)
|
||||
drawSeries(ctx, card.trendDataA, minV, range, Theme.primaryAccent, card.seriesLen)
|
||||
drawSeries(ctx, card.trendDataB, minV, range, Theme.themeSkill, card.seriesLen)
|
||||
drawScrubMarker(ctx)
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: card
|
||||
function onTrendDataAChanged() { overlayCanvas.requestPaint() }
|
||||
function onTrendDataBChanged() { overlayCanvas.requestPaint() }
|
||||
}
|
||||
Connections {
|
||||
target: scrubber
|
||||
function onValueChanged() { overlayCanvas.requestPaint() }
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state
|
||||
ColumnLayout {
|
||||
anchors.centerIn: parent
|
||||
visible: !card.hasSeries
|
||||
spacing: 8
|
||||
|
||||
IconImage {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
visible: !card.comparing
|
||||
source: "../icons/bar-chart.svg"
|
||||
width: 32; height: 32
|
||||
sourceSize.width: 32
|
||||
sourceSize.height: 32
|
||||
color: Theme.sideMutedText
|
||||
}
|
||||
Label {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: card.comparing ? "Running DTW comparison…" : "Select two runs and run comparison"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
Label {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
visible: !card.comparing
|
||||
text: "Click a run box on the right, then select a file from the left panel."
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontXs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Alignment Timeline Scrubber ────────────
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
visible: card.hasSeries
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.cardBorder
|
||||
}
|
||||
|
||||
SectionTitle {
|
||||
text: "TIMELINE SCRUBBER"
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 8
|
||||
}
|
||||
|
||||
Slider {
|
||||
id: scrubber
|
||||
Layout.fillWidth: true
|
||||
leftPadding: overlayCanvas.padLeft
|
||||
rightPadding: overlayCanvas.padRight
|
||||
from: 0
|
||||
to: Math.max(1, card.seriesLen - 1)
|
||||
stepSize: 1
|
||||
value: 0
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
|
||||
// Right-column bento stack: DTW readout, overlap settings, frame/time
|
||||
// controls, and scrubber readout. Pure view — all comparison state lives
|
||||
// on DataTab; scrubber writes go up via frameIndexRequested.
|
||||
ColumnLayout {
|
||||
id: panel
|
||||
|
||||
required property real warpingDistance
|
||||
required property int frameOffset
|
||||
required property real frameOffsetSeconds
|
||||
required property real maxSensorDeviation
|
||||
required property int seriesLen
|
||||
required property bool hasSeries
|
||||
required property var trendDataA
|
||||
required property var trendDataB
|
||||
required property var timeDataA
|
||||
required property var timeDataB
|
||||
required property var frameForTime // function(seconds) -> frame index
|
||||
required property real frameIndex // bound from ComparisonChartCard.frameIndex
|
||||
|
||||
// Overlap settings outputs (consumed by OverlapMapCard via DataTab)
|
||||
property alias overlapEnabled: enableOverlapCheck.checked
|
||||
property alias blendAmount: blendSlider.value
|
||||
|
||||
signal frameIndexRequested(real idx)
|
||||
signal scrubberFocusRequested()
|
||||
|
||||
spacing: 12
|
||||
|
||||
// Bento 3: DTW Comparison Readout
|
||||
PanelBox {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: readoutCol.implicitHeight + 24
|
||||
|
||||
ColumnLayout {
|
||||
id: readoutCol
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
spacing: 8
|
||||
|
||||
SectionTitle { text: "DTW READOUT" }
|
||||
|
||||
ReadoutStat {
|
||||
label: "DTW ALIGNMENT DISTANCE"
|
||||
value: panel.warpingDistance >= 0 ? panel.warpingDistance.toFixed(2) : "--"
|
||||
valueColor: panel.warpingDistance >= 0
|
||||
? (panel.warpingDistance < 50 ? Theme.metricGood : panel.warpingDistance < 100 ? Theme.metricWarn : Theme.metricBad)
|
||||
: Theme.sideMutedText
|
||||
}
|
||||
ReadoutStat {
|
||||
label: "TEMPORAL FRAME OFFSET"
|
||||
value: panel.warpingDistance >= 0
|
||||
? ((panel.frameOffset >= 0 ? "+" : "") + panel.frameOffset + " frames / "
|
||||
+ (panel.frameOffsetSeconds >= 0 ? "+" : "") + panel.frameOffsetSeconds.toFixed(1) + "s")
|
||||
: "--"
|
||||
valueColor: panel.warpingDistance >= 0 ? Theme.headingColor : Theme.sideMutedText
|
||||
}
|
||||
ReadoutStat {
|
||||
label: "MAX SENSOR DEVIATION"
|
||||
value: panel.maxSensorDeviation >= 0 ? panel.maxSensorDeviation.toFixed(2) + "°C" : "--"
|
||||
valueColor: panel.maxSensorDeviation >= 0
|
||||
? (panel.maxSensorDeviation < 1.0 ? Theme.metricGood : panel.maxSensorDeviation < 3.0 ? Theme.metricWarn : Theme.metricBad)
|
||||
: Theme.sideMutedText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bento 2: Overlap Map Settings
|
||||
PanelBox {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: overlapCol.implicitHeight + 24
|
||||
|
||||
ColumnLayout {
|
||||
id: overlapCol
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
spacing: 8
|
||||
|
||||
SectionTitle { text: "OVERLAP SETTINGS" }
|
||||
|
||||
PanelCheckBox {
|
||||
id: enableOverlapCheck
|
||||
Layout.fillWidth: true
|
||||
checked: true
|
||||
text: "Enable Overlap Map"
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
visible: enableOverlapCheck.checked
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.cardBorder
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
Label {
|
||||
text: "Blend"
|
||||
font.pixelSize: Theme.fontSm
|
||||
color: Theme.bodyColor
|
||||
Layout.preferredWidth: 40
|
||||
}
|
||||
PanelSlider {
|
||||
id: blendSlider
|
||||
Layout.fillWidth: true
|
||||
from: 0; to: 100; value: 50
|
||||
stepSize: 1
|
||||
}
|
||||
Label {
|
||||
text: Math.round(blendSlider.value) + "%"
|
||||
font.pixelSize: Theme.fontSm
|
||||
color: Theme.sideMutedText
|
||||
Layout.preferredWidth: 30
|
||||
}
|
||||
}
|
||||
|
||||
// Diff color legend
|
||||
Row {
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
Row {
|
||||
spacing: 3
|
||||
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorLow; anchors.verticalCenter: parent.verticalCenter }
|
||||
Label { text: "B cooler"; font.pixelSize: Theme.fontSm; color: Theme.sideMutedText }
|
||||
}
|
||||
Row {
|
||||
spacing: 3
|
||||
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorInRange; anchors.verticalCenter: parent.verticalCenter }
|
||||
Label { text: "Match"; font.pixelSize: Theme.fontSm; color: Theme.sideMutedText }
|
||||
}
|
||||
Row {
|
||||
spacing: 3
|
||||
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorHigh; anchors.verticalCenter: parent.verticalCenter }
|
||||
Label { text: "B hotter"; font.pixelSize: Theme.fontSm; color: Theme.sideMutedText }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bento: Frame / Time Controls
|
||||
PanelBox {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: frameTimeCol.implicitHeight + 32
|
||||
|
||||
ColumnLayout {
|
||||
id: frameTimeCol
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
spacing: 20
|
||||
|
||||
SectionTitle { text: "FRAME / TIME" }
|
||||
|
||||
EditableScrubStat {
|
||||
label: "Frame"
|
||||
value: scrubReadout.scrubIdx
|
||||
unit: "/ " + Math.max(0, panel.seriesLen - 1) + (Math.max(0, panel.seriesLen - 1) <= 1 ? " frame" : " frames")
|
||||
editable: panel.hasSeries
|
||||
onValueEdited: (newValue) => {
|
||||
var maxVal = Math.max(0, panel.seriesLen - 1)
|
||||
var val = Math.max(0, Math.min(newValue, maxVal))
|
||||
panel.frameIndexRequested(val)
|
||||
}
|
||||
onEditingDone: panel.scrubberFocusRequested()
|
||||
}
|
||||
|
||||
EditableScrubStat {
|
||||
label: "Time"
|
||||
value: scrubReadout.scrubTime
|
||||
unit: "/ " + scrubReadout.maxTime.toFixed(1) + "s"
|
||||
editable: panel.hasSeries
|
||||
onValueEdited: (newValue) => {
|
||||
var closestIdx = panel.frameForTime(newValue)
|
||||
panel.frameIndexRequested(closestIdx)
|
||||
}
|
||||
onEditingDone: panel.scrubberFocusRequested()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bento: Scrub Readout
|
||||
PanelBox {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: scrubReadout.implicitHeight + 24
|
||||
|
||||
ColumnLayout {
|
||||
id: scrubReadout
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
spacing: 8
|
||||
|
||||
readonly property int scrubIdx: Math.round(panel.frameIndex)
|
||||
readonly property bool hasA: scrubIdx < panel.trendDataA.length
|
||||
readonly property bool hasB: scrubIdx < panel.trendDataB.length
|
||||
readonly property real tempA: hasA ? panel.trendDataA[scrubIdx] : 0
|
||||
readonly property real tempB: hasB ? panel.trendDataB[scrubIdx] : 0
|
||||
readonly property real scrubTime: {
|
||||
var t = scrubIdx < panel.timeDataA.length ? panel.timeDataA[scrubIdx]
|
||||
: (scrubIdx < panel.timeDataB.length ? panel.timeDataB[scrubIdx] : 0)
|
||||
return t
|
||||
}
|
||||
readonly property real maxTime: {
|
||||
var ta = panel.timeDataA.length > 0 ? panel.timeDataA[panel.timeDataA.length - 1] : 0
|
||||
var tb = panel.timeDataB.length > 0 ? panel.timeDataB[panel.timeDataB.length - 1] : 0
|
||||
return Math.max(ta, tb)
|
||||
}
|
||||
|
||||
SectionTitle { text: "SCRUBBER READOUT" }
|
||||
|
||||
ReadoutStat {
|
||||
label: "Temp (Run A)"
|
||||
value: scrubReadout.hasA ? scrubReadout.tempA.toFixed(1) + "°C" : "none"
|
||||
valueColor: Theme.primaryAccent
|
||||
}
|
||||
ReadoutStat {
|
||||
label: "Temp (Run B)"
|
||||
value: scrubReadout.hasB ? scrubReadout.tempB.toFixed(1) + "°C" : "none"
|
||||
valueColor: Theme.themeSkill
|
||||
}
|
||||
ReadoutStat {
|
||||
label: "Difference"
|
||||
value: (scrubReadout.hasA && scrubReadout.hasB)
|
||||
? Math.abs(scrubReadout.tempA - scrubReadout.tempB).toFixed(1) + "°C" : "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
|
||||
// ── BOX 3: Hardware Status Footer ──────────────────────────
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Theme.sideFooterConnection
|
||||
visible: root.selectedTabIndex !== 1
|
||||
radius: Theme.sidePanelRadius
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
color: Theme.sidePanelBackground
|
||||
clip: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 14
|
||||
spacing: 6
|
||||
|
||||
// Row 1: Title
|
||||
Label {
|
||||
text: "CONNECTION FLOW"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// Row 2: Port / descriptive text
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
text: {
|
||||
if (deviceController.connectionStatus === "Connected")
|
||||
return deviceController.selectedPort || "Connected"
|
||||
if (deviceController.selectedPort)
|
||||
return deviceController.selectedPort
|
||||
return "No port selected"
|
||||
}
|
||||
color: Theme.bodyColor
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
|
||||
// Row 3: Mode chip left, status badge bottom-right
|
||||
RowLayout {
|
||||
spacing: 6
|
||||
Layout.fillWidth: true
|
||||
|
||||
Rectangle {
|
||||
width: modeChipLabel.implicitWidth + 12
|
||||
height: 18
|
||||
radius: 4
|
||||
color: Theme.fieldBackground
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
|
||||
Label {
|
||||
id: modeChipLabel
|
||||
anchors.centerIn: parent
|
||||
text: streamController.mode
|
||||
? streamController.mode.toUpperCase() : "REVIEW"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.bold: true
|
||||
font.letterSpacing: 0.8
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Rectangle {
|
||||
height: 18
|
||||
width: badgeLabel.implicitWidth + 14
|
||||
radius: 9
|
||||
color: {
|
||||
var s = deviceController.connectionStatus
|
||||
if (s === "Connected") return Theme.statusSuccessColor
|
||||
if (s === "Disconnected") return Theme.statusErrorColor
|
||||
return Theme.statusWarningColor
|
||||
}
|
||||
|
||||
Label {
|
||||
id: badgeLabel
|
||||
anchors.centerIn: parent
|
||||
text: deviceController.connectionStatus || "Disconnected"
|
||||
color: Theme.statusBadgeText
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
|
||||
// Editable stat box for scrubber readout (styled to match DTW ReadoutCard)
|
||||
Rectangle {
|
||||
id: edStat
|
||||
property string label
|
||||
property real value
|
||||
property string unit
|
||||
property string extraText: ""
|
||||
property bool editable: true
|
||||
signal valueEdited(real newValue)
|
||||
// Fires when the input field commits (Enter/focus loss), even if the
|
||||
// entered text was invalid — callers use it to hand focus back to the
|
||||
// timeline scrubber, matching the pre-extraction behavior.
|
||||
signal editingDone()
|
||||
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: edStatRow.implicitHeight + 16
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.subtleSectionBackground
|
||||
border.color: (edStat.editable && inputField.activeFocus) ? Theme.fieldBorderFocus : Theme.cardBorder
|
||||
border.width: (edStat.editable && inputField.activeFocus) ? Theme.borderStrong : Theme.borderThin
|
||||
|
||||
RowLayout {
|
||||
id: edStatRow
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
spacing: 6
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
Label {
|
||||
text: edStat.label.toUpperCase()
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.bold: true
|
||||
font.letterSpacing: 0.2
|
||||
color: Theme.sideMutedText
|
||||
}
|
||||
RowLayout {
|
||||
spacing: 4
|
||||
TextField {
|
||||
id: inputField
|
||||
readOnly: !edStat.editable
|
||||
hoverEnabled: edStat.editable
|
||||
font.pixelSize: Theme.fontLg
|
||||
font.bold: true
|
||||
font.family: Theme.codeFontFamily
|
||||
color: edStat.editable ? Theme.headingColor : Theme.sideMutedText
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
horizontalAlignment: TextInput.AlignHCenter
|
||||
background: Rectangle {
|
||||
visible: edStat.editable
|
||||
color: Theme.fieldBackground
|
||||
radius: Theme.radiusXs
|
||||
border.color: inputField.activeFocus ? Theme.fieldBorderFocus : (inputField.hovered ? Theme.fieldBorderFocus : Theme.fieldBorder)
|
||||
border.width: 1
|
||||
}
|
||||
padding: 0
|
||||
leftPadding: edStat.editable ? 6 : 0
|
||||
rightPadding: edStat.editable ? 6 : 0
|
||||
topPadding: edStat.editable ? 5 : 0
|
||||
bottomPadding: edStat.editable ? 5 : 0
|
||||
selectByMouse: edStat.editable
|
||||
inputMethodHints: edStat.label === "Time" ? Qt.ImhFormattedNumbersOnly : Qt.ImhDigitsOnly
|
||||
Layout.preferredWidth: edStat.editable ? Math.max(36, contentWidth + 12) : contentWidth
|
||||
Layout.preferredHeight: edStat.editable ? 32 : implicitHeight
|
||||
|
||||
text: edStat.value.toFixed(edStat.label === "Time" ? 1 : 0)
|
||||
|
||||
onEditingFinished: {
|
||||
var val = parseFloat(text)
|
||||
if (!isNaN(val)) {
|
||||
edStat.valueEdited(val)
|
||||
}
|
||||
text = edStat.value.toFixed(edStat.label === "Time" ? 1 : 0)
|
||||
edStat.editingDone()
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: edStat
|
||||
function onValueChanged() {
|
||||
if (!inputField.activeFocus) {
|
||||
inputField.text = edStat.value.toFixed(edStat.label === "Time" ? 1 : 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Label {
|
||||
text: edStat.unit
|
||||
font.pixelSize: Theme.fontLg
|
||||
font.bold: true
|
||||
font.family: Theme.codeFontFamily
|
||||
color: edStat.editable ? Theme.headingColor : Theme.sideMutedText
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
visible: edStat.extraText !== ""
|
||||
text: edStat.extraText
|
||||
font.pixelSize: Theme.fontLg
|
||||
font.bold: true
|
||||
font.family: Theme.codeFontFamily
|
||||
color: Theme.headingColor
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
|
||||
RowLayout {
|
||||
property string label
|
||||
property string value
|
||||
property color valueColor: Theme.headingColor
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
|
||||
Label {
|
||||
text: label
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.bold: true
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
Label {
|
||||
text: value
|
||||
color: valueColor
|
||||
font.family: Theme.codeFontFamily
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
import ISC.Wafer
|
||||
|
||||
// Wafer overlap map card — renders per-sensor Run A/Run B temperature
|
||||
// differences at the current scrub position.
|
||||
PanelBox {
|
||||
id: card
|
||||
|
||||
required property var sensorLayout
|
||||
required property var diffValues
|
||||
required property var diffBands // function(values) -> band thresholds
|
||||
required property string waferShape
|
||||
required property real waferSize
|
||||
required property bool overlapEnabled
|
||||
required property real blendAmount // 0–100, percent
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
spacing: 8
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
SectionTitle {
|
||||
text: "WAFER OVERLAP"
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: Theme.fieldBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
clip: true
|
||||
|
||||
WaferMapItem {
|
||||
id: overlapMapItem
|
||||
anchors.fill: parent
|
||||
visible: card.sensorLayout.length > 0 && card.overlapEnabled
|
||||
sensors: card.sensorLayout
|
||||
values: card.diffValues
|
||||
bands: card.diffBands(overlapMapItem.values)
|
||||
shape: card.waferShape
|
||||
size: card.waferSize
|
||||
target: 0
|
||||
margin: 1.0
|
||||
blend: card.blendAmount / 100
|
||||
showLabels: true
|
||||
ringColor: Theme.waferRingColor
|
||||
axisColor: Theme.waferAxisColor
|
||||
lowColor: Theme.sensorLow
|
||||
inRangeColor: Theme.sensorInRange
|
||||
highColor: Theme.sensorHigh
|
||||
textColor: Theme.headingColor
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.centerIn: parent
|
||||
visible: card.sensorLayout.length === 0 || !card.overlapEnabled
|
||||
spacing: 4
|
||||
IconImage {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
source: "../icons/map.svg"
|
||||
width: 24; height: 24
|
||||
sourceSize.width: 24
|
||||
sourceSize.height: 24
|
||||
color: Theme.sideMutedText
|
||||
}
|
||||
Label {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: !card.overlapEnabled ? "Overlap map disabled"
|
||||
: "No sensor map available"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontXs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import QtQuick
|
||||
import ISC
|
||||
|
||||
// Card container for the Data tab bento boxes.
|
||||
Rectangle {
|
||||
color: Theme.sidePanelBackground
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
radius: Theme.sidePanelRadius
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import ISC
|
||||
|
||||
// Checkbox styled to match MapTab's right-rail ReadoutPanel (square accent
|
||||
// indicator + check icon) instead of the platform-default CheckBox look.
|
||||
CheckBox {
|
||||
id: toggle
|
||||
indicator: Rectangle {
|
||||
implicitWidth: 18
|
||||
implicitHeight: 18
|
||||
x: toggle.leftPadding
|
||||
y: parent.height / 2 - height / 2
|
||||
radius: Theme.radiusXs
|
||||
color: toggle.checked ? Theme.primaryAccent : "transparent"
|
||||
border.width: Theme.borderThin
|
||||
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
|
||||
|
||||
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
|
||||
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
|
||||
|
||||
IconImage {
|
||||
anchors.centerIn: parent
|
||||
source: "../icons/check.svg"
|
||||
width: 12; height: 12
|
||||
sourceSize.width: 12
|
||||
sourceSize.height: 12
|
||||
color: Theme.panelBackground
|
||||
opacity: toggle.checked ? 1.0 : 0.0
|
||||
Behavior on opacity { NumberAnimation { duration: Theme.durationFast } }
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Text {
|
||||
text: toggle.text
|
||||
color: Theme.headingColor
|
||||
font.family: Theme.uiFontFamily
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
leftPadding: toggle.indicator.width + toggle.spacing
|
||||
font.pixelSize: toggle.font.pixelSize || Theme.fontXs
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import ISC
|
||||
|
||||
// Slider styled to match MapTab's right-rail heatmap slider (circular
|
||||
// accent handle that grows slightly on press).
|
||||
Slider {
|
||||
id: panelSlider
|
||||
handle: Rectangle {
|
||||
x: panelSlider.leftPadding + panelSlider.visualPosition * (panelSlider.availableWidth - width)
|
||||
y: panelSlider.topPadding + panelSlider.availableHeight / 2 - height / 2
|
||||
implicitWidth: 18
|
||||
implicitHeight: 18
|
||||
radius: 9
|
||||
color: Theme.primaryAccent
|
||||
scale: panelSlider.pressed ? 1.15 : 1.0
|
||||
Behavior on scale { NumberAnimation { duration: Theme.durationFast; easing.type: Theme.easeStandard } }
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import ISC
|
||||
|
||||
// ===== Rail Action Button =====
|
||||
// Reusable pill-style button for rail action panels.
|
||||
// Icon left, uppercase label, fixed height, hover/active backgrounds.
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string label: ""
|
||||
property string iconSource: ""
|
||||
property bool destructive: false
|
||||
// Set true to show the button as "active" (e.g. a one-shot toggle
|
||||
// that's mid-operation), independent of hover/press state.
|
||||
property bool toggled: false
|
||||
property color _textColor: !root.enabled ? Theme.sideMutedText
|
||||
: root.destructive ? Theme.statusErrorColor
|
||||
: Theme.headingColor
|
||||
|
||||
signal clicked()
|
||||
|
||||
implicitHeight: Theme.sideButtonHeight
|
||||
radius: 8
|
||||
color: root.toggled || mouseArea.containsMouse || mouseArea.pressed
|
||||
? Theme.sideActiveBackground : "transparent"
|
||||
border.width: 0
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: 100 }
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
spacing: 10
|
||||
|
||||
IconImage {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 18
|
||||
height: 18
|
||||
source: root.iconSource
|
||||
sourceSize.width: 18
|
||||
sourceSize.height: 18
|
||||
color: root._textColor
|
||||
opacity: root.enabled ? 0.85 : 0.35
|
||||
}
|
||||
|
||||
Label {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.label
|
||||
color: root._textColor
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.2
|
||||
opacity: root.enabled ? 1.0 : 0.4
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: root.enabled
|
||||
cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: {
|
||||
if (root.enabled) root.clicked()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Dialogs
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
|
||||
// ===== Readout Panel =====
|
||||
// Right-rail panel with three bento-tile sections: READOUT, DISPLAY,
|
||||
// THRESHOLDS. Uses the same PanelBox / SectionTitle / ReadoutStat /
|
||||
// PanelCheckBox / PanelSlider components as the Data tab's
|
||||
// ComparisonSidePanel, so both right rails share one visual language.
|
||||
ColumnLayout {
|
||||
id: root
|
||||
spacing: 6
|
||||
property var s: streamController.stats
|
||||
property alias showLabels: labelsToggle.checked
|
||||
property alias showExtremes: extremesToggle.checked
|
||||
property alias heatmapBlend: heatmapSlider.value
|
||||
property alias showThickness: thicknessToggle.checked
|
||||
property bool hasThicknessData: false
|
||||
|
||||
signal exportRequested(string filePath, string extra)
|
||||
signal thicknessFileChosen(string filePath)
|
||||
|
||||
component BadgePill: Rectangle {
|
||||
implicitWidth: badgeLabel.implicitWidth + 10
|
||||
implicitHeight: 10
|
||||
radius: 9
|
||||
color: Theme.fieldBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
|
||||
property alias text: badgeLabel.text
|
||||
Label {
|
||||
id: badgeLabel
|
||||
anchors.centerIn: parent
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.font2xs
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
|
||||
StreamControlPanel {
|
||||
Layout.fillWidth: true
|
||||
onExportRequested: function(filePath, extra) {
|
||||
root.exportRequested(filePath, extra);
|
||||
}
|
||||
}
|
||||
|
||||
// ── READOUT ─────────────────────────────────────────────────────
|
||||
PanelBox {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: readoutCol.implicitHeight + 24
|
||||
|
||||
ColumnLayout {
|
||||
id: readoutCol
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
spacing: 8
|
||||
|
||||
SectionTitle { text: "READOUT" }
|
||||
|
||||
ReadoutStat {
|
||||
label: "Min Temp"
|
||||
value: s.min !== undefined
|
||||
? s.min + ((s.minIndex !== undefined && s.minIndex >= 0) ? " #" + s.minIndex : "")
|
||||
: "—"
|
||||
valueColor: Theme.sensorLow
|
||||
}
|
||||
ReadoutStat {
|
||||
label: "Max Temp"
|
||||
value: s.max !== undefined
|
||||
? s.max + ((s.maxIndex !== undefined && s.maxIndex >= 0) ? " #" + s.maxIndex : "")
|
||||
: "—"
|
||||
valueColor: Theme.sensorHigh
|
||||
}
|
||||
ReadoutStat {
|
||||
label: "Diff"
|
||||
value: s.diff !== undefined ? s.diff : "—"
|
||||
valueColor: Theme.diffAccent
|
||||
}
|
||||
ReadoutStat {
|
||||
label: "Average"
|
||||
value: s.avg !== undefined ? s.avg : "—"
|
||||
}
|
||||
ReadoutStat {
|
||||
label: "Sigma (Σ)"
|
||||
value: s.sigma !== undefined ? s.sigma : "—"
|
||||
}
|
||||
ReadoutStat {
|
||||
label: "3Σ Value"
|
||||
value: s.threeSigma !== undefined ? s.threeSigma : "—"
|
||||
}
|
||||
ReadoutStat {
|
||||
visible: s.ecMinDelta !== undefined
|
||||
label: "E-C Δ Min"
|
||||
value: s.ecMinDelta !== undefined
|
||||
? s.ecMinDelta.toFixed(2) + " (#" + s.ecMinEdgeIndex + "→#" + s.ecMinCenterIndex + ")"
|
||||
: "—"
|
||||
}
|
||||
ReadoutStat {
|
||||
visible: s.ecMaxDelta !== undefined
|
||||
label: "E-C Δ Max"
|
||||
value: s.ecMaxDelta !== undefined
|
||||
? s.ecMaxDelta.toFixed(2) + " (#" + s.ecMaxEdgeIndex + "→#" + s.ecMaxCenterIndex + ")"
|
||||
: "—"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── DISPLAY ─────────────────────────────────────────────────────
|
||||
PanelBox {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: displayCard.implicitHeight + 24
|
||||
|
||||
ColumnLayout {
|
||||
id: displayCard
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
spacing: 8
|
||||
|
||||
SectionTitle { text: "DISPLAY" }
|
||||
|
||||
PanelCheckBox {
|
||||
id: thicknessToggle
|
||||
text: "Show Thickness"
|
||||
checked: false
|
||||
font.pixelSize: Theme.fontSm
|
||||
// First check with no data prompts for the customer CSV;
|
||||
// unchecking only hides the overlay, data stays loaded.
|
||||
onToggled: {
|
||||
if (checked && !root.hasThicknessData)
|
||||
thicknessDialog.open();
|
||||
}
|
||||
}
|
||||
|
||||
FileDialog {
|
||||
id: thicknessDialog
|
||||
title: "Select Thickness Data File"
|
||||
nameFilters: ["CSV files (*.csv)"]
|
||||
onAccepted: root.thicknessFileChosen(String(selectedFile).replace(/^file:\/\//, ""))
|
||||
onRejected: thicknessToggle.checked = false
|
||||
}
|
||||
|
||||
PanelCheckBox {
|
||||
id: labelsToggle
|
||||
text: "Labels"
|
||||
checked: true
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
|
||||
PanelCheckBox {
|
||||
id: extremesToggle
|
||||
text: "Highlight Min/Max"
|
||||
checked: true
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
|
||||
PanelCheckBox {
|
||||
id: clusterAverageToggle
|
||||
text: "Average Clusters"
|
||||
checked: streamController.clusterAveragingEnabled
|
||||
font.pixelSize: Theme.fontSm
|
||||
onCheckedChanged: streamController.clusterAveragingEnabled = checked
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.cardBorder
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
Label {
|
||||
text: "Heatmap"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
Layout.preferredWidth: 52
|
||||
}
|
||||
PanelSlider {
|
||||
id: heatmapSlider
|
||||
from: 0
|
||||
to: 1
|
||||
value: 0
|
||||
Layout.fillWidth: true
|
||||
|
||||
AppToolTip {
|
||||
visible: heatmapSlider.hovered
|
||||
text: Math.round(heatmapSlider.value * 100) + "%"
|
||||
}
|
||||
}
|
||||
Label {
|
||||
text: Math.round(heatmapSlider.value * 100) + "%"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.weight: Font.Medium
|
||||
Layout.preferredWidth: 32
|
||||
horizontalAlignment: Text.AlignRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── THRESHOLDS ──────────────────────────────────────────────────
|
||||
PanelBox {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: thresholdCard.implicitHeight + 24
|
||||
|
||||
ColumnLayout {
|
||||
id: thresholdCard
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
spacing: 6
|
||||
|
||||
SectionTitle { text: "THRESHOLDS" }
|
||||
|
||||
Label {
|
||||
text: "Set Point (°C)"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
TextField {
|
||||
id: spField
|
||||
Layout.fillWidth: true
|
||||
text: "149.0"
|
||||
font.pixelSize: Theme.fontSm
|
||||
inputMethodHints: Qt.ImhFormattedNumbersOnly
|
||||
color: Theme.fieldText
|
||||
placeholderTextColor: Theme.fieldPlaceholder
|
||||
selectedTextColor: Theme.fieldBackground
|
||||
selectionColor: Theme.fieldText
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusXs
|
||||
color: Theme.fieldBackground
|
||||
border.width: spField.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||
border.color: spField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
Behavior on border.color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
onEditingFinished: pushThresholds()
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "Margin (±°C)"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
TextField {
|
||||
id: mgField
|
||||
Layout.fillWidth: true
|
||||
text: "1.0"
|
||||
font.pixelSize: Theme.fontSm
|
||||
inputMethodHints: Qt.ImhFormattedNumbersOnly
|
||||
color: Theme.fieldText
|
||||
placeholderTextColor: Theme.fieldPlaceholder
|
||||
selectedTextColor: Theme.fieldBackground
|
||||
selectionColor: Theme.fieldText
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusXs
|
||||
color: Theme.fieldBackground
|
||||
border.width: mgField.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||
border.color: mgField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
Behavior on border.color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
onEditingFinished: pushThresholds()
|
||||
}
|
||||
|
||||
PanelCheckBox {
|
||||
id: autoCheck
|
||||
text: "Auto range (mean ± 1σ)"
|
||||
checked: true
|
||||
font.pixelSize: Theme.fontSm
|
||||
onCheckedChanged: pushThresholds()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
|
||||
function pushThresholds() {
|
||||
var sp = parseFloat(spField.text) || 149.0;
|
||||
var mg = parseFloat(mgField.text) || 1.0;
|
||||
streamController.setThresholds(sp, mg, autoCheck.checked);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
|
||||
// Small labeled value tile — DTW readout panel, scrubber readout, and any
|
||||
// other compact label/value stat display share this one shape.
|
||||
Rectangle {
|
||||
id: readoutStat
|
||||
property string label
|
||||
property string value
|
||||
property color valueColor: Theme.headingColor
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 52
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.subtleSectionBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
spacing: 2
|
||||
Label {
|
||||
text: readoutStat.label.toUpperCase()
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.bold: true
|
||||
font.letterSpacing: 0.2
|
||||
color: Theme.sideMutedText
|
||||
}
|
||||
Label {
|
||||
text: readoutStat.value
|
||||
font.pixelSize: Theme.fontLg
|
||||
font.bold: true
|
||||
font.family: Theme.codeFontFamily
|
||||
color: readoutStat.valueColor
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
|
||||
Dialog {
|
||||
id: root
|
||||
title: "Sensor Override"
|
||||
modal: true
|
||||
standardButtons: Dialog.NoButton
|
||||
width: 300
|
||||
|
||||
// Anchor to the top-left of the window instead of the default centered position.
|
||||
x: 24
|
||||
y: 24
|
||||
|
||||
// called by WaferMapView's TapHandler
|
||||
property int sensorIndex: -1
|
||||
property string sensorLabel: ""
|
||||
property real currentValue: 0.0
|
||||
property bool hasOverride: streamController.overriddenSensors.indexOf(sensorIndex) >= 0
|
||||
|
||||
function openFor(index) {
|
||||
sensorIndex = index;
|
||||
var dot = streamController.sensorDots[index];
|
||||
if (!dot)
|
||||
return;
|
||||
sensorLabel = dot.label !== undefined ? String(dot.label) : String(index + 1);
|
||||
currentValue = dot.value;
|
||||
valueField.text = "";
|
||||
offsetField.text = "";
|
||||
open();
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: Theme.cardBackground
|
||||
radius: Theme.radiusLg
|
||||
border.color: Theme.cardBorder
|
||||
border.width: Theme.borderThin
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: Theme.rightPaneGap
|
||||
|
||||
// ── header ───────────────────────────────────────────────
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Label {
|
||||
text: "Sensor #" + (root.sensorIndex + 1) + (root.sensorLabel ? " (" + root.sensorLabel + ")" : "")
|
||||
color: Theme.headingColor
|
||||
font.bold: true
|
||||
font.pixelSize: Theme.fontSm
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
Rectangle {
|
||||
visible: root.hasOverride
|
||||
width: overrideTag.implicitWidth + 12
|
||||
height: overrideTag.implicitHeight + 6
|
||||
radius: 4
|
||||
color: Theme.statusWarningColor
|
||||
opacity: 0.85
|
||||
Label {
|
||||
id: overrideTag
|
||||
anchors.centerIn: parent
|
||||
text: "OVERRIDE"
|
||||
color: Theme.statusBadgeText
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.bold: true
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "Live: " + root.currentValue.toFixed(2)
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.cardBorder
|
||||
}
|
||||
|
||||
// ── replace field ─────────────────────────────────────────
|
||||
Label {
|
||||
text: "Replace with value"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontXs
|
||||
}
|
||||
TextField {
|
||||
id: valueField
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "100"
|
||||
inputMethodHints: Qt.ImhFormattedNumbersOnly
|
||||
color: Theme.fieldText
|
||||
placeholderTextColor: Theme.fieldPlaceholder
|
||||
selectedTextColor: Theme.fieldBackground
|
||||
selectionColor: Theme.fieldText
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusXs
|
||||
color: Theme.fieldBackground
|
||||
border.width: valueField.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||
border.color: valueField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
Behavior on border.color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── offset field ──────────────────────────────────────────
|
||||
Label {
|
||||
text: "Or add offset (± delta)"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontXs
|
||||
}
|
||||
TextField {
|
||||
id: offsetField
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "e.g. +0.5 or -0.5 (blank = skip)"
|
||||
inputMethodHints: Qt.ImhFormattedNumbersOnly
|
||||
color: Theme.fieldText
|
||||
placeholderTextColor: Theme.fieldPlaceholder
|
||||
selectedTextColor: Theme.fieldBackground
|
||||
selectionColor: Theme.fieldText
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusXs
|
||||
color: Theme.fieldBackground
|
||||
border.width: offsetField.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||
border.color: offsetField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
Behavior on border.color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.cardBorder
|
||||
}
|
||||
|
||||
// ── buttons ───────────────────────────────────────────────
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Button {
|
||||
id: applyBtn
|
||||
text: "Apply"
|
||||
Layout.fillWidth: true
|
||||
hoverEnabled: true
|
||||
enabled: valueField.text !== "" || offsetField.text !== ""
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: applyBtn.down ? Theme.buttonNeutralPressed : applyBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||
border.width: Theme.borderThin
|
||||
border.color: Theme.fieldBorder
|
||||
}
|
||||
contentItem: Text {
|
||||
text: applyBtn.text
|
||||
color: Theme.buttonNeutralText
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
font.bold: true
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
onClicked: {
|
||||
if (valueField.text !== "") {
|
||||
var v = parseFloat(valueField.text);
|
||||
if (!isNaN(v))
|
||||
streamController.replaceSensor(root.sensorIndex, v);
|
||||
}
|
||||
if (offsetField.text !== "") {
|
||||
var d = parseFloat(offsetField.text);
|
||||
if (!isNaN(d))
|
||||
streamController.offsetSensor(root.sensorIndex, d);
|
||||
}
|
||||
root.close();
|
||||
}
|
||||
}
|
||||
Button {
|
||||
id: clearBtn
|
||||
text: "Clear"
|
||||
Layout.fillWidth: true
|
||||
hoverEnabled: true
|
||||
enabled: root.hasOverride
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: clearBtn.down ? Theme.buttonNeutralPressed : clearBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||
border.width: Theme.borderThin
|
||||
border.color: Theme.fieldBorder
|
||||
}
|
||||
contentItem: Text {
|
||||
text: clearBtn.text
|
||||
color: Theme.buttonNeutralText
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
font.bold: true
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
onClicked: {
|
||||
streamController.clearSensorEdit(root.sensorIndex);
|
||||
root.close();
|
||||
}
|
||||
}
|
||||
Button {
|
||||
id: cancelBtn
|
||||
text: "Cancel"
|
||||
hoverEnabled: true
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: cancelBtn.down ? Theme.buttonNeutralPressed : cancelBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||
border.width: Theme.borderThin
|
||||
border.color: Theme.fieldBorder
|
||||
}
|
||||
contentItem: Text {
|
||||
text: cancelBtn.text
|
||||
color: Theme.buttonNeutralText
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
font.bold: true
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
onClicked: root.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
import ISC.Wafer
|
||||
|
||||
// ===== Run Chart =====
|
||||
// Zoomable whole-run multi-sensor chart (C# parity: PopupChartForm.cs).
|
||||
// Lives in the Graph tab. Mouse wheel zooms around the cursor, drag pans;
|
||||
// the strip below shows the aggregate min/max/avg over the *visible* range
|
||||
// (not the whole run), matching PopupChartForm's recalc_stats. Cursor/
|
||||
// playhead tracking is out of scope — see specs/plans/2026-07-10-popup-chart.md
|
||||
// and docs/adr/0004-graphquickitem-viewport-for-replay-chart.md.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property alias sensorNames: chart.sensorNames
|
||||
property alias seriesData: chart.seriesData
|
||||
property alias title: chart.title
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 8
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
GraphQuickItem {
|
||||
id: chart
|
||||
anchors.fill: parent
|
||||
showMinMaxMarkers: true
|
||||
xLabel: "Measurement Interval"
|
||||
yLabel: "Temperature (°C)"
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: interaction
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.LeftButton
|
||||
property real lastX: 0
|
||||
|
||||
onWheel: function(wheel) {
|
||||
var frac = Math.max(0, Math.min(1, wheel.x / width));
|
||||
var factor = wheel.angleDelta.y > 0 ? 0.8 : 1.25;
|
||||
chart.zoomAtFraction(frac, factor);
|
||||
wheel.accepted = true;
|
||||
}
|
||||
onPressed: function(mouse) {
|
||||
lastX = mouse.x;
|
||||
}
|
||||
onPositionChanged: function(mouse) {
|
||||
if (pressed && width > 0) {
|
||||
chart.panByFraction(-(mouse.x - lastX) / width);
|
||||
lastX = mouse.x;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Min/Max/Avg readout strip + Reset Zoom ─────────────────────
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 16
|
||||
|
||||
Label {
|
||||
text: "Min:"
|
||||
color: Theme.sensorLow
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
Label {
|
||||
text: chart.viewMin.toFixed(2) + (chart.viewMinSensor ? " (" + chart.viewMinSensor + ")" : "")
|
||||
color: Theme.headingColor
|
||||
font.family: Theme.codeFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "Max:"
|
||||
color: Theme.sensorHigh
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
Label {
|
||||
text: chart.viewMax.toFixed(2) + (chart.viewMaxSensor ? " (" + chart.viewMaxSensor + ")" : "")
|
||||
color: Theme.headingColor
|
||||
font.family: Theme.codeFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "Avg:"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
Label {
|
||||
text: chart.viewAvg.toFixed(2)
|
||||
color: Theme.headingColor
|
||||
font.family: Theme.codeFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Button {
|
||||
id: resetZoomBtn
|
||||
implicitHeight: 32
|
||||
hoverEnabled: true
|
||||
text: "Reset Zoom"
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.weight: Font.Medium
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: resetZoomBtn.pressed ? Theme.transportButtonHover : Theme.transportButtonBg
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
}
|
||||
contentItem: Text {
|
||||
text: resetZoomBtn.text
|
||||
color: Theme.headingColor
|
||||
font: resetZoomBtn.font
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
onClicked: chart.resetZoom()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import ISC
|
||||
|
||||
// Uppercase card heading — shared by the Data tab cards.
|
||||
Label {
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
color: Theme.sideMutedText
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls.impl
|
||||
import ISC
|
||||
import ".."
|
||||
|
||||
ColumnLayout {
|
||||
spacing: 6
|
||||
|
||||
// ── Section label & Refresh ───────────────────────────────────────────
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
|
||||
Label {
|
||||
text: "SOURCE"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
topPadding: 6
|
||||
bottomPadding: 8
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
Button {
|
||||
id: editBtn
|
||||
implicitWidth: 28
|
||||
implicitHeight: 28
|
||||
hoverEnabled: true
|
||||
background: Rectangle {
|
||||
color: editBtn.pressed ? Theme.buttonPressed : editBtn.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||
radius: Theme.radiusSm
|
||||
}
|
||||
contentItem: IconImage {
|
||||
source: "../icons/edit.svg"
|
||||
sourceSize.width: 16
|
||||
sourceSize.height: 16
|
||||
color: Theme.bodyColor
|
||||
}
|
||||
onClicked: {
|
||||
file_browser.refreshFiles();
|
||||
csvEditorDialog.open();
|
||||
}
|
||||
|
||||
AppToolTip {
|
||||
visible: editBtn.hovered
|
||||
text: "Edit"
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: refreshBtn
|
||||
implicitWidth: 28
|
||||
implicitHeight: 28
|
||||
hoverEnabled: true
|
||||
background: Rectangle {
|
||||
color: refreshBtn.pressed ? Theme.buttonPressed : refreshBtn.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||
radius: Theme.radiusSm
|
||||
}
|
||||
contentItem: IconImage {
|
||||
source: "../icons/refresh.svg"
|
||||
sourceSize.width: 16
|
||||
sourceSize.height: 16
|
||||
color: Theme.bodyColor
|
||||
}
|
||||
onClicked: file_browser.refreshFiles()
|
||||
|
||||
AppToolTip {
|
||||
visible: refreshBtn.hovered
|
||||
text: "Refresh"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Directory button ──────────────────────────────────────────────────
|
||||
Button {
|
||||
id: dirBtn
|
||||
Layout.fillWidth: true
|
||||
hoverEnabled: true
|
||||
implicitHeight: 36
|
||||
leftPadding: 8
|
||||
rightPadding: 8
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: dirBtn.pressed ? Theme.buttonNeutralPressed : dirBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||
border.width: Theme.borderThin
|
||||
border.color: Theme.fieldBorder
|
||||
}
|
||||
contentItem: RowLayout {
|
||||
spacing: 6
|
||||
IconImage {
|
||||
source: "../icons/folder.svg"
|
||||
width: 14; height: 14
|
||||
sourceSize.width: 14
|
||||
sourceSize.height: 14
|
||||
color: Theme.bodyColor
|
||||
}
|
||||
Label {
|
||||
text: file_browser.currentDirectory.split("/").pop() || file_browser.currentDirectory
|
||||
color: Theme.headingColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
elide: Text.ElideMiddle
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
onClicked: file_browser.chooseDirectory()
|
||||
}
|
||||
|
||||
// ── Filter ────────────────────────────────────────────────────────────
|
||||
TextField {
|
||||
id: filter
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "Filter…"
|
||||
font.pixelSize: Theme.fontSm
|
||||
color: Theme.fieldText
|
||||
placeholderTextColor: Theme.fieldPlaceholder
|
||||
selectedTextColor: Theme.fieldBackground
|
||||
selectionColor: Theme.fieldText
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusXs
|
||||
color: Theme.fieldBackground
|
||||
border.width: filter.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||
border.color: filter.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
Behavior on border.color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── File list ─────────────────────────────────────────────────────────
|
||||
ListView {
|
||||
id: fileList
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
clip: true
|
||||
spacing: 2
|
||||
highlight: null
|
||||
highlightFollowsCurrentItem: false
|
||||
|
||||
model: file_browser.files
|
||||
|
||||
delegate: ItemDelegate {
|
||||
id: fileItem
|
||||
width: ListView.view.width
|
||||
padding: 8
|
||||
highlighted: false
|
||||
focusPolicy: Qt.NoFocus
|
||||
|
||||
readonly property bool matchesFilter: {
|
||||
if (filter.text === "")
|
||||
return true;
|
||||
var q = filter.text.toLowerCase();
|
||||
return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0;
|
||||
}
|
||||
|
||||
readonly property var dataTab: {
|
||||
try {
|
||||
return (root.selectedTabIndex === 1 && dataTabLoader.item) ? dataTabLoader.item : null;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
// Run A's effective file: the master file when "Compare vs Master
|
||||
// File" is toggled on, otherwise the manually selected file.
|
||||
readonly property string effectiveRunAFile: fileItem.dataTab
|
||||
? (fileItem.dataTab.useMasterFile ? fileItem.dataTab.masterFileForRunB : fileItem.dataTab.compareFileA)
|
||||
: ""
|
||||
readonly property bool isRunA: fileItem.dataTab !== null
|
||||
&& fileItem.effectiveRunAFile !== "" && modelData.fileName === fileItem.effectiveRunAFile
|
||||
readonly property bool isRunB: fileItem.dataTab !== null
|
||||
&& modelData.fileName === fileItem.dataTab.compareFileB
|
||||
|
||||
// Active highlight logic: on Data tab, highlight the Run A/Run B files (or the
|
||||
// resolved master file standing in for Run A); otherwise highlight loadedFile (Map tab).
|
||||
readonly property bool isActive: fileItem.dataTab !== null
|
||||
? (fileItem.isRunA || fileItem.isRunB)
|
||||
: (streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile)
|
||||
|
||||
// A file registered as any family's master in Settings → Master Files.
|
||||
readonly property bool isMasterFile: {
|
||||
var m = settingsModel.masters
|
||||
for (var fam in m) {
|
||||
if (m[fam] && m[fam] === modelData.fileName) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
readonly property bool isLiveFile: (modelData.baseName || "").toLowerCase().indexOf("live") >= 0
|
||||
readonly property bool liveFileBlocked: root.selectedTabIndex === 1 && isLiveFile
|
||||
|
||||
visible: matchesFilter
|
||||
height: matchesFilter ? implicitHeight : 0
|
||||
enabled: !liveFileBlocked
|
||||
opacity: liveFileBlocked ? 0.45 : 1.0
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
|
||||
onClicked: streamController.loadFile(modelData.fileName)
|
||||
|
||||
background: Rectangle {
|
||||
color: fileItem.isActive ? Theme.listActiveBackground : fileItem.hovered ? Theme.listHoverBackground : "transparent"
|
||||
radius: Theme.radiusSm
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
|
||||
// Left accent bar — shown only when active
|
||||
Rectangle {
|
||||
visible: fileItem.isActive
|
||||
width: 3
|
||||
radius: 1.5
|
||||
anchors {
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
left: parent.left
|
||||
topMargin: 5
|
||||
bottomMargin: 5
|
||||
}
|
||||
color: {
|
||||
if (fileItem.isRunA) return Theme.primaryAccent;
|
||||
if (fileItem.isRunB) return Theme.themeSkill;
|
||||
var t = modelData.waferType;
|
||||
if (t === "A" || t === "E" || t === "P")
|
||||
return Theme.familyBlueAccent;
|
||||
if (t === "B" || t === "C" || t === "D")
|
||||
return Theme.familyGreenAccent;
|
||||
if (t === "Z")
|
||||
return Theme.familyVioletAccent;
|
||||
return Theme.headingColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: RowLayout {
|
||||
spacing: 8
|
||||
|
||||
// Wafer-type avatar (circle)
|
||||
Rectangle {
|
||||
implicitWidth: 28
|
||||
implicitHeight: 28
|
||||
Layout.preferredWidth: 28
|
||||
Layout.preferredHeight: 28
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
radius: 14
|
||||
color: {
|
||||
var t = modelData.waferType;
|
||||
if (t === "A" || t === "E" || t === "P")
|
||||
return Theme.familyBlueFill;
|
||||
if (t === "B" || t === "C" || t === "D")
|
||||
return Theme.familyGreenFill;
|
||||
if (t === "Z")
|
||||
return Theme.familyVioletFill;
|
||||
return Theme.familyNeutralFill;
|
||||
}
|
||||
Label {
|
||||
anchors.centerIn: parent
|
||||
text: modelData.waferType || "?"
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: Font.Bold
|
||||
color: Theme.textOnColor
|
||||
}
|
||||
|
||||
// Recording indicator dot — distinguishes a live-recorded CSV
|
||||
// (SessionController.startRecording) from a read-memory dump
|
||||
// (DeviceController.parseAndSaveData).
|
||||
Rectangle {
|
||||
visible: modelData.isRecording === true
|
||||
width: 10
|
||||
height: 10
|
||||
radius: 5
|
||||
color: Theme.recordColor
|
||||
border.color: Theme.subtleSectionBackground
|
||||
border.width: 1.5
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.topMargin: -2
|
||||
anchors.rightMargin: -2
|
||||
}
|
||||
|
||||
// Accent ring — this file is registered as a master (Settings → Master Files).
|
||||
Rectangle {
|
||||
visible: fileItem.isMasterFile
|
||||
anchors.fill: parent
|
||||
radius: parent.radius
|
||||
color: "transparent"
|
||||
border.color: Theme.primaryAccent
|
||||
border.width: 2
|
||||
}
|
||||
}
|
||||
|
||||
// Typography hierarchy
|
||||
ColumnLayout {
|
||||
spacing: 1
|
||||
Layout.fillWidth: true
|
||||
|
||||
// Primary: wafer type + serial number, plus a REC badge for recordings
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
|
||||
Label {
|
||||
text: modelData.serialNumber ? (modelData.waferType + modelData.serialNumber) : modelData.baseName
|
||||
color: Theme.headingColor
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: Font.Bold
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: modelData.isRecording === true
|
||||
radius: 4
|
||||
color: Qt.alpha(Theme.recordColor, 0.18)
|
||||
implicitWidth: recLabel.implicitWidth + 10
|
||||
implicitHeight: recLabel.implicitHeight + 4
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
Label {
|
||||
id: recLabel
|
||||
anchors.centerIn: parent
|
||||
text: "REC"
|
||||
color: Theme.recordColor
|
||||
font.pixelSize: Theme.font2xs
|
||||
font.bold: true
|
||||
font.letterSpacing: 0.6
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: fileItem.isMasterFile
|
||||
radius: 4
|
||||
color: Qt.alpha(Theme.primaryAccent, 0.18)
|
||||
implicitWidth: masterLabel.implicitWidth + 10
|
||||
implicitHeight: masterLabel.implicitHeight + 4
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
Label {
|
||||
id: masterLabel
|
||||
anchors.centerIn: parent
|
||||
text: "MASTER"
|
||||
color: Theme.primaryAccent
|
||||
font.pixelSize: Theme.font2xs
|
||||
font.bold: true
|
||||
font.letterSpacing: 0.6
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary: date · time
|
||||
RowLayout {
|
||||
spacing: 3
|
||||
Layout.fillWidth: true
|
||||
Label {
|
||||
text: {
|
||||
var d = modelData.date || "";
|
||||
return d.length >= 10 ? d.substring(0, 10) : (d || "—");
|
||||
}
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
Label {
|
||||
visible: (modelData.timeStr || "") !== ""
|
||||
text: "·"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
opacity: 0.45
|
||||
}
|
||||
Label {
|
||||
visible: (modelData.timeStr || "") !== ""
|
||||
text: modelData.timeStr || ""
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
opacity: 0.65
|
||||
}
|
||||
}
|
||||
|
||||
// Tertiary: edited metadata (chamber · notes) if any
|
||||
Label {
|
||||
visible: {
|
||||
var c = modelData.chamber || "";
|
||||
var n = modelData.notes || "";
|
||||
return c !== "" || n !== "";
|
||||
}
|
||||
text: {
|
||||
var parts = [];
|
||||
if (modelData.chamber)
|
||||
parts.push(modelData.chamber);
|
||||
if (modelData.notes)
|
||||
parts.push(modelData.notes);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontXs
|
||||
opacity: 0.7
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Footer ────────────────────────────────────────────────────────────
|
||||
Label {
|
||||
text: file_browser.files.length + " files · CSV only"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
|
||||
SelectFileDialog {
|
||||
id: csvEditorDialog
|
||||
tableModel: file_browser.files
|
||||
}
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Dialogs
|
||||
import ISC
|
||||
|
||||
// ===== Split Dialog (Threshold Segmentation) =====
|
||||
// Modal popup for segmenting temperature profile into Ramp/Soak/Cool phases.
|
||||
// Backend: splitter.segment_profile() returns list of DataSegment objects.
|
||||
|
||||
Popup {
|
||||
id: root
|
||||
modal: true
|
||||
dim: true
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
width: Math.min(parent.width - 100, 800)
|
||||
height: Math.min(parent.height - 100, 600)
|
||||
anchors.centerIn: Overlay.overlay
|
||||
|
||||
property real threshold: 40.0
|
||||
property bool segmenting: false
|
||||
property var segments: []
|
||||
property string currentFile: streamController.loadedFile || ""
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusMd
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
spacing: 16
|
||||
|
||||
// ── Header ────────────────────────────────────────────────
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
|
||||
Label {
|
||||
text: "SPLIT DATA"
|
||||
font.pixelSize: Theme.fontLg
|
||||
font.bold: true
|
||||
color: Theme.headingColor
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "\u2715"
|
||||
flat: true
|
||||
Layout.preferredWidth: 32
|
||||
Layout.preferredHeight: 32
|
||||
onClicked: root.close()
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusXs
|
||||
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||
}
|
||||
contentItem: Label {
|
||||
text: parent.text
|
||||
color: Theme.bodyColor
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Threshold Input ───────────────────────────────────────
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 16
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
|
||||
Label {
|
||||
text: "THRESHOLD TEMPERATURE (°C)"
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.0
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
SpinBox {
|
||||
id: thresholdSpin
|
||||
from: 0
|
||||
to: 200
|
||||
value: root.threshold
|
||||
stepSize: 1
|
||||
editable: true
|
||||
Layout.fillWidth: true
|
||||
background: Rectangle {
|
||||
color: Theme.fieldBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "°C"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontMd
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
|
||||
Label {
|
||||
text: "SESSION CSV"
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.0
|
||||
}
|
||||
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
text: root.currentFile !== ""
|
||||
? root.currentFile.split("/").pop()
|
||||
: "No file loaded"
|
||||
color: root.currentFile !== "" ? Theme.bodyColor : Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontSm
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Split Button ──────────────────────────────────────────
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
|
||||
Button {
|
||||
id: splitBtn
|
||||
text: "SPLIT"
|
||||
Layout.fillWidth: true
|
||||
enabled: !root.segmenting && root.currentFile !== ""
|
||||
onClicked: {
|
||||
root.threshold = thresholdSpin.value;
|
||||
root.segmenting = true;
|
||||
streamController.splitData(root.currentFile, root.threshold);
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: splitBtn.enabled ? Theme.primaryAccent : Theme.buttonNeutralBackground
|
||||
border.color: splitBtn.enabled ? Theme.primaryAccent : "transparent"
|
||||
border.width: 1
|
||||
}
|
||||
contentItem: Row {
|
||||
spacing: 8
|
||||
anchors.centerIn: parent
|
||||
|
||||
Label {
|
||||
text: splitBtn.enabled ? (root.segmenting ? "Segmenting..." : "SPLIT") : "LOAD DATA FIRST"
|
||||
color: splitBtn.enabled ? Theme.tone100 : Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.0
|
||||
}
|
||||
|
||||
BusyIndicator {
|
||||
running: root.segmenting
|
||||
visible: root.segmenting
|
||||
width: 16
|
||||
height: 16
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Segments Table ────────────────────────────────────────
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.panelBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 0
|
||||
spacing: 0
|
||||
|
||||
// Table header
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 36
|
||||
color: Theme.subtleSectionBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
spacing: 8
|
||||
|
||||
Label { text: "PHASE"; Layout.preferredWidth: 100; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||
Label { text: "START (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||
Label { text: "END (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||
Label { text: "AVG TEMP (°C)"; Layout.fillWidth: true; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||
Item { Layout.preferredWidth: 80 }
|
||||
}
|
||||
}
|
||||
|
||||
// Table body (scrollable)
|
||||
ScrollView {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
clip: true
|
||||
|
||||
ColumnLayout {
|
||||
width: parent.width - 32
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: root.segments.length > 0 ? root.segments : []
|
||||
delegate: Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 40
|
||||
color: index % 2 === 0 ? Theme.cardBackground : "transparent"
|
||||
border.color: Theme.cardBorder
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
spacing: 8
|
||||
|
||||
Label {
|
||||
text: modelData.label || ""
|
||||
Layout.preferredWidth: 100
|
||||
color: modelData.label === "Soak" ? Theme.statusSuccessColor :
|
||||
modelData.label === "Ramp" ? Theme.statusWarningColor : Theme.bodyColor
|
||||
font.bold: true
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
|
||||
Label {
|
||||
text: (modelData.start_frame || 0).toString()
|
||||
Layout.preferredWidth: 120
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
|
||||
Label {
|
||||
text: (modelData.end_frame || 0).toString()
|
||||
Layout.preferredWidth: 120
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
|
||||
Label {
|
||||
text: (modelData.avg_temp || 0).toFixed(2) + " °C"
|
||||
Layout.fillWidth: true
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
|
||||
Button {
|
||||
id: exportBtn
|
||||
text: "Export"
|
||||
Layout.preferredWidth: 80
|
||||
Layout.preferredHeight: 26
|
||||
hoverEnabled: true
|
||||
onClicked: streamController.exportSegment(index)
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusXs
|
||||
color: exportBtn.pressed ? Theme.buttonNeutralPressed
|
||||
: exportBtn.hovered ? Theme.buttonNeutralHover
|
||||
: Theme.buttonNeutralBackground
|
||||
border.color: Theme.fieldBorder
|
||||
border.width: 1
|
||||
}
|
||||
contentItem: Label {
|
||||
text: exportBtn.text
|
||||
color: Theme.buttonNeutralText
|
||||
font.pixelSize: Theme.fontXs
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state
|
||||
Label {
|
||||
visible: root.segments.length === 0
|
||||
text: root.currentFile !== "" ? "Click SPLIT to segment data" : "Load a CSV file first"
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontSm
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 100
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary line ──────────────────────────────────────────
|
||||
Label {
|
||||
visible: root.segments.length > 0
|
||||
text: root.segments.length + " segment" + (root.segments.length === 1 ? "" : "s")
|
||||
+ " found above " + root.threshold.toFixed(0) + "°C threshold."
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
|
||||
// ── Export feedback ───────────────────────────────────────
|
||||
Label {
|
||||
id: exportStatus
|
||||
visible: text !== ""
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
elide: Text.ElideMiddle
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
// ── Backend Connection ────────────────────────────────────────
|
||||
Connections {
|
||||
target: streamController
|
||||
function onSplitResult(result) {
|
||||
root.segmenting = false;
|
||||
exportStatus.text = "";
|
||||
if (result && result.success) {
|
||||
root.segments = result.segments || [];
|
||||
console.log("[SplitDialog] Segmented into", root.segments.length, "phases");
|
||||
} else {
|
||||
root.segments = [];
|
||||
console.log("[SplitDialog] Split failed:", result.error || "unknown");
|
||||
}
|
||||
}
|
||||
|
||||
function onSegmentExported(result) {
|
||||
if (result && result.success)
|
||||
exportStatus.text = "Exported: " + result.path.split("/").pop();
|
||||
else
|
||||
exportStatus.text = "Export failed: " + (result.error || "unknown");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
import ISC.Tabs.components
|
||||
|
||||
// ── Status tab: Hardware Actions + Log Actions ──────────
|
||||
ColumnLayout {
|
||||
spacing: Theme.sideRailSpacing
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: hwActionsCol.implicitHeight + 28
|
||||
radius: Theme.sidePanelRadius
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
color: Theme.sidePanelBackground
|
||||
|
||||
ColumnLayout {
|
||||
id: hwActionsCol
|
||||
anchors.fill: parent
|
||||
anchors.margins: 14
|
||||
spacing: 6
|
||||
|
||||
Label {
|
||||
text: "HARDWARE ACTIONS"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
RailActionButton {
|
||||
label: "DETECT WAFER"
|
||||
iconSource: "../icons/detect.svg"
|
||||
Layout.fillWidth: true
|
||||
enabled: !deviceController.operationInProgress
|
||||
onClicked: root._doDetect()
|
||||
}
|
||||
|
||||
RailActionButton {
|
||||
label: "READ MEMORY"
|
||||
iconSource: "../icons/read.svg"
|
||||
Layout.fillWidth: true
|
||||
enabled: deviceController.waferDetected && !deviceController.operationInProgress
|
||||
onClicked: deviceController.readMemoryAsync()
|
||||
}
|
||||
|
||||
RailActionButton {
|
||||
label: "ERASE MEMORY"
|
||||
iconSource: "../icons/erase.svg"
|
||||
Layout.fillWidth: true
|
||||
destructive: true
|
||||
enabled: deviceController.waferDetected && !deviceController.operationInProgress
|
||||
onClicked: deviceController.eraseMemory(
|
||||
deviceController.selectedPort)
|
||||
}
|
||||
|
||||
RailActionButton {
|
||||
label: "READ DEBUG"
|
||||
iconSource: "../icons/read.svg"
|
||||
Layout.fillWidth: true
|
||||
// One-shot toggle: "on" while the debug read is in flight,
|
||||
// resets automatically once deviceController flips status.
|
||||
toggled: deviceController.connectionStatus === "Reading debug..."
|
||||
enabled: deviceController.waferDetected && !deviceController.operationInProgress
|
||||
onClicked: deviceController.readDebug(deviceController.selectedPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: logActionsCol.implicitHeight + 28
|
||||
radius: Theme.sidePanelRadius
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
color: Theme.sidePanelBackground
|
||||
|
||||
ColumnLayout {
|
||||
id: logActionsCol
|
||||
anchors.fill: parent
|
||||
anchors.margins: 14
|
||||
spacing: 6
|
||||
|
||||
Label {
|
||||
text: "LOG ACTIONS"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
RailActionButton {
|
||||
label: "REFRESH SESSION"
|
||||
iconSource: "../icons/refresh.svg"
|
||||
Layout.fillWidth: true
|
||||
enabled: !deviceController.operationInProgress
|
||||
onClicked: deviceController.clearSession()
|
||||
}
|
||||
|
||||
RailActionButton {
|
||||
label: "CLEAR LOG"
|
||||
iconSource: "../icons/x.svg"
|
||||
Layout.fillWidth: true
|
||||
onClicked: deviceController.clearActivityLog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Dialogs
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: contentCol.implicitHeight + 24
|
||||
|
||||
color: Theme.sidePanelBackground
|
||||
radius: Theme.sidePanelRadius
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
|
||||
signal exportRequested(string filePath, string extra)
|
||||
|
||||
property int _liveSecs: 0
|
||||
Connections {
|
||||
target: streamController
|
||||
function onModeChanged() {
|
||||
if (streamController.mode === "review" && modeBar.currentIndex !== 0) {
|
||||
modeBar.currentIndex = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Timer {
|
||||
id: liveTimer
|
||||
interval: 1000
|
||||
repeat: true
|
||||
running: streamController.mode === "live" && streamController.state !== "idle"
|
||||
onTriggered: root._liveSecs++
|
||||
onRunningChanged: if (!running) root._liveSecs = 0
|
||||
}
|
||||
|
||||
function fmtTime(s) {
|
||||
var m = Math.floor(s / 60);
|
||||
var ss = s % 60;
|
||||
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss;
|
||||
}
|
||||
|
||||
function switchToReview() {
|
||||
modeBar.currentIndex = 0;
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: contentCol
|
||||
anchors {
|
||||
fill: parent
|
||||
topMargin: 10
|
||||
leftMargin: 12
|
||||
rightMargin: 12
|
||||
bottomMargin: 12
|
||||
}
|
||||
spacing: 8
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
|
||||
TabBar {
|
||||
id: modeBar
|
||||
currentIndex: 0
|
||||
spacing: 2
|
||||
padding: 2
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 28
|
||||
background: Rectangle {
|
||||
color: Theme.subtleSectionBackground
|
||||
radius: Theme.radiusXs
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
TabButton {
|
||||
text: "Review"
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 24
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: parent.checked ? Font.Medium : Font.Normal
|
||||
background: Rectangle {
|
||||
color: parent.checked ? Theme.tabActiveBackground : "transparent"
|
||||
radius: Theme.radiusXs - 1
|
||||
}
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: parent.checked ? Theme.headingColor : Theme.bodyColor
|
||||
font: parent.font
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
TabButton {
|
||||
id: liveTab
|
||||
text: "Live"
|
||||
enabled: deviceController.connectionStatus === "Connected"
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 24
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: parent.checked ? Font.Medium : Font.Normal
|
||||
|
||||
AppToolTip {
|
||||
visible: disabledHover.containsMouse && !liveTab.enabled
|
||||
text: "Connect a wafer to enable Live mode"
|
||||
delay: 300
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: disabledHover
|
||||
anchors.fill: parent
|
||||
enabled: !liveTab.enabled
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: parent.checked ? Theme.tabActiveBackground : "transparent"
|
||||
radius: Theme.radiusXs - 1
|
||||
}
|
||||
|
||||
contentItem: Item {
|
||||
Row {
|
||||
spacing: 4
|
||||
anchors.centerIn: parent
|
||||
Rectangle {
|
||||
id: liveTabDot
|
||||
width: 5; height: 5; radius: 2.5
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: liveTab.enabled ? Theme.metricGood : Theme.sideMutedText
|
||||
|
||||
SequentialAnimation on opacity {
|
||||
running: streamController.mode === "live" && streamController.state !== "idle"
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation { to: 0.2; duration: 600; easing.type: Easing.InOutQuad }
|
||||
NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutQuad }
|
||||
onRunningChanged: if (!running) liveTabDot.opacity = 1.0
|
||||
}
|
||||
}
|
||||
Text {
|
||||
text: liveTab.text
|
||||
color: liveTab.checked ? Theme.headingColor : Theme.bodyColor
|
||||
font: liveTab.font
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
height: parent.height
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onCurrentIndexChanged: {
|
||||
if (currentIndex === 1) {
|
||||
if (deviceController.connectionStatus !== "Connected") {
|
||||
currentIndex = 0;
|
||||
return;
|
||||
}
|
||||
streamController.setMode("live");
|
||||
var fc = "";
|
||||
if (deviceController.lastWaferInfo && deviceController.lastWaferInfo.length > 0) {
|
||||
fc = deviceController.lastWaferInfo[0];
|
||||
}
|
||||
streamController.startStream(deviceController.selectedPort, fc);
|
||||
} else {
|
||||
streamController.setMode("review");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.cardBorder
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "METRICS"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
Layout.bottomMargin: 2
|
||||
}
|
||||
|
||||
MetricRow {
|
||||
label: "Received Frames"
|
||||
value: modeBar.currentIndex === 1 ? streamController.receivedCount : "—"
|
||||
}
|
||||
|
||||
MetricRow {
|
||||
label: "Errors"
|
||||
value: modeBar.currentIndex === 1 ? streamController.errorCount : "—"
|
||||
valueColor: streamController.errorCount > 0 ? Theme.statusWarningColor : Theme.headingColor
|
||||
}
|
||||
|
||||
MetricRow {
|
||||
label: "Resyncs"
|
||||
value: modeBar.currentIndex === 1 ? streamController.resyncCount : "—"
|
||||
}
|
||||
|
||||
MetricRow {
|
||||
label: "Elapsed"
|
||||
value: modeBar.currentIndex === 1 ? root.fmtTime(root._liveSecs) : "—"
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.cardBorder
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "ACTIONS"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
Layout.bottomMargin: 2
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
|
||||
Button {
|
||||
id: recordBtn
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 28
|
||||
enabled: streamController.mode === "live"
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
text: streamController.recording ? "Stop REC" : "Record"
|
||||
|
||||
AppToolTip {
|
||||
visible: recordBtn.hovered && !recordBtn.enabled
|
||||
text: "Connect to Live to start recording"
|
||||
delay: 300
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
if (streamController.recording) {
|
||||
streamController.stopRecording();
|
||||
} else {
|
||||
var info = deviceController.lastWaferInfo;
|
||||
var serial = (info && info.length > 1) ? info[1] : "";
|
||||
var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss");
|
||||
var name = "live_" + (serial ? serial + "_" : "") + ts + ".csv";
|
||||
streamController.startRecording(deviceController.saveDataDir + "/" + name, serial);
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: recordBtn.down ? Theme.transportButtonHover
|
||||
: (recordBtn.hovered ? Theme.transportButtonBg : "transparent")
|
||||
border.width: Theme.borderThin
|
||||
border.color: Theme.sideBorder
|
||||
}
|
||||
contentItem: Text {
|
||||
text: recordBtn.text
|
||||
color: Theme.headingColor
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: Font.Medium
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: exportBtn
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 28
|
||||
text: "Export"
|
||||
// Nothing to export until a file is loaded or a stream has played
|
||||
enabled: streamController.sensorValues.length > 0
|
||||
opacity: enabled ? 1 : 0.4
|
||||
|
||||
onClicked: {
|
||||
// Default into <saveDataDir>/Export (created on demand)
|
||||
exportDialog.selectedFile = "file://" + deviceController.exportDir() + "/wafer_map.png"
|
||||
exportDialog.open()
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: exportBtn.down ? Theme.transportButtonHover
|
||||
: (exportBtn.hovered ? Theme.transportButtonBg : "transparent")
|
||||
border.width: Theme.borderThin
|
||||
border.color: Theme.sideBorder
|
||||
}
|
||||
contentItem: Text {
|
||||
text: exportBtn.text
|
||||
color: Theme.headingColor
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: Font.Medium
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
FileDialog {
|
||||
id: exportDialog
|
||||
title: "Export Wafer Map"
|
||||
fileMode: FileDialog.SaveFile
|
||||
defaultSuffix: "png"
|
||||
nameFilters: ["PNG files (*.png)"]
|
||||
onAccepted: {
|
||||
var path = String(selectedFile).replace(/^file:\/\//, "");
|
||||
var extra = "";
|
||||
if (streamController.mode === "live") {
|
||||
extra = "Frames: " + streamController.receivedCount
|
||||
+ " Errors: " + streamController.errorCount
|
||||
+ " Resyncs: " + streamController.resyncCount
|
||||
+ " Elapsed: " + root.fmtTime(root._liveSecs);
|
||||
}
|
||||
root.exportRequested(path, extra);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.cardBorder
|
||||
}
|
||||
|
||||
Button {
|
||||
id: primaryBtn
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 32
|
||||
text: streamController.mode === "live" ? "STOP" : "START"
|
||||
enabled: streamController.mode === "live"
|
||||
? true
|
||||
: deviceController.connectionStatus === "Connected"
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
|
||||
AppToolTip {
|
||||
visible: primaryBtn.hovered && !primaryBtn.enabled
|
||||
text: "Connect a wafer to enable Live mode"
|
||||
delay: 300
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
if (streamController.mode === "live") {
|
||||
streamController.stopStream();
|
||||
modeBar.currentIndex = 0;
|
||||
} else {
|
||||
modeBar.currentIndex = 1;
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: primaryBtn.down ? Theme.transportButtonHover
|
||||
: (primaryBtn.hovered ? Theme.transportButtonBg : "transparent")
|
||||
border.width: Theme.borderThin
|
||||
border.color: Theme.sideBorder
|
||||
}
|
||||
contentItem: Text {
|
||||
text: primaryBtn.text
|
||||
color: Theme.headingColor
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: Font.Medium
|
||||
font.letterSpacing: 1.0
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
|
||||
// ===== Control Bar =====
|
||||
// Review-mode transport footer (frame counter, play/pause/step, speed).
|
||||
// Live-mode's Received/Errors/Stop moved to ReadoutPanel's "LIVE STREAM"
|
||||
// card so this bar can collapse to zero height and free vertical space for
|
||||
// the trend chart while streaming.
|
||||
Item {
|
||||
id: bar
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: hasContent ? 80 : 0
|
||||
Behavior on implicitHeight {
|
||||
NumberAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard }
|
||||
}
|
||||
clip: true
|
||||
|
||||
// Only shown in review mode, and only once a file is loaded
|
||||
readonly property bool hasContent: streamController.mode !== "live" && streamController.loadedFile !== ""
|
||||
|
||||
// ── REVIEW MODE ──────────────────────────────────────────────
|
||||
Item {
|
||||
id: reviewContent
|
||||
anchors.fill: parent
|
||||
|
||||
// Single centered pill for frame counter + transport + speed
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
implicitWidth: reviewRow.implicitWidth + 48
|
||||
implicitHeight: 68
|
||||
radius: Theme.radiusMd
|
||||
color: Theme.sidePanelBackground
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
|
||||
Row {
|
||||
id: reviewRow
|
||||
anchors.centerIn: parent
|
||||
spacing: 24
|
||||
|
||||
// Frame counter
|
||||
Row {
|
||||
spacing: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Label {
|
||||
text: "Frame"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: Font.Medium
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: frameInput
|
||||
text: String(streamController.frameIndex + 1)
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: Font.Medium
|
||||
font.family: Theme.codeFontFamily
|
||||
color: Theme.headingColor
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
horizontalAlignment: TextInput.AlignHCenter
|
||||
selectByMouse: true
|
||||
inputMethodHints: Qt.ImhDigitsOnly
|
||||
padding: 0
|
||||
width: Math.max(44, contentWidth + 16)
|
||||
height: 28
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
background: Rectangle {
|
||||
color: Theme.fieldBackground
|
||||
radius: Theme.radiusXs
|
||||
border.color: frameInput.activeFocus ? Theme.fieldBorderFocus : (frameInput.hovered ? Theme.fieldBorderFocus : Theme.sideBorder)
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
onEditingFinished: {
|
||||
var val = parseInt(text)
|
||||
if (!isNaN(val)) {
|
||||
var targetFrame = Math.max(1, Math.min(val, streamController.frameTotal))
|
||||
streamController.seek(targetFrame - 1)
|
||||
}
|
||||
text = String(streamController.frameIndex + 1)
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: streamController
|
||||
function onFrameUpdated() {
|
||||
if (!frameInput.activeFocus) {
|
||||
frameInput.text = String(streamController.frameIndex + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "/ " + streamController.frameTotal
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: Font.Medium
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
// Separator 1
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 1
|
||||
height: 36
|
||||
color: Theme.sideBorder
|
||||
}
|
||||
|
||||
// Media Buttons Row
|
||||
Row {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 12
|
||||
|
||||
// Skip-start
|
||||
Button {
|
||||
id: skipStartBtn
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
implicitWidth: 56
|
||||
implicitHeight: 56
|
||||
hoverEnabled: true
|
||||
background: Rectangle {
|
||||
radius: 8
|
||||
color: skipStartBtn.pressed ? Theme.transportButtonHover
|
||||
: skipStartBtn.hovered ? Theme.transportButtonBg
|
||||
: "transparent"
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
contentItem: IconImage {
|
||||
source: "../icons/prev.svg"
|
||||
width: 22; height: 22
|
||||
sourceSize.width: 22
|
||||
sourceSize.height: 22
|
||||
color: Theme.headingColor
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
onClicked: streamController.step(-1)
|
||||
}
|
||||
|
||||
// Stop
|
||||
Button {
|
||||
id: reviewStopBtn
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
implicitWidth: 56
|
||||
implicitHeight: 56
|
||||
hoverEnabled: true
|
||||
background: Rectangle {
|
||||
radius: 8
|
||||
color: reviewStopBtn.pressed ? Theme.transportButtonHover
|
||||
: reviewStopBtn.hovered ? Theme.transportButtonBg
|
||||
: "transparent"
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
contentItem: IconImage {
|
||||
source: "../icons/stop.svg"
|
||||
width: 20; height: 20
|
||||
sourceSize.width: 20
|
||||
sourceSize.height: 20
|
||||
color: Theme.headingColor
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
onClicked: streamController.stop()
|
||||
}
|
||||
|
||||
// Play / Pause toggle
|
||||
Button {
|
||||
id: playPauseBtn
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
implicitWidth: 56
|
||||
implicitHeight: 56
|
||||
hoverEnabled: true
|
||||
background: Rectangle {
|
||||
radius: 8
|
||||
color: playPauseBtn.pressed ? Theme.transportButtonHover
|
||||
: playPauseBtn.hovered ? Theme.transportButtonBg
|
||||
: "transparent"
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
contentItem: IconImage {
|
||||
source: streamController.playing ? "../icons/pause.svg" : "../icons/play.svg"
|
||||
width: 22; height: 22
|
||||
sourceSize.width: 22
|
||||
sourceSize.height: 22
|
||||
color: Theme.headingColor
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
onClicked: {
|
||||
if (streamController.playing) {
|
||||
streamController.pause();
|
||||
} else {
|
||||
streamController.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip-end
|
||||
Button {
|
||||
id: skipEndBtn
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
implicitWidth: 56
|
||||
implicitHeight: 56
|
||||
hoverEnabled: true
|
||||
background: Rectangle {
|
||||
radius: 8
|
||||
color: skipEndBtn.pressed ? Theme.transportButtonHover
|
||||
: skipEndBtn.hovered ? Theme.transportButtonBg
|
||||
: "transparent"
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
contentItem: IconImage {
|
||||
source: "../icons/next.svg"
|
||||
width: 22; height: 22
|
||||
sourceSize.width: 22
|
||||
sourceSize.height: 22
|
||||
color: Theme.headingColor
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
onClicked: streamController.step(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Separator 2
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 1
|
||||
height: 36
|
||||
color: Theme.sideBorder
|
||||
}
|
||||
|
||||
// Speed capsule button
|
||||
Button {
|
||||
id: speedBtn
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
property var speeds: [1, 2, 4]
|
||||
property int idx: 0
|
||||
implicitWidth: 72
|
||||
implicitHeight: 48
|
||||
hoverEnabled: true
|
||||
text: speeds[idx] + "x"
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.weight: Font.Medium
|
||||
background: Rectangle {
|
||||
radius: 8
|
||||
color: speedBtn.pressed ? Theme.transportButtonHover
|
||||
: Theme.transportButtonBg
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: Theme.headingColor
|
||||
font: parent.font
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
onClicked: {
|
||||
idx = (idx + 1) % speeds.length;
|
||||
streamController.setSpeed(speeds[idx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
|
||||
// ── BOX 4: Utility Buttons ─────────────────────────────────
|
||||
Rectangle {
|
||||
id: utilFooter
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Theme.sideFooterUtility
|
||||
radius: Theme.sidePanelRadius
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
color: Theme.sidePanelBackground
|
||||
|
||||
property var settingsPopup: null
|
||||
property var aboutDialog: null
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 4
|
||||
spacing: 4
|
||||
|
||||
Button {
|
||||
id: settingsBtn
|
||||
flat: true
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
icon.source: "../icons/settings.svg"
|
||||
onClicked: if (settingsPopup) settingsPopup.open()
|
||||
|
||||
background: Rectangle {
|
||||
radius: 8
|
||||
color: settingsBtn.hovered
|
||||
? Theme.sideActiveBackground : "transparent"
|
||||
}
|
||||
|
||||
contentItem: Row {
|
||||
spacing: 6
|
||||
anchors.centerIn: parent
|
||||
|
||||
IconImage {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 16
|
||||
height: 16
|
||||
source: "../icons/settings.svg"
|
||||
sourceSize.width: 16
|
||||
sourceSize.height: 16
|
||||
color: Theme.headingColor
|
||||
opacity: 0.75
|
||||
}
|
||||
|
||||
Label {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "SETTINGS"
|
||||
color: Theme.headingColor
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.2
|
||||
opacity: 0.8
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 1
|
||||
height: 24
|
||||
color: Theme.sideBorder
|
||||
}
|
||||
|
||||
Button {
|
||||
id: aboutBtn
|
||||
flat: true
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
icon.source: "../icons/about.svg"
|
||||
onClicked: if (aboutDialog) aboutDialog.open()
|
||||
|
||||
background: Rectangle {
|
||||
radius: 8
|
||||
color: aboutBtn.hovered
|
||||
? Theme.sideActiveBackground : "transparent"
|
||||
}
|
||||
|
||||
contentItem: Row {
|
||||
spacing: 6
|
||||
anchors.centerIn: parent
|
||||
|
||||
IconImage {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 16
|
||||
height: 16
|
||||
source: "../icons/about.svg"
|
||||
sourceSize.width: 16
|
||||
sourceSize.height: 16
|
||||
color: Theme.headingColor
|
||||
opacity: 0.75
|
||||
}
|
||||
|
||||
Label {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "ABOUT"
|
||||
color: Theme.headingColor
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.2
|
||||
opacity: 0.8
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import ISC
|
||||
import ISC.Wafer
|
||||
|
||||
Item {
|
||||
id: root
|
||||
property real blend: 0.0
|
||||
property bool showLabels: true
|
||||
property alias showExtremes: map.showExtremes
|
||||
property alias showThickness: map.showThickness
|
||||
property alias hasThickness: map.hasThickness
|
||||
|
||||
WaferMapItem {
|
||||
id: map
|
||||
anchors.fill: parent
|
||||
sensors: streamController.sensorLayout // [{label,x,y}]
|
||||
values: streamController.sensorValues // [float]
|
||||
bands: streamController.sensorBands // ["in_range"|"high"|"low"]
|
||||
shape: streamController.waferShape
|
||||
size: streamController.waferSize
|
||||
target: streamController.target
|
||||
margin: streamController.margin
|
||||
blend: root.blend
|
||||
showLabels: root.showLabels
|
||||
// Bind to Theme so colors update on dark/light mode switch
|
||||
ringColor: Theme.waferRingColor
|
||||
axisColor: Theme.waferAxisColor
|
||||
lowColor: Theme.sensorLow
|
||||
inRangeColor: Theme.sensorInRange
|
||||
highColor: Theme.sensorHigh
|
||||
textColor: Theme.headingColor
|
||||
|
||||
// Grows the hovered marker smoothly; hoverScale setter triggers a repaint.
|
||||
hoverScale: 1.0 + 0.35 * hoverPulse
|
||||
property real hoverPulse: map.hoveredIndex >= 0 ? 1.0 : 0.0
|
||||
Behavior on hoverPulse {
|
||||
NumberAnimation { duration: 150; easing.type: Easing.OutQuad }
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: hoverHandler
|
||||
onPointChanged: {
|
||||
map.hoveredIndex = streamController.mode === "live"
|
||||
? -1
|
||||
: map.which_marker(hoverHandler.point.position.x, hoverHandler.point.position.y);
|
||||
}
|
||||
onHoveredChanged: if (!hoverHandler.hovered) map.hoveredIndex = -1
|
||||
cursorShape: map.hoveredIndex >= 0 ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: ev => {
|
||||
if (streamController.mode === "live")
|
||||
return;
|
||||
var idx = map.which_marker(ev.position.x, ev.position.y);
|
||||
if (idx >= 0)
|
||||
replaceDialog.openFor(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ReplaceSensorDialog {
|
||||
id: replaceDialog
|
||||
}
|
||||
|
||||
function exportImage(filePath, extra) {
|
||||
return map.export_image(filePath, extra || "");
|
||||
}
|
||||
|
||||
function loadThickness(filePath) {
|
||||
return map.loadThickness(filePath);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
# ===== ISC Tabs Components Module =====
|
||||
module ISC.Tabs.components
|
||||
SourcePanel 1.0 SourcePanel.qml
|
||||
ReadoutPanel 1.0 ReadoutPanel.qml
|
||||
TransportBar 1.0 TransportBar.qml
|
||||
WaferMapView 1.0 WaferMapView.qml
|
||||
ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml
|
||||
RailActionButton 1.0 RailActionButton.qml
|
||||
SelectFileDialog 1.0 SelectFileDialog.qml
|
||||
SplitDialog 1.0 SplitDialog.qml
|
||||
StreamControlPanel 1.0 StreamControlPanel.qml
|
||||
StatusActionsPanel 1.0 StatusActionsPanel.qml
|
||||
ConnectionFooter 1.0 ConnectionFooter.qml
|
||||
UtilityFooter 1.0 UtilityFooter.qml
|
||||
PanelCheckBox 1.0 PanelCheckBox.qml
|
||||
ReadoutStat 1.0 ReadoutStat.qml
|
||||
MetricRow 1.0 MetricRow.qml
|
||||
AppToolTip 1.0 AppToolTip.qml
|
||||
SectionTitle 1.0 SectionTitle.qml
|
||||
PanelBox 1.0 PanelBox.qml
|
||||
PanelSlider 1.0 PanelSlider.qml
|
||||
EditableScrubStat 1.0 EditableScrubStat.qml
|
||||
ComparisonChartCard 1.0 ComparisonChartCard.qml
|
||||
OverlapMapCard 1.0 OverlapMapCard.qml
|
||||
ComparisonSidePanel 1.0 ComparisonSidePanel.qml
|
||||
RunChart 1.0 RunChart.qml
|
||||
@@ -1,15 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4" />
|
||||
<path d="M12 8h.01" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 294 B |
@@ -1,14 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m12 19-7-7 7-7" />
|
||||
<path d="M19 12H5" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 262 B |
@@ -1,15 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="18" x2="18" y1="20" y2="10" />
|
||||
<line x1="12" x2="12" y1="20" y2="4" />
|
||||
<line x1="6" x2="6" y1="20" y2="14" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 334 B |
@@ -1,13 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 239 B |
@@ -1,13 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 236 B |
@@ -1,16 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M8 3 4 7l4 4" />
|
||||
<path d="M4 7h16" />
|
||||
<path d="m16 21 4-4-4-4" />
|
||||
<path d="M20 17H4" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 313 B |
@@ -1,15 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
|
||||
<path d="M3 12A9 3 0 0 0 21 12" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 329 B |
@@ -1,14 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m21 21-4.34-4.34" />
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 275 B |
@@ -1,14 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
<path d="m15 5 4 4" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 297 B |
@@ -1,17 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 11v6" />
|
||||
<path d="M14 11v6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 389 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-1.5, 0)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.29289 1.29289C9.48043 1.10536 9.73478 1 10 1H18C19.6569 1 21 2.34315 21 4V9C21 9.55228 20.5523 10 20 10C19.4477 10 19 9.55228 19 9V4C19 3.44772 18.5523 3 18 3H11V8C11 8.55228 10.5523 9 10 9H5V20C5 20.5523 5.44772 21 6 21H7C7.55228 21 8 21.4477 8 22C8 22.5523 7.55228 23 7 23H6C4.34315 23 3 21.6569 3 20V8C3 7.73478 3.10536 7.48043 3.29289 7.29289L9.29289 1.29289ZM6.41421 7H9V4.41421L6.41421 7ZM19 12C19.5523 12 20 12.4477 20 13V19H23C23.5523 19 24 19.4477 24 20C24 20.5523 23.5523 21 23 21H19C18.4477 21 18 20.5523 18 20V13C18 12.4477 18.4477 12 19 12ZM11.8137 12.4188C11.4927 11.9693 10.8682 11.8653 10.4188 12.1863C9.96935 12.5073 9.86526 13.1318 10.1863 13.5812L12.2711 16.5L10.1863 19.4188C9.86526 19.8682 9.96935 20.4927 10.4188 20.8137C10.8682 21.1347 11.4927 21.0307 11.8137 20.5812L13.5 18.2205L15.1863 20.5812C15.5073 21.0307 16.1318 21.1347 16.5812 20.8137C17.0307 20.4927 17.1347 19.8682 16.8137 19.4188L14.7289 16.5L16.8137 13.5812C17.1347 13.1318 17.0307 12.5073 16.5812 12.1863C16.1318 11.8653 15.5073 11.9693 15.1863 12.4188L13.5 14.7795L11.8137 12.4188Z" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,17 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
<path d="M10 9H8" />
|
||||
<path d="M16 13H8" />
|
||||
<path d="M16 17H8" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 392 B |
@@ -1,13 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 342 B |
@@ -1,17 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M3 9h18" />
|
||||
<path d="M3 15h18" />
|
||||
<path d="M9 3v18" />
|
||||
<path d="M15 3v18" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 355 B |
@@ -1,13 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 237 B |
@@ -1,14 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="14" y="3" width="5" height="18" rx="1" />
|
||||
<rect x="6" y="3" width="5" height="18" rx="1" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 313 B |
@@ -1,13 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polygon points="6 3 20 12 6 21 6 3" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 250 B |
@@ -1,13 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 238 B |
@@ -1,14 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 360 B |
@@ -1,16 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||
<path d="M3 21v-5h5" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 393 B |
@@ -1,14 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 297 B |
@@ -1,14 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 586 B |
@@ -1,14 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M12 3v18" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 285 B |
@@ -1,14 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 271 B |
@@ -1,13 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 261 B |
@@ -1,15 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
|
||||
<path d="M12 9v4" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 346 B |
@@ -1,14 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 260 B |