diff --git a/config/dot/quickshell/modules/settings/AvailableLightRow.qml b/config/dot/quickshell/modules/settings/AvailableLightRow.qml new file mode 100644 index 0000000..e80f2ba --- /dev/null +++ b/config/dot/quickshell/modules/settings/AvailableLightRow.qml @@ -0,0 +1,75 @@ +import QtQuick +import qs.config + +Item { + id: root + + required property var entity + signal addRequested(string id) + + width: parent ? parent.width : 620 + implicitHeight: 62 + + Column { + anchors.left: parent.left + anchors.right: stateCopy.left + anchors.rightMargin: 16 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + + Text { + width: parent.width + text: root.entity.sourceName + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + width: parent.width + text: root.entity.id + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideMiddle + } + } + + Text { + id: stateCopy + anchors.right: addButton.left + anchors.rightMargin: 14 + anchors.verticalCenter: parent.verticalCenter + width: 92 + text: root.entity.available === false ? "Unavailable" : String(root.entity.state || "Unknown") + color: root.entity.available === false ? Theme.fgMuted : Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + } + + SettingsButton { + id: addButton + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: "Add" + tone: "accent" + activeFocusOnTab: true + border.width: activeFocus ? 2 : 0 + border.color: activeFocus ? Theme.fg : Theme.alpha(Theme.fg, 0) + onClicked: root.addRequested(root.entity.id) + Keys.onReturnPressed: root.addRequested(root.entity.id) + Keys.onSpacePressed: root.addRequested(root.entity.id) + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: Theme.alpha(Theme.fg, 0.06) + } +} diff --git a/config/dot/quickshell/modules/settings/HomeFavoriteCard.qml b/config/dot/quickshell/modules/settings/HomeFavoriteCard.qml new file mode 100644 index 0000000..0c6e245 --- /dev/null +++ b/config/dot/quickshell/modules/settings/HomeFavoriteCard.qml @@ -0,0 +1,203 @@ +import QtQuick +import qs.config +import qs.widgets + +Rectangle { + id: root + + required property var favorite + required property string sourceName + required property int index + required property bool featured + + signal aliasCommitted(string id, string alias) + signal removeRequested(string id) + signal moveRequested(string id, int targetIndex) + + readonly property bool dragging: dragHandler.active + + implicitHeight: 108 + radius: Theme.cardRadius + color: root.dragging + ? Theme.mix(Theme.bgDark, Theme.accent, 0.09) + : Theme.alpha(Theme.bgDark, 0.7) + border.width: root.dragging ? 2 : 1 + border.color: root.dragging + ? Theme.alpha(Theme.accent, 0.82) + : Theme.alpha(Theme.fg, 0.07) + z: root.dragging ? 10 : 0 + + transform: Translate { + x: root.dragging ? dragHandler.translation.x : 0 + y: root.dragging ? dragHandler.translation.y : 0 + } + + PrismEdge { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + inset: root.radius + opacity: root.dragging ? 0.82 : 0.2 + } + + Rectangle { + id: dragHandle + anchors.left: parent.left + anchors.leftMargin: 11 + anchors.verticalCenter: parent.verticalCenter + width: 30 + height: 42 + radius: 9 + activeFocusOnTab: true + color: root.dragging || activeFocus + ? Theme.alpha(Theme.accent, 0.14) + : (handleMouse.containsMouse ? Theme.alpha(Theme.fg, 0.09) : Theme.alpha(Theme.fg, 0.045)) + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.06) + + Text { + anchors.centerIn: parent + text: "⠿" + color: root.dragging ? Theme.accent : Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: 16 + } + + MouseArea { + id: handleMouse + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + cursorShape: Qt.SizeAllCursor + } + + DragHandler { + id: dragHandler + target: null + onActiveChanged: { + if (!active) + root.commitDrag(); + } + } + + Keys.onPressed: event => { + if (event.key === Qt.Key_Left || event.key === Qt.Key_Up) { + root.moveRequested(root.favorite.id, Math.max(0, root.index - 1)); + event.accepted = true; + } else if (event.key === Qt.Key_Right || event.key === Qt.Key_Down) { + const grid = root.GridView.view; + const lastIndex = grid ? grid.count - 1 : root.index; + root.moveRequested(root.favorite.id, Math.min(lastIndex, root.index + 1)); + event.accepted = true; + } + } + } + + Rectangle { + id: aliasFrame + anchors.left: dragHandle.right + anchors.leftMargin: 10 + anchors.right: removeButton.left + anchors.rightMargin: 12 + anchors.top: parent.top + anchors.topMargin: 12 + height: 34 + radius: 8 + color: Theme.alpha(Theme.fg, aliasInput.activeFocus ? 0.075 : 0.045) + border.width: aliasInput.activeFocus ? 2 : 1 + border.color: aliasInput.activeFocus + ? Theme.alpha(Theme.accent, 0.78) + : Theme.alpha(Theme.fg, 0.065) + + TextInput { + id: aliasInput + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + activeFocusOnTab: true + text: String(root.favorite.alias || "") + color: Theme.fg + selectionColor: Theme.accent + selectedTextColor: Theme.bgDark + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.Medium + verticalAlignment: TextInput.AlignVCenter + selectByMouse: true + clip: true + onEditingFinished: root.aliasCommitted(root.favorite.id, text) + + Text { + anchors.fill: parent + visible: aliasInput.text === "" && !aliasInput.activeFocus + text: root.sourceName + color: Theme.fgDim + font: aliasInput.font + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + } + + Text { + anchors.left: aliasFrame.left + anchors.right: removeButton.left + anchors.rightMargin: 12 + anchors.top: aliasFrame.bottom + anchors.topMargin: 7 + text: root.sourceName + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + } + + Rectangle { + anchors.left: aliasFrame.left + anchors.bottom: parent.bottom + anchors.bottomMargin: 10 + width: badgeCopy.implicitWidth + 14 + height: 21 + radius: Theme.pillRadius + visible: root.featured + color: Theme.alpha(Theme.accent, 0.1) + border.width: 1 + border.color: Theme.alpha(Theme.accent, 0.2) + + Text { + id: badgeCopy + anchors.centerIn: parent + text: "Control Center" + color: Theme.accent + font.family: Theme.fontFamily + font.pixelSize: 9 + font.weight: Font.DemiBold + } + } + + SettingsButton { + id: removeButton + anchors.right: parent.right + anchors.rightMargin: 11 + anchors.verticalCenter: parent.verticalCenter + text: "Remove" + activeFocusOnTab: true + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) + onClicked: root.removeRequested(root.favorite.id) + Keys.onReturnPressed: root.removeRequested(root.favorite.id) + Keys.onSpacePressed: root.removeRequested(root.favorite.id) + } + + function commitDrag(): void { + const grid = root.GridView.view; + if (!grid || grid.count <= 0) + return; + const centerX = root.x + dragHandler.translation.x + root.width / 2; + const centerY = root.y + dragHandler.translation.y + root.height / 2; + const modelCount = grid.count; + const column = Math.max(0, Math.min(1, Math.floor(centerX / grid.cellWidth))); + const row = Math.max(0, Math.floor(centerY / grid.cellHeight)); + root.moveRequested(root.favorite.id, Math.min(modelCount - 1, row * 2 + column)); + } +} diff --git a/config/dot/quickshell/modules/settings/HomePhonePage.qml b/config/dot/quickshell/modules/settings/HomePhonePage.qml new file mode 100644 index 0000000..4a4fc5b --- /dev/null +++ b/config/dot/quickshell/modules/settings/HomePhonePage.qml @@ -0,0 +1,305 @@ +import QtQuick +import qs.config +import qs.services + +Item { + id: root + + property string lightQuery: searchInput.text.trim().toLowerCase() + + readonly property var availableLights: HomeAssistant.catalog.filter(entity => { + if (HomePreferences.isSelected(entity.id)) + return false; + const haystack = (entity.sourceName + " " + entity.id).toLowerCase(); + return root.lightQuery === "" || haystack.includes(root.lightQuery); + }) + + function homeStatus(): string { + if (HomeAssistant.phase === "ready") + return `Connected · ${HomeAssistant.discoveredCount} lights discovered`; + if (HomeAssistant.phase === "degraded") + return "Last update unavailable · showing saved controls"; + if (HomeAssistant.lastError === "authentication-required") + return "Authentication required"; + if (HomeAssistant.lastError === "not-configured") + return "Home Assistant is not configured"; + if (HomeAssistant.phase === "loading") + return "Connecting to Home Assistant"; + return "Home Assistant is unavailable"; + } + + Flickable { + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: content.implicitHeight + 64 + boundsBehavior: Flickable.StopAtBounds + + Column { + id: content + width: parent.width - 68 + x: 34 + y: 30 + spacing: 16 + + Text { + text: "Home & Phone" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: 27 + font.weight: Font.DemiBold + } + + Text { + text: "Choose what appears in Control Center and keep phone continuity close at hand." + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + bottomPadding: 6 + } + + SettingsCard { + title: "Home Assistant" + subtitle: root.homeStatus() + + SettingRow { + label: "Light catalog" + detail: "Panama reads light state through the Home Assistant helper." + divider: false + controlWidth: 176 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + SettingsButton { + id: refreshButton + text: "Refresh" + activeFocusOnTab: true + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) + onClicked: HomeAssistant.refresh() + Keys.onReturnPressed: HomeAssistant.refresh() + Keys.onSpacePressed: HomeAssistant.refresh() + } + + SettingsButton { + id: openHomeButton + text: "Open" + activeFocusOnTab: true + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) + onClicked: HomeAssistant.open() + Keys.onReturnPressed: HomeAssistant.open() + Keys.onSpacePressed: HomeAssistant.open() + } + } + } + } + + SettingsCard { + title: "Control Center lights" + subtitle: HomeAssistant.selectedEntities.length === 0 + ? "Select the lights that belong on your shelf." + : `${Math.min(4, HomeAssistant.selectedEntities.length)} in Control Center · ${HomeAssistant.selectedEntities.length} selected` + + Text { + width: parent.width + visible: HomeAssistant.selectedEntities.length === 0 + text: "Choose lights below to build your Control Center shelf." + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + wrapMode: Text.WordWrap + topPadding: 3 + bottomPadding: 13 + } + + GridView { + id: favoritesGrid + width: parent.width + height: Math.ceil(count / 2) * cellHeight + visible: count > 0 + interactive: false + clip: false + cellWidth: width / 2 + cellHeight: 116 + model: HomeAssistant.selectedEntities + + delegate: HomeFavoriteCard { + required property var modelData + width: GridView.view.cellWidth - 6 + height: 108 + favorite: ({ + id: modelData.id, + alias: modelData.name === modelData.sourceName ? "" : modelData.name + }) + sourceName: modelData.sourceName + featured: index < 4 + onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias) + onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex) + onRemoveRequested: id => HomePreferences.remove(id) + } + } + + Rectangle { + width: parent.width + height: 46 + visible: HomePreferences.saveError !== "" + radius: 9 + color: Theme.alpha(Theme.warn, 0.09) + border.width: 1 + border.color: Theme.alpha(Theme.warn, 0.24) + + Text { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.right: retryButton.left + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: HomePreferences.saveError + color: Theme.warn + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + } + + SettingsButton { + id: retryButton + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + text: "Retry" + activeFocusOnTab: true + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.warn : Theme.alpha(Theme.fg, 0.08) + onClicked: HomePreferences.retrySave() + Keys.onReturnPressed: HomePreferences.retrySave() + Keys.onSpacePressed: HomePreferences.retrySave() + } + } + } + + SettingsCard { + title: "Available lights" + subtitle: "Search the Home Assistant catalog by source name or entity ID." + + Rectangle { + width: parent.width + height: 38 + radius: 10 + color: Theme.alpha(Theme.fg, searchInput.activeFocus ? 0.08 : 0.05) + border.width: searchInput.activeFocus ? 2 : 1 + border.color: searchInput.activeFocus + ? Theme.alpha(Theme.accent, 0.72) + : Theme.alpha(Theme.fg, 0.065) + + Text { + anchors.left: parent.left + anchors.leftMargin: 11 + anchors.verticalCenter: parent.verticalCenter + text: "\u{F0349}" + color: Theme.fgDim + font.family: Theme.fontMono + font.pixelSize: 14 + } + + TextInput { + id: searchInput + anchors.left: parent.left + anchors.leftMargin: 36 + anchors.right: parent.right + anchors.rightMargin: 11 + anchors.verticalCenter: parent.verticalCenter + activeFocusOnTab: true + color: Theme.fg + selectionColor: Theme.accent + selectedTextColor: Theme.bgDark + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + clip: true + + Text { + anchors.fill: parent + visible: searchInput.text === "" && !searchInput.activeFocus + text: "Search available lights" + color: Theme.fgMuted + font: searchInput.font + verticalAlignment: Text.AlignVCenter + } + } + } + + Column { + width: parent.width + visible: root.availableLights.length > 0 + + Repeater { + model: root.availableLights + + AvailableLightRow { + required property var modelData + width: parent.width + entity: modelData + onAddRequested: id => HomePreferences.add(id) + } + } + } + + Text { + width: parent.width + visible: root.availableLights.length === 0 + text: root.lightQuery === "" && HomeAssistant.catalog.length > 0 + ? "All discovered lights are already selected" + : "No lights match that search" + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + topPadding: 18 + bottomPadding: 10 + } + } + + SettingsCard { + title: "Phone continuity" + subtitle: "Keep the Messages handoff independent from phone connectivity." + + SettingRow { + label: "Messages" + detail: "Opens BlueBubbles" + divider: false + controlWidth: 204 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 12 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: SystemSettings.bluebubblesAvailable ? "Installed" : "Unavailable" + color: SystemSettings.bluebubblesAvailable ? Theme.ok : Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + + SettingsButton { + id: openBlueBubblesButton + text: "Open" + enabled: SystemSettings.bluebubblesAvailable + activeFocusOnTab: enabled + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) + onClicked: SystemSettings.openApplication("bluebubbles") + Keys.onReturnPressed: if (enabled) SystemSettings.openApplication("bluebubbles") + Keys.onSpacePressed: if (enabled) SystemSettings.openApplication("bluebubbles") + } + } + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index cc63829..7ea5701 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -88,6 +88,7 @@ Rectangle { case "appearance": return appearancePage; case "displays": return displaysPage; case "connectivity": return connectivityPage; + case "home-phone": return homePhonePage; case "desktop": return desktopPage; case "sound": return soundPage; case "notifications": return notificationsPage; @@ -133,6 +134,7 @@ Rectangle { Component { id: appearancePage; AppearancePage {} } Component { id: displaysPage; DisplaysPage {} } Component { id: connectivityPage; ConnectivityPage {} } + Component { id: homePhonePage; HomePhonePage {} } Component { id: desktopPage; DesktopPage {} } Component { id: soundPage; SoundPage {} } Component { id: notificationsPage; NotificationsPage {} } diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index c665334..6d3dc6e 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -13,6 +13,7 @@ Rectangle { { page: "appearance", label: "Appearance", icon: "\u{F0E0D}" }, { page: "displays", label: "Displays", icon: "\u{F0379}" }, { page: "connectivity", label: "Network & Devices", icon: "\u{F08D4}" }, + { page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" }, { page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" }, { page: "sound", label: "Sound", icon: "\u{F057E}" }, { page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" }, diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 9b0960d..56d86bb 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -2,6 +2,9 @@ module qs.modules.settings AboutPage 1.0 AboutPage.qml AppearancePage 1.0 AppearancePage.qml ConnectivityPage 1.0 ConnectivityPage.qml +HomePhonePage 1.0 HomePhonePage.qml +HomeFavoriteCard 1.0 HomeFavoriteCard.qml +AvailableLightRow 1.0 AvailableLightRow.qml DesktopPage 1.0 DesktopPage.qml DisplaysPage 1.0 DisplaysPage.qml HomePage 1.0 HomePage.qml diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index dd503de..3dd4a3f 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -92,7 +92,7 @@ Singleton { } function openSettings(page: string): void { - const allowed = ["home", "appearance", "displays", "connectivity", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "services", "about"]; + const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "services", "about"]; root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home"; DesktopPreferences.lastPage = root.settingsPage; root.settingsOpen = true; diff --git a/config/dot/quickshell/services/SystemSettings.qml b/config/dot/quickshell/services/SystemSettings.qml index 9f83aef..db2dddf 100644 --- a/config/dot/quickshell/services/SystemSettings.qml +++ b/config/dot/quickshell/services/SystemSettings.qml @@ -27,13 +27,16 @@ Singleton { property bool hyprpaperActive: false property bool hypridleActive: false property bool vicinaeActive: false + property bool bluebubblesDetected: false property string hyprlandVersion: "" property string quickshellVersion: "0.3.0" property string lastError: "" readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running - || autoHdrWrite.running || vrrWrite.running || directScanoutWrite.running + || bluebubblesQuery.running || autoHdrWrite.running || vrrWrite.running || directScanoutWrite.running + + readonly property bool bluebubblesAvailable: root.bluebubblesDetected readonly property bool autoHdr: DesktopPreferences.autoHdr readonly property int vrrPolicy: DesktopPreferences.vrrPolicy @@ -79,6 +82,12 @@ Singleton { } } + Process { + id: bluebubblesQuery + command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"] + onExited: (exitCode, exitStatus) => root.bluebubblesDetected = exitCode === 0 + } + Process { id: autoHdrWrite property bool requested: true @@ -136,6 +145,8 @@ Singleton { serviceQuery.running = true; if (!versionQuery.running && !root.hyprlandVersion) versionQuery.running = true; + if (!bluebubblesQuery.running) + bluebubblesQuery.running = true; } function parseMonitors(text: string): void { @@ -225,7 +236,8 @@ Singleton { "nextcloud": ["nextcloud"], "rustdesk": ["rustdesk"], "kdeconnect": ["kdeconnect-app"], - "mission-center": ["flatpak", "run", "io.missioncenter.MissionCenter"] + "mission-center": ["flatpak", "run", "io.missioncenter.MissionCenter"], + "bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"] }; const command = commands[id]; if (!command) { diff --git a/config/dot/quickshell/shell.qml b/config/dot/quickshell/shell.qml index e17c1ed..6f84599 100644 --- a/config/dot/quickshell/shell.qml +++ b/config/dot/quickshell/shell.qml @@ -291,7 +291,12 @@ ShellRoot { function close(): void { ShellState.closeSettings(); } function page(name: string): void { ShellState.openSettings(name); } function status(): string { - return JSON.stringify({ open: ShellState.settingsOpen, page: ShellState.settingsPage }); + return JSON.stringify({ + open: ShellState.settingsOpen, + page: ShellState.settingsPage, + discoveredCount: HomeAssistant.discoveredCount, + selectedCount: HomeAssistant.configuredCount + }); } } @@ -306,6 +311,7 @@ ShellRoot { autoHdr: SystemSettings.autoHdr, vrrPolicy: SystemSettings.vrrPolicy, directScanoutPolicy: SystemSettings.directScanoutPolicy, + bluebubblesAvailable: SystemSettings.bluebubblesAvailable, busy: SystemSettings.busy, lastError: SystemSettings.lastError }); diff --git a/tests/quickshell/home-phone-settings-contract.sh b/tests/quickshell/home-phone-settings-contract.sh new file mode 100755 index 0000000..7840213 --- /dev/null +++ b/tests/quickshell/home-phone-settings-contract.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +state_home="$(mktemp -d /tmp/panama-home-phone-settings-state.XXXXXX)" +source_config_path="$repo_dir/config/dot/quickshell" +config_path="$state_home/quickshell" +test_bin="$state_home/bin" +shell_log="$state_home/quickshell.log" +flatpak_log="$state_home/flatpak.log" + +cleanup_bootstrap() { + rm -rf "$state_home" +} +trap cleanup_bootstrap EXIT + +mkdir -p "$test_bin" +cp -a "$source_config_path" "$config_path" +: >"$flatpak_log" + +cat >"$config_path/scripts/panama-home-assistant" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +case "${1:-}" in + catalog) + printf '%s\n' '{"ok":false,"error":"test-helper"}' + ;; + toggle|brightness) + printf '%s\n' '{"ok":true}' + ;; +esac +EOF +chmod +x "$config_path/scripts/panama-home-assistant" + +cat >"$test_bin/hyprctl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then + printf '%s\n' '[{"focused":true,"name":"TEST-1","description":"Home and Phone contract","width":1920,"height":1080,"refreshRate":60,"scale":1,"currentFormat":"XRGB8888","colorManagementPreset":"srgb","vrr":false}]' + exit 0 +fi +if [[ "${1:-}" == "keyword" ]]; then + exit 0 +fi +exec /usr/sbin/hyprctl "$@" +EOF +chmod +x "$test_bin/hyprctl" + +cat >"$test_bin/flatpak" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +printf '%s\n' "$*" >>"$PANAMA_FLATPAK_LOG" +if [[ "${1:-}" == "info" && "${2:-}" == "app.bluebubbles.BlueBubbles" ]]; then + exit 0 +fi +exit 97 +EOF +chmod +x "$test_bin/flatpak" + +fail() { + printf 'home phone settings contract: %s\n' "$1" >&2 + exit 1 +} + +assert_contains() { + local needle="$1" + local file="$2" + rg -Fq "$needle" "$file" || fail "$file is missing: $needle" +} + +home_page="$repo_dir/config/dot/quickshell/modules/settings/HomePhonePage.qml" +favorite_card="$repo_dir/config/dot/quickshell/modules/settings/HomeFavoriteCard.qml" +available_row="$repo_dir/config/dot/quickshell/modules/settings/AvailableLightRow.qml" +system_settings="$repo_dir/config/dot/quickshell/services/SystemSettings.qml" + +[[ -f "$home_page" ]] || fail 'HomePhonePage.qml is missing' +[[ -f "$favorite_card" ]] || fail 'HomeFavoriteCard.qml is missing' +[[ -f "$available_row" ]] || fail 'AvailableLightRow.qml is missing' +assert_contains 'text: "Home & Phone"' "$home_page" +assert_contains 'text: "Choose what appears in Control Center and keep phone continuity close at hand."' "$home_page" +assert_contains 'Connected · ' "$home_page" +assert_contains 'Last update unavailable · showing saved controls' "$home_page" +assert_contains 'Authentication required' "$home_page" +assert_contains 'Home Assistant is not configured' "$home_page" +assert_contains 'HomePreferences.setAlias' "$home_page" +assert_contains 'HomePreferences.move' "$home_page" +assert_contains 'HomePreferences.remove' "$home_page" +assert_contains 'HomePreferences.add' "$home_page" +assert_contains 'HomePreferences.retrySave' "$home_page" +assert_contains 'Choose lights below to build your Control Center shelf.' "$home_page" +assert_contains 'All discovered lights are already selected' "$home_page" +assert_contains 'No lights match that search' "$home_page" +assert_contains 'Opens BlueBubbles' "$home_page" +[[ "$(rg -Fc 'required property var modelData' "$home_page")" -ge 2 ]] \ + || fail 'HomePhonePage.qml does not bind both reusable delegates to modelData' +if rg -Fq 'index: model.index' "$home_page"; then + fail 'HomePhonePage.qml reads an undefined model.index instead of the delegate index' +fi +if rg -qi 'token|bearer|api/states' "$home_page"; then + fail 'HomePhonePage.qml crosses the credential or REST privacy boundary' +fi +assert_contains 'signal aliasCommitted(string id, string alias)' "$favorite_card" +assert_contains 'signal removeRequested(string id)' "$favorite_card" +assert_contains 'signal moveRequested(string id, int targetIndex)' "$favorite_card" +assert_contains 'DragHandler {' "$favorite_card" +assert_contains 'onEditingFinished:' "$favorite_card" +assert_contains 'text: "Control Center"' "$favorite_card" +assert_contains 'activeFocusOnTab: true' "$favorite_card" +assert_contains 'signal addRequested(string id)' "$available_row" +assert_contains 'activeFocusOnTab: true' "$available_row" +assert_contains 'readonly property bool bluebubblesAvailable: root.bluebubblesDetected' "$system_settings" +assert_contains 'command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]' "$system_settings" +assert_contains '"bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"]' "$system_settings" + +qs_for_test() { + PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \ + PANAMA_FLATPAK_LOG="$flatpak_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_for_test ipc call settings close >/dev/null 2>&1 || true + if stop_test_shell; then + rm -rf "$state_home" + else + printf 'home phone settings 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 settings-system$' >/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,240p' "$shell_log" >&2 + fail 'isolated branch shell did not start' +} + +start_test_shell +qs_for_test ipc call home-assistant fixture ready >/dev/null +qs_for_test ipc call settings page home-phone >/dev/null + +status='{}' +for _ in $(seq 1 40); do + status="$(qs_for_test ipc call settings status | jq -c .)" + if jq -e '.page == "home-phone" and .discoveredCount == 7 and .selectedCount == 7' \ + <<<"$status" >/dev/null; then + break + fi + sleep 0.1 +done +jq -e '.open == true and .page == "home-phone" and .discoveredCount == 7 and .selectedCount == 7' \ + <<<"$status" >/dev/null || fail "Home & Phone diagnostics are incomplete: $status" + +system_status="$(qs_for_test ipc call settings-system status | jq -c .)" +jq -e '.bluebubblesAvailable == true' <<<"$system_status" >/dev/null \ + || fail "BlueBubbles availability was not exposed: $system_status" + +shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')" +[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process' +for _ in $(seq 1 40); do + if /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \ + '[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null; then + break + fi + sleep 0.1 +done +/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \ + '[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null \ + || fail 'the branch shell did not own exactly one tiled Panama Settings client' + +if [[ -n "${PANAMA_TEST_SCREENSHOT_PATH:-}" ]]; then + geometry="$(/usr/sbin/hyprctl -j clients | jq -r --argjson pid "$shell_pid" \ + '.[] | select(.pid == $pid and .title == "Panama Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')" + [[ -n "$geometry" ]] || fail 'could not resolve the Settings client geometry' + grim -g "$geometry" "$PANAMA_TEST_SCREENSHOT_PATH" +fi + +rg -Fxq 'info app.bluebubbles.BlueBubbles' "$flatpak_log" \ + || fail 'the fixed BlueBubbles availability probe did not run' +if rg -q '^run ' "$flatpak_log"; then + fail 'the contract started BlueBubbles' +fi +if find "$state_home" -name panama-home.json -print -quit | rg -q .; then + fail 'the read-only fixture route wrote Home preferences' +fi + +trap - EXIT +cleanup +[[ ! -e "$state_home" ]] || fail 'temporary Home & Phone state was not removed after shell exit' +printf 'home phone settings contract: PASS\n' diff --git a/tests/quickshell/settings-pages-contract.sh b/tests/quickshell/settings-pages-contract.sh index d35c19d..8e217cf 100755 --- a/tests/quickshell/settings-pages-contract.sh +++ b/tests/quickshell/settings-pages-contract.sh @@ -2,34 +2,135 @@ set -euo pipefail +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +state_home="$(mktemp -d /tmp/panama-settings-pages-state.XXXXXX)" +source_config_path="$repo_dir/config/dot/quickshell" +config_path="$state_home/quickshell" +test_bin="$state_home/bin" +shell_log="$state_home/quickshell.log" + +cleanup_bootstrap() { + rm -rf "$state_home" +} +trap cleanup_bootstrap EXIT + +mkdir -p "$test_bin" +cp -a "$source_config_path" "$config_path" + +cat >"$config_path/scripts/panama-home-assistant" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +case "${1:-}" in + catalog) + printf '%s\n' '{"ok":false,"error":"test-helper"}' + ;; + toggle|brightness) + printf '%s\n' '{"ok":true}' + ;; +esac +EOF +chmod +x "$config_path/scripts/panama-home-assistant" + +cat >"$test_bin/hyprctl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then + printf '%s\n' '[{"focused":true,"name":"TEST-1","description":"Settings contract","width":1920,"height":1080,"refreshRate":60,"scale":1,"currentFormat":"XRGB8888","colorManagementPreset":"srgb","vrr":false}]' + exit 0 +fi +if [[ "${1:-}" == "keyword" ]]; then + exit 0 +fi +exec /usr/sbin/hyprctl "$@" +EOF +chmod +x "$test_bin/hyprctl" + +cat >"$test_bin/flatpak" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == "info" ]]; then + exit 1 +fi +exit 97 +EOF +chmod +x "$test_bin/flatpak" + fail() { printf 'settings pages contract: %s\n' "$1" >&2 exit 1 } +qs_for_test() { + PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \ + 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 settings close >/dev/null 2>&1 || true + qs_for_test ipc call settings close >/dev/null 2>&1 || true + if stop_test_shell; then + rm -rf "$state_home" + else + printf 'settings pages contract: branch shell did not stop; retained %s\n' \ + "$state_home" >&2 + fi } trap cleanup EXIT -pages=(home appearance displays connectivity desktop sound notifications screen-intelligence shortcuts services about) +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 settings$' >/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' +} + +start_test_shell +qs_for_test ipc call home-assistant fixture ready >/dev/null +shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')" +[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process' + +pages=(home appearance displays connectivity home-phone desktop sound notifications screen-intelligence shortcuts services about) for page in "${pages[@]}"; do - qs ipc call settings page "$page" >/dev/null + qs_for_test ipc call settings page "$page" >/dev/null for _ in $(seq 1 20); do - [[ "$(qs ipc call settings status | jq -r .page)" == "$page" ]] && break + [[ "$(qs_for_test ipc call settings status | jq -r .page)" == "$page" ]] && break sleep 0.1 done - [[ "$(qs ipc call settings status | jq -r .page)" == "$page" ]] || fail "$page did not route" - hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 1' >/dev/null \ - || fail "$page created a missing or duplicate Settings window" + [[ "$(qs_for_test ipc call settings status | jq -r .page)" == "$page" ]] || fail "$page did not route" + /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \ + '[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null \ + || fail "$page created a missing, floating, or duplicate Settings window" done -qs ipc call settings page '__unsupported__' >/dev/null -[[ "$(qs ipc call settings status | jq -r .page)" == "home" ]] || fail 'unsupported page did not fall back to Home' +qs_for_test ipc call settings page '__unsupported__' >/dev/null +[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "home" ]] || fail 'unsupported page did not fall back to Home' -hyprctl -j binds | jq -e '.[] | select(.description == "Panama Settings" and .key == "I" and .modmask == 64)' >/dev/null \ +/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Panama Settings" and .key == "I" and .modmask == 64)' >/dev/null \ || fail 'Super+I is not registered as Panama Settings' -hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \ +/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \ || fail 'Super+Shift+S is not registered as Screen Intelligence' desktop_file="$HOME/.local/share/applications/panama-settings.desktop" @@ -44,4 +145,5 @@ desktop-file-validate "$intelligence_desktop_file" >/dev/null || fail 'Screen In trap - EXIT cleanup +[[ ! -e "$state_home" ]] || fail 'temporary Settings state was not removed after shell exit' printf 'settings pages contract: PASS\n'