diff --git a/config/dot/quickshell/modules/quicksettings/HomeBrightnessSlider.qml b/config/dot/quickshell/modules/quicksettings/HomeBrightnessSlider.qml new file mode 100644 index 0000000..a2ffb4f --- /dev/null +++ b/config/dot/quickshell/modules/quicksettings/HomeBrightnessSlider.qml @@ -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); + } +} diff --git a/config/dot/quickshell/modules/quicksettings/HomeControls.qml b/config/dot/quickshell/modules/quicksettings/HomeControls.qml index eafb16e..cf8f18b 100644 --- a/config/dot/quickshell/modules/quicksettings/HomeControls.qml +++ b/config/dot/quickshell/modules/quicksettings/HomeControls.qml @@ -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"); } } diff --git a/config/dot/quickshell/modules/quicksettings/HomeTile.qml b/config/dot/quickshell/modules/quicksettings/HomeTile.qml index e897e22..9bec1df 100644 --- a/config/dot/quickshell/modules/quicksettings/HomeTile.qml +++ b/config/dot/quickshell/modules/quicksettings/HomeTile.qml @@ -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"; } } diff --git a/config/dot/quickshell/modules/quicksettings/qmldir b/config/dot/quickshell/modules/quicksettings/qmldir new file mode 100644 index 0000000..67885c2 --- /dev/null +++ b/config/dot/quickshell/modules/quicksettings/qmldir @@ -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 diff --git a/tests/quickshell/control-center-contract.sh b/tests/quickshell/control-center-contract.sh index 454f4bd..3c824bb 100755 --- a/tests/quickshell/control-center-contract.sh +++ b/tests/quickshell/control-center-contract.sh @@ -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'