Build the Panama Control Center
This commit is contained in:
@@ -8,3 +8,6 @@
|
|||||||
/config/dot/nvim/lazy-lock.json
|
/config/dot/nvim/lazy-lock.json
|
||||||
# Local visual-companion sessions and generated design mocks.
|
# Local visual-companion sessions and generated design mocks.
|
||||||
/.superpowers/
|
/.superpowers/
|
||||||
|
# Python helper bytecode is local runtime state.
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ Singleton {
|
|||||||
readonly property int popoverRadius: 18
|
readonly property int popoverRadius: 18
|
||||||
readonly property int popoverPadding: 14
|
readonly property int popoverPadding: 14
|
||||||
readonly property int popoverWidth: 380
|
readonly property int popoverWidth: 380
|
||||||
|
readonly property int controlCenterWidth: 430
|
||||||
|
readonly property int controlCenterTopGap: 2
|
||||||
|
|
||||||
readonly property int cardRadius: 12
|
readonly property int cardRadius: 12
|
||||||
readonly property int pillRadius: 999
|
readonly property int pillRadius: 999
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Quickshell.Networking
|
|||||||
import Quickshell.Services.Pipewire
|
import Quickshell.Services.Pipewire
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
|
import qs.services
|
||||||
import qs.widgets
|
import qs.widgets
|
||||||
|
|
||||||
Pill {
|
Pill {
|
||||||
@@ -106,4 +107,10 @@ Pill {
|
|||||||
glyph: "\u{F0176}" // md-coffee
|
glyph: "\u{F0176}" // md-coffee
|
||||||
color: Theme.warn
|
color: Theme.warn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StatusGlyph {
|
||||||
|
visible: KdeConnect.phoneReachable
|
||||||
|
glyph: "\u{F03F2}" // md-cellphone-link
|
||||||
|
color: Theme.cyan
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,15 +18,21 @@ PanelWindow {
|
|||||||
visible: ShellState.quickSettingsOpen
|
visible: ShellState.quickSettingsOpen
|
||||||
color: "transparent"
|
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.top: true
|
||||||
anchors.right: true
|
anchors.right: true
|
||||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
margins.top: Theme.barHeight + Theme.controlCenterTopGap
|
||||||
margins.right: Theme.barSideMargin
|
margins.right: Theme.barSideMargin
|
||||||
exclusiveZone: 0
|
exclusiveZone: 0
|
||||||
|
|
||||||
implicitWidth: Theme.popoverWidth
|
implicitWidth: Theme.controlCenterWidth
|
||||||
implicitHeight: panel.implicitHeight
|
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.
|
// The `qs-popover` prefix is matched by a layerrule in hypr/rules.lua.
|
||||||
WlrLayershell.namespace: "qs-popover-quicksettings"
|
WlrLayershell.namespace: "qs-popover-quicksettings"
|
||||||
@@ -36,10 +42,13 @@ PanelWindow {
|
|||||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
|
||||||
|
|
||||||
onVisibleChanged: {
|
onVisibleChanged: {
|
||||||
if (root.visible)
|
if (root.visible) {
|
||||||
|
KdeConnect.refresh();
|
||||||
|
HomeAssistant.refresh();
|
||||||
openAnim.restart();
|
openAnim.restart();
|
||||||
else
|
} else {
|
||||||
panel.reset();
|
panel.reset();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Closes the panel when a click lands anywhere else on the desktop.
|
// Closes the panel when a click lands anywhere else on the desktop.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import qs.widgets
|
|||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
implicitWidth: Theme.popoverWidth
|
implicitWidth: Theme.controlCenterWidth
|
||||||
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
|
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
|
||||||
|
|
||||||
// "" | "wifi" | "bluetooth" | "sink" | "source". Only one detail list is
|
// "" | "wifi" | "bluetooth" | "sink" | "source". Only one detail list is
|
||||||
@@ -233,6 +233,19 @@ Item {
|
|||||||
width: content.width
|
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 {
|
Rectangle {
|
||||||
width: content.width
|
width: content.width
|
||||||
height: 1
|
height: 1
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@ ShellRoot {
|
|||||||
// overview or quick settings is instant rather than paying a load.
|
// overview or quick settings is instant rather than paying a load.
|
||||||
Overview {}
|
Overview {}
|
||||||
// QuickSettings is the window; QuickSettingsPanel is its content.
|
// QuickSettings is the window; QuickSettingsPanel is its content.
|
||||||
QuickSettings {}
|
QuickSettings { id: quickSettings }
|
||||||
DateMenu { id: dateMenu }
|
DateMenu { id: dateMenu }
|
||||||
ClipboardPanel {}
|
ClipboardPanel {}
|
||||||
CaptureOverlay {}
|
CaptureOverlay {}
|
||||||
@@ -223,8 +223,12 @@ ShellRoot {
|
|||||||
function toggle(): void { ShellState.toggle("quicksettings"); }
|
function toggle(): void { ShellState.toggle("quicksettings"); }
|
||||||
function open(): void { ShellState.open("quicksettings"); }
|
function open(): void { ShellState.open("quicksettings"); }
|
||||||
function close(): void { ShellState.close(); }
|
function close(): void { ShellState.close(); }
|
||||||
|
function section(name: string): void { quickSettings.expand(name); }
|
||||||
function status(): string {
|
function status(): string {
|
||||||
return JSON.stringify({ open: ShellState.quickSettingsOpen });
|
return JSON.stringify({
|
||||||
|
open: ShellState.quickSettingsOpen,
|
||||||
|
expandedSection: quickSettings.expandedSection
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Executable
+73
@@ -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'
|
||||||
Reference in New Issue
Block a user