Build the Home accessory shelf
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
// A light dimmer that previews locally and sends one value when interaction
|
||||
// ends. Home Assistant never sees the intermediate pointer positions.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property int value: 0
|
||||
property int previewValue: 0
|
||||
|
||||
readonly property bool pressed: drag.pressed
|
||||
|
||||
signal previewChanged(int value)
|
||||
signal committed(int value)
|
||||
|
||||
implicitWidth: 160
|
||||
implicitHeight: 32
|
||||
activeFocusOnTab: root.enabled
|
||||
opacity: root.enabled ? 1 : 0.42
|
||||
|
||||
onValueChanged: {
|
||||
if (!drag.pressed)
|
||||
root.updatePreview(root.value, false);
|
||||
}
|
||||
|
||||
Component.onCompleted: root.updatePreview(root.value, false)
|
||||
|
||||
Keys.onLeftPressed: root.commitStep(-5)
|
||||
Keys.onDownPressed: root.commitStep(-5)
|
||||
Keys.onRightPressed: root.commitStep(5)
|
||||
Keys.onUpPressed: root.commitStep(5)
|
||||
|
||||
Rectangle {
|
||||
id: track
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: 10
|
||||
radius: 5
|
||||
color: Theme.alpha(Theme.fg, 0.105)
|
||||
border.width: root.activeFocus ? 1 : 0
|
||||
border.color: Theme.alpha(Theme.warn, 0.72)
|
||||
|
||||
Rectangle {
|
||||
id: fill
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: track.width * root.previewValue / 100
|
||||
radius: track.radius
|
||||
color: Theme.warn
|
||||
|
||||
Behavior on width {
|
||||
enabled: !drag.pressed
|
||||
NumberAnimation {
|
||||
duration: Theme.durFast
|
||||
easing.type: Easing.OutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: knob
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
x: Math.max(0, Math.min(track.width - width,
|
||||
track.width * root.previewValue / 100 - width / 2))
|
||||
width: 16
|
||||
height: 16
|
||||
radius: 8
|
||||
color: Theme.fg
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.bgDark, 0.38)
|
||||
}
|
||||
}
|
||||
|
||||
// The 32 px interaction surface is intentionally much taller than the
|
||||
// 10 px track, while remaining inside this component's layout bounds.
|
||||
MouseArea {
|
||||
id: drag
|
||||
anchors.fill: parent
|
||||
enabled: root.enabled
|
||||
hoverEnabled: true
|
||||
preventStealing: true
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
|
||||
onPressed: event => {
|
||||
root.forceActiveFocus();
|
||||
root.previewAt(event.x);
|
||||
event.accepted = true;
|
||||
}
|
||||
onPositionChanged: event => {
|
||||
if (pressed)
|
||||
root.previewAt(event.x);
|
||||
}
|
||||
onReleased: event => {
|
||||
root.committed(root.previewValue);
|
||||
event.accepted = true;
|
||||
}
|
||||
onCanceled: root.updatePreview(root.value, false)
|
||||
onWheel: event => {
|
||||
const direction = event.angleDelta.y >= 0 ? 5 : -5;
|
||||
root.commitStep(direction);
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(candidate: real): int {
|
||||
return Math.max(0, Math.min(100, Math.round(candidate)));
|
||||
}
|
||||
|
||||
function updatePreview(candidate: real, announce: bool): void {
|
||||
const nextValue = root.clamp(candidate);
|
||||
if (root.previewValue === nextValue)
|
||||
return;
|
||||
root.previewValue = nextValue;
|
||||
if (announce)
|
||||
root.previewChanged(nextValue);
|
||||
}
|
||||
|
||||
function previewAt(pointerX: real): void {
|
||||
root.updatePreview(pointerX / Math.max(1, drag.width) * 100, true);
|
||||
}
|
||||
|
||||
function commitStep(delta: int): void {
|
||||
if (!root.enabled)
|
||||
return;
|
||||
root.updatePreview(root.previewValue + delta, true);
|
||||
root.committed(root.previewValue);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ Item {
|
||||
property bool expanded: false
|
||||
signal toggleExpanded
|
||||
|
||||
readonly property bool hasSelection: HomeAssistant.selectedEntities.length > 0
|
||||
readonly property bool showSetup: HomeAssistant.phase === "ready" && !root.hasSelection
|
||||
|
||||
implicitHeight: content.implicitHeight
|
||||
|
||||
Column {
|
||||
@@ -18,46 +21,50 @@ Item {
|
||||
ControlSectionHeader {
|
||||
width: parent.width
|
||||
label: "Home"
|
||||
action: HomeAssistant.entities.length > 0
|
||||
? HomeAssistant.entities.length + " accessories " + (root.expanded ? "⌃" : "›")
|
||||
: "Retry"
|
||||
action: root.headerAction()
|
||||
actionEnabled: HomeAssistant.phase !== "loading"
|
||||
onActionTriggered: {
|
||||
if (HomeAssistant.entities.length > 0)
|
||||
if (root.hasSelection) {
|
||||
root.toggleExpanded();
|
||||
else
|
||||
} else if (root.showSetup) {
|
||||
root.openHomeSettings();
|
||||
} else {
|
||||
HomeAssistant.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: homeCard
|
||||
id: restingCard
|
||||
width: parent.width
|
||||
height: visible ? homeGrid.implicitHeight + 20 : 0
|
||||
visible: HomeAssistant.entities.length > 0
|
||||
height: visible ? restingGrid.implicitHeight + 20 : 0
|
||||
visible: root.hasSelection && !root.expanded
|
||||
radius: Theme.cardRadius + 2
|
||||
color: Theme.alpha(Theme.accent, 0.045)
|
||||
color: Theme.alpha(Theme.warn, 0.028)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.accent, 0.12)
|
||||
border.color: Theme.alpha(Theme.warn, 0.095)
|
||||
|
||||
Grid {
|
||||
id: homeGrid
|
||||
id: restingGrid
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: 10
|
||||
columns: 4
|
||||
spacing: 7
|
||||
columns: 2
|
||||
spacing: 8
|
||||
|
||||
Repeater {
|
||||
model: HomeAssistant.visibleEntities
|
||||
|
||||
HomeTile {
|
||||
required property var modelData
|
||||
width: (homeGrid.width - homeGrid.spacing * 3) / 4
|
||||
width: (restingGrid.width - restingGrid.spacing) / 2
|
||||
entity: modelData
|
||||
busy: HomeAssistant.busyEntityId === modelData.id
|
||||
onActivated: HomeAssistant.toggleEntity(modelData.id)
|
||||
busy: HomeAssistant.isBusy(modelData.id)
|
||||
pendingBrightness: HomeAssistant.pendingFor(modelData.id)
|
||||
actionError: HomeAssistant.errorFor(modelData.id)
|
||||
onPowerRequested: HomeAssistant.toggleEntity(modelData.id)
|
||||
onBrightnessRequested: value => HomeAssistant.setBrightness(modelData.id, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,28 +72,32 @@ Item {
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: visible ? 66 : 0
|
||||
visible: HomeAssistant.entities.length === 0
|
||||
height: visible ? 72 : 0
|
||||
visible: !root.hasSelection
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.alpha(Theme.fg, 0.05)
|
||||
color: root.showSetup
|
||||
? Theme.alpha(Theme.accent, 0.055)
|
||||
: Theme.alpha(Theme.fg, 0.045)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.045)
|
||||
border.color: root.showSetup
|
||||
? Theme.alpha(Theme.accent, 0.12)
|
||||
: Theme.alpha(Theme.fg, 0.055)
|
||||
|
||||
Text {
|
||||
id: stateIcon
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 12
|
||||
anchors.leftMargin: 13
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: HomeAssistant.phase === "loading" ? "\u{F0772}" : "\u{F02DC}"
|
||||
color: HomeAssistant.phase === "loading" ? Theme.accent : Theme.fgDim
|
||||
text: root.showSetup ? "\u{F0335}" : "\u{F02DC}"
|
||||
color: root.showSetup ? Theme.accent : Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 16
|
||||
font.pixelSize: 17
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: stateIcon.right
|
||||
anchors.leftMargin: 11
|
||||
anchors.right: openHome.left
|
||||
anchors.right: emptyAction.left
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 3
|
||||
@@ -111,61 +122,66 @@ Item {
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: openHome
|
||||
id: emptyAction
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 48
|
||||
height: 28
|
||||
radius: 9
|
||||
width: root.showSetup ? 68 : 52
|
||||
height: 30
|
||||
radius: 10
|
||||
visible: HomeAssistant.phase !== "loading"
|
||||
color: openMouse.containsMouse
|
||||
color: emptyActionMouse.containsMouse
|
||||
? Theme.alpha(Theme.accent, 0.20)
|
||||
: Theme.alpha(Theme.accent, 0.11)
|
||||
: Theme.alpha(Theme.accent, 0.105)
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "Open"
|
||||
text: root.showSetup ? "Manage" : "Open"
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
MouseArea {
|
||||
id: openMouse
|
||||
id: emptyActionMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: HomeAssistant.open()
|
||||
onClicked: {
|
||||
if (root.showSetup)
|
||||
root.openHomeSettings();
|
||||
else
|
||||
HomeAssistant.open();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: visible ? 30 : 0
|
||||
height: visible ? 34 : 0
|
||||
visible: HomeAssistant.stale
|
||||
radius: 9
|
||||
color: Theme.alpha(Theme.warn, 0.08)
|
||||
radius: 10
|
||||
color: Theme.alpha(Theme.warn, 0.075)
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.leftMargin: 11
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Showing the last known state"
|
||||
text: "Last known state · " + root.countSummary()
|
||||
color: Theme.warn
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 10
|
||||
anchors.rightMargin: 11
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Retry"
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
font.weight: Font.DemiBold
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
anchors.margins: -6
|
||||
@@ -177,88 +193,66 @@ Item {
|
||||
|
||||
Section {
|
||||
width: parent.width
|
||||
expanded: root.expanded && HomeAssistant.entities.length > 0
|
||||
expanded: root.expanded && root.hasSelection
|
||||
|
||||
ScrollColumn {
|
||||
width: parent.width
|
||||
maxHeight: 270
|
||||
spacing: 2
|
||||
maxHeight: 324
|
||||
spacing: 8
|
||||
|
||||
Repeater {
|
||||
model: HomeAssistant.entities
|
||||
Grid {
|
||||
id: expandedGrid
|
||||
width: parent.width
|
||||
columns: 2
|
||||
spacing: 8
|
||||
|
||||
Rectangle {
|
||||
id: entityRow
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
height: 44
|
||||
radius: 10
|
||||
color: entityMouse.containsMouse
|
||||
? Theme.alpha(Theme.fg, 0.09)
|
||||
: "transparent"
|
||||
Repeater {
|
||||
model: HomeAssistant.selectedEntities
|
||||
|
||||
Text {
|
||||
id: entityGlyph
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 11
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "\u{F0335}"
|
||||
color: entityRow.modelData.active ? Theme.warn : Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 14
|
||||
}
|
||||
Column {
|
||||
anchors.left: entityGlyph.right
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: entityState.left
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
Text {
|
||||
width: parent.width
|
||||
text: entityRow.modelData.name
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: !entityRow.modelData.available ? "Unavailable" : entityRow.modelData.state
|
||||
color: Theme.fgMuted
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
Text {
|
||||
id: entityState
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 11
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: HomeAssistant.busyEntityId === entityRow.modelData.id
|
||||
? "Working"
|
||||
: (entityRow.modelData.active ? "On" : "Off")
|
||||
color: entityRow.modelData.active ? Theme.warn : Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
MouseArea {
|
||||
id: entityMouse
|
||||
anchors.fill: parent
|
||||
enabled: entityRow.modelData.available && HomeAssistant.busyEntityId === ""
|
||||
hoverEnabled: true
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: HomeAssistant.toggleEntity(entityRow.modelData.id)
|
||||
HomeTile {
|
||||
required property var modelData
|
||||
width: (expandedGrid.width - expandedGrid.spacing) / 2
|
||||
entity: modelData
|
||||
busy: HomeAssistant.isBusy(modelData.id)
|
||||
pendingBrightness: HomeAssistant.pendingFor(modelData.id)
|
||||
actionError: HomeAssistant.errorFor(modelData.id)
|
||||
onPowerRequested: HomeAssistant.toggleEntity(modelData.id)
|
||||
onBrightnessRequested: value => HomeAssistant.setBrightness(modelData.id, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowButton {
|
||||
width: parent.width
|
||||
icon: "preferences-system-symbolic"
|
||||
label: "Manage in Settings"
|
||||
sublabel: root.countSummary()
|
||||
onClicked: root.openHomeSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function headerAction(): string {
|
||||
if (root.hasSelection) {
|
||||
const noun = HomeAssistant.configuredCount === 1 ? "accessory" : "accessories";
|
||||
return HomeAssistant.configuredCount + " " + noun + (root.expanded ? " ⌃" : " ›");
|
||||
}
|
||||
if (HomeAssistant.phase === "loading")
|
||||
return "Loading";
|
||||
if (root.showSetup)
|
||||
return "Manage";
|
||||
return "Retry";
|
||||
}
|
||||
|
||||
function countSummary(): string {
|
||||
return HomeAssistant.configuredCount + " selected · "
|
||||
+ HomeAssistant.discoveredCount + " discovered";
|
||||
}
|
||||
|
||||
function emptyTitle(): string {
|
||||
if (root.showSetup)
|
||||
return "Choose your accessories";
|
||||
if (HomeAssistant.phase === "loading")
|
||||
return "Loading your home";
|
||||
if (HomeAssistant.lastError === "authentication-required")
|
||||
@@ -269,12 +263,23 @@ Item {
|
||||
}
|
||||
|
||||
function emptyDetail(): string {
|
||||
if (root.showSetup) {
|
||||
if (HomeAssistant.discoveredCount === 0)
|
||||
return "No lights discovered yet";
|
||||
const noun = HomeAssistant.discoveredCount === 1 ? "light" : "lights";
|
||||
return HomeAssistant.discoveredCount + " " + noun + " ready to add";
|
||||
}
|
||||
if (HomeAssistant.phase === "loading")
|
||||
return "Reading configured favourites";
|
||||
return "Finding your selected lights";
|
||||
if (HomeAssistant.lastError === "authentication-required")
|
||||
return "Update the long-lived access token";
|
||||
if (HomeAssistant.lastError === "not-configured")
|
||||
return "Add a URL, token and favourites";
|
||||
return "Check the connection and retry";
|
||||
return "Connect Home Assistant in Settings";
|
||||
return "Check the connection, then retry";
|
||||
}
|
||||
|
||||
function openHomeSettings(): void {
|
||||
ShellState.close();
|
||||
ShellState.openSettings("home-phone");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,76 +6,164 @@ Rectangle {
|
||||
|
||||
required property var entity
|
||||
property bool busy: false
|
||||
signal activated
|
||||
property int pendingBrightness: -1
|
||||
property string actionError: ""
|
||||
|
||||
implicitHeight: 72
|
||||
radius: Theme.cardRadius
|
||||
signal powerRequested
|
||||
signal brightnessRequested(int value)
|
||||
|
||||
readonly property int confirmedBrightness: root.clamp(root.entity.brightnessPct ?? 0)
|
||||
readonly property int displayedBrightness: brightnessSlider.previewValue
|
||||
readonly property bool powerEnabled: root.entity.available && !root.busy
|
||||
readonly property bool dimmerEnabled: root.entity.available
|
||||
&& root.entity.dimmable
|
||||
&& !root.busy
|
||||
|
||||
implicitHeight: 124
|
||||
radius: 14
|
||||
color: {
|
||||
if (root.entity.active)
|
||||
return Theme.alpha(Theme.warn, tileMouse.containsMouse ? 0.20 : 0.14);
|
||||
return Theme.alpha(Theme.fg, tileMouse.containsMouse ? 0.10 : 0.055);
|
||||
return Theme.alpha(Theme.warn, powerArea.containsMouse ? 0.145 : 0.095);
|
||||
return Theme.alpha(Theme.fg, powerArea.containsMouse ? 0.078 : 0.045);
|
||||
}
|
||||
border.width: 1
|
||||
border.color: root.entity.active
|
||||
? Theme.alpha(Theme.warn, 0.20)
|
||||
: Theme.alpha(Theme.fg, 0.045)
|
||||
border.color: {
|
||||
if (root.activeFocus)
|
||||
return Theme.alpha(Theme.accent, 0.72);
|
||||
if (root.entity.active)
|
||||
return Theme.alpha(Theme.warn, 0.17);
|
||||
return Theme.alpha(Theme.fg, 0.065);
|
||||
}
|
||||
activeFocusOnTab: root.powerEnabled
|
||||
|
||||
Behavior on color { ColorAnimation { duration: Theme.durFast } }
|
||||
Behavior on border.color { ColorAnimation { duration: Theme.durFast } }
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 9
|
||||
text: root.busy ? "\u{F0772}" : root.glyphFor(root.entity.domain)
|
||||
color: root.entity.active ? Theme.warn : Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 15
|
||||
Keys.onSpacePressed: {
|
||||
if (root.powerEnabled)
|
||||
root.powerRequested();
|
||||
}
|
||||
Keys.onReturnPressed: {
|
||||
if (root.powerEnabled)
|
||||
root.powerRequested();
|
||||
}
|
||||
|
||||
Column {
|
||||
Rectangle {
|
||||
id: bulb
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 8
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 8
|
||||
spacing: 1
|
||||
anchors.leftMargin: 13
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 13
|
||||
width: 30
|
||||
height: 30
|
||||
radius: 10
|
||||
color: root.entity.active
|
||||
? Theme.alpha(Theme.warn, 0.16)
|
||||
: Theme.alpha(Theme.fg, 0.065)
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.entity.name
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: !root.entity.available ? "Unavailable" : (root.entity.active ? "On" : "Off")
|
||||
color: root.entity.active ? Theme.warn : Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 9
|
||||
anchors.centerIn: parent
|
||||
text: root.busy ? "\u{F0772}" : "\u{F0335}"
|
||||
color: root.entity.active ? Theme.warn : Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 15
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 13
|
||||
anchors.verticalCenter: bulb.verticalCenter
|
||||
text: root.entity.dimmable ? root.displayedBrightness + "%" : "—"
|
||||
color: root.entity.active || root.pendingBrightness >= 0
|
||||
? Theme.warn
|
||||
: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
font.features: Theme.tabularFigures
|
||||
}
|
||||
|
||||
Text {
|
||||
id: aliasLabel
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 13
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 13
|
||||
anchors.top: bulb.bottom
|
||||
anchors.topMargin: 8
|
||||
text: root.entity.name
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: aliasLabel.left
|
||||
anchors.right: aliasLabel.right
|
||||
anchors.top: aliasLabel.bottom
|
||||
anchors.topMargin: 2
|
||||
text: root.secondaryText()
|
||||
color: root.actionError !== ""
|
||||
? Theme.warn
|
||||
: (root.entity.active ? Theme.warn : Theme.fgMuted)
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: tileMouse
|
||||
anchors.fill: parent
|
||||
enabled: root.entity.available && !root.busy
|
||||
id: powerArea
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: brightnessSlider.top
|
||||
anchors.bottomMargin: 1
|
||||
enabled: root.powerEnabled
|
||||
hoverEnabled: true
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: root.activated()
|
||||
onPressed: root.forceActiveFocus()
|
||||
onClicked: root.powerRequested()
|
||||
}
|
||||
|
||||
function glyphFor(domain: string): string {
|
||||
if (domain === "light")
|
||||
return "\u{F0335}";
|
||||
if (domain === "switch")
|
||||
return "\u{F0521}";
|
||||
if (domain === "scene")
|
||||
return "\u{F0FCE}";
|
||||
return "\u{F02DC}";
|
||||
HomeBrightnessSlider {
|
||||
id: brightnessSlider
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 13
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 13
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 5
|
||||
value: root.pendingBrightness >= 0
|
||||
? root.pendingBrightness
|
||||
: root.confirmedBrightness
|
||||
enabled: root.dimmerEnabled
|
||||
onCommitted: value => root.brightnessRequested(value)
|
||||
}
|
||||
|
||||
function clamp(candidate: real): int {
|
||||
return Math.max(0, Math.min(100, Math.round(candidate)));
|
||||
}
|
||||
|
||||
function secondaryText(): string {
|
||||
if (root.actionError !== "")
|
||||
return root.errorText(root.actionError);
|
||||
if (!root.entity.available)
|
||||
return "Unavailable";
|
||||
if (root.busy && root.pendingBrightness >= 0)
|
||||
return "Setting " + root.pendingBrightness + "%";
|
||||
if (root.busy)
|
||||
return "Updating";
|
||||
return root.entity.active ? "On" : "Off";
|
||||
}
|
||||
|
||||
function errorText(code: string): string {
|
||||
if (code === "authentication-required")
|
||||
return "Authentication required";
|
||||
if (code === "entity-not-discovered")
|
||||
return "Light is unavailable";
|
||||
return "Couldn’t update light";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
module qs.modules.quicksettings
|
||||
AudioDeviceList 1.0 AudioDeviceList.qml
|
||||
AudioSlider 1.0 AudioSlider.qml
|
||||
BluetoothList 1.0 BluetoothList.qml
|
||||
BrightnessControl 1.0 BrightnessControl.qml
|
||||
ControlSectionHeader 1.0 ControlSectionHeader.qml
|
||||
HomeBrightnessSlider 1.0 HomeBrightnessSlider.qml
|
||||
HomeControls 1.0 HomeControls.qml
|
||||
HomeTile 1.0 HomeTile.qml
|
||||
IconButton 1.0 IconButton.qml
|
||||
PhoneControls 1.0 PhoneControls.qml
|
||||
QuickSettings 1.0 QuickSettings.qml
|
||||
QuickSettingsPanel 1.0 QuickSettingsPanel.qml
|
||||
RecentExchange 1.0 RecentExchange.qml
|
||||
RowButton 1.0 RowButton.qml
|
||||
ScrollColumn 1.0 ScrollColumn.qml
|
||||
Section 1.0 Section.qml
|
||||
WifiList 1.0 WifiList.qml
|
||||
@@ -2,72 +2,181 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source_config_path="$project_root/config/dot/quickshell"
|
||||
quicksettings_path="$source_config_path/modules/quicksettings"
|
||||
state_home="$(mktemp -d /tmp/panama-control-center-ui.XXXXXX)"
|
||||
config_path="$state_home/quickshell"
|
||||
helper_log="$state_home/home-helper.log"
|
||||
shell_log="$state_home/quickshell.log"
|
||||
|
||||
fail() {
|
||||
printf 'Control Center contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
qs_for_test() {
|
||||
QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \
|
||||
QS_DISABLE_CRASH_HANDLER=1 \
|
||||
PANAMA_HOME_HELPER_LOG="$helper_log" \
|
||||
qs -p "$config_path" "$@"
|
||||
}
|
||||
|
||||
stop_test_shell() {
|
||||
qs_for_test kill >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 80); do
|
||||
if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \
|
||||
&& ! qs_for_test ipc show >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
qs ipc call quicksettings close >/dev/null 2>&1 || true
|
||||
qs ipc call kdeconnect reset >/dev/null 2>&1 || true
|
||||
qs ipc call home-assistant reset >/dev/null 2>&1 || true
|
||||
qs_for_test ipc call quicksettings close >/dev/null 2>&1 || true
|
||||
qs_for_test ipc call kdeconnect reset >/dev/null 2>&1 || true
|
||||
qs_for_test ipc call home-assistant reset >/dev/null 2>&1 || true
|
||||
if stop_test_shell; then
|
||||
rm -rf "$state_home"
|
||||
else
|
||||
printf 'Control Center contract: branch shell did not stop; retained %s\n' \
|
||||
"$state_home" >&2
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
start_test_shell() {
|
||||
stop_test_shell || fail 'pre-existing branch shell did not stop cleanly'
|
||||
for _attempt in 1 2; do
|
||||
qs_for_test --daemonize >"$shell_log" 2>&1
|
||||
for _ in $(seq 1 80); do
|
||||
if qs_for_test ipc show 2>/dev/null | rg '^target quicksettings$' >/dev/null; then
|
||||
return
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
stop_test_shell || fail 'failed branch-shell attempt did not stop cleanly'
|
||||
done
|
||||
sed -n '1,200p' "$shell_log" >&2
|
||||
fail 'isolated branch shell did not start'
|
||||
}
|
||||
|
||||
rg -Fq 'readonly property int controlCenterWidth: 430' \
|
||||
"$project_root/config/dot/quickshell/config/Theme.qml" \
|
||||
"$source_config_path/config/Theme.qml" \
|
||||
|| fail 'approved Control Center width is missing'
|
||||
rg -Fq 'readonly property int controlCenterTopGap: 2' \
|
||||
"$project_root/config/dot/quickshell/config/Theme.qml" \
|
||||
"$source_config_path/config/Theme.qml" \
|
||||
|| fail 'approved top attachment is missing'
|
||||
rg -Fq 'margins.top: Theme.barHeight + Theme.controlCenterTopGap' \
|
||||
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettings.qml" \
|
||||
"$quicksettings_path/QuickSettings.qml" \
|
||||
|| fail 'Control Center is not tightly attached to the bar'
|
||||
rg -Fq 'implicitWidth: Theme.controlCenterWidth' \
|
||||
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettings.qml" \
|
||||
"$quicksettings_path/QuickSettings.qml" \
|
||||
|| fail 'Control Center window does not use its geometry token'
|
||||
rg -Fq 'HomeControls' \
|
||||
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml" \
|
||||
"$quicksettings_path/QuickSettingsPanel.qml" \
|
||||
|| fail 'Home controls are not mounted'
|
||||
rg -Fq 'PhoneControls' \
|
||||
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml" \
|
||||
"$quicksettings_path/QuickSettingsPanel.qml" \
|
||||
|| fail 'Phone controls are not mounted'
|
||||
rg -Fq 'visible: KdeConnect.phoneReachable' \
|
||||
"$project_root/config/dot/quickshell/modules/bar/StatusCluster.qml" \
|
||||
"$source_config_path/modules/bar/StatusCluster.qml" \
|
||||
|| fail 'reachable phone state is absent from the bar'
|
||||
|
||||
for component in ControlSectionHeader HomeControls HomeTile PhoneControls RecentExchange; do
|
||||
[[ -f "$project_root/config/dot/quickshell/modules/quicksettings/$component.qml" ]] \
|
||||
for component in ControlSectionHeader HomeBrightnessSlider HomeControls HomeTile PhoneControls RecentExchange; do
|
||||
[[ -f "$quicksettings_path/$component.qml" ]] \
|
||||
|| fail "$component is missing"
|
||||
done
|
||||
|
||||
qs ipc call home-assistant fixture ready >/dev/null
|
||||
qs ipc call kdeconnect fixture reachable >/dev/null
|
||||
qs ipc call quicksettings open >/dev/null
|
||||
[[ -f "$quicksettings_path/qmldir" ]] \
|
||||
|| fail 'quick-settings module manifest is missing'
|
||||
rg -Fq 'HomeBrightnessSlider 1.0 HomeBrightnessSlider.qml' \
|
||||
"$quicksettings_path/qmldir" \
|
||||
|| fail 'brightness slider is not registered in the quick-settings module'
|
||||
|
||||
[[ "$(rg -c '^[[:space:]]*columns: 2$' "$quicksettings_path/HomeControls.qml")" -ge 2 ]] \
|
||||
|| fail 'resting and expanded Home shelves are not both two-column grids'
|
||||
rg -Fq 'model: HomeAssistant.visibleEntities' "$quicksettings_path/HomeControls.qml" \
|
||||
|| fail 'resting Home shelf does not use the first four selected lights'
|
||||
rg -Fq 'model: HomeAssistant.selectedEntities' "$quicksettings_path/HomeControls.qml" \
|
||||
|| fail 'expanded Home shelf does not use every selected light'
|
||||
rg -Fq 'HomeAssistant.pendingFor(' "$quicksettings_path/HomeControls.qml" \
|
||||
|| fail 'Home tiles do not receive per-light pending brightness'
|
||||
rg -Fq 'HomeAssistant.setBrightness(' "$quicksettings_path/HomeControls.qml" \
|
||||
|| fail 'Home brightness commits are not wired to the service'
|
||||
rg -Fq 'ShellState.openSettings("home-phone")' "$quicksettings_path/HomeControls.qml" \
|
||||
|| fail 'Manage in Settings does not open Home & Phone'
|
||||
|
||||
rg -Fq 'signal previewChanged(int value)' "$quicksettings_path/HomeBrightnessSlider.qml" \
|
||||
|| fail 'brightness slider has no preview contract'
|
||||
rg -Fq 'signal committed(int value)' "$quicksettings_path/HomeBrightnessSlider.qml" \
|
||||
|| fail 'brightness slider has no release-commit contract'
|
||||
rg -Fq 'onReleased:' "$quicksettings_path/HomeBrightnessSlider.qml" \
|
||||
|| fail 'brightness slider does not commit on pointer release'
|
||||
rg -Fq 'onWheel:' "$quicksettings_path/HomeBrightnessSlider.qml" \
|
||||
|| fail 'brightness slider has no wheel commit path'
|
||||
rg -Fq 'onCommitted: value => root.brightnessRequested(value)' "$quicksettings_path/HomeTile.qml" \
|
||||
|| fail 'Home tile does not forward the slider release commit'
|
||||
|
||||
cp -a "$source_config_path" "$config_path"
|
||||
: >"$helper_log"
|
||||
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
printf '%s\n' "$*" >>"$PANAMA_HOME_HELPER_LOG"
|
||||
case "${1:-}" in
|
||||
catalog)
|
||||
printf '%s\n' '{"ok":false,"configured":false,"entities":[],"legacyEntityIds":[],"error":"test-helper"}'
|
||||
;;
|
||||
toggle|brightness)
|
||||
printf '%s\n' '{"ok":false,"error":"contract-action-forbidden"}'
|
||||
exit 73
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$config_path/scripts/panama-home-assistant"
|
||||
|
||||
start_test_shell
|
||||
|
||||
qs_for_test ipc call home-assistant fixture ready >/dev/null
|
||||
home_ready="$(qs_for_test ipc call home-assistant status)"
|
||||
jq -e '.fixture == true and .visibleCount == 4 and .configuredCount == 7' \
|
||||
<<<"$home_ready" >/dev/null \
|
||||
|| fail 'Home fixture does not expose four resting and seven expanded accessories'
|
||||
|
||||
qs_for_test ipc call kdeconnect fixture reachable >/dev/null
|
||||
qs_for_test ipc call quicksettings open >/dev/null
|
||||
|
||||
hyprctl layers | rg -q 'namespace: qs-popover-quicksettings' \
|
||||
|| fail 'Control Center layer did not map'
|
||||
jq -e '.open == true and .expandedSection == ""' \
|
||||
<<<"$(qs ipc call quicksettings status)" >/dev/null \
|
||||
<<<"$(qs_for_test ipc call quicksettings status)" >/dev/null \
|
||||
|| fail 'Control Center did not open in its resting state'
|
||||
|
||||
qs ipc call quicksettings section home >/dev/null
|
||||
qs_for_test ipc call quicksettings section home >/dev/null
|
||||
jq -e '.open == true and .expandedSection == "home"' \
|
||||
<<<"$(qs ipc call quicksettings status)" >/dev/null \
|
||||
<<<"$(qs_for_test ipc call quicksettings status)" >/dev/null \
|
||||
|| fail 'Home section did not expand'
|
||||
|
||||
qs ipc call quicksettings section phone >/dev/null
|
||||
qs_for_test ipc call quicksettings section phone >/dev/null
|
||||
jq -e '.open == true and .expandedSection == "phone"' \
|
||||
<<<"$(qs ipc call quicksettings status)" >/dev/null \
|
||||
<<<"$(qs_for_test ipc call quicksettings status)" >/dev/null \
|
||||
|| fail 'Phone did not replace Home as the expanded section'
|
||||
|
||||
qs ipc call quicksettings section phone >/dev/null
|
||||
qs_for_test ipc call quicksettings section phone >/dev/null
|
||||
jq -e '.open == true and .expandedSection == ""' \
|
||||
<<<"$(qs ipc call quicksettings status)" >/dev/null \
|
||||
<<<"$(qs_for_test ipc call quicksettings status)" >/dev/null \
|
||||
|| fail 'expanded Phone section did not collapse'
|
||||
|
||||
if rg '^(toggle|brightness)( |$)' "$helper_log" >&2; then
|
||||
fail 'Control Center contract attempted a real Home action path'
|
||||
fi
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
[[ ! -e "$state_home" ]] \
|
||||
|| fail 'temporary Control Center state was not removed after shell exit'
|
||||
printf 'Control Center contract: PASS\n'
|
||||
|
||||
Reference in New Issue
Block a user