Compare commits

..

4 Commits

Author SHA1 Message Date
Jack.Le 7e76cffc88 Add SettingsTab, StatusTab, DataTab and wire up DeviceController
- SettingsTab: chamber ID, master CSV mapping, wafer behavior toggle,
  light/dark mode toggle, Edit CSV Metadata dialog
- StatusTab: connection status, wafer info, activity log (blank until
  Detect Wafer fires)
- DataTab: empty state, ready for parsed data
- HomePage: side rail buttons dispatch device actions and jump to Status
  tab; selectedSideActionIndex defaults to -1 (nothing active on startup)
- DeviceController registered as QML context property in main.py
- LocalSettings: added status persistence fields
- Theme: added subheadingColor and disabledText
- Added serialcomm/ package and backend data/graph modules
- gitignore: added build/, dist/, *.spec
2026-05-28 15:02:58 -07:00
Jack.Le f24dd89e8e Update dependencies and enhance CSV metadata handling
- Updated requirements.txt to specify minimum versions for PySide6, pyqtgraph, and numpy.
- Modified CSVFileMetadata class to include a new master_type attribute for improved metadata representation.
- Enhanced FileBrowser class to handle master_type in metadata saving and retrieval.
- Added a new method in FileBrowser for parsing CSV metadata, improving error handling and data extraction.
- Adjusted LocalSettingsModel to format code and change default reverseZWafer setting to False.
2026-05-05 10:00:00 -07:00
Jack.Le bbe7baea5b Refactor SelectFileDialog and SettingsTab for improved metadata handling and UI consistency
- Removed unused local_settings reference from qmldir.
- Enhanced SelectFileDialog to include a masterTypeFieldText property for better metadata representation.
- Updated normalizedRows to merge settingsModel.masters with sidecar data for dynamic UI updates.
- Adjusted column headers and removed the save button from the table model for a cleaner interface.
- Modified SettingsTab to streamline CSV file handling and ensure settings are saved upon updates.
2026-05-05 09:59:18 -07:00
Jack.Le b338eedb13 chore: remove trailing character from pyproject.toml 2026-04-27 14:38:21 -07:00
177 changed files with 1289 additions and 16697 deletions
-23
View File
@@ -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/
-16
View File
@@ -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"
]
}
+222
View File
@@ -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
}
}
}
}
}
}
}
}
}
+2 -2
View File
@@ -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
+153
View File
@@ -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 ?? "";
}
@@ -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,7 +409,7 @@ Dialog {
return record.notes ?? "";
}
elide: Text.ElideRight
font.pixelSize: Theme.fontSm
font.pixelSize: 13
color: Theme.bodyColor
}
}
@@ -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
}
}
}
}
+276
View File
@@ -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
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
# ===== ISC Tabs Module =====
module ISC.Tabs
# ===== Tab Components =====
SettingsTab 1.0 SettingsTab.qml
+129
View File
@@ -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
}
-1
View File
@@ -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
-23
View File
@@ -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 {} +
+16 -168
View File
@@ -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
![ISenseCloud UI displaying successful connection on COM6](image-1.png)
*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`**.
![HHD Virtual Serial Port Tools showing COM5 ↔ COM6 Local Bridge](image.png)
#### 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.
![Wafer Simulator Control Panel configured on COM5 and streaming](image-2.png)
#### 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).
![ISenseCloud UI displaying successful connection on COM6](image-1.png)
-44
View File
@@ -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] },
}
-46
View File
@@ -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] },
}
-43
View File
@@ -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] },
}
-69
View File
@@ -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] }
}
-68
View File
@@ -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] }
}
-71
View File
@@ -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] },
}
+1
View File
@@ -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,88 +97,37 @@ 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()
@Property(int, notify=parsedDataReady)
@@ -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:
"""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.operationInProgressChanged.emit()
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._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._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._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)
@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."""
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()
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"})
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
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"})
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 ""
self._connection_status = "Connected"
self._append_log(
f"Debug read complete: {len(sensor_data)} sensor bytes, "
f"{len(debug_data)} debug bytes"
)
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({
self.debugResult.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._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,10 +70,11 @@ 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:
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
@@ -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):
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

+45
View File
@@ -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 -115
View File
@@ -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
-4
View File
@@ -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}"
-203
View File
@@ -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
}
}
}
}
}
-664
View File
@@ -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
}
}
}
}
}
-375
View File
@@ -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
}
}
}
}
}
-49
View File
@@ -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"
}
}
}
-639
View File
@@ -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;
}
}
}
}
-223
View File
@@ -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 // 0100, 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()
}
}
}
}
-135
View File
@@ -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);
}
}
-26
View File
@@ -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
-15
View File
@@ -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

-14
View File
@@ -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

-15
View File
@@ -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

-13
View File
@@ -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

-13
View File
@@ -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

-16
View File
@@ -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

-15
View File
@@ -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

-14
View File
@@ -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

-14
View File
@@ -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

-17
View File
@@ -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

-5
View File
@@ -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

-17
View File
@@ -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

-13
View File
@@ -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

-17
View File
@@ -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

-13
View File
@@ -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

-14
View File
@@ -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

-13
View File
@@ -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

-13
View File
@@ -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

-14
View File
@@ -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

-16
View File
@@ -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

-14
View File
@@ -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

-14
View File
@@ -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

-14
View File
@@ -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

-14
View File
@@ -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

-13
View File
@@ -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

-14
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More