diff --git a/src/pygui/ISC/Tabs/WaferMapTab.qml b/src/pygui/ISC/Tabs/WaferMapTab.qml index 330c331..1eafc6d 100644 --- a/src/pygui/ISC/Tabs/WaferMapTab.qml +++ b/src/pygui/ISC/Tabs/WaferMapTab.qml @@ -1,7 +1,6 @@ import QtQuick import QtQuick.Controls import QtQuick.Controls.impl -import QtQuick.Dialogs import QtQuick.Layouts import ISC import ISC.Tabs.components @@ -11,22 +10,6 @@ Item { id: root anchors.fill: parent - // Live elapsed-time counter - property int _liveSecs: 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; - } // Wire streamController.trendData (Signal(str) carrying JSON list of floats) // into the trend chart's data property. The slot parseJsonToData() is defined // on the Python TrendChartItem; we use it so malformed payloads are logged @@ -37,13 +20,8 @@ Item { function onTrendData(avgsJson) { trendChart.setDataFromJson(avgsJson); } - // A file was picked from the rail (any tab) — loadFile() switches the - // backend to review mode; mirror that in the toolbar toggle so a - // successful load is never hidden behind a stale "Live" selection. function onLoadedFileChanged() { root.loadErrorMessage = ""; - if (streamController.mode === "review") - modeBar.currentIndex = 0; } function onLoadFileError(message) { root.loadErrorMessage = message; @@ -60,107 +38,6 @@ Item { Layout.fillWidth: true spacing: 10 - // Mode toggle - TabBar { - id: modeBar - currentIndex: 0 // Default to "Review" - spacing: 2 - padding: 2 - background: Rectangle { - color: Theme.subtleSectionBackground - radius: Theme.radiusSm - border.color: Theme.cardBorder - border.width: Theme.borderThin - } - TabButton { - text: "Review" - implicitWidth: 84 - implicitHeight: 34 - background: Rectangle { - color: parent.checked ? Theme.tabActiveBackground : "transparent" - radius: Theme.radiusSm - 1 - } - contentItem: Text { - text: parent.text - color: parent.checked ? Theme.headingColor : Theme.bodyColor - font.pixelSize: Theme.fontMd - font.weight: parent.checked ? Font.Medium : Font.Normal - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - } - TabButton { - id: liveTab - text: "Live" - enabled: deviceController.connectionStatus === "Connected" - opacity: enabled ? 1.0 : 0.4 - implicitWidth: 84 - implicitHeight: 34 - // Disabled controls swallow no events at all, so hover for the - // "why is this greyed out" tooltip needs its own MouseArea. - 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.radiusSm - 1 - } - contentItem: Row { - spacing: 5 - leftPadding: 8 - Rectangle { - id: liveTabDot - width: 7; height: 7; radius: 3.5 - anchors.verticalCenter: parent.verticalCenter - color: liveTab.enabled ? Theme.metricGood : Theme.sideMutedText - - // Flash while actually streaming live - 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.pixelSize: Theme.fontMd - font.weight: liveTab.checked ? Font.Medium : Font.Normal - verticalAlignment: Text.AlignVCenter - height: parent.height - } - } - } - onCurrentIndexChanged: if (currentIndex === 1) { - // Guard: revert to Review if not connected - if (deviceController.connectionStatus !== "Connected") { - currentIndex = 0; - return; - } - // Entering Live mode - streamController.setMode("live"); - var fc = ""; - if (deviceController.lastWaferInfo && deviceController.lastWaferInfo.length > 0) { - fc = deviceController.lastWaferInfo[0]; - } - streamController.startStream(deviceController.selectedPort, fc); - } else { - // Entering Review Mode (automatically calls stopStream in backend) - streamController.setMode("review"); - } - } - Item { Layout.fillWidth: true } @@ -215,49 +92,6 @@ Item { font.letterSpacing: 1.2 } } - - // Record start/stop toggle (Live mode) - Button { - visible: streamController.mode === "live" - enabled: streamController.state !== "idle" || streamController.recording - text: streamController.recording ? "Stop REC" : "Record" - 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); - } - } - } - - // Stream statistics popup (Live mode) - Button { - visible: streamController.mode === "live" - text: "Stats" - onClicked: streamStatsDialog.open() - } - - StreamStatsDialog { - id: streamStatsDialog - elapsedSecs: root._liveSecs - } - - Button { - text: "Export PNG" - onClicked: exportDialog.open() - } - - FileDialog { - id: exportDialog - title: "Export Wafer Map" - fileMode: FileDialog.SaveFile - nameFilters: ["PNG files (*.png)"] - onAccepted: waferView.exportImage(String(selectedFile).replace(/^file:\/\//, "")) - } } // ── Load error banner ─────────────────────────────────────────── @@ -354,6 +188,9 @@ Item { id: readoutPanel anchors.fill: parent anchors.margins: 0 + onExportRequested: function(filePath) { + waferView.exportImage(filePath); + } } } } diff --git a/src/pygui/ISC/Tabs/components/MetricRow.qml b/src/pygui/ISC/Tabs/components/MetricRow.qml new file mode 100644 index 0000000..244b325 --- /dev/null +++ b/src/pygui/ISC/Tabs/components/MetricRow.qml @@ -0,0 +1,28 @@ +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 + } +} diff --git a/src/pygui/ISC/Tabs/components/ReadoutPanel.qml b/src/pygui/ISC/Tabs/components/ReadoutPanel.qml index 4fef1af..ba34f41 100644 --- a/src/pygui/ISC/Tabs/components/ReadoutPanel.qml +++ b/src/pygui/ISC/Tabs/components/ReadoutPanel.qml @@ -14,6 +14,8 @@ ColumnLayout { property alias heatmapBlend: heatmapSlider.value property alias showThickness: thicknessToggle.checked + signal exportRequested(string filePath) + component PanelCheckBox: CheckBox { id: toggle indicator: Rectangle { @@ -75,112 +77,10 @@ ColumnLayout { } } - // Live-mode stream stats + stop — moved here from the footer transport bar - // so that bar collapses to zero height in live mode, freeing vertical - // space for the trend chart. - Rectangle { + StreamControlPanel { Layout.fillWidth: true - visible: streamController.mode === "live" - implicitHeight: visible ? liveStatsCard.implicitHeight + 24 : 0 - color: Theme.sidePanelBackground - radius: Theme.sidePanelRadius - border.color: Theme.sideBorder - border.width: 1 - - ColumnLayout { - id: liveStatsCard - anchors { - left: parent.left - right: parent.right - top: parent.top - margins: 12 - } - spacing: 8 - - Label { - text: "LIVE STREAM" - color: Theme.sideMutedText - font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontSm - font.letterSpacing: 1.5 - font.bold: true - Layout.bottomMargin: 4 - } - - RowLayout { - Layout.fillWidth: true - Label { - text: "Received" - color: Theme.sideMutedText - font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontSm - font.bold: true - } - Item { Layout.fillWidth: true } - Label { - text: streamController.receivedCount - color: Theme.headingColor - font.family: Theme.codeFontFamily - font.pixelSize: Theme.fontLg - font.bold: true - } - } - - Rectangle { - Layout.fillWidth: true - height: 1 - color: Theme.cardBorder - } - - RowLayout { - Layout.fillWidth: true - Label { - text: "Errors" - color: Theme.sideMutedText - font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontSm - font.bold: true - } - Item { Layout.fillWidth: true } - Label { - text: streamController.errorCount - color: streamController.errorCount > 0 ? Theme.statusWarningColor : Theme.headingColor - font.family: Theme.codeFontFamily - font.pixelSize: Theme.fontLg - font.bold: true - } - } - - Button { - id: stopStreamBtn - Layout.fillWidth: true - Layout.topMargin: 4 - hoverEnabled: true - text: "STOP" - font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontSm - font.weight: Font.Medium - font.letterSpacing: 1.0 - - background: Rectangle { - radius: 8 - color: stopStreamBtn.down ? Theme.transportButtonHover - : (stopStreamBtn.hovered ? Theme.transportButtonBg : "transparent") - border.width: Theme.borderThin - border.color: Theme.sideBorder - Behavior on color { - ColorAnimation { duration: Theme.durationFast } - } - } - contentItem: Text { - text: stopStreamBtn.text - color: Theme.headingColor - font: stopStreamBtn.font - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - onClicked: streamController.stopStream() - } + onExportRequested: function(filePath) { + root.exportRequested(filePath); } } diff --git a/src/pygui/ISC/Tabs/components/StreamControlPanel.qml b/src/pygui/ISC/Tabs/components/StreamControlPanel.qml new file mode 100644 index 0000000..1e6b825 --- /dev/null +++ b/src/pygui/ISC/Tabs/components/StreamControlPanel.qml @@ -0,0 +1,359 @@ +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) + + 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" + + onClicked: 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 + nameFilters: ["PNG files (*.png)"] + onAccepted: { + var path = String(selectedFile).replace(/^file:\/\//, ""); + root.exportRequested(path); + } + } + } + + 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 + } + } + } +} diff --git a/src/pygui/ISC/Tabs/components/StreamStatsDialog.qml b/src/pygui/ISC/Tabs/components/StreamStatsDialog.qml deleted file mode 100644 index 0eb47d6..0000000 --- a/src/pygui/ISC/Tabs/components/StreamStatsDialog.qml +++ /dev/null @@ -1,130 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import QtQuick.Controls -import QtQuick.Controls.impl -import ISC - -// ===== Stream Stats Dialog ===== -// Popup showing live stream statistics: received frames, errors, -// resync count, and elapsed time. Opened from the Live toolbar. - -Popup { - id: root - modal: true - dim: true - closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside - width: 320 - implicitHeight: content.implicitHeight + 40 - anchors.centerIn: Overlay.overlay - - // Elapsed seconds supplied by the Live timer in WaferMapTab. - property int elapsedSecs: 0 - - function fmtTime(s) { - var m = Math.floor(s / 60); - var ss = s % 60; - return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss; - } - - background: Rectangle { - radius: Theme.radiusMd - color: Theme.cardBackground - border.color: Theme.cardBorder - border.width: 1 - } - - component StatRow: RowLayout { - property string label - property string value - Layout.fillWidth: true - spacing: 8 - - Label { - text: label - color: Theme.bodyColor - font.pixelSize: Theme.fontXs - Layout.fillWidth: true - } - Label { - text: value - color: Theme.headingColor - font.pixelSize: Theme.fontSm - font.weight: Font.Medium - } - } - - ColumnLayout { - id: content - anchors.fill: parent - anchors.margins: 20 - spacing: 16 - - // ── Header ──────────────────────────────────────────────── - RowLayout { - Layout.fillWidth: true - spacing: 10 - - Label { - text: "STREAM DATA" - font.pixelSize: Theme.fontLg - font.bold: true - color: Theme.headingColor - Layout.fillWidth: true - } - - Button { - flat: true - Layout.preferredWidth: 32 - Layout.preferredHeight: 32 - onClicked: root.close() - - background: Rectangle { - radius: Theme.radiusXs - color: parent.hovered ? Theme.buttonNeutralHover : "transparent" - } - contentItem: IconImage { - source: "../icons/x.svg" - width: 14; height: 14 - sourceSize.width: 14 - sourceSize.height: 14 - color: Theme.bodyColor - anchors.centerIn: parent - } - } - } - - // ── Stats box ───────────────────────────────────────────── - Rectangle { - Layout.fillWidth: true - implicitHeight: statRows.implicitHeight + 20 - radius: Theme.radiusSm - color: Theme.subtleSectionBackground - border.color: Theme.cardBorder - border.width: 1 - - ColumnLayout { - id: statRows - anchors.fill: parent - anchors.margins: 10 - spacing: 8 - - StatRow { - label: "Received frames" - value: streamController.receivedCount - } - StatRow { - label: "Errors" - value: streamController.errorCount - } - StatRow { - label: "Resyncs" - value: streamController.resyncCount - } - StatRow { - label: "Elapsed" - value: root.fmtTime(root.elapsedSecs) - } - } - } - } -} diff --git a/src/pygui/ISC/Tabs/components/qmldir b/src/pygui/ISC/Tabs/components/qmldir index c77654d..6c7a233 100644 --- a/src/pygui/ISC/Tabs/components/qmldir +++ b/src/pygui/ISC/Tabs/components/qmldir @@ -8,12 +8,13 @@ ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml RailActionButton 1.0 RailActionButton.qml SelectFileDialog 1.0 SelectFileDialog.qml SplitDialog 1.0 SplitDialog.qml -StreamStatsDialog 1.0 StreamStatsDialog.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