diff --git a/src/pygui/ISC/Tabs/DataTab.qml b/src/pygui/ISC/Tabs/DataTab.qml index 146d7bb..812b7e3 100644 --- a/src/pygui/ISC/Tabs/DataTab.qml +++ b/src/pygui/ISC/Tabs/DataTab.qml @@ -3,6 +3,7 @@ import QtQuick.Controls import QtQuick.Controls.impl import QtQuick.Layouts import ISC +import ISC.Tabs.components import ISC.Wafer // ===== Data Tab (Compare Content Only) ===== @@ -184,8 +185,8 @@ Item { root.compareWaferShape = result.wafer_shape || "round" root.compareWaferSize = result.wafer_size || 300.0 root.errorMessage = "" - scrubber.value = Math.floor(root.seriesLen / 2) - scrubber.forceActiveFocus() + 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 @@ -204,250 +205,6 @@ Item { } } - // ── Reusable pieces ──────────────────────────────────────────── - component SectionTitle: Label { - font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontSm - font.letterSpacing: 1.5 - font.bold: true - color: Theme.sideMutedText - } - - component PanelBox: Rectangle { - color: Theme.sidePanelBackground - border.color: Theme.sideBorder - border.width: 1 - radius: Theme.sidePanelRadius - } - - // Checkbox styled to match MapTab's right-rail ReadoutPanel (square accent - // indicator + check icon) instead of the platform-default CheckBox look. - component PanelCheckBox: 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 - } - } - - // Slider styled to match MapTab's right-rail heatmap slider (circular - // accent handle that grows slightly on press). - component PanelSlider: 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 } } - } - } - - // Mini stat box under the alignment scrubber (styled to match DTW ReadoutCard) - component ScrubStat: Rectangle { - id: scrubStat - 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: scrubStat.label.toUpperCase() - font.pixelSize: Theme.fontXs - font.bold: true - font.letterSpacing: 0.2 - color: Theme.sideMutedText - } - Label { - text: scrubStat.value - font.pixelSize: Theme.fontLg - font.bold: true - font.family: Theme.codeFontFamily - color: scrubStat.valueColor - } - } - } - - // Editable stat box for scrubber readout (styled to match DTW ReadoutCard) - component EditableScrubStat: Rectangle { - id: edStat - property string label - property real value - property string unit - property string extraText: "" - property bool editable: true - signal valueEdited(real newValue) - - 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) - scrubber.forceActiveFocus() - } - - 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 - } - } - } - - // Large labeled value card for the DTW readout panel - component ReadoutCard: Rectangle { - id: readoutCard - 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: readoutCard.label - font.pixelSize: Theme.fontXs - font.bold: true - font.letterSpacing: 0.2 - color: Theme.sideMutedText - } - Label { - text: readoutCard.value - font.pixelSize: Theme.fontLg - font.bold: true - font.family: Theme.codeFontFamily - color: readoutCard.valueColor - } - } - } - // ── Main Layout ─────────────────────────────────────────────── ColumnLayout { anchors.fill: parent @@ -506,533 +263,53 @@ Item { spacing: 12 // Wafer Map Overlap View card - PanelBox { + OverlapMapCard { Layout.fillWidth: true Layout.fillHeight: true Layout.minimumHeight: 220 - - 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: root.compareSensorLayout.length > 0 && enableOverlapCheck.checked - sensors: root.compareSensorLayout - values: streamController.getSensorDiffAt(scrubReadout.scrubIdx) - bands: root.diffBands(overlapMapItem.values) - shape: root.compareWaferShape - size: root.compareWaferSize - target: 0 - margin: 1.0 - blend: blendSlider.value / 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: root.compareSensorLayout.length === 0 || !enableOverlapCheck.checked - 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: !enableOverlapCheck.checked ? "Overlap map disabled" - : "No sensor map available" - color: Theme.bodyColor - font.pixelSize: Theme.fontXs - } - } - } - } + 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 - PanelBox { + ComparisonChartCard { + id: chartCard Layout.fillWidth: true - Layout.preferredHeight: 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: root.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: root.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 (root.seriesLen < 2) return - var plotW = width - padLeft - padRight - var x = padLeft + (scrubber.value / (root.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 (!root.hasSeries) return - var all = root.trendDataA.concat(root.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, root.seriesLen) - drawSeries(ctx, root.trendDataA, minV, range, Theme.primaryAccent, root.seriesLen) - drawSeries(ctx, root.trendDataB, minV, range, Theme.themeSkill, root.seriesLen) - drawScrubMarker(ctx) - } - - Connections { - target: root - function onTrendDataAChanged() { overlayCanvas.requestPaint() } - function onTrendDataBChanged() { overlayCanvas.requestPaint() } - } - Connections { - target: scrubber - function onValueChanged() { overlayCanvas.requestPaint() } - } - } - - // Empty state - ColumnLayout { - anchors.centerIn: parent - visible: !root.hasSeries - spacing: 8 - - IconImage { - Layout.alignment: Qt.AlignHCenter - visible: !root.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: root.comparing ? "Running DTW comparison…" : "Select two runs and run comparison" - color: Theme.bodyColor - font.pixelSize: Theme.fontSm - } - Label { - Layout.alignment: Qt.AlignHCenter - visible: !root.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: root.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, root.seriesLen - 1) - stepSize: 1 - value: 0 - } - - - } - } + trendDataA: root.trendDataA + trendDataB: root.trendDataB + seriesLen: root.seriesLen + hasSeries: root.hasSeries + comparing: root.comparing } } // --- Right Column: run selection, overlap settings, readout --- - ColumnLayout { - Layout.fillWidth: false + ComparisonSidePanel { + id: sidePanel + Layout.fillWidth: false Layout.preferredWidth: 280 Layout.minimumWidth: 280 Layout.maximumWidth: 280 Layout.fillHeight: true - 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" } - - ReadoutCard { - label: "DTW ALIGNMENT DISTANCE" - value: root.warpingDistance >= 0 ? root.warpingDistance.toFixed(2) : "--" - valueColor: root.warpingDistance >= 0 - ? (root.warpingDistance < 50 ? Theme.metricGood : root.warpingDistance < 100 ? Theme.metricWarn : Theme.metricBad) - : Theme.sideMutedText - } - ReadoutCard { - label: "TEMPORAL FRAME OFFSET" - value: root.warpingDistance >= 0 - ? ((root.frameOffset >= 0 ? "+" : "") + root.frameOffset + " frames / " - + (root.frameOffsetSeconds >= 0 ? "+" : "") + root.frameOffsetSeconds.toFixed(1) + "s") - : "--" - valueColor: root.warpingDistance >= 0 ? Theme.headingColor : Theme.sideMutedText - } - ReadoutCard { - label: "MAX SENSOR DEVIATION" - value: root.maxSensorDeviation >= 0 ? root.maxSensorDeviation.toFixed(2) + "°C" : "--" - valueColor: root.maxSensorDeviation >= 0 - ? (root.maxSensorDeviation < 1.0 ? Theme.metricGood : root.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, root.seriesLen - 1) + (Math.max(0, root.seriesLen - 1) <= 1 ? " frame" : " frames") - editable: root.hasSeries - onValueEdited: (newValue) => { - var maxVal = Math.max(0, root.seriesLen - 1) - var val = Math.max(0, Math.min(newValue, maxVal)) - scrubber.value = val - } - } - - EditableScrubStat { - label: "Time" - value: scrubReadout.scrubTime - unit: "/ " + scrubReadout.maxTime.toFixed(1) + "s" - editable: root.hasSeries - onValueEdited: (newValue) => { - var closestIdx = root.frameForTime(newValue) - scrubber.value = closestIdx - } - } - } - } - - // 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(scrubber.value) - readonly property bool hasA: scrubIdx < root.trendDataA.length - readonly property bool hasB: scrubIdx < root.trendDataB.length - readonly property real tempA: hasA ? root.trendDataA[scrubIdx] : 0 - readonly property real tempB: hasB ? root.trendDataB[scrubIdx] : 0 - readonly property real scrubTime: { - var t = scrubIdx < root.timeDataA.length ? root.timeDataA[scrubIdx] - : (scrubIdx < root.timeDataB.length ? root.timeDataB[scrubIdx] : 0) - return t - } - readonly property real maxTime: { - var ta = root.timeDataA.length > 0 ? root.timeDataA[root.timeDataA.length - 1] : 0 - var tb = root.timeDataB.length > 0 ? root.timeDataB[root.timeDataB.length - 1] : 0 - return Math.max(ta, tb) - } - - SectionTitle { text: "SCRUBBER READOUT" } - - ScrubStat { - label: "Temp (Run A)" - value: scrubReadout.hasA ? scrubReadout.tempA.toFixed(1) + "°C" : "none" - valueColor: Theme.primaryAccent - } - ScrubStat { - label: "Temp (Run B)" - value: scrubReadout.hasB ? scrubReadout.tempB.toFixed(1) + "°C" : "none" - valueColor: Theme.themeSkill - } - ScrubStat { - label: "Difference" - value: (scrubReadout.hasA && scrubReadout.hasB) - ? Math.abs(scrubReadout.tempA - scrubReadout.tempB).toFixed(1) + "°C" : "none" - } - } - } - - Item { 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() } } } diff --git a/src/pygui/ISC/Tabs/components/ReadoutStat.qml b/src/pygui/ISC/Tabs/components/ReadoutStat.qml new file mode 100644 index 0000000..25d2dd0 --- /dev/null +++ b/src/pygui/ISC/Tabs/components/ReadoutStat.qml @@ -0,0 +1,39 @@ +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 + } + } +} diff --git a/src/pygui/ISC/Tabs/components/SelectFileDialog.qml b/src/pygui/ISC/Tabs/components/SelectFileDialog.qml new file mode 100644 index 0000000..4c9dd8d --- /dev/null +++ b/src/pygui/ISC/Tabs/components/SelectFileDialog.qml @@ -0,0 +1,619 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Controls.impl +import QtQuick.Layouts +import Qt.labs.qmlmodels +import ISC + +Dialog { + id: root + title: "Select File" + width: 980 + height: 640 + modal: true + padding: 16 + topPadding: 0 + parent: Overlay.overlay + x: Math.round(((parent ? parent.width : width) - width) / 2) + y: Math.round(((parent ? parent.height : height) - height) / 2) + + signal fileChosen(string path) + + // ===== Dialog State ===== + property string selectedPath: "" + property var tableModel: [] + property int selectedRow: -1 + property int metadataEditRow: -1 + readonly property string masterTypeFieldText: { + if (metadataEditRow < 0) + return ""; + const rec = tableModel[metadataEditRow]; + if (!rec) + return ""; + return rec.masterType ?? ""; + } + + function openMetadataEditor(row) { + metadataEditRow = row; + const rec = tableModel[row]; + if (!rec) { + metadataEditDialog.close(); + return; + } + waferField.text = rec.wafer ?? ""; + dateField.text = rec.date ?? ""; + chamberField.text = rec.chamber ?? ""; + if (!chamberField.text && settingsModel.chamberId) + chamberField.text = settingsModel.chamberId; + notesField.text = rec.notes ?? ""; + filePathLabel.text = rec.fileName ?? ""; + metadataEditDialog.open(); + } + + function applyMetadataFromEditor() { + if (metadataEditRow < 0) + return; + const rec = tableModel[metadataEditRow]; + if (!rec) { + metadataEditDialog.close(); + return; + } + file_browser.saveMetadata(rec.fileName ?? "", waferField.text, dateField.text, chamberField.text, notesField.text, false, ""); + metadataEditDialog.close(); + metadataEditRow = -1; + } + + readonly property var headerTitles: ["Master", "Wafer", "Date", "Chamber", "Notes", "File", "Edit"] + // Build a path→family reverse map from settingsModel.masters. + // settingsModel is a QML context property; accessing it here makes the + // binding reactive — any mastersChanged signal re-evaluates normalizedRows. + function masterFamilyForPath(filePath) { + const m = settingsModel.masters; + for (var fam in m) { + if (m[fam] && m[fam] === filePath) + return fam; + } + return ""; + } + + readonly property var normalizedRows: { + // Touch settingsModel.masters so QML tracks this dependency. + const _ = settingsModel.masters; + 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 || ""; + return { + masterType: masterType, + wafer: row.wafer ?? "", + date: row.date ?? "", + chamber: row.chamber ?? "", + edit: "", + notes: row.notes ?? "", + fileName: row.fileName ?? "", + highlight: row.highlight ?? false + }; + }); + } + + // ===== Reusable Controls ===== + component DialogActionButton: Button { + id: dialogButton + hoverEnabled: true + implicitHeight: 36 + + background: Rectangle { + radius: Theme.radiusSm + color: dialogButton.down ? Theme.buttonNeutralPressed : dialogButton.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground + border.width: Theme.borderThin + border.color: Theme.fieldBorder + } + + contentItem: Text { + text: dialogButton.text + color: Theme.buttonNeutralText + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + font.bold: true + font.pixelSize: Theme.fontSm + } + } + + component DialogTextInput: TextField { + id: dialogTextInput + color: Theme.fieldText + placeholderTextColor: Theme.fieldPlaceholder + selectedTextColor: Theme.fieldBackground + selectionColor: Theme.fieldText + + background: Rectangle { + radius: Theme.radiusXs + 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 } + } + } + } + + // ===== Dialog Chrome ===== + background: Rectangle { + radius: Theme.radiusLg + color: Theme.panelBackground + border.color: Theme.outerFrameBorder + border.width: Theme.borderStrong + } + + // Custom header — replaces the native dark title bar + header: Rectangle { + width: root.width + height: 48 + radius: Theme.radiusLg + color: Theme.subtleSectionBackground + border.color: Theme.innerFrameBorder + border.width: Theme.borderThin + + // Square off the bottom corners + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: parent.radius + color: parent.color + } + + Label { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 16 + text: root.title + font.pixelSize: Theme.fontLg + font.bold: true + color: Theme.headingColor + } + + // Close button + DialogActionButton { + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: 12 + 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 + } + } + } + + // ===== Content ===== + ColumnLayout { + anchors.fill: parent + spacing: 10 + + // ===== Browser Toolbar ===== + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 12 + spacing: 8 + + DialogActionButton { + text: "Choose Folder" + Layout.preferredWidth: 140 + onClicked: file_browser.chooseDirectory() + } + + DialogActionButton { + text: "Refresh" + Layout.preferredWidth: 100 + onClicked: file_browser.refreshFiles() + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 36 + Layout.minimumWidth: 80 + clip: true + radius: Theme.radiusXs + color: Theme.fieldBackground + border.color: Theme.fieldBorder + border.width: Theme.borderThin + + Text { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + verticalAlignment: Text.AlignVCenter + text: file_browser.currentDirectory + color: Theme.fieldText + elide: Text.ElideMiddle + font.pixelSize: Theme.fontSm + } + } + } + + // ===== File Table ===== + HorizontalHeaderView { + id: headerView + syncView: tableView + Layout.fillWidth: true + + delegate: Rectangle { + implicitHeight: 34 + color: Theme.subtleSectionBackground + border.color: Theme.innerFrameBorder + border.width: Theme.borderThin + + Text { + anchors.centerIn: parent + text: root.headerTitles[index] ?? "" + font.bold: true + font.pixelSize: Theme.fontSm + color: Theme.headingColor + } + } + } + + TableView { + id: tableView + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + columnSpacing: 0 + rowSpacing: 0 + flickableDirection: Flickable.VerticalFlick + + columnWidthProvider: function (col) { + // [master, wafer, date, chamber, notes, file (variable), edit] + const fixed = { + 0: 48, + 1: 90, + 2: 170, + 3: 120, + 4: 180, + 6: 56 + }; + if (col in fixed) + return fixed[col]; + // col 5 (File) takes remaining width + const w = Math.max(400, tableView.width); + const used = 48 + 90 + 170 + 120 + 180 + 56; + return Math.max(120, w - used); + } + + model: TableModel { + TableModelColumn { + display: "masterType" + } + TableModelColumn { + display: "wafer" + } + TableModelColumn { + display: "date" + } + TableModelColumn { + display: "chamber" + } + TableModelColumn { + display: "notes" + } + TableModelColumn { + display: "fileName" + } + TableModelColumn { + display: "edit" + } + rows: root.normalizedRows + } + + delegate: Rectangle { + required property bool current + required property int row + required property int column + + implicitHeight: 40 + border.color: Theme.softBorder + border.width: Theme.borderThin + color: { + if (root.selectedRow === row) + return Theme.sideActiveBackground; + if (root.tableModel[row]?.highlight) + return Theme.subtleSectionBackground; + return row % 2 === 0 ? Theme.cardBackground : Theme.tone100; + } + + // Master type indicator (col 0) + // Reads normalizedRows (which merges settingsModel.masters + sidecar). + Loader { + anchors.fill: parent + active: column === 0 + sourceComponent: Text { + anchors.fill: parent + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + text: { + const mt = root.normalizedRows[row]?.masterType ?? ""; + return mt || "—"; + } + font.pixelSize: Theme.fontMd + font.bold: true + color: { + const mt = root.normalizedRows[row]?.masterType ?? ""; + return mt ? Theme.primaryAccent : Theme.fieldPlaceholder; + } + } + } + + // Edit button (col 6) + Loader { + anchors.fill: parent + anchors.margins: 4 + active: column === 6 + sourceComponent: Button { + hoverEnabled: true + text: "Edit…" + font.pixelSize: Theme.fontSm + onClicked: root.openMetadataEditor(row) + + background: Rectangle { + radius: Theme.radiusXs + color: parent.down ? Theme.buttonNeutralPressed : parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground + border.width: Theme.borderThin + border.color: Theme.fieldBorder + } + + contentItem: Text { + text: parent.text + color: Theme.buttonNeutralText + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.pixelSize: Theme.fontSm + } + } + } + + // Text columns: Wafer (1), Date (2), File (5) + Loader { + anchors.fill: parent + active: column === 1 || column === 2 || column === 5 + sourceComponent: Text { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + verticalAlignment: Text.AlignVCenter + text: { + const record = root.tableModel[row] || {}; + if (column === 1) + return record.wafer ?? ""; + if (column === 2) + return record.date ?? ""; + if (column === 5) { + const p = record.fileName ?? ""; + return p.split("/").pop(); + } + return ""; + } + elide: Text.ElideRight + font.pixelSize: Theme.fontSm + color: Theme.bodyColor + } + } + + // Text columns: Chamber (3), Notes (4) + Loader { + anchors.fill: parent + active: column === 3 || column === 4 + sourceComponent: Text { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + verticalAlignment: Text.AlignVCenter + text: { + const record = root.tableModel[row] || {}; + if (column === 3) + return record.chamber ?? ""; + return record.notes ?? ""; + } + elide: Text.ElideRight + font.pixelSize: Theme.fontSm + color: Theme.bodyColor + } + } + + MouseArea { + anchors.fill: parent + enabled: column !== 6 + onClicked: { + root.selectedRow = row; + root.selectedPath = root.tableModel[row]?.fileName ?? ""; + } + } + } + } + + // ===== Bottom Bar ===== + RowLayout { + Layout.fillWidth: true + spacing: 8 + + DialogActionButton { + text: "OK" + Layout.preferredWidth: 90 + onClicked: { + root.fileChosen(root.selectedPath); + root.close(); + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 32 + Layout.minimumWidth: 120 + clip: true + radius: Theme.radiusXs + color: Theme.fieldBackground + border.color: Theme.fieldBorder + border.width: Theme.borderThin + + Text { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + verticalAlignment: Text.AlignVCenter + text: root.selectedPath + elide: Text.ElideLeft + font.pixelSize: Theme.fontSm + color: root.selectedPath ? Theme.fieldText : Theme.fieldPlaceholder + } + } + } + } + + // ===== Metadata Editor Dialog ===== + Dialog { + id: metadataEditDialog + parent: Overlay.overlay + modal: true + title: qsTr("Edit Metadata") + width: 520 + height: 520 + padding: 16 + topPadding: 0 + x: Math.round(((parent ? parent.width : width) - width) / 2) + y: Math.round(((parent ? parent.height : height) - height) / 2) + + background: Rectangle { + radius: Theme.radiusLg + color: Theme.panelBackground + border.color: Theme.outerFrameBorder + border.width: Theme.borderStrong + } + + header: Rectangle { + width: metadataEditDialog.width + height: 48 + radius: Theme.radiusLg + color: Theme.subtleSectionBackground + border.color: Theme.innerFrameBorder + border.width: Theme.borderThin + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: parent.radius + color: parent.color + } + + Label { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 16 + text: metadataEditDialog.title + font.pixelSize: Theme.fontLg + font.bold: true + color: Theme.headingColor + } + } + + ColumnLayout { + anchors.fill: parent + spacing: Theme.settingsGridSpacing + + Label { + text: qsTr("File") + font.bold: true + color: Theme.headingColor + Layout.topMargin: 8 + } + + Text { + id: filePathLabel + Layout.fillWidth: true + Layout.maximumHeight: 56 + Layout.bottomMargin: 16 + wrapMode: Text.WrapAnywhere + maximumLineCount: 3 + elide: Text.ElideRight + color: Theme.bodyColor + font.pixelSize: Theme.fontSm + } + + Label { + text: qsTr("Wafer") + color: Theme.headingColor + } + DialogTextInput { + id: waferField + Layout.fillWidth: true + Layout.preferredHeight: 36 + } + + Label { + text: qsTr("Date") + color: Theme.headingColor + } + DialogTextInput { + id: dateField + Layout.fillWidth: true + Layout.preferredHeight: 36 + } + + Label { + text: qsTr("Chamber") + color: Theme.headingColor + } + DialogTextInput { + id: chamberField + Layout.fillWidth: true + Layout.preferredHeight: 36 + } + + Label { + text: qsTr("Notes") + color: Theme.headingColor + } + DialogTextInput { + id: notesField + Layout.fillWidth: true + Layout.preferredHeight: 36 + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 16 + Layout.bottomMargin: 16 + spacing: 8 + + Item { + Layout.fillWidth: true + } + + DialogActionButton { + text: qsTr("Cancel") + Layout.preferredWidth: 90 + onClicked: { + root.metadataEditRow = -1; + metadataEditDialog.close(); + } + } + + DialogActionButton { + text: qsTr("Save") + Layout.preferredWidth: 90 + onClicked: root.applyMetadataFromEditor() + } + } + } + } +} diff --git a/src/pygui/__main__.py b/src/pygui/__main__.py index d5dd088..3154251 100644 --- a/src/pygui/__main__.py +++ b/src/pygui/__main__.py @@ -9,12 +9,22 @@ from PySide6.QtWidgets import QApplication from pygui.backend.controllers.device_controller import DeviceController from pygui.backend.controllers.session_controller import SessionController +from pygui.backend.data.constants import default_data_dir from pygui.backend.data.file_browser import FileBrowser from pygui.backend.data.local_settings import LocalSettings from pygui.backend.data.local_settings_model import LocalSettingsModel from pygui.backend.license.license_model import LicenseModel # Importing wafer_map_item + trend_chart_item registers @QmlElement types (QML: import ISC.Wafer). +from pygui.backend.visualization import ( + graph_quick_item as graph_quick_item, # noqa: F401 +) +from pygui.backend.visualization import ( + trend_chart_item as trend_chart_item, # noqa: F401 +) +from pygui.backend.visualization import ( + wafer_map_item as wafer_map_item, # noqa: F401 +) # ===== Application Entry Point ===== @@ -32,8 +42,15 @@ def main() -> int: app = QApplication(sys.argv) engine = QQmlApplicationEngine() - # ===== Shared Models ===== - settings_model = LocalSettingsModel() + # ===== Shared Settings ===== + # One LocalSettings instance, one disk read. LocalSettingsModel and + # DeviceController both hold this same object, so a save from the + # Settings tab is visible to the device layer immediately — no reload, + # no second copy to go stale. + data_dir = str(default_data_dir()) + raw_settings = LocalSettings.read_settings(data_dir) + + settings_model = LocalSettingsModel(raw_settings, data_dir) select_file_dialog_model = FileBrowser() settings_model.loadSettings() engine.rootContext().setContextProperty("settingsModel", settings_model) @@ -54,8 +71,6 @@ def main() -> int: # 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) engine.rootContext().setContextProperty("deviceController", device_controller) @@ -64,20 +79,9 @@ def main() -> int: engine.rootContext().setContextProperty("licenseModel", license_model) # ===== Session Controller (live/review wafer dashboard) ===== - raw_settings_dict = raw_settings.__dict__.copy() if hasattr(raw_settings, '__dict__') else {} - stream_controller = SessionController(settings=raw_settings_dict) + stream_controller = SessionController() engine.rootContext().setContextProperty("streamController", stream_controller) - # Persist session state back to settings when it changes - def _persist_session_settings(): - session_state = stream_controller.collect_settings() - for k, v in session_state.items(): - if hasattr(raw_settings, k): - setattr(raw_settings, k, v) - LocalSettings.save_settings(data_dir, raw_settings) - - stream_controller.settingsChanged.connect(_persist_session_settings) - # ===== QML Startup ===== # The "ISC" QML module lives alongside this file (src/pygui/ISC), so the # package directory is the import path the engine searches for qmldir. diff --git a/src/pygui/backend/controllers/device_controller.py b/src/pygui/backend/controllers/device_controller.py index 73da1b3..dc23dc8 100644 --- a/src/pygui/backend/controllers/device_controller.py +++ b/src/pygui/backend/controllers/device_controller.py @@ -13,7 +13,6 @@ from typing import Any, Optional from PySide6.QtCore import Property, QObject, Qt, Signal, Slot -from pygui.backend.controllers.session_controller import SessionController from pygui.backend.data.local_settings import LocalSettings from pygui.backend.models.data_model import TemperatureTableModel from pygui.backend.utils import slot_error_boundary @@ -26,11 +25,6 @@ from pygui.serialcomm.data_parser import ( ) from pygui.serialcomm.serial_port import WaferInfo -# import pygui.backend.wafer_map_item - -stream_controller = SessionController() -# engine.rootContext().setContextProperty("streamController", stream_controller) - log = logging.getLogger(__name__)