Add Home and Phone settings

This commit is contained in:
Gabriel Brown
2026-08-17 17:48:05 -04:00
parent 2130d6a1a5
commit 65966200fe
11 changed files with 939 additions and 15 deletions
@@ -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)
}
}
@@ -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));
}
}
@@ -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")
}
}
}
}
}
}
}
@@ -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 {} }
@@ -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}" },
@@ -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
@@ -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;
@@ -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) {
+7 -1
View File
@@ -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
});
+215
View File
@@ -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'
+113 -11
View File
@@ -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'