diff --git a/src/pygui/ISC/HomePage.qml b/src/pygui/ISC/HomePage.qml index 0c64155..a2ab767 100644 --- a/src/pygui/ISC/HomePage.qml +++ b/src/pygui/ISC/HomePage.qml @@ -28,15 +28,28 @@ Rectangle { 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){ - root.waferDetected = (result != null && result != undefined) + // 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 } @@ -63,6 +76,7 @@ Rectangle { function _doDetect() { root.memoryRead = false + root.waferDetected = false streamController.setMode("review") streamController.stopStream() deviceController.detectWafer() @@ -213,6 +227,15 @@ Rectangle { model: pillModel delegate: Button { id: pillBtn + // TODO P3.1: lock MAP pill (index 2) when no license: + // `enabled: index !== 2 || licenseModel.licenses.length > 0` + // and dim icon/label when disabled. licensesChanged + // notify already fires on Load License → unlocks live, + // no restart. Rule is "any valid license" (not + // per-wafer): Map also replays CSVs with no wafer + // attached. Also gate the Map Loader (see TODO at the + // StackLayout below) and the auto-switch at + // onParsedDataReady. See plan §3.1. checked: root.selectedTabIndex === index flat: true Layout.fillWidth: true @@ -523,10 +546,12 @@ Rectangle { 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 - enabled: !!dt && fileA !== "" && dt.compareFileB !== "" && !sameFile && !dt.comparing && !masterMissing - ToolTip.visible: (sameFile || masterMissing) && hovered + readonly property string gateError: !!dt ? dt.familyGateError : "" + enabled: !!dt && fileA !== "" && dt.compareFileB !== "" && !sameFile && !dt.comparing && !masterMissing && gateError === "" + ToolTip.visible: (sameFile || masterMissing || gateError !== "") && hovered ToolTip.text: masterMissing ? "No master file registered for this wafer type (Settings → Master Files)" + : gateError !== "" ? gateError : "Choose two different runs to compare" onClicked: { dt.comparing = true @@ -552,7 +577,9 @@ Rectangle { : compareRunBtn.enabled ? "Run DTW Comparison" : (compareRunBtn.masterMissing ? "No master for this type" - : compareRunBtn.sameFile ? "Choose different runs" : "Select both runs") + : 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 @@ -569,27 +596,28 @@ Rectangle { } // ── BOX 2: Context-Sensitive Rail Panel ──────────────────── - Rectangle { + StackLayout { Layout.fillWidth: true Layout.fillHeight: true Layout.minimumHeight: 120 - radius: Theme.sidePanelRadius - border.color: Theme.sideBorder - border.width: 1 - color: Theme.sidePanelBackground + currentIndex: root.selectedTabIndex === 0 ? 0 : 1 + // ── Status tab: Hardware Actions + Log Actions ────────── ColumnLayout { - anchors.fill: parent - anchors.margins: 14 - spacing: 8 + spacing: Theme.sideRailSpacing - StackLayout { + Rectangle { Layout.fillWidth: true - Layout.fillHeight: true - currentIndex: root.selectedTabIndex === 0 ? 0 : 1 + Layout.preferredHeight: hwActionsCol.implicitHeight + 28 + radius: Theme.sidePanelRadius + border.color: Theme.sideBorder + border.width: 1 + color: Theme.sidePanelBackground - // ── Status tab: Hardware Actions ──────────────── ColumnLayout { + id: hwActionsCol + anchors.fill: parent + anchors.margins: 14 spacing: 6 Label { @@ -597,7 +625,6 @@ Rectangle { color: Theme.sideMutedText font.family: Theme.uiFontFamily font.pixelSize: Theme.fontSm - topPadding: 6 font.letterSpacing: 1.5 font.bold: true } @@ -627,16 +654,62 @@ Rectangle { onClicked: deviceController.eraseMemory( deviceController.selectedPort) } - - Item { Layout.fillHeight: true } } + } - // ── Map tab: Source file browser ────────────── - SourcePanel { - Layout.fillWidth: true - Layout.fillHeight: true + 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 + onClicked: deviceController.clearSession() + } + + RailActionButton { + label: "CLEAR LOG" + iconSource: "../icons/x.svg" + Layout.fillWidth: true + onClicked: deviceController.clearActivityLog() + } } } + + Item { 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 + } } } @@ -657,90 +730,42 @@ Rectangle { anchors.margins: 14 spacing: 6 - // Row 1: Title + status badge + // 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: 8 - Layout.fillWidth: true - - 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 - } - - Rectangle { - height: 18 - width: badgeLabel.implicitWidth + 14 - radius: 9 - Layout.alignment: Qt.AlignVCenter - 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.font2xs - font.bold: true - } - } - } - - // Row 2: Status dot + descriptive text (replaces dotted trail) - Row { - spacing: 8 - Layout.fillWidth: true - - Rectangle { - id: statusDot - anchors.verticalCenter: parent.verticalCenter - width: 8 - height: 8 - radius: 4 - color: deviceController.connectionStatus === "Connected" - ? Theme.statusSuccessColor : Theme.statusErrorColor - - SequentialAnimation on opacity { - running: deviceController.connectionStatus === "Connected" - loops: Animation.Infinite - NumberAnimation { to: 0.4; duration: 1200 } - NumberAnimation { to: 1.0; duration: 1200 } - } - } - - Label { - anchors.verticalCenter: parent.verticalCenter - 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.fontXs - elide: Text.ElideRight - } - } - - // Row 3: Mode chip — neutral gray tag - Row { spacing: 6 Layout.fillWidth: true Rectangle { - anchors.verticalCenter: parent.verticalCenter width: modeChipLabel.implicitWidth + 12 height: 18 radius: 4 @@ -755,11 +780,34 @@ Rectangle { ? streamController.mode.toUpperCase() : "REVIEW" color: Theme.sideMutedText font.family: Theme.uiFontFamily - font.pixelSize: Theme.font2xs + 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 + } + } } } } @@ -894,6 +942,8 @@ Rectangle { } 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 } } diff --git a/src/pygui/ISC/Tabs/DataTab.qml b/src/pygui/ISC/Tabs/DataTab.qml index b9258ec..146d7bb 100644 --- a/src/pygui/ISC/Tabs/DataTab.qml +++ b/src/pygui/ISC/Tabs/DataTab.qml @@ -24,11 +24,20 @@ Item { 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) return (rows[i].waferType || "").toUpperCase() + 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() : "" } @@ -37,6 +46,19 @@ Item { // 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 @@ -609,7 +631,15 @@ Item { readonly property real padTop: 24 readonly property real padBottom: 36 - function drawGrid(ctx, minV, maxV, dataLen) { + // 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 @@ -620,7 +650,8 @@ Item { ctx.font = "10px " + Theme.uiFontFamily ctx.textAlign = "right" - var ySteps = 5 + 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 @@ -631,16 +662,17 @@ Item { ctx.lineTo(padLeft + plotW, yPx) ctx.stroke() - ctx.fillText(yVal.toFixed(1), padLeft - 6, yPx + 3) + ctx.fillText(yVal.toFixed(yDec), padLeft - 6, yPx + 3) } ctx.textAlign = "center" - var xSteps = Math.min(5, dataLen - 1) - for (var xi = 0; xi <= xSteps; xi++) { - var xFrac = xi / xSteps - var xPx = padLeft + plotW * xFrac - var xIdx = Math.round(xFrac * (dataLen - 1)) - ctx.fillText(xIdx.toString(), xPx, height - 20) + 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() @@ -700,9 +732,13 @@ Item { 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, root.seriesLen) + drawGrid(ctx, minV, maxV, yStep, root.seriesLen) drawSeries(ctx, root.trendDataA, minV, range, Theme.primaryAccent, root.seriesLen) drawSeries(ctx, root.trendDataB, minV, range, Theme.themeSkill, root.seriesLen) drawScrubMarker(ctx) diff --git a/src/pygui/ISC/Tabs/StatusTab.qml b/src/pygui/ISC/Tabs/StatusTab.qml index 7aa3a5a..4865cf8 100644 --- a/src/pygui/ISC/Tabs/StatusTab.qml +++ b/src/pygui/ISC/Tabs/StatusTab.qml @@ -30,36 +30,45 @@ ColumnLayout { radius: Theme.radiusSm ColumnLayout { - anchors.centerIn: parent + 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.alignment: Qt.AlignHCenter - } - Text { - id: labelText - color: Theme.sideMutedText - font.pixelSize: Theme.fontXs - font.weight: Font.Medium - font.letterSpacing: 1.2 - font.family: Theme.uiFontFamily - Layout.alignment: Qt.AlignHCenter + Layout.fillHeight: true + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + fontSizeMode: Text.HorizontalFit + minimumPixelSize: Theme.fontMd } } } - component GridCell : RowLayout { + // 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: 8 + spacing: 2 Layout.fillWidth: true + Layout.fillHeight: true + Item { Layout.fillHeight: true } Text { id: labelText color: Theme.sideMutedText @@ -67,17 +76,15 @@ ColumnLayout { font.family: Theme.uiFontFamily Layout.fillWidth: true elide: Text.ElideRight - verticalAlignment: Text.AlignVCenter } Text { id: valueText color: active ? Theme.headingColor : Theme.sideMutedText - font.pixelSize: Theme.fontSm + font.pixelSize: Theme.fontXl font.weight: Font.Bold font.family: Theme.uiFontFamily - Layout.alignment: Qt.AlignRight - verticalAlignment: Text.AlignVCenter } + Item { Layout.fillHeight: true } } // ── Aliases for external wiring ── @@ -120,16 +127,64 @@ ColumnLayout { } } - // ═══════════════════════════════════════════════════════════════════════ - // ROW 1: Connection Status (100%) - // ═══════════════════════════════════════════════════════════════════════ - RowLayout { - Layout.fillWidth: true - Layout.preferredHeight: 90 - Layout.maximumHeight: 90 - spacing: 8 + // 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: deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder)) + } + // 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 @@ -138,13 +193,9 @@ ColumnLayout { radius: Theme.radiusSm ColumnLayout { - id: connStatusCol - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: Theme.panelPadding - anchors.rightMargin: Theme.panelPadding - spacing: 8 + anchors.fill: parent + anchors.margins: Theme.panelPadding + spacing: 6 Text { text: "CONNECTION STATUS" @@ -155,26 +206,11 @@ ColumnLayout { font.family: Theme.uiFontFamily } + Item { Layout.fillHeight: true } + RowLayout { Layout.fillWidth: true - spacing: 0 - - ColumnLayout { - spacing: 1 - Text { - text: "HOST PC" - color: Theme.sideMutedText - font.pixelSize: Theme.fontXs - font.family: Theme.uiFontFamily - } - Text { - text: "localhost" - color: Theme.headingColor - font.pixelSize: Theme.fontMd - font.weight: Font.Bold - font.family: Theme.uiFontFamily - } - } + spacing: 8 Item { Layout.fillWidth: true @@ -239,221 +275,204 @@ ColumnLayout { } } - ColumnLayout { - spacing: 1 - Text { - text: "COM PORT" - color: Theme.sideMutedText - font.pixelSize: Theme.fontXs - font.family: Theme.uiFontFamily - Layout.alignment: Qt.AlignRight - } - Text { - text: root.portName - color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor - font.pixelSize: Theme.fontMd - font.weight: Font.Bold - font.family: Theme.uiFontFamily - Layout.alignment: Qt.AlignRight - } - } - } - } - } - } - - // ═══════════════════════════════════════════════════════════════════════ - // ROW 2: Wafer & Sensor + Directory (65%) | Total Runtime (35%) - // ═══════════════════════════════════════════════════════════════════════ - RowLayout { - Layout.fillWidth: true - Layout.preferredHeight: 90 - Layout.maximumHeight: 90 - spacing: 8 - - RowLayout { - Layout.preferredWidth: 65 - Layout.fillWidth: true - Layout.fillHeight: true - spacing: 8 - - 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 - - ColumnLayout { - Layout.fillWidth: true - spacing: 2 - - Text { - text: "DIRECTORY" - color: Theme.sideMutedText - font.pixelSize: Theme.fontSm - font.bold: true - font.letterSpacing: 1.5 - font.family: Theme.uiFontFamily - } - Text { - text: deviceController.saveDataDir || "Not configured" - color: Theme.bodyColor - font.pixelSize: Theme.fontSm - font.family: Theme.uiFontFamily - font.italic: true - opacity: 0.7 - elide: Text.ElideMiddle - Layout.fillWidth: true - } - } - - RowLayout { - Layout.fillWidth: true - spacing: 8 - - Rectangle { - visible: root.csvPath !== "" - width: sessionSavedLabel.implicitWidth + 12 - height: 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.fillWidth: true } - - Button { - id: browseBtn - text: "Save As" - font.pixelSize: Theme.fontSm - font.weight: Font.Medium - font.family: Theme.uiFontFamily - implicitHeight: 24 - implicitWidth: 70 - 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: saveDirDialog.open() - } - } - } - } - - 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: 10 - spacing: 4 - Text { - text: "WAFER & SENSOR DATA" - color: Theme.sideMutedText - font.pixelSize: Theme.fontXs + text: root.portName + color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor + font.pixelSize: Theme.fontMd font.weight: Font.Bold font.family: Theme.uiFontFamily - font.letterSpacing: 0.6 - Layout.bottomMargin: 2 + 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] : "—"; + } } - GridLayout { + Rectangle { + Layout.columnSpan: 2 Layout.fillWidth: true - Layout.fillHeight: true - columns: 2 - rowSpacing: 4 - columnSpacing: 16 + Layout.preferredHeight: 1 + color: Theme.cardBorder + opacity: 0.5 + } - GridCell { - id: waferInfoFamilyCell - label: "Family Code" - value: { - var info = deviceController.lastWaferInfo; - return (info && info.length > 0 && info[0]) ? info[0] : "—"; - } + GridCell { + id: waferCyclesCell + label: "Cycles Completed" + value: { + var info = deviceController.lastWaferInfo; + return (info && info.length > 5 && info[5] !== undefined) ? String(info[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 - } - - Rectangle { - Layout.columnSpan: 2 - Layout.fillWidth: true - height: 1 - color: Theme.cardBorder - opacity: 0.5 - } - - GridCell { - id: waferSerialCell - label: "Serial" - value: { - var info = deviceController.lastWaferInfo; - return (info && info.length > 1 && info[1]) ? info[1] : "—"; - } - } - GridCell { - id: waferSensorsCell - label: "Sensors" - value: { - var info = deviceController.lastWaferInfo; - return (info && info.length > 2 && info[2]) ? String(info[2]) : "—"; - } + 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 - Layout.preferredWidth: 35 + label: "TOTAL RUNTIME" value: { var info = deviceController.lastWaferInfo; - return (info && info.length > 4 && info[4] !== undefined) ? info[4] + "s" : "—"; + 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"; } - label: "TOTAL RUNTIME" } } @@ -473,10 +492,20 @@ ColumnLayout { anchors.fill: parent spacing: 0 - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 36 - color: Theme.subtleSectionBackground + 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 @@ -489,64 +518,22 @@ ColumnLayout { width: 14; height: 14 sourceSize.width: 14 sourceSize.height: 14 - color: Theme.bodyColor + color: Theme.sideMutedText visible: root.csvPath !== "" Layout.alignment: Qt.AlignVCenter } Label { - text: root.csvPath !== "" ? root.csvPath.split("/").pop() : "ACTIVITY LOG" - color: Theme.bodyColor - font.pixelSize: root.csvPath !== "" ? Theme.fontMd : Theme.fontSm - font.letterSpacing: root.csvPath !== "" ? 0 : 1.8 - font.weight: Font.Medium + 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 } - - Button { - id: refreshSessionBtn - text: "Refresh" - font.pixelSize: Theme.fontSm - font.family: Theme.uiFontFamily - implicitHeight: 24 - hoverEnabled: true - background: Rectangle { - color: refreshSessionBtn.hovered ? Theme.buttonNeutralHover : "transparent" - radius: Theme.radiusSm - } - contentItem: Text { - text: refreshSessionBtn.text - color: Theme.bodyColor - font: refreshSessionBtn.font - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - onClicked: deviceController.clearSession() - } - - Button { - id: clearLogBtn - text: "Clear" - font.pixelSize: Theme.fontSm - font.family: Theme.uiFontFamily - implicitHeight: 24 - hoverEnabled: true - background: Rectangle { - color: clearLogBtn.hovered ? Theme.buttonNeutralHover : "transparent" - radius: Theme.radiusSm - } - contentItem: Text { - text: clearLogBtn.text - color: Theme.bodyColor - font: clearLogBtn.font - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - onClicked: deviceController.clearActivityLog() - } } Rectangle { @@ -567,7 +554,9 @@ ColumnLayout { TextArea { id: activityLog width: parent.width - text: qsTr("Welcome — use the side rail to detect a wafer, or import data to review.") + // 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 @@ -578,12 +567,6 @@ ColumnLayout { } } - Connections { - target: deviceController - function onActivityLogUpdated(newLog) { - activityLog.text = newLog ? newLog : qsTr("Welcome — use the side rail to detect a wafer, or import data to review."); - } - } } // ═══════════════════════════════════════════════════════════════════════ @@ -635,6 +618,13 @@ ColumnLayout { 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; } diff --git a/src/pygui/__main__.py b/src/pygui/__main__.py index f3ecc3f..d5dd088 100644 --- a/src/pygui/__main__.py +++ b/src/pygui/__main__.py @@ -48,6 +48,12 @@ def main() -> int: engine.rootContext().setContextProperty("appVersion", app_version) # ===== Device Controller (serial comm) ===== + # TODO P1.1: construct LicenseModel BEFORE DeviceController and pass + # license_lookup=license_model.levelForWafer into the ctor. + # THINKING: controller must answer "is this serial licensed?" at + # read/erase time (P2.1); a bound Callable[[str], str] avoids a + # LicenseModel import in the controller and keeps it testable with a + # lambda. See docs/pending/license-gating-plan.md §1.1. data_dir = str(settings_model._data_dir) raw_settings = LocalSettings.read_settings(data_dir) device_controller = DeviceController(raw_settings, data_dir) diff --git a/src/pygui/backend/controllers/device_controller.py b/src/pygui/backend/controllers/device_controller.py index 3c1d209..73da1b3 100644 --- a/src/pygui/backend/controllers/device_controller.py +++ b/src/pygui/backend/controllers/device_controller.py @@ -73,6 +73,15 @@ class DeviceController(QObject): self._activity_log: list[str] = [] 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._last_csv_path: str = "" self._selected_port: str = "" @@ -116,6 +125,11 @@ class DeviceController(QObject): """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.""" @@ -324,6 +338,7 @@ class DeviceController(QObject): 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) @@ -349,20 +364,27 @@ class DeviceController(QObject): self, port: Optional[str], info: Optional[WaferInfo] ) -> None: """Main-thread completion handler for detectWafer.""" - if info is not None: - self._selected_port = port or "" - self._set_connection_status("Connected") - wafer_dict = self._wafer_info_to_dict(info) - self._last_wafer_info = wafer_dict - self.detectResult.emit(wafer_dict) - self._save_status() - else: - self._selected_port = "" - self._set_connection_status("Disconnected") - self._append_log("No wafer detected on any port") - self._last_wafer_info = {} - self.detectResult.emit(None) - self._set_operation_progress(False) + try: + if info is not None: + # Build the dict (can raise) before declaring success, so a + # bad payload can't leave status="Connected" while + # waferDetected stays false and Read/Erase stay greyed. + wafer_dict = self._wafer_info_to_dict(info) + self._selected_port = port or "" + self._last_wafer_info = wafer_dict + self._set_connection_status("Connected") + self.detectResult.emit(wafer_dict) + self.selectedPortChanged.emit() + self._save_status() + else: + self._selected_port = "" + self._set_connection_status("Disconnected") + self._append_log("No wafer detected on any port") + self._last_wafer_info = {} + self.detectResult.emit(None) + self.selectedPortChanged.emit() + finally: + self._set_operation_progress(False) @Slot() @slot_error_boundary @@ -377,6 +399,16 @@ class DeviceController(QObject): 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"}) @@ -436,6 +468,9 @@ class DeviceController(QObject): 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.portsUpdated.emit(self._service.enumerate_ports()) diff --git a/src/pygui/backend/data/file_browser.py b/src/pygui/backend/data/file_browser.py index 9fa6075..ecb2cb5 100644 --- a/src/pygui/backend/data/file_browser.py +++ b/src/pygui/backend/data/file_browser.py @@ -71,17 +71,16 @@ 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 None: - self._show_error("The selected CSV row is no longer available.") - return - - row["wafer"] = wafer - row["date"] = date - row["chamber"] = chamber - row["notes"] = notes - row["selected"] = selected - row["masterType"] = master_type + if row is not None: + row["wafer"] = wafer + row["date"] = date + row["chamber"] = chamber + row["notes"] = notes + row["selected"] = selected + row["masterType"] = master_type payload = { "wafer": wafer, diff --git a/src/pygui/serialcomm/serial_port.py b/src/pygui/serialcomm/serial_port.py index 1e187b4..b78d6e6 100644 --- a/src/pygui/serialcomm/serial_port.py +++ b/src/pygui/serialcomm/serial_port.py @@ -65,6 +65,10 @@ class SerialPort: ) 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) diff --git a/tests/test_device_controller_license.py b/tests/test_device_controller_license.py new file mode 100644 index 0000000..b94f378 --- /dev/null +++ b/tests/test_device_controller_license.py @@ -0,0 +1,8 @@ +# TODO P4.2: unit tests for the DeviceController license guard (plan §4.2, +# docs/pending/license-gating-plan.md). Two cases: +# 1. license_lookup=lambda s: "" + fake _last_wafer_info → readMemoryAsync() +# emits readResult {"error": ...} and spawns no thread. +# 2. lookup returning "02" → proceeds to _read_worker (mock DeviceService). +# THINKING: guard is a trust boundary (plan §2.1); these fail if someone +# reorders the busy/license checks or renames the "serialNumber" key. +# Depends on P1.2 + P2.1 being implemented.