diff --git a/Modules/energy/package/contents/ui/Graph.qml b/Modules/energy/package/contents/ui/Graph.qml index afe6613..13e562f 100644 --- a/Modules/energy/package/contents/ui/Graph.qml +++ b/Modules/energy/package/contents/ui/Graph.qml @@ -1,138 +1,250 @@ /* * * Copyright (C) 2015 David Edmundson * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA * */ import QtQuick 2.3 /** * We need to draw a graph, all other libs are not suitable as we are basically * a connected scatter plot with non linear X spacing. * Currently this is not available in kdeclarative nor kqtquickcharts * * We only paint once, so canvas is fast enough for our purposes. * It is designed to look identical to those in ksysguard. */ Canvas { width: 500 height: 500 id: canvas antialiasing: true property int xPadding: 45 - property int yPadding: 10 + property int yPadding: 40 property var data //expect an array of QPointF property real yMax: 100 property real xMax: 100 property real yMin: 0 property real xMin: 0 property real yStep: 20 property string yUnits: "" property string xUnits: "" + property real xDuration: 3600 + property real xDivisions: 6 + property real xDivisionWidth: 600000 + property real xTicksAt: xTicksAtDontCare + //internal - property real plotWidth: width - xPadding - property real plotHeight: height - yPadding *2 + property real plotWidth: width - xPadding * 1.5 + property real plotHeight: height - yPadding * 2 onDataChanged: { canvas.requestPaint(); } //take a QPointF - function scalePoint(plot) { - var scaledX = (plot.x - xMin) * plotWidth / (xMax-xMin); - var scaledY = (plot.y - yMin) * plotHeight / (yMax-yMin); + function scalePoint(plot, currentUnixTime) { + var scaledX = (plot.x - (currentUnixTime / 1000 - xDuration)) / xDuration * plotWidth + var scaledY = (plot.y - yMin) * plotHeight / (yMax - yMin); return Qt.point(xPadding + scaledX, height - yPadding - scaledY); } SystemPalette { id: palette; colorGroup: SystemPalette.Active } onPaint: { var c = canvas.getContext('2d'); c.clearRect(0,0, width, height) //draw the background c.fillStyle = palette.base c.fillRect(xPadding, yPadding, plotWidth, plotHeight); //reset for fonts and stuff c.fillStyle = palette.text //Draw the lines - c.lineWidth = 2; + c.lineWidth = 1; c.lineJoin = 'round'; c.lineCap = 'round'; c.strokeStyle = 'rgba(255, 0, 0, 1)'; var gradient = c.createLinearGradient(0,0,0,height); gradient.addColorStop(0, 'rgba(255, 0, 0, 0.2)'); gradient.addColorStop(1, 'rgba(255, 0, 0, 0.05)'); c.fillStyle = gradient; + // For scaling + var currentUnixTime = Date.now() + var xMinUnixTime = currentUnixTime - xDuration * 1000 + // Draw the line graph c.beginPath(); - var point = scalePoint(data[0]); - c.moveTo(point.x, point.y); - for(var i = 1; i < data.length; i ++) { - point = scalePoint(data[i]) - c.lineTo(point.x, point.y); + + var index = 0 + + while (data[index].x < (xMinUnixTime / 1000)) { + index++ + } + + var firstPoint = scalePoint(data[index], currentUnixTime) + c.moveTo(firstPoint.x, firstPoint.y) + + var point + for (var i = index + 1; i < data.length; i++) { + if (data[i].x > (xMinUnixTime / 1000)) { + point = scalePoint(data[i], currentUnixTime) + c.lineTo(point.x, point.y) + } } + c.stroke(); - c.lineTo(point.x, height-yPadding); - c.lineTo(xPadding, height-yPadding); + c.strokeStyle = 'rgba(0, 0, 0, 0)'; + c.lineTo(point.x, height - yPadding); + c.lineTo(firstPoint.x, height - yPadding); c.fill(); c.closePath() - // Draw the frame on top //draw an outline - c.strokeStyle = 'rgba(0,0,0,0.02)'; + c.strokeStyle = 'rgba(0,50,0,0.02)'; c.lineWidth = 1; - c.rect(xPadding-1, yPadding-1, plotWidth+2, plotHeight+2); + c.rect(xPadding - 1, yPadding - 1, plotWidth + 2, plotHeight + 2); // Draw the Y value texts c.fillStyle = palette.text; c.textAlign = "right" c.textBaseline = "middle"; for(var i = 0; i <= yMax; i += yStep) { var y = scalePoint(Qt.point(0,i)).y; c.fillText(i + canvas.yUnits, xPadding - 10, y); //grid line - c.moveTo(xPadding,y) - c.lineTo(canvas.width, y) - c.stroke() + c.moveTo(xPadding, y) + c.lineTo(plotWidth + xPadding, y) + } + c.stroke() + + // Draw the X value texts + c.textAlign = "center" + c.lineWidth = 1 + c.strokeStyle = 'rgba(0, 0, 0, 0.15)' + + var xDivisions = xDuration / xDivisionWidth * 1000 + var xGridDistance = plotWidth / xDivisions + var xTickPos + var xTickDateTime + var xTickDateStr + var xTickTimeStr + + var currentDateTime = new Date() + var lastDateStr = currentDateTime.toLocaleDateString(Qt.locale(), Locale.ShortFormat) + + var hours = currentDateTime.getHours() + var minutes = currentDateTime.getMinutes() + var seconds = currentDateTime.getSeconds() + + var diff + + switch (xTicksAt) { + case xTicksAtTwelveOClock: + diff = ((hours - 12) * 60 * 60 + minutes * 60 + seconds) + break + case xTicksAtFullHour: + diff = (minutes * 60 + seconds) + break + case xTicksAtFullSecondHour: + diff = (minutes * 60 + seconds) + break + case xTicksAtHalfHour: + diff = ((minutes - 30) * 60 + seconds) + break + case xTicksAtTenMinutes: + diff = ((minutes % 10) * 60 + seconds) + break + default: + diff = 0 + } + + var xGridOffset = plotWidth * (diff / xDuration) + var dateChanged = false + + var dashedLines = 50 + var dashedLineLength = plotHeight / dashedLines + var dashedLineDutyCycle + + for (var i = xDivisions; i >= -1; i--) { + xTickPos = i * xGridDistance + xPadding - xGridOffset + + if ((xTickPos > xPadding) && (xTickPos < plotWidth + xPadding)) + { + xTickDateTime = new Date(currentUnixTime - (xDivisions - i) * xDivisionWidth - diff * 1000) + xTickDateStr = xTickDateTime.toLocaleDateString(Qt.locale(), Locale.ShortFormat) + xTickTimeStr = xTickDateTime.toLocaleTimeString(Qt.locale(), Locale.ShortFormat) + + if (lastDateStr != xTickDateStr) { + dateChanged = true + } + + if ((i % 2 == 0) || (xDivisions < 10)) + { + // Display the time + c.fillText(xTickTimeStr, xTickPos, canvas.height - yPadding / 2) + + // If the date has changed and is not the current day in a <= 24h graph, display it + // Always display the date for 48h and 1 week graphs + if (dateChanged || (xDuration > (60*60*48))) { + c.fillText(xTickDateStr, xTickPos, canvas.height - yPadding / 4) + dateChanged = false + } + + // Tick markers + c.moveTo(xTickPos, canvas.height - yPadding) + c.lineTo(xTickPos, canvas.height - (yPadding * 4) / 5) + + dashedLineDutyCycle = 0.5 + } else { + dashedLineDutyCycle = 0.1 + } + + for (var j = 0; j < dashedLines; j++) { + c.moveTo(xTickPos, yPadding + j * dashedLineLength) + c.lineTo(xTickPos, yPadding + j * dashedLineLength + dashedLineDutyCycle * dashedLineLength) + } + lastDateStr = xTickDateStr + } } + c.stroke() } } diff --git a/Modules/energy/package/contents/ui/main.qml b/Modules/energy/package/contents/ui/main.qml index 0f5a671..fef4aad 100644 --- a/Modules/energy/package/contents/ui/main.qml +++ b/Modules/energy/package/contents/ui/main.qml @@ -1,493 +1,508 @@ /*************************************************************************** * Copyright (C) 2015 Kai Uwe Broulik * * * * This program is free software; you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation; either version 2 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program; if not, write to the * * Free Software Foundation, Inc., * * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA * ***************************************************************************/ import QtQuick 2.5 import QtQuick.Controls 2.5 as QQC2 import QtQuick.Layouts 1.1 import org.kde.kirigami 2.5 as Kirigami import org.kde.kquickcontrolsaddons 2.0 import org.kde.kinfocenter.energy.private 1.0 import org.kde.plasma.core 2.0 as PlasmaCore import org.kde.kcm 1.1 as KCM KCM.SimpleKCM { id: root KCM.ConfigModule.quickHelp: i18n("This module lets you see energy information and statistics.") property QtObject currentBattery: null property string currentUdi: "" property string currentVendor: "" property string currentProduct: "" property bool compact: (root.width / units.gridUnit) < 25 function initCurrentBattery() { currentBattery = kcm.batteries.data(kcm.batteries.index(0, 0), BatteryModel.BatteryRole) currentVendor = kcm.batteries.data(kcm.batteries.index(0, 0), BatteryModel.VendorRole) currentProduct = kcm.batteries.data(kcm.batteries.index(0, 0), BatteryModel.ProductRole) currentUdi = kcm.batteries.data(kcm.batteries.index(0, 0), BatteryModel.UdiRole) } Component.onCompleted: initCurrentBattery() onCurrentBatteryChanged: { if (!currentBattery) { initCurrentBattery() } } property bool showWakeUps: true property int historyType: HistoryModel.ChargeType readonly property var details: [ { title: i18n("Battery"), data: [ {label: i18n("Rechargeable"), value: "rechargeable"}, {label: i18n("Charge state"), value: "chargeState", modifier: "chargeState"}, {label: i18n("Current charge"), value: "chargePercent", unit: i18n("%"), precision: 0}, {label: i18n("Health"), value: "capacity", unit: i18n("%"), precision: 0}, {label: i18n("Vendor"), value: "vendor", source:"Vendor"}, {label: i18n("Model"), value: "model", source:"Product"}, {label: i18n("Serial Number"), value: "serial"} ] }, { title: i18n("Energy"), data: [ {label: i18nc("current power draw from the battery in W", "Consumption"), value: "energyRate", unit: i18nc("Watt", "W"), precision: 2}, {label: i18n("Voltage"), value: "voltage", unit: i18nc("Volt", "V"), precision: 2}, {label: i18n("Remaining energy"), value: "energy", unit: i18nc("Watt-hours", "Wh"), precision: 2}, {label: i18n("Last full charge"), value: "energyFull", unit: i18nc("Watt-hours", "Wh"), precision: 2}, {label: i18n("Original charge capacity"), value: "energyFullDesign", unit: i18nc("Watt-hours", "Wh"), precision: 2} ] }, { title: i18n("Environment"), data: [ {label: i18n("Temperature"), value: "temperature", unit: i18nc("Degree Celsius", "°C"), precision: 2} ] }, ] function modifier_chargeState(value) { switch(value) { case 0: return i18n("Not charging") case 1: return i18n("Charging") case 2: return i18n("Discharging") case 3: return i18n("Fully charged") } } implicitWidth: units.gridUnit * 30 implicitHeight: !!currentBattery ? units.gridUnit * 30 : units.gridUnit * 12 readonly property var timespanComboChoices: [i18n("Last hour"),i18n("Last 2 hours"),i18n("Last 12 hours"),i18n("Last 24 hours"),i18n("Last 48 hours"), i18n("Last 7 days")] readonly property var timespanComboDurations: [3600, 7200, 43200, 86400, 172800, 604800] ColumnLayout { id: column QQC2.ScrollView { id: tabView Layout.fillWidth: true Layout.minimumHeight: units.gridUnit * 4 Layout.maximumHeight: Layout.minimumHeight visible: kcm.batteries.count > 1 Row { spacing: Kirigami.Units.smallSpacing Repeater { model: kcm.batteries QQC2.Button { id: button width: units.gridUnit * 10 height: tabView.height checked: model.battery == root.currentBattery checkable: true onClicked: { root.currentUdi = model.udi root.currentVendor = model.vendor root.currentProduct = model.product root.currentBattery = model.battery // override checked property checked = Qt.binding(function() { return model.battery == root.currentBattery }) showWakeUps = (index === 0) } ColumnLayout { anchors { fill: parent margins: units.smallSpacing * 2 } RowLayout { Kirigami.Icon { id: batteryIcon Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium Layout.preferredHeight: Layout.preferredWidth source: { switch(model.battery.type) { case 3: return model.battery.chargeState === 1 ? "battery-full-charging" : "battery-full" case 2: return "battery-ups" case 9: return "monitor" case 4: return "input-mouse" case 5: return "input-keyboard" case 1: return "phone" case 7: return "smartphone" default: return "paint-unknown" } } } QQC2.Label { Layout.fillWidth: true text: { switch(model.battery.type) { case 3: return i18n("Internal battery") case 2: return i18n("UPS battery") case 9: return i18n("Monitor battery") case 4: return i18n("Mouse battery") case 5: return i18n("Keyboard battery") case 1: return i18n("PDA battery") case 7: return i18n("Phone battery") default: return i18n("Unknown battery") } } elide: Text.ElideRight } } RowLayout { QQC2.ProgressBar { id: percentageSlider Layout.fillWidth: true from: 0 to: 100 value: model.battery.chargePercent } QQC2.Label { text: model.battery.chargeState === 1 ? i18nc("Battery charge percentage", "%1% (Charging)", Math.round(percentageSlider.value)) : i18nc("Battery charge percentage", "%1%", Math.round(percentageSlider.value)) } } } } } } } ColumnLayout { Layout.fillWidth: true spacing: units.smallSpacing visible: !!currentBattery GridLayout { Layout.fillWidth: true columns: !compact ? 5 : 3 QQC2.Button { id: chargeButton checked: true checkable: true text: i18n("Charge Percentage") onClicked: { historyType = HistoryModel.ChargeType rateButton.checked = false } } QQC2.Button { id: rateButton checkable: true text: i18n("Energy Consumption") onClicked: { historyType = HistoryModel.RateType chargeButton.checked = false } } Item { Layout.fillWidth: true } QQC2.ComboBox { id: timespanCombo Layout.minimumWidth: units.gridUnit * 6 model: timespanComboChoices Accessible.name: i18n("Timespan") Accessible.description: i18n("Timespan of data to display") } QQC2.Button { icon.name: "view-refresh" hoverEnabled: true QQC2.ToolTip.text: i18n("Refresh") QQC2.ToolTip.visible: hovered QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay Accessible.name: QQC2.ToolTip.text onClicked: history.refresh() } } HistoryModel { id: history duration: timespanComboDurations[timespanCombo.currentIndex] device: currentUdi type: root.historyType } Graph { id: graph Layout.fillWidth: true Layout.minimumHeight: column.width / 3 Layout.maximumHeight: column.width / 3 Layout.topMargin: units.largeSpacing data: history.points + readonly property real xTicksAtDontCare: 0 + readonly property real xTicksAtTwelveOClock: 1 + readonly property real xTicksAtFullHour: 2 + readonly property real xTicksAtHalfHour: 3 + readonly property real xTicksAtFullSecondHour: 4 + readonly property real xTicksAtTenMinutes: 5 + readonly property var xTicksAtList: [xTicksAtTenMinutes, xTicksAtHalfHour, xTicksAtHalfHour, + xTicksAtFullHour, xTicksAtFullSecondHour, xTicksAtTwelveOClock] + + // Set grid lines distances which directly correspondent to the xTicksAt variables + readonly property var xDivisionWidths: [1000 * 60 * 10, 1000 * 60 * 60 * 12, 1000 * 60 * 60, 1000 * 60 * 30, 1000 * 60 * 60 * 2, 1000 * 60 * 10] + xTicksAt: xTicksAtList[timespanCombo.currentIndex] + xDivisionWidth: xDivisionWidths[xTicksAt] + xMin: history.firstDataPointTime xMax: history.lastDataPointTime - + xDuration: history.duration + yUnits: root.historyType == HistoryModel.RateType ? i18nc("Shorthand for Watts","W") : i18nc("literal percent sign","%") yMax: { if (root.historyType == HistoryModel.RateType) { var max = history.largestValue var modulo = max % 10 if (modulo > 0) { max = max - modulo + 10 // ceil to nearest 10s } return max; } else { return 100; } } yStep: root.historyType == HistoryModel.RateType ? 10 : 20 visible: history.count > 1 } Kirigami.InlineMessage { Layout.fillWidth: true Layout.topMargin: units.smallSpacing showCloseButton: true text: i18n("This type of history is currently not available for this device.") visible: !graph.visible } } ColumnLayout { Layout.fillWidth: true spacing: units.smallSpacing visible: showWakeUps && kcm.wakeUps.count > 0 RowLayout { Layout.fillWidth: true Kirigami.Heading { Layout.fillWidth: true Layout.columnSpan: 2 level: 4 text: i18n("Application Energy Consumption") } } GridLayout { id: wakeUpsGrid Layout.fillWidth: true rows: compact ? kcm.wakeUps.count : kcm.wakeUps.count / 2 flow: GridLayout.TopToBottom rowSpacing: units.smallSpacing columnSpacing: units.smallSpacing property int barWidth: (compact ? root.width - units.gridUnit: root.width / 2) - units.smallSpacing * 2 Repeater { model: showWakeUps ? kcm.wakeUps : null PlasmaCore.ToolTipArea { // FIXME use widget style tooltip Layout.minimumWidth: wakeUpsGrid.barWidth Layout.maximumWidth: wakeUpsGrid.barWidth height: childrenRect.height z: 2 // since the progress bar eats mouse events mainText: model.prettyName || model.name subText: { var text = "" if (model.prettyName && model.name !== model.prettyName) { text += i18n("Path: %1", model.name) + "\n" } if (model.pid) { text += i18n("PID: %1", model.pid) + "\n" } // FIXME format decimals text += i18n("Wakeups per second: %1 (%2%)", Math.round(model.wakeUps * 100) / 100, Math.round(model.wakeUps / kcm.wakeUps.total * 100)) + "\n" if (model.details) { text += i18n("Details: %1", model.details) } return text } icon: model.iconName RowLayout { id: wakeUpItemRow width: parent.width QIconItem { width: units.iconSizes.medium height: width icon: model.iconName } ColumnLayout { Layout.fillWidth: true spacing: 0 RowLayout { Layout.fillWidth: true QQC2.Label { Layout.fillWidth: true elide: Text.ElideRight text: model.prettyName || model.name } /*QQC2.Label { text: i18n("System Service") visible: !model.userSpace opacity: 0.6 }*/ } QQC2.ProgressBar { Layout.fillWidth: true from: 0 to: 100 value: model.wakeUps / kcm.wakeUps.total * 100 } } } } } } } Rectangle { Layout.fillWidth: true height: 1 color: Kirigami.Theme.textColor // FIXME palette //Layout.topMargin: (compact ? units.gridUnit * 2 : 0) visible: wakeUpsGrid.visible } ColumnLayout { id: detailsColumn spacing: 0 Layout.fillWidth: true visible: !!currentBattery Repeater { id: titleRepeater model: root.details property list layouts delegate: Kirigami.FormLayout { id: currentLayout Component.onCompleted: { // ensure that all visible FormLayout share the same set of twinFormLayouts titleRepeater.layouts.push(currentLayout); for (var i = 0, length = titleRepeater.layouts.length; i < length; ++i) { titleRepeater.layouts[i].twinFormLayouts = titleRepeater.layouts; } } Kirigami.Heading { text: modelData.title Kirigami.FormData.isSection: true level: 2 // HACK hide section header if all labels are invisible visible: { for (var i = 0, length = detailsRepeater.count; i < length; ++i) { var item = detailsRepeater.itemAt(i) if (item && item.visible) { return true } } return false } } Repeater { id: detailsRepeater model: modelData.data || [] QQC2.Label { id: valueLabel Kirigami.FormData.label: i18n("%1:", modelData.label) text: { var value; if (modelData.source) { value = root["current" + modelData.source]; } else { value = currentBattery[modelData.value] } if (typeof value === "boolean") { if (value) { return i18n("Yes") } else { return i18n("No") } } if (!value) { return "" } var precision = modelData.precision if (typeof precision === "number") { // round to decimals value = Number(value).toLocaleString(Qt.locale(), "f", precision) } if (modelData.modifier && root["modifier_" + modelData.modifier]) { value = root["modifier_" + modelData.modifier](value) } if (modelData.unit) { value = i18nc("%1 is value, %2 is unit", "%1 %2", value, modelData.unit) } return value } visible: valueLabel.text !== "" } } } } } } }