Build the Home accessory shelf

This commit is contained in:
Gabriel Brown
2026-08-17 18:22:27 -04:00
parent c8eb344a0b
commit 673aac6aba
5 changed files with 538 additions and 186 deletions
@@ -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 property bool expanded: false
signal toggleExpanded signal toggleExpanded
readonly property bool hasSelection: HomeAssistant.selectedEntities.length > 0
readonly property bool showSetup: HomeAssistant.phase === "ready" && !root.hasSelection
implicitHeight: content.implicitHeight implicitHeight: content.implicitHeight
Column { Column {
@@ -18,46 +21,50 @@ Item {
ControlSectionHeader { ControlSectionHeader {
width: parent.width width: parent.width
label: "Home" label: "Home"
action: HomeAssistant.entities.length > 0 action: root.headerAction()
? HomeAssistant.entities.length + " accessories " + (root.expanded ? "⌃" : "")
: "Retry"
actionEnabled: HomeAssistant.phase !== "loading" actionEnabled: HomeAssistant.phase !== "loading"
onActionTriggered: { onActionTriggered: {
if (HomeAssistant.entities.length > 0) if (root.hasSelection) {
root.toggleExpanded(); root.toggleExpanded();
else } else if (root.showSetup) {
root.openHomeSettings();
} else {
HomeAssistant.refresh(); HomeAssistant.refresh();
} }
} }
}
Rectangle { Rectangle {
id: homeCard id: restingCard
width: parent.width width: parent.width
height: visible ? homeGrid.implicitHeight + 20 : 0 height: visible ? restingGrid.implicitHeight + 20 : 0
visible: HomeAssistant.entities.length > 0 visible: root.hasSelection && !root.expanded
radius: Theme.cardRadius + 2 radius: Theme.cardRadius + 2
color: Theme.alpha(Theme.accent, 0.045) color: Theme.alpha(Theme.warn, 0.028)
border.width: 1 border.width: 1
border.color: Theme.alpha(Theme.accent, 0.12) border.color: Theme.alpha(Theme.warn, 0.095)
Grid { Grid {
id: homeGrid id: restingGrid
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.top: parent.top anchors.top: parent.top
anchors.margins: 10 anchors.margins: 10
columns: 4 columns: 2
spacing: 7 spacing: 8
Repeater { Repeater {
model: HomeAssistant.visibleEntities model: HomeAssistant.visibleEntities
HomeTile { HomeTile {
required property var modelData required property var modelData
width: (homeGrid.width - homeGrid.spacing * 3) / 4 width: (restingGrid.width - restingGrid.spacing) / 2
entity: modelData entity: modelData
busy: HomeAssistant.busyEntityId === modelData.id busy: HomeAssistant.isBusy(modelData.id)
onActivated: HomeAssistant.toggleEntity(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 { Rectangle {
width: parent.width width: parent.width
height: visible ? 66 : 0 height: visible ? 72 : 0
visible: HomeAssistant.entities.length === 0 visible: !root.hasSelection
radius: Theme.cardRadius 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.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 { Text {
id: stateIcon id: stateIcon
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 12 anchors.leftMargin: 13
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: HomeAssistant.phase === "loading" ? "\u{F0772}" : "\u{F02DC}" text: root.showSetup ? "\u{F0335}" : "\u{F02DC}"
color: HomeAssistant.phase === "loading" ? Theme.accent : Theme.fgDim color: root.showSetup ? Theme.accent : Theme.fgDim
font.family: Theme.fontMono font.family: Theme.fontMono
font.pixelSize: 16 font.pixelSize: 17
} }
Column { Column {
anchors.left: stateIcon.right anchors.left: stateIcon.right
anchors.leftMargin: 11 anchors.leftMargin: 11
anchors.right: openHome.left anchors.right: emptyAction.left
anchors.rightMargin: 10 anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
spacing: 3 spacing: 3
@@ -111,61 +122,66 @@ Item {
} }
Rectangle { Rectangle {
id: openHome id: emptyAction
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: 10 anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
width: 48 width: root.showSetup ? 68 : 52
height: 28 height: 30
radius: 9 radius: 10
visible: HomeAssistant.phase !== "loading" visible: HomeAssistant.phase !== "loading"
color: openMouse.containsMouse color: emptyActionMouse.containsMouse
? Theme.alpha(Theme.accent, 0.20) ? Theme.alpha(Theme.accent, 0.20)
: Theme.alpha(Theme.accent, 0.11) : Theme.alpha(Theme.accent, 0.105)
Text { Text {
anchors.centerIn: parent anchors.centerIn: parent
text: "Open" text: root.showSetup ? "Manage" : "Open"
color: Theme.accent color: Theme.accent
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium font.weight: Font.DemiBold
} }
MouseArea { MouseArea {
id: openMouse id: emptyActionMouse
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: HomeAssistant.open() onClicked: {
if (root.showSetup)
root.openHomeSettings();
else
HomeAssistant.open();
}
} }
} }
} }
Rectangle { Rectangle {
width: parent.width width: parent.width
height: visible ? 30 : 0 height: visible ? 34 : 0
visible: HomeAssistant.stale visible: HomeAssistant.stale
radius: 9 radius: 10
color: Theme.alpha(Theme.warn, 0.08) color: Theme.alpha(Theme.warn, 0.075)
Text { Text {
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 10 anchors.leftMargin: 11
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: "Showing the last known state" text: "Last known state · " + root.countSummary()
color: Theme.warn color: Theme.warn
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
} }
Text { Text {
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: 10 anchors.rightMargin: 11
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: "Retry" text: "Retry"
color: Theme.accent color: Theme.accent
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium font.weight: Font.DemiBold
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
anchors.margins: -6 anchors.margins: -6
@@ -177,88 +193,66 @@ Item {
Section { Section {
width: parent.width width: parent.width
expanded: root.expanded && HomeAssistant.entities.length > 0 expanded: root.expanded && root.hasSelection
ScrollColumn { ScrollColumn {
width: parent.width width: parent.width
maxHeight: 270 maxHeight: 324
spacing: 2 spacing: 8
Grid {
id: expandedGrid
width: parent.width
columns: 2
spacing: 8
Repeater { Repeater {
model: HomeAssistant.entities model: HomeAssistant.selectedEntities
Rectangle { HomeTile {
id: entityRow
required property var modelData required property var modelData
width: parent.width width: (expandedGrid.width - expandedGrid.spacing) / 2
height: 44 entity: modelData
radius: 10 busy: HomeAssistant.isBusy(modelData.id)
color: entityMouse.containsMouse pendingBrightness: HomeAssistant.pendingFor(modelData.id)
? Theme.alpha(Theme.fg, 0.09) actionError: HomeAssistant.errorFor(modelData.id)
: "transparent" onPowerRequested: HomeAssistant.toggleEntity(modelData.id)
onBrightnessRequested: value => HomeAssistant.setBrightness(modelData.id, value)
}
}
}
Text { RowButton {
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 width: parent.width
text: entityRow.modelData.name icon: "preferences-system-symbolic"
color: Theme.fg label: "Manage in Settings"
elide: Text.ElideRight sublabel: root.countSummary()
font.family: Theme.fontFamily onClicked: root.openHomeSettings()
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)
} }
} }
} }
} }
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 { function emptyTitle(): string {
if (root.showSetup)
return "Choose your accessories";
if (HomeAssistant.phase === "loading") if (HomeAssistant.phase === "loading")
return "Loading your home"; return "Loading your home";
if (HomeAssistant.lastError === "authentication-required") if (HomeAssistant.lastError === "authentication-required")
@@ -269,12 +263,23 @@ Item {
} }
function emptyDetail(): string { 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") if (HomeAssistant.phase === "loading")
return "Reading configured favourites"; return "Finding your selected lights";
if (HomeAssistant.lastError === "authentication-required") if (HomeAssistant.lastError === "authentication-required")
return "Update the long-lived access token"; return "Update the long-lived access token";
if (HomeAssistant.lastError === "not-configured") if (HomeAssistant.lastError === "not-configured")
return "Add a URL, token and favourites"; return "Connect Home Assistant in Settings";
return "Check the connection and retry"; return "Check the connection, then retry";
}
function openHomeSettings(): void {
ShellState.close();
ShellState.openSettings("home-phone");
} }
} }
@@ -6,76 +6,164 @@ Rectangle {
required property var entity required property var entity
property bool busy: false property bool busy: false
signal activated property int pendingBrightness: -1
property string actionError: ""
implicitHeight: 72 signal powerRequested
radius: Theme.cardRadius 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: { color: {
if (root.entity.active) if (root.entity.active)
return Theme.alpha(Theme.warn, tileMouse.containsMouse ? 0.20 : 0.14); return Theme.alpha(Theme.warn, powerArea.containsMouse ? 0.145 : 0.095);
return Theme.alpha(Theme.fg, tileMouse.containsMouse ? 0.10 : 0.055); return Theme.alpha(Theme.fg, powerArea.containsMouse ? 0.078 : 0.045);
} }
border.width: 1 border.width: 1
border.color: root.entity.active border.color: {
? Theme.alpha(Theme.warn, 0.20) if (root.activeFocus)
: Theme.alpha(Theme.fg, 0.045) 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 color { ColorAnimation { duration: Theme.durFast } }
Behavior on border.color { ColorAnimation { duration: Theme.durFast } }
Keys.onSpacePressed: {
if (root.powerEnabled)
root.powerRequested();
}
Keys.onReturnPressed: {
if (root.powerEnabled)
root.powerRequested();
}
Rectangle {
id: bulb
anchors.left: parent.left
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 { Text {
anchors.left: parent.left anchors.centerIn: parent
anchors.leftMargin: 10 text: root.busy ? "\u{F0772}" : "\u{F0335}"
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 color: root.entity.active ? Theme.warn : Theme.fgDim
font.family: Theme.fontMono font.family: Theme.fontMono
font.pixelSize: 15 font.pixelSize: 15
} }
}
Column {
anchors.left: parent.left
anchors.leftMargin: 10
anchors.right: parent.right
anchors.rightMargin: 8
anchors.bottom: parent.bottom
anchors.bottomMargin: 8
spacing: 1
Text { Text {
width: parent.width 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 text: root.entity.name
color: Theme.fg color: Theme.fg
elide: Text.ElideRight elide: Text.ElideRight
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold font.weight: Font.DemiBold
} }
Text { Text {
width: parent.width anchors.left: aliasLabel.left
text: !root.entity.available ? "Unavailable" : (root.entity.active ? "On" : "Off") anchors.right: aliasLabel.right
color: root.entity.active ? Theme.warn : Theme.fgMuted 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.family: Theme.fontFamily
font.pixelSize: 9 font.pixelSize: Theme.fontSizeSmall
}
} }
MouseArea { MouseArea {
id: tileMouse id: powerArea
anchors.fill: parent anchors.left: parent.left
enabled: root.entity.available && !root.busy anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: brightnessSlider.top
anchors.bottomMargin: 1
enabled: root.powerEnabled
hoverEnabled: true hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.activated() onPressed: root.forceActiveFocus()
onClicked: root.powerRequested()
} }
function glyphFor(domain: string): string { HomeBrightnessSlider {
if (domain === "light") id: brightnessSlider
return "\u{F0335}"; anchors.left: parent.left
if (domain === "switch") anchors.leftMargin: 13
return "\u{F0521}"; anchors.right: parent.right
if (domain === "scene") anchors.rightMargin: 13
return "\u{F0FCE}"; anchors.bottom: parent.bottom
return "\u{F02DC}"; 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 "Couldnt 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
+132 -23
View File
@@ -2,72 +2,181 @@
set -euo pipefail 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() { fail() {
printf 'Control Center contract: %s\n' "$1" >&2 printf 'Control Center contract: %s\n' "$1" >&2
exit 1 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() { cleanup() {
qs ipc call quicksettings close >/dev/null 2>&1 || true qs_for_test ipc call quicksettings close >/dev/null 2>&1 || true
qs ipc call kdeconnect reset >/dev/null 2>&1 || true qs_for_test 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 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 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' \ 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' || fail 'approved Control Center width is missing'
rg -Fq 'readonly property int controlCenterTopGap: 2' \ 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' || fail 'approved top attachment is missing'
rg -Fq 'margins.top: Theme.barHeight + Theme.controlCenterTopGap' \ 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' || fail 'Control Center is not tightly attached to the bar'
rg -Fq 'implicitWidth: Theme.controlCenterWidth' \ 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' || fail 'Control Center window does not use its geometry token'
rg -Fq 'HomeControls' \ rg -Fq 'HomeControls' \
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml" \ "$quicksettings_path/QuickSettingsPanel.qml" \
|| fail 'Home controls are not mounted' || fail 'Home controls are not mounted'
rg -Fq 'PhoneControls' \ rg -Fq 'PhoneControls' \
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml" \ "$quicksettings_path/QuickSettingsPanel.qml" \
|| fail 'Phone controls are not mounted' || fail 'Phone controls are not mounted'
rg -Fq 'visible: KdeConnect.phoneReachable' \ 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' || fail 'reachable phone state is absent from the bar'
for component in ControlSectionHeader HomeControls HomeTile PhoneControls RecentExchange; do for component in ControlSectionHeader HomeBrightnessSlider HomeControls HomeTile PhoneControls RecentExchange; do
[[ -f "$project_root/config/dot/quickshell/modules/quicksettings/$component.qml" ]] \ [[ -f "$quicksettings_path/$component.qml" ]] \
|| fail "$component is missing" || fail "$component is missing"
done done
qs ipc call home-assistant fixture ready >/dev/null [[ -f "$quicksettings_path/qmldir" ]] \
qs ipc call kdeconnect fixture reachable >/dev/null || fail 'quick-settings module manifest is missing'
qs ipc call quicksettings open >/dev/null 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' \ hyprctl layers | rg -q 'namespace: qs-popover-quicksettings' \
|| fail 'Control Center layer did not map' || fail 'Control Center layer did not map'
jq -e '.open == true and .expandedSection == ""' \ 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' || 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"' \ 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' || 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"' \ 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' || 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 == ""' \ 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' || 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 trap - EXIT
cleanup cleanup
[[ ! -e "$state_home" ]] \
|| fail 'temporary Control Center state was not removed after shell exit'
printf 'Control Center contract: PASS\n' printf 'Control Center contract: PASS\n'