Commit f8f77c17 authored by Fushan Wen's avatar Fushan Wen 💬
Browse files

applets/batterymonitor: port to `PC3.ItemDelegate` and improve a11y

1. Add arrow key navigation support
2. Imrpove a11y description for battery items, so messages like "Your
   battery is unhealthy" can be read by a screen reader.
parent a38fbf48
......@@ -16,7 +16,7 @@ import org.kde.plasma.workspace.components 2.0
import "logic.js" as Logic
RowLayout {
PlasmaComponents3.ItemDelegate {
id: root
// We'd love to use `required` properties, especially since the model provides role names for them;
......@@ -54,148 +54,168 @@ RowLayout {
property PlasmaComponents3.Slider matchHeightOfSlider: PlasmaComponents3.Slider {}
readonly property real extraMargin: Math.max(0, Math.floor((matchHeightOfSlider.height - chargeBar.height) / 2))
spacing: PlasmaCore.Units.gridUnit
background.visible: highlighted
highlighted: activeFocus
text: battery["Pretty Name"]
BatteryIcon {
id: batteryIcon
Accessible.description: `${isPowerSupplyLabel.text} ${percentLabel.text}; ${details.Accessible.description}`
Layout.alignment: Qt.AlignTop
Layout.preferredWidth: PlasmaCore.Units.iconSizes.medium
Layout.preferredHeight: PlasmaCore.Units.iconSizes.medium
contentItem: RowLayout {
spacing: PlasmaCore.Units.gridUnit
batteryType: root.battery.Type
percent: root.battery.Percent
hasBattery: root.isPresent
pluggedIn: root.battery.State === "Charging" && root.battery["Is Power Supply"]
}
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: root.isPresent ? Qt.AlignTop : Qt.AlignVCenter
spacing: 0
RowLayout {
spacing: PlasmaCore.Units.smallSpacing
PlasmaComponents3.Label {
Layout.fillWidth: true
elide: Text.ElideRight
text: root.battery["Pretty Name"]
}
BatteryIcon {
id: batteryIcon
PlasmaComponents3.Label {
text: Logic.stringForBatteryState(root.battery)
visible: root.battery["Is Power Supply"]
enabled: false
}
Layout.alignment: Qt.AlignTop
Layout.preferredWidth: PlasmaCore.Units.iconSizes.medium
Layout.preferredHeight: PlasmaCore.Units.iconSizes.medium
PlasmaComponents3.Label {
horizontalAlignment: Text.AlignRight
visible: root.isPresent
text: i18nc("Placeholder is battery percentage", "%1%", root.battery.Percent)
}
batteryType: root.battery.Type
percent: root.battery.Percent
hasBattery: root.isPresent
pluggedIn: root.battery.State === "Charging" && root.battery["Is Power Supply"]
}
PlasmaComponents3.ProgressBar {
id: chargeBar
Layout.fillWidth: true
Layout.topMargin: root.extraMargin
Layout.bottomMargin: root.extraMargin
from: 0
to: 100
visible: root.isPresent
value: Number(root.battery.Percent)
}
// This gridLayout basically emulates an at-most-two-rows table with a
// single wide fillWidth/columnSpan header. Not really worth it trying
// to refactor it into some more clever fancy model-delegate stuff.
GridLayout {
id: details
ColumnLayout {
Layout.fillWidth: true
Layout.topMargin: PlasmaCore.Units.smallSpacing
Layout.alignment: root.isPresent ? Qt.AlignTop : Qt.AlignVCenter
spacing: 0
RowLayout {
spacing: PlasmaCore.Units.smallSpacing
PlasmaComponents3.Label {
Layout.fillWidth: true
elide: Text.ElideRight
text: root.text
}
PlasmaComponents3.Label {
id: isPowerSupplyLabel
text: Logic.stringForBatteryState(root.battery)
visible: root.battery["Is Power Supply"]
enabled: false
}
PlasmaComponents3.Label {
id: percentLabel
horizontalAlignment: Text.AlignRight
visible: root.isPresent
text: i18nc("Placeholder is battery percentage", "%1%", root.battery.Percent)
}
}
columns: 2
columnSpacing: PlasmaCore.Units.smallSpacing
rowSpacing: 0
PlasmaComponents3.ProgressBar {
id: chargeBar
component LeftLabel : PlasmaComponents3.Label {
// fillWidth is true, so using internal alignment
horizontalAlignment: Text.AlignLeft
Layout.fillWidth: true
font: PlasmaCore.Theme.smallestFont
wrapMode: Text.WordWrap
enabled: false
}
component RightLabel : PlasmaComponents3.Label {
// fillWidth is false, so using external (grid-cell-internal) alignment
Layout.alignment: Qt.AlignRight
Layout.fillWidth: false
font: PlasmaCore.Theme.smallestFont
enabled: false
}
Layout.topMargin: root.extraMargin
Layout.bottomMargin: root.extraMargin
PlasmaComponents3.Label {
Layout.fillWidth: true
Layout.columnSpan: 2
text: root.isBroken && typeof root.battery.Capacity !== "undefined"
? i18n("This battery's health is at only %1% and it should be replaced. Contact the manufacturer.", root.battery.Capacity)
: ""
font: PlasmaCore.Theme.smallestFont
color: PlasmaCore.Theme.neutralTextColor
visible: root.isBroken
wrapMode: Text.WordWrap
from: 0
to: 100
visible: root.isPresent
value: Number(root.battery.Percent)
}
readonly property bool remainingTimeRowVisible: root.battery !== null
&& root.remainingTime > 0
&& root.battery["Is Power Supply"]
&& ["Discharging", "Charging"].includes(root.battery.State)
LeftLabel {
text: root.battery.State === "Charging"
? i18n("Time To Full:")
: i18n("Remaining Time:")
visible: details.remainingTimeRowVisible
}
// This gridLayout basically emulates an at-most-two-rows table with a
// single wide fillWidth/columnSpan header. Not really worth it trying
// to refactor it into some more clever fancy model-delegate stuff.
GridLayout {
id: details
RightLabel {
text: KCoreAddons.Format.formatDuration(root.remainingTime, KCoreAddons.FormatTypes.HideSeconds)
visible: details.remainingTimeRowVisible
Layout.fillWidth: true
Layout.topMargin: PlasmaCore.Units.smallSpacing
columns: 2
columnSpacing: PlasmaCore.Units.smallSpacing
rowSpacing: 0
Accessible.description: {
let description = [];
for (let i = 0; i < children.length; i++) {
if (children[i].visible && children[i].hasOwnProperty("text")) {
description.push(children[i].text);
}
}
return description.join(" ");
}
component LeftLabel : PlasmaComponents3.Label {
// fillWidth is true, so using internal alignment
horizontalAlignment: Text.AlignLeft
Layout.fillWidth: true
font: PlasmaCore.Theme.smallestFont
wrapMode: Text.WordWrap
enabled: false
}
component RightLabel : PlasmaComponents3.Label {
// fillWidth is false, so using external (grid-cell-internal) alignment
Layout.alignment: Qt.AlignRight
Layout.fillWidth: false
font: PlasmaCore.Theme.smallestFont
enabled: false
}
PlasmaComponents3.Label {
Layout.fillWidth: true
Layout.columnSpan: 2
text: root.isBroken && typeof root.battery.Capacity !== "undefined"
? i18n("This battery's health is at only %1% and it should be replaced. Contact the manufacturer.", root.battery.Capacity)
: ""
font: PlasmaCore.Theme.smallestFont
color: PlasmaCore.Theme.neutralTextColor
visible: root.isBroken
wrapMode: Text.WordWrap
}
readonly property bool remainingTimeRowVisible: root.battery !== null
&& root.remainingTime > 0
&& root.battery["Is Power Supply"]
&& ["Discharging", "Charging"].includes(root.battery.State)
LeftLabel {
text: root.battery.State === "Charging"
? i18n("Time To Full:")
: i18n("Remaining Time:")
visible: details.remainingTimeRowVisible
}
RightLabel {
text: KCoreAddons.Format.formatDuration(root.remainingTime, KCoreAddons.FormatTypes.HideSeconds)
visible: details.remainingTimeRowVisible
}
readonly property bool healthRowVisible: root.battery !== null
&& root.battery["Is Power Supply"]
&& root.battery.Capacity !== ""
&& typeof root.battery.Capacity === "number"
&& !root.isBroken
LeftLabel {
text: i18n("Battery Health:")
visible: details.healthRowVisible
}
RightLabel {
text: details.healthRowVisible
? i18nc("Placeholder is battery health percentage", "%1%", root.battery.Capacity)
: ""
visible: details.healthRowVisible
}
}
readonly property bool healthRowVisible: root.battery !== null
&& root.battery["Is Power Supply"]
&& root.battery.Capacity !== ""
&& typeof root.battery.Capacity === "number"
&& !root.isBroken
LeftLabel {
text: i18n("Battery Health:")
visible: details.healthRowVisible
}
InhibitionHint {
Layout.fillWidth: true
Layout.topMargin: PlasmaCore.Units.smallSpacing
RightLabel {
text: details.healthRowVisible
? i18nc("Placeholder is battery health percentage", "%1%", root.battery.Capacity)
: ""
visible: details.healthRowVisible
readonly property var chargeStopThreshold: pmSource.data["Battery"] ? pmSource.data["Battery"]["Charge Stop Threshold"] : undefined
readonly property bool pluggedIn: pmSource.data["AC Adapter"] !== undefined && pmSource.data["AC Adapter"]["Plugged in"]
visible: pluggedIn && root.isPowerSupply && typeof chargeStopThreshold === "number" && chargeStopThreshold > 0 && chargeStopThreshold < 100
iconSource: "kt-speed-limits" // FIXME good icon
text: i18n("Battery is configured to charge up to approximately %1%.", chargeStopThreshold || 0)
}
}
InhibitionHint {
Layout.fillWidth: true
Layout.topMargin: PlasmaCore.Units.smallSpacing
readonly property var chargeStopThreshold: pmSource.data["Battery"] ? pmSource.data["Battery"]["Charge Stop Threshold"] : undefined
readonly property bool pluggedIn: pmSource.data["AC Adapter"] !== undefined && pmSource.data["AC Adapter"]["Plugged in"]
visible: pluggedIn && root.isPowerSupply && typeof chargeStopThreshold === "number" && chargeStopThreshold > 0 && chargeStopThreshold < 100
iconSource: "kt-speed-limits" // FIXME good icon
text: i18n("Battery is configured to charge up to approximately %1%.", chargeStopThreshold || 0)
}
}
}
......@@ -11,11 +11,9 @@ import QtQuick.Layouts 1.15
import org.kde.plasma.components 3.0 as PlasmaComponents3
import org.kde.plasma.core 2.1 as PlasmaCore
RowLayout {
PlasmaComponents3.ItemDelegate {
id: root
property alias icon: image.source
property alias label: title.text
property alias slider: control
property alias value: control.value
property alias maximumValue: control.to
......@@ -26,48 +24,62 @@ RowLayout {
signal moved()
spacing: PlasmaCore.Units.gridUnit
background.visible: highlighted
highlighted: activeFocus
hoverEnabled: false
PlasmaCore.IconItem {
id: image
Layout.alignment: Qt.AlignTop
Layout.preferredWidth: PlasmaCore.Units.iconSizes.medium
Layout.preferredHeight: PlasmaCore.Units.iconSizes.medium
}
Accessible.description: percent.text
Accessible.role: Accessible.Slider
Keys.forwardTo: [slider]
contentItem: RowLayout {
spacing: PlasmaCore.Units.gridUnit
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignTop
spacing: 0
PlasmaCore.IconItem {
id: image
Layout.alignment: Qt.AlignTop
Layout.preferredWidth: PlasmaCore.Units.iconSizes.medium
Layout.preferredHeight: PlasmaCore.Units.iconSizes.medium
source: root.icon.name
}
RowLayout {
ColumnLayout {
Layout.fillWidth: true
spacing: PlasmaCore.Units.smallSpacing
Layout.alignment: Qt.AlignTop
spacing: 0
PlasmaComponents3.Label {
id: title
RowLayout {
Layout.fillWidth: true
spacing: PlasmaCore.Units.smallSpacing
PlasmaComponents3.Label {
id: title
Layout.fillWidth: true
text: root.text
}
PlasmaComponents3.Label {
id: percent
Layout.alignment: Qt.AlignRight
text: i18nc("Placeholder is brightness percentage", "%1%", root.percentage)
}
}
PlasmaComponents3.Label {
id: percent
Layout.alignment: Qt.AlignRight
text: i18nc("Placeholder is brightness percentage", "%1%", root.percentage)
}
}
PlasmaComponents3.Slider {
id: control
Layout.fillWidth: true
PlasmaComponents3.Slider {
id: control
Layout.fillWidth: true
// Don't allow the slider to turn off the screen
// Please see https://git.reviewboard.kde.org/r/122505/ for more information
from: to > 100 ? 1 : 0
stepSize: 1
activeFocusOnTab: false
// Don't allow the slider to turn off the screen
// Please see https://git.reviewboard.kde.org/r/122505/ for more information
from: to > 100 ? 1 : 0
stepSize: 1
Accessible.name: root.label
Accessible.description: percent.text
Accessible.name: root.text
Accessible.description: percent.text
onMoved: root.moved()
onMoved: root.moved()
}
}
}
}
......@@ -16,7 +16,7 @@ import org.kde.plasma.extras 2.0 as PlasmaExtras
PlasmaExtras.Representation {
id: dialog
property alias model: batteryList.model
property alias model: batteryRepeater.model
property bool pluggedIn
property int remainingTime
......@@ -48,6 +48,8 @@ PlasmaExtras.Representation {
collapseMarginsHint: true
KeyNavigation.down: pmSwitch.pmCheckBox
header: PlasmaExtras.PlasmoidHeading {
leftPadding: PlasmaCore.Units.smallSpacing
contentItem: PowerManagementItem {
......@@ -57,127 +59,134 @@ PlasmaExtras.Representation {
inhibitsLidAction: dialog.inhibitsLidAction
pluggedIn: dialog.pluggedIn
onDisabledChanged: powerManagementChanged(disabled)
KeyNavigation.tab: if (batteryList.headerItem) {
if (isBrightnessAvailable) {
return batteryList.headerItem.children[1];
} else if (isKeyboardBrightnessAvailable) {
return batteryList.headerItem.children[2];
} else if (dialog.profiles.length > 0) {
return batteryList.headerItem.children[3];
} else {
return batteryList;
}
}
}
}
PlasmaComponents3.ScrollView {
focus: true
anchors.fill: parent
contentItem: PlasmaComponents3.ScrollView {
id: scrollView
focus: false
// HACK: workaround for https://bugreports.qt.io/browse/QTBUG-83890
PlasmaComponents3.ScrollBar.horizontal.policy: PlasmaComponents3.ScrollBar.AlwaysOff
contentItem: ListView {
Column {
id: batteryList
keyNavigationEnabled: true
leftMargin: PlasmaCore.Units.smallSpacing * 2
rightMargin: PlasmaCore.Units.smallSpacing * 2
topMargin: PlasmaCore.Units.smallSpacing * 2
bottomMargin: PlasmaCore.Units.smallSpacing * 2
spacing: PlasmaCore.Units.smallSpacing
// header so that it scroll with the content of the ListView
header: ColumnLayout {
spacing: PlasmaCore.Units.smallSpacing * 2
width: parent.width
BrightnessItem {
id: brightnessSlider
Layout.fillWidth: true
icon: "video-display-brightness"
label: i18n("Display Brightness")
visible: isBrightnessAvailable
value: batterymonitor.screenBrightness
maximumValue: batterymonitor.maximumScreenBrightness
KeyNavigation.tab: if (isKeyboardBrightnessAvailable) {
return keyboardBrightnessSlider;
} else if (dialog.profiles.length > 0) {
return powerProfileItem
} else {
return batteryList
}
stepSize: batterymonitor.maximumScreenBrightness/100
onMoved: batterymonitor.screenBrightness = value
spacing: PlasmaCore.Units.smallSpacing * 2
// Manually dragging the slider around breaks the binding
Connections {
target: batterymonitor
function onScreenBrightnessChanged() {
brightnessSlider.value = batterymonitor.screenBrightness;
}
}
readonly property Item firstHeaderItem: {
if (brightnessSlider.visible) {
return brightnessSlider;
} else if (keyboardBrightnessSlider.visible) {
return keyboardBrightnessSlider;
} else if (powerProfileItem.visible) {
return powerProfileItem;
}
return null;
}
readonly property Item lastHeaderItem: {
if (powerProfileItem.visible) {
return powerProfileItem;
} else if (keyboardBrightnessSlider.visible) {
return keyboardBrightnessSlider;
} else if (brightnessSlider.visible) {
return brightnessSlider;
}
return null;
}
BrightnessItem {
id: keyboardBrightnessSlider
Layout.fillWidth: true
icon: "input-keyboard-brightness"
label: i18n("Keyboard Brightness")
showPercentage: false
value: batterymonitor.keyboardBrightness
maximumValue: batterymonitor.maximumKeyboardBrightness
visible: isKeyboardBrightnessAvailable
KeyNavigation.tab: if (dialog.profiles.length > 0) {
return powerProfileItem
} else {
return batteryList
}
BrightnessItem {
id: brightnessSlider
width: scrollView.availableWidth
onMoved: batterymonitor.keyboardBrightness = value
icon.name: "video-display-brightness"
text: i18n("Display Brightness")
visible: isBrightnessAvailable
value: batterymonitor.screenBrightness
maximumValue: batterymonitor.maximumScreenBrightness
// Manually dragging the slider around breaks the binding
Connections {
target: batterymonitor
function onKeyboardBrightnessChanged() {
keyboardBrightnessSlider.value = batterymonitor.keyboardBrightness;
}
KeyNavigation.up: pmSwitch.pmCheckBox
KeyNavigation.down: keyboardBrightnessSlider.visible ? keyboardBrightnessSlider : keyboardBrightnessSlider.KeyNavigation.down
KeyNavigation.backtab: KeyNavigation.up
KeyNavigation.tab: KeyNavigation.down
stepSize: batterymonitor.maximumScreenBrightness/100
onMoved: batterymonitor.screenBrightness = value
// Manually dragging the slider around breaks the binding
Connections {
target: batterymonitor
function onScreenBrightnessChanged() {
brightnessSlider.value = batterymonitor.screenBrightness;
}
}
}
PowerProfileItem {
id: powerProfileItem
Layout.fillWidth: true
KeyNavigation.tab: batteryList
activeProfile: dialog.activeProfile
inhibitionReason: dialog.inhibitionReason
visible: dialog.profiles.length > 0