From 5940d6b4bdb89000666d5c1c8499e30498de64e4 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 17 Aug 2026 13:44:18 -0400 Subject: [PATCH] Build the Panama Control Center --- .gitignore | 3 + config/dot/quickshell/config/Theme.qml | 2 + .../quickshell/modules/bar/StatusCluster.qml | 7 + .../quicksettings/ControlSectionHeader.qml | 50 ++++ .../modules/quicksettings/HomeControls.qml | 280 ++++++++++++++++++ .../modules/quicksettings/HomeTile.qml | 81 +++++ .../modules/quicksettings/PhoneControls.qml | 273 +++++++++++++++++ .../modules/quicksettings/QuickSettings.qml | 19 +- .../quicksettings/QuickSettingsPanel.qml | 15 +- .../modules/quicksettings/RecentExchange.qml | 64 ++++ config/dot/quickshell/shell.qml | 8 +- tests/quickshell/control-center-contract.sh | 73 +++++ 12 files changed, 867 insertions(+), 8 deletions(-) create mode 100644 config/dot/quickshell/modules/quicksettings/ControlSectionHeader.qml create mode 100644 config/dot/quickshell/modules/quicksettings/HomeControls.qml create mode 100644 config/dot/quickshell/modules/quicksettings/HomeTile.qml create mode 100644 config/dot/quickshell/modules/quicksettings/PhoneControls.qml create mode 100644 config/dot/quickshell/modules/quicksettings/RecentExchange.qml create mode 100755 tests/quickshell/control-center-contract.sh diff --git a/.gitignore b/.gitignore index 90c9912..398d763 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ /config/dot/nvim/lazy-lock.json # Local visual-companion sessions and generated design mocks. /.superpowers/ +# Python helper bytecode is local runtime state. +__pycache__/ +*.py[cod] diff --git a/config/dot/quickshell/config/Theme.qml b/config/dot/quickshell/config/Theme.qml index 8307c2c..8f2095b 100644 --- a/config/dot/quickshell/config/Theme.qml +++ b/config/dot/quickshell/config/Theme.qml @@ -78,6 +78,8 @@ Singleton { readonly property int popoverRadius: 18 readonly property int popoverPadding: 14 readonly property int popoverWidth: 380 + readonly property int controlCenterWidth: 430 + readonly property int controlCenterTopGap: 2 readonly property int cardRadius: 12 readonly property int pillRadius: 999 diff --git a/config/dot/quickshell/modules/bar/StatusCluster.qml b/config/dot/quickshell/modules/bar/StatusCluster.qml index 4befa21..474e4de 100644 --- a/config/dot/quickshell/modules/bar/StatusCluster.qml +++ b/config/dot/quickshell/modules/bar/StatusCluster.qml @@ -10,6 +10,7 @@ import Quickshell.Networking import Quickshell.Services.Pipewire import QtQuick import qs.config +import qs.services import qs.widgets Pill { @@ -106,4 +107,10 @@ Pill { glyph: "\u{F0176}" // md-coffee color: Theme.warn } + + StatusGlyph { + visible: KdeConnect.phoneReachable + glyph: "\u{F03F2}" // md-cellphone-link + color: Theme.cyan + } } diff --git a/config/dot/quickshell/modules/quicksettings/ControlSectionHeader.qml b/config/dot/quickshell/modules/quicksettings/ControlSectionHeader.qml new file mode 100644 index 0000000..48fe593 --- /dev/null +++ b/config/dot/quickshell/modules/quicksettings/ControlSectionHeader.qml @@ -0,0 +1,50 @@ +import QtQuick +import qs.config + +Item { + id: root + + property string label: "" + property string action: "" + property bool actionEnabled: true + signal actionTriggered + + implicitHeight: 24 + + Text { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: root.label.toUpperCase() + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + font.letterSpacing: 0.9 + } + + Text { + id: actionLabel + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: root.action !== "" + text: root.action + color: root.actionEnabled + ? (actionMouse.containsMouse ? Theme.accentAlt : Theme.accent) + : Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + + Behavior on color { ColorAnimation { duration: Theme.durFast } } + + MouseArea { + id: actionMouse + anchors.fill: parent + anchors.margins: -6 + enabled: root.actionEnabled + hoverEnabled: true + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: root.actionTriggered() + } + } +} diff --git a/config/dot/quickshell/modules/quicksettings/HomeControls.qml b/config/dot/quickshell/modules/quicksettings/HomeControls.qml new file mode 100644 index 0000000..eafb16e --- /dev/null +++ b/config/dot/quickshell/modules/quicksettings/HomeControls.qml @@ -0,0 +1,280 @@ +import QtQuick +import qs.config +import qs.services + +Item { + id: root + + property bool expanded: false + signal toggleExpanded + + implicitHeight: content.implicitHeight + + Column { + id: content + width: parent.width + spacing: 6 + + ControlSectionHeader { + width: parent.width + label: "Home" + action: HomeAssistant.entities.length > 0 + ? HomeAssistant.entities.length + " accessories " + (root.expanded ? "⌃" : "›") + : "Retry" + actionEnabled: HomeAssistant.phase !== "loading" + onActionTriggered: { + if (HomeAssistant.entities.length > 0) + root.toggleExpanded(); + else + HomeAssistant.refresh(); + } + } + + Rectangle { + id: homeCard + width: parent.width + height: visible ? homeGrid.implicitHeight + 20 : 0 + visible: HomeAssistant.entities.length > 0 + radius: Theme.cardRadius + 2 + color: Theme.alpha(Theme.accent, 0.045) + border.width: 1 + border.color: Theme.alpha(Theme.accent, 0.12) + + Grid { + id: homeGrid + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 10 + columns: 4 + spacing: 7 + + Repeater { + model: HomeAssistant.visibleEntities + + HomeTile { + required property var modelData + width: (homeGrid.width - homeGrid.spacing * 3) / 4 + entity: modelData + busy: HomeAssistant.busyEntityId === modelData.id + onActivated: HomeAssistant.toggleEntity(modelData.id) + } + } + } + } + + Rectangle { + width: parent.width + height: visible ? 66 : 0 + visible: HomeAssistant.entities.length === 0 + radius: Theme.cardRadius + color: Theme.alpha(Theme.fg, 0.05) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.045) + + Text { + id: stateIcon + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: HomeAssistant.phase === "loading" ? "\u{F0772}" : "\u{F02DC}" + color: HomeAssistant.phase === "loading" ? Theme.accent : Theme.fgDim + font.family: Theme.fontMono + font.pixelSize: 16 + } + + Column { + anchors.left: stateIcon.right + anchors.leftMargin: 11 + anchors.right: openHome.left + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + + Text { + width: parent.width + text: root.emptyTitle() + color: Theme.fg + elide: Text.ElideRight + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.DemiBold + } + Text { + width: parent.width + text: root.emptyDetail() + color: Theme.fgDim + elide: Text.ElideRight + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + + Rectangle { + id: openHome + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + width: 48 + height: 28 + radius: 9 + visible: HomeAssistant.phase !== "loading" + color: openMouse.containsMouse + ? Theme.alpha(Theme.accent, 0.20) + : Theme.alpha(Theme.accent, 0.11) + + Text { + anchors.centerIn: parent + text: "Open" + color: Theme.accent + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + } + MouseArea { + id: openMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: HomeAssistant.open() + } + } + } + + Rectangle { + width: parent.width + height: visible ? 30 : 0 + visible: HomeAssistant.stale + radius: 9 + color: Theme.alpha(Theme.warn, 0.08) + + Text { + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: "Showing the last known state" + color: Theme.warn + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + Text { + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: "Retry" + color: Theme.accent + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + MouseArea { + anchors.fill: parent + anchors.margins: -6 + cursorShape: Qt.PointingHandCursor + onClicked: HomeAssistant.refresh() + } + } + } + + Section { + width: parent.width + expanded: root.expanded && HomeAssistant.entities.length > 0 + + ScrollColumn { + width: parent.width + maxHeight: 270 + spacing: 2 + + Repeater { + model: HomeAssistant.entities + + Rectangle { + id: entityRow + required property var modelData + width: parent.width + height: 44 + radius: 10 + color: entityMouse.containsMouse + ? Theme.alpha(Theme.fg, 0.09) + : "transparent" + + 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) + } + } + } + } + } + } + + function emptyTitle(): string { + if (HomeAssistant.phase === "loading") + return "Loading your home"; + if (HomeAssistant.lastError === "authentication-required") + return "Authentication required"; + if (HomeAssistant.lastError === "not-configured") + return "Home Assistant is not configured"; + return "Home Assistant is unavailable"; + } + + function emptyDetail(): string { + if (HomeAssistant.phase === "loading") + return "Reading configured favourites"; + 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"; + } +} diff --git a/config/dot/quickshell/modules/quicksettings/HomeTile.qml b/config/dot/quickshell/modules/quicksettings/HomeTile.qml new file mode 100644 index 0000000..e897e22 --- /dev/null +++ b/config/dot/quickshell/modules/quicksettings/HomeTile.qml @@ -0,0 +1,81 @@ +import QtQuick +import qs.config + +Rectangle { + id: root + + required property var entity + property bool busy: false + signal activated + + implicitHeight: 72 + radius: Theme.cardRadius + 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); + } + border.width: 1 + border.color: root.entity.active + ? Theme.alpha(Theme.warn, 0.20) + : Theme.alpha(Theme.fg, 0.045) + + Behavior on 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 + } + + Column { + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.bottom: parent.bottom + anchors.bottomMargin: 8 + spacing: 1 + + Text { + 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 + } + } + + MouseArea { + id: tileMouse + anchors.fill: parent + enabled: root.entity.available && !root.busy + hoverEnabled: true + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: root.activated() + } + + 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}"; + } +} diff --git a/config/dot/quickshell/modules/quicksettings/PhoneControls.qml b/config/dot/quickshell/modules/quicksettings/PhoneControls.qml new file mode 100644 index 0000000..94a974b --- /dev/null +++ b/config/dot/quickshell/modules/quicksettings/PhoneControls.qml @@ -0,0 +1,273 @@ +import QtQuick +import QtQuick.Dialogs +import qs.config +import qs.services + +Item { + id: root + + property bool expanded: false + signal toggleExpanded + + readonly property var phone: KdeConnect.preferredPhone + readonly property var actionModels: [ + { id: "share", glyph: "\u{F0142}", label: "Send file" }, + { id: "clipboard", glyph: "\u{F014C}", label: "Clipboard" }, + { id: "ring", glyph: "\u{F009A}", label: "Ring" } + ].filter(item => KdeConnect.supports(item.id)) + + implicitHeight: content.implicitHeight + + Column { + id: content + width: parent.width + spacing: 6 + + ControlSectionHeader { + width: parent.width + label: "Phone" + action: root.phone ? (root.expanded ? "Details ⌃" : "Details ›") : "Refresh" + onActionTriggered: { + if (root.phone) + root.toggleExpanded(); + else + KdeConnect.refresh(); + } + } + + Rectangle { + width: parent.width + height: phoneContent.implicitHeight + 22 + radius: Theme.cardRadius + 2 + color: Theme.alpha(Theme.fg, 0.055) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.05) + + Column { + id: phoneContent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 11 + spacing: 9 + + Item { + width: parent.width + height: 46 + + Rectangle { + id: phoneSilhouette + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 29 + height: 43 + radius: 8 + color: Theme.alpha(Theme.accent, 0.07) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.42) + + Rectangle { + anchors.top: parent.top + anchors.topMargin: 4 + anchors.horizontalCenter: parent.horizontalCenter + width: 10 + height: 2 + radius: 1 + color: Theme.alpha(Theme.fg, 0.35) + } + Rectangle { + anchors.right: parent.right + anchors.rightMargin: -3 + anchors.top: parent.top + anchors.topMargin: 10 + width: 6 + height: 6 + radius: 3 + visible: KdeConnect.phoneReachable + color: Theme.ok + } + } + + Column { + anchors.left: phoneSilhouette.right + anchors.leftMargin: 11 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + + Text { + width: parent.width + text: root.phone?.name ?? (KdeConnect.available ? "No paired phone" : "KDE Connect unavailable") + color: Theme.fg + elide: Text.ElideRight + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.DemiBold + } + Text { + width: parent.width + text: root.phone + ? (KdeConnect.phoneReachable ? "● Nearby on Wi-Fi" : "Not nearby") + : (KdeConnect.available ? "Pair a device to continue" : "Open settings to repair the service") + color: KdeConnect.phoneReachable ? Theme.ok : Theme.fgDim + elide: Text.ElideRight + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + } + + Grid { + id: actionsGrid + width: parent.width + columns: Math.max(1, root.actionModels.length) + spacing: 7 + visible: root.actionModels.length > 0 + + Repeater { + model: root.actionModels + + Rectangle { + id: actionButton + required property var modelData + width: (actionsGrid.width - actionsGrid.spacing * Math.max(0, root.actionModels.length - 1)) / Math.max(1, root.actionModels.length) + height: 48 + radius: 11 + color: actionMouse.containsMouse && actionMouse.enabled + ? Theme.alpha(Theme.accent, 0.15) + : Theme.alpha(Theme.accent, 0.075) + opacity: actionMouse.enabled ? 1 : 0.48 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: 7 + text: actionButton.modelData.glyph + color: Theme.accent + font.family: Theme.fontMono + font.pixelSize: 13 + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 6 + text: actionButton.modelData.label + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: 9 + font.weight: Font.Medium + } + MouseArea { + id: actionMouse + anchors.fill: parent + enabled: KdeConnect.phoneReachable && !KdeConnect.transferActive + hoverEnabled: true + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: root.invoke(actionButton.modelData.id) + } + } + } + } + + Text { + width: parent.width + visible: root.phone && !KdeConnect.phoneReachable && root.actionModels.length > 0 + text: "Actions become available when the iPhone reconnects" + color: Theme.fgMuted + horizontalAlignment: Text.AlignHCenter + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + + Text { + width: parent.width + visible: KdeConnect.lastError !== "" + text: root.errorText(KdeConnect.lastError) + color: Theme.warn + horizontalAlignment: Text.AlignHCenter + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + + Rectangle { + width: parent.width + height: 1 + visible: KdeConnect.recentExchange !== null + color: Theme.alpha(Theme.fg, 0.07) + } + + RecentExchange { + width: parent.width + height: visible ? implicitHeight : 0 + visible: KdeConnect.recentExchange !== null + exchange: KdeConnect.recentExchange ?? ({}) + } + } + } + + Section { + width: parent.width + expanded: root.expanded && root.phone !== null + + Column { + width: parent.width + spacing: 2 + + RowButton { + width: parent.width + icon: "network-wireless-symbolic" + label: "Connection" + sublabel: KdeConnect.phoneReachable ? "Nearby on Wi-Fi" : "Waiting for the phone" + selected: KdeConnect.phoneReachable + onClicked: KdeConnect.refresh() + } + RowButton { + width: parent.width + icon: "preferences-system-symbolic" + label: "Device settings" + sublabel: "Pairing, plugins and service health" + onClicked: { + ShellState.close(); + ShellState.openSettings("connectivity"); + } + } + } + } + } + + FileDialog { + id: fileDialog + title: root.phone ? "Send a file to " + root.phone.name : "Send a file" + fileMode: FileDialog.OpenFile + nameFilters: ["All files (*)"] + onAccepted: { + const path = root.localPath(selectedFile); + if (path !== "") + KdeConnect.sendFile(path); + } + } + + function invoke(action: string): void { + if (action === "share") + fileDialog.open(); + else if (action === "clipboard") + KdeConnect.sendClipboard(); + else if (action === "ring") + KdeConnect.ring(); + } + + function localPath(selectedUrl: url): string { + const value = String(selectedUrl); + if (!value.startsWith("file://")) + return ""; + return decodeURIComponent(value.slice(7)); + } + + function errorText(code: string): string { + if (code === "device-offline") + return "The iPhone is not nearby"; + if (code === "unsupported-action") + return "That action is not available on this iPhone"; + return "The phone action did not finish"; + } +} diff --git a/config/dot/quickshell/modules/quicksettings/QuickSettings.qml b/config/dot/quickshell/modules/quicksettings/QuickSettings.qml index 5a1652c..080e7a9 100644 --- a/config/dot/quickshell/modules/quicksettings/QuickSettings.qml +++ b/config/dot/quickshell/modules/quicksettings/QuickSettings.qml @@ -18,15 +18,21 @@ PanelWindow { visible: ShellState.quickSettingsOpen color: "transparent" - // Hangs off the top-right corner, clear of the floating bar. + // Hangs directly beneath the top-right status cluster. Two logical pixels + // preserve the Prism edge without making the surface feel detached. anchors.top: true anchors.right: true - margins.top: Theme.barHeight + Theme.barGap * 2 + margins.top: Theme.barHeight + Theme.controlCenterTopGap margins.right: Theme.barSideMargin exclusiveZone: 0 - implicitWidth: Theme.popoverWidth + implicitWidth: Theme.controlCenterWidth implicitHeight: panel.implicitHeight + readonly property string expandedSection: panel.expandedSection + + function expand(name: string): void { + panel.expand(name); + } // The `qs-popover` prefix is matched by a layerrule in hypr/rules.lua. WlrLayershell.namespace: "qs-popover-quicksettings" @@ -36,10 +42,13 @@ PanelWindow { WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand onVisibleChanged: { - if (root.visible) + if (root.visible) { + KdeConnect.refresh(); + HomeAssistant.refresh(); openAnim.restart(); - else + } else { panel.reset(); + } } // Closes the panel when a click lands anywhere else on the desktop. diff --git a/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml b/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml index 1bfb32a..7f6ec59 100644 --- a/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml +++ b/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml @@ -18,7 +18,7 @@ import qs.widgets Item { id: root - implicitWidth: Theme.popoverWidth + implicitWidth: Theme.controlCenterWidth implicitHeight: content.implicitHeight + Theme.popoverPadding * 2 // "" | "wifi" | "bluetooth" | "sink" | "source". Only one detail list is @@ -233,6 +233,19 @@ Item { width: content.width } + // ── Connected life ───────────────────────────────────────────────── + HomeControls { + width: content.width + expanded: root.expandedSection === "home" + onToggleExpanded: root.expand("home") + } + + PhoneControls { + width: content.width + expanded: root.expandedSection === "phone" + onToggleExpanded: root.expand("phone") + } + Rectangle { width: content.width height: 1 diff --git a/config/dot/quickshell/modules/quicksettings/RecentExchange.qml b/config/dot/quickshell/modules/quicksettings/RecentExchange.qml new file mode 100644 index 0000000..5298207 --- /dev/null +++ b/config/dot/quickshell/modules/quicksettings/RecentExchange.qml @@ -0,0 +1,64 @@ +import QtQuick +import qs.config + +Item { + id: root + + required property var exchange + + implicitHeight: 40 + + Rectangle { + width: 24 + height: 24 + radius: 8 + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + color: Theme.alpha(Theme.ok, 0.10) + + Text { + anchors.centerIn: parent + text: "\u{F012C}" + color: Theme.ok + font.family: Theme.fontMono + font.pixelSize: 12 + } + } + + Column { + anchors.left: parent.left + anchors.leftMargin: 34 + anchors.right: timeLabel.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + Text { + width: parent.width + text: root.exchange.fileName || "File" + color: Theme.fg + elide: Text.ElideMiddle + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + } + Text { + width: parent.width + text: "Sent to " + (root.exchange.deviceName || "phone") + color: Theme.fgMuted + elide: Text.ElideRight + font.family: Theme.fontFamily + font.pixelSize: 9 + } + } + + Text { + id: timeLabel + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: "Now" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: 9 + } +} diff --git a/config/dot/quickshell/shell.qml b/config/dot/quickshell/shell.qml index 3401581..09a8396 100644 --- a/config/dot/quickshell/shell.qml +++ b/config/dot/quickshell/shell.qml @@ -78,7 +78,7 @@ ShellRoot { // overview or quick settings is instant rather than paying a load. Overview {} // QuickSettings is the window; QuickSettingsPanel is its content. - QuickSettings {} + QuickSettings { id: quickSettings } DateMenu { id: dateMenu } ClipboardPanel {} CaptureOverlay {} @@ -223,8 +223,12 @@ ShellRoot { function toggle(): void { ShellState.toggle("quicksettings"); } function open(): void { ShellState.open("quicksettings"); } function close(): void { ShellState.close(); } + function section(name: string): void { quickSettings.expand(name); } function status(): string { - return JSON.stringify({ open: ShellState.quickSettingsOpen }); + return JSON.stringify({ + open: ShellState.quickSettingsOpen, + expandedSection: quickSettings.expandedSection + }); } } diff --git a/tests/quickshell/control-center-contract.sh b/tests/quickshell/control-center-contract.sh new file mode 100755 index 0000000..454f4bd --- /dev/null +++ b/tests/quickshell/control-center-contract.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { + printf 'Control Center contract: %s\n' "$1" >&2 + exit 1 +} + +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +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 +} +trap cleanup EXIT + +rg -Fq 'readonly property int controlCenterWidth: 430' \ + "$project_root/config/dot/quickshell/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" \ + || fail 'approved top attachment is missing' +rg -Fq 'margins.top: Theme.barHeight + Theme.controlCenterTopGap' \ + "$project_root/config/dot/quickshell/modules/quicksettings/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" \ + || fail 'Control Center window does not use its geometry token' +rg -Fq 'HomeControls' \ + "$project_root/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml" \ + || fail 'Home controls are not mounted' +rg -Fq 'PhoneControls' \ + "$project_root/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml" \ + || fail 'Phone controls are not mounted' +rg -Fq 'visible: KdeConnect.phoneReachable' \ + "$project_root/config/dot/quickshell/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" ]] \ + || 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 + +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 \ + || fail 'Control Center did not open in its resting state' + +qs ipc call quicksettings section home >/dev/null +jq -e '.open == true and .expandedSection == "home"' \ + <<<"$(qs ipc call quicksettings status)" >/dev/null \ + || fail 'Home section did not expand' + +qs ipc call quicksettings section phone >/dev/null +jq -e '.open == true and .expandedSection == "phone"' \ + <<<"$(qs ipc call quicksettings status)" >/dev/null \ + || fail 'Phone did not replace Home as the expanded section' + +qs ipc call quicksettings section phone >/dev/null +jq -e '.open == true and .expandedSection == ""' \ + <<<"$(qs ipc call quicksettings status)" >/dev/null \ + || fail 'expanded Phone section did not collapse' + +trap - EXIT +cleanup +printf 'Control Center contract: PASS\n'