Merge branch 'feat/home-accessories-customization'

# Conflicts:
#	config/dot/quickshell/config/qmldir
#	config/dot/quickshell/services/SystemSettings.qml
#	tests/quickshell/settings-pages-contract.sh
This commit is contained in:
Gabriel Brown
2026-08-17 23:31:33 -04:00
36 changed files with 3539 additions and 452 deletions
@@ -0,0 +1,169 @@
// A light dimmer that previews locally and sends one value when interaction
// ends. Home Assistant never sees the intermediate pointer positions.
import QtQuick
import qs.config
Item {
id: root
property int value: 0
property int previewValue: 0
property bool interactionActive: false
property string accessibleName: "Brightness"
readonly property bool pressed: root.interactionActive
signal previewChanged(int value)
signal committed(int value)
implicitWidth: 160
implicitHeight: 32
activeFocusOnTab: root.enabled
opacity: root.enabled ? 1 : 0.42
Accessible.role: Accessible.Slider
Accessible.name: root.accessibleName
// Qt 6.11's installed Accessible attached type has no structured value or
// range properties, so expose both in the supported live description.
Accessible.description: root.previewValue + " percent, range 0 to 100"
Accessible.focusable: root.enabled
Accessible.focused: root.activeFocus
Accessible.onIncreaseAction: root.commitStep(5)
Accessible.onDecreaseAction: root.commitStep(-5)
onValueChanged: {
if (!root.interactionActive)
root.updatePreview(root.value, false);
}
Component.onCompleted: root.updatePreview(root.value, false)
Keys.onLeftPressed: root.commitStep(-5)
Keys.onDownPressed: root.commitStep(-5)
Keys.onRightPressed: root.commitStep(5)
Keys.onUpPressed: root.commitStep(5)
Rectangle {
id: track
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
height: 10
radius: 5
color: Theme.alpha(Theme.fg, 0.105)
border.width: root.activeFocus ? 1 : 0
border.color: Theme.alpha(Theme.warn, 0.72)
Rectangle {
id: fill
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: track.width * root.previewValue / 100
radius: track.radius
color: Theme.warn
Behavior on width {
enabled: !root.interactionActive
NumberAnimation {
duration: Theme.durFast
easing.type: Easing.OutQuad
}
}
}
Rectangle {
id: knob
anchors.verticalCenter: parent.verticalCenter
x: Math.max(0, Math.min(track.width - width,
track.width * root.previewValue / 100 - width / 2))
width: 16
height: 16
radius: 8
color: Theme.fg
border.width: 1
border.color: Theme.alpha(Theme.bgDark, 0.38)
}
}
// The 32 px interaction surface is intentionally much taller than the
// 10 px track, while remaining inside this component's layout bounds.
MouseArea {
id: drag
anchors.fill: parent
enabled: root.enabled
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onPressed: event => {
root.forceActiveFocus();
root.beginPointerInteraction(event.x);
event.accepted = true;
}
onPositionChanged: event => root.movePointerInteraction(event.x)
onReleased: event => {
root.releasePointerInteraction();
event.accepted = true;
}
onCanceled: root.cancelPointerInteraction()
onWheel: event => {
root.commitWheel(event.angleDelta.y);
event.accepted = true;
}
}
function clamp(candidate: real): int {
return Math.max(0, Math.min(100, Math.round(candidate)));
}
function updatePreview(candidate: real, announce: bool): void {
const nextValue = root.clamp(candidate);
if (root.previewValue === nextValue)
return;
root.previewValue = nextValue;
if (announce)
root.previewChanged(nextValue);
}
function previewAt(pointerX: real): void {
root.updatePreview(pointerX / Math.max(1, drag.width) * 100, true);
}
function beginPointerInteraction(pointerX: real): void {
if (!root.enabled)
return;
root.interactionActive = true;
root.previewAt(pointerX);
}
function movePointerInteraction(pointerX: real): void {
if (root.interactionActive)
root.previewAt(pointerX);
}
function releasePointerInteraction(): void {
if (!root.interactionActive)
return;
root.interactionActive = false;
root.committed(root.previewValue);
}
function cancelPointerInteraction(): void {
root.interactionActive = false;
root.updatePreview(root.value, false);
}
function commitWheel(delta: int): void {
if (delta === 0)
return;
root.commitStep(delta > 0 ? 5 : -5);
}
function commitStep(delta: int): void {
if (!root.enabled)
return;
root.updatePreview(root.previewValue + delta, true);
root.committed(root.previewValue);
}
}
@@ -8,6 +8,9 @@ Item {
property bool expanded: false
signal toggleExpanded
readonly property bool hasSelection: HomeAssistant.selectedEntities.length > 0
readonly property bool showSetup: HomeAssistant.phase === "ready" && !root.hasSelection
implicitHeight: content.implicitHeight
Column {
@@ -18,46 +21,50 @@ Item {
ControlSectionHeader {
width: parent.width
label: "Home"
action: HomeAssistant.entities.length > 0
? HomeAssistant.entities.length + " accessories " + (root.expanded ? "⌃" : "")
: "Retry"
action: root.headerAction()
actionEnabled: HomeAssistant.phase !== "loading"
onActionTriggered: {
if (HomeAssistant.entities.length > 0)
if (root.hasSelection) {
root.toggleExpanded();
else
} else if (root.showSetup) {
root.openHomeSettings();
} else {
HomeAssistant.refresh();
}
}
}
Rectangle {
id: homeCard
id: restingCard
width: parent.width
height: visible ? homeGrid.implicitHeight + 20 : 0
visible: HomeAssistant.entities.length > 0
height: visible ? restingGrid.implicitHeight + 20 : 0
visible: root.hasSelection && !root.expanded
radius: Theme.cardRadius + 2
color: Theme.alpha(Theme.accent, 0.045)
color: Theme.alpha(Theme.warn, 0.028)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.12)
border.color: Theme.alpha(Theme.warn, 0.095)
Grid {
id: homeGrid
id: restingGrid
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 10
columns: 4
spacing: 7
columns: 2
spacing: 8
Repeater {
model: HomeAssistant.visibleEntities
HomeTile {
required property var modelData
width: (homeGrid.width - homeGrid.spacing * 3) / 4
width: (restingGrid.width - restingGrid.spacing) / 2
entity: modelData
busy: HomeAssistant.busyEntityId === modelData.id
onActivated: HomeAssistant.toggleEntity(modelData.id)
busy: HomeAssistant.isBusy(modelData.id)
pendingBrightness: HomeAssistant.pendingFor(modelData.id)
actionError: HomeAssistant.errorFor(modelData.id)
onPowerRequested: HomeAssistant.toggleEntity(modelData.id)
onBrightnessRequested: value => HomeAssistant.setBrightness(modelData.id, value)
}
}
}
@@ -65,28 +72,32 @@ Item {
Rectangle {
width: parent.width
height: visible ? 66 : 0
visible: HomeAssistant.entities.length === 0
height: visible ? 72 : 0
visible: !root.hasSelection
radius: Theme.cardRadius
color: Theme.alpha(Theme.fg, 0.05)
color: root.showSetup
? Theme.alpha(Theme.accent, 0.055)
: Theme.alpha(Theme.fg, 0.045)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.045)
border.color: root.showSetup
? Theme.alpha(Theme.accent, 0.12)
: Theme.alpha(Theme.fg, 0.055)
Text {
id: stateIcon
anchors.left: parent.left
anchors.leftMargin: 12
anchors.leftMargin: 13
anchors.verticalCenter: parent.verticalCenter
text: HomeAssistant.phase === "loading" ? "\u{F0772}" : "\u{F02DC}"
color: HomeAssistant.phase === "loading" ? Theme.accent : Theme.fgDim
text: root.showSetup ? "\u{F0335}" : "\u{F02DC}"
color: root.showSetup ? Theme.accent : Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 16
font.pixelSize: 17
}
Column {
anchors.left: stateIcon.right
anchors.leftMargin: 11
anchors.right: openHome.left
anchors.right: emptyAction.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
spacing: 3
@@ -111,61 +122,66 @@ Item {
}
Rectangle {
id: openHome
id: emptyAction
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
width: 48
height: 28
radius: 9
width: root.showSetup ? 68 : 52
height: 30
radius: 10
visible: HomeAssistant.phase !== "loading"
color: openMouse.containsMouse
color: emptyActionMouse.containsMouse
? Theme.alpha(Theme.accent, 0.20)
: Theme.alpha(Theme.accent, 0.11)
: Theme.alpha(Theme.accent, 0.105)
Text {
anchors.centerIn: parent
text: "Open"
text: root.showSetup ? "Manage" : "Open"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
font.weight: Font.DemiBold
}
MouseArea {
id: openMouse
id: emptyActionMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: HomeAssistant.open()
onClicked: {
if (root.showSetup)
root.openHomeSettings();
else
HomeAssistant.open();
}
}
}
}
Rectangle {
width: parent.width
height: visible ? 30 : 0
height: visible ? 34 : 0
visible: HomeAssistant.stale
radius: 9
color: Theme.alpha(Theme.warn, 0.08)
radius: 10
color: Theme.alpha(Theme.warn, 0.075)
Text {
anchors.left: parent.left
anchors.leftMargin: 10
anchors.leftMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: "Showing the last known state"
text: "Last known state · " + root.countSummary()
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
anchors.right: parent.right
anchors.rightMargin: 10
anchors.rightMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: "Retry"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
font.weight: Font.DemiBold
MouseArea {
anchors.fill: parent
anchors.margins: -6
@@ -177,88 +193,66 @@ Item {
Section {
width: parent.width
expanded: root.expanded && HomeAssistant.entities.length > 0
expanded: root.expanded && root.hasSelection
ScrollColumn {
width: parent.width
maxHeight: 270
spacing: 2
maxHeight: 324
spacing: 8
Repeater {
model: HomeAssistant.entities
Grid {
id: expandedGrid
width: parent.width
columns: 2
spacing: 8
Rectangle {
id: entityRow
required property var modelData
width: parent.width
height: 44
radius: 10
color: entityMouse.containsMouse
? Theme.alpha(Theme.fg, 0.09)
: "transparent"
Repeater {
model: HomeAssistant.selectedEntities
Text {
id: entityGlyph
anchors.left: parent.left
anchors.leftMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: "\u{F0335}"
color: entityRow.modelData.active ? Theme.warn : Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 14
}
Column {
anchors.left: entityGlyph.right
anchors.leftMargin: 10
anchors.right: entityState.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: entityRow.modelData.name
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
width: parent.width
text: !entityRow.modelData.available ? "Unavailable" : entityRow.modelData.state
color: Theme.fgMuted
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Text {
id: entityState
anchors.right: parent.right
anchors.rightMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: HomeAssistant.busyEntityId === entityRow.modelData.id
? "Working"
: (entityRow.modelData.active ? "On" : "Off")
color: entityRow.modelData.active ? Theme.warn : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
MouseArea {
id: entityMouse
anchors.fill: parent
enabled: entityRow.modelData.available && HomeAssistant.busyEntityId === ""
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: HomeAssistant.toggleEntity(entityRow.modelData.id)
HomeTile {
required property var modelData
width: (expandedGrid.width - expandedGrid.spacing) / 2
entity: modelData
busy: HomeAssistant.isBusy(modelData.id)
pendingBrightness: HomeAssistant.pendingFor(modelData.id)
actionError: HomeAssistant.errorFor(modelData.id)
onPowerRequested: HomeAssistant.toggleEntity(modelData.id)
onBrightnessRequested: value => HomeAssistant.setBrightness(modelData.id, value)
}
}
}
RowButton {
width: parent.width
icon: "preferences-system-symbolic"
label: "Manage in Settings"
sublabel: root.countSummary()
onClicked: root.openHomeSettings()
}
}
}
}
function headerAction(): string {
if (root.hasSelection) {
const noun = HomeAssistant.configuredCount === 1 ? "accessory" : "accessories";
return HomeAssistant.configuredCount + " " + noun + (root.expanded ? " ⌃" : " ");
}
if (HomeAssistant.phase === "loading")
return "Loading";
if (root.showSetup)
return "Manage";
return "Retry";
}
function countSummary(): string {
return HomeAssistant.configuredCount + " selected · "
+ HomeAssistant.discoveredCount + " discovered";
}
function emptyTitle(): string {
if (root.showSetup)
return "Choose your accessories";
if (HomeAssistant.phase === "loading")
return "Loading your home";
if (HomeAssistant.lastError === "authentication-required")
@@ -269,12 +263,23 @@ Item {
}
function emptyDetail(): string {
if (root.showSetup) {
if (HomeAssistant.discoveredCount === 0)
return "No lights discovered yet";
const noun = HomeAssistant.discoveredCount === 1 ? "light" : "lights";
return HomeAssistant.discoveredCount + " " + noun + " ready to add";
}
if (HomeAssistant.phase === "loading")
return "Reading configured favourites";
return "Finding your selected lights";
if (HomeAssistant.lastError === "authentication-required")
return "Update the long-lived access token";
if (HomeAssistant.lastError === "not-configured")
return "Add a URL, token and favourites";
return "Check the connection and retry";
return "Connect Home Assistant in Settings";
return "Check the connection, then retry";
}
function openHomeSettings(): void {
ShellState.close();
ShellState.openSettings("home-phone");
}
}
@@ -6,76 +6,165 @@ Rectangle {
required property var entity
property bool busy: false
signal activated
property int pendingBrightness: -1
property string actionError: ""
implicitHeight: 72
radius: Theme.cardRadius
signal powerRequested
signal brightnessRequested(int value)
readonly property int confirmedBrightness: root.clamp(root.entity.brightnessPct ?? 0)
readonly property int displayedBrightness: brightnessSlider.previewValue
readonly property bool powerEnabled: root.entity.available && !root.busy
readonly property bool dimmerEnabled: root.entity.available
&& root.entity.dimmable
&& !root.busy
implicitHeight: 124
radius: 14
color: {
if (root.entity.active)
return Theme.alpha(Theme.warn, tileMouse.containsMouse ? 0.20 : 0.14);
return Theme.alpha(Theme.fg, tileMouse.containsMouse ? 0.10 : 0.055);
return Theme.alpha(Theme.warn, powerArea.containsMouse ? 0.145 : 0.095);
return Theme.alpha(Theme.fg, powerArea.containsMouse ? 0.078 : 0.045);
}
border.width: 1
border.color: root.entity.active
? Theme.alpha(Theme.warn, 0.20)
: Theme.alpha(Theme.fg, 0.045)
border.color: {
if (root.activeFocus)
return Theme.alpha(Theme.accent, 0.72);
if (root.entity.active)
return Theme.alpha(Theme.warn, 0.17);
return Theme.alpha(Theme.fg, 0.065);
}
activeFocusOnTab: root.powerEnabled
Behavior on color { ColorAnimation { duration: Theme.durFast } }
Behavior on border.color { ColorAnimation { duration: Theme.durFast } }
Text {
anchors.left: parent.left
anchors.leftMargin: 10
anchors.top: parent.top
anchors.topMargin: 9
text: root.busy ? "\u{F0772}" : root.glyphFor(root.entity.domain)
color: root.entity.active ? Theme.warn : Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 15
Keys.onSpacePressed: {
if (root.powerEnabled)
root.powerRequested();
}
Keys.onReturnPressed: {
if (root.powerEnabled)
root.powerRequested();
}
Column {
Rectangle {
id: bulb
anchors.left: parent.left
anchors.leftMargin: 10
anchors.right: parent.right
anchors.rightMargin: 8
anchors.bottom: parent.bottom
anchors.bottomMargin: 8
spacing: 1
anchors.leftMargin: 13
anchors.top: parent.top
anchors.topMargin: 13
width: 30
height: 30
radius: 10
color: root.entity.active
? Theme.alpha(Theme.warn, 0.16)
: Theme.alpha(Theme.fg, 0.065)
Text {
width: parent.width
text: root.entity.name
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: !root.entity.available ? "Unavailable" : (root.entity.active ? "On" : "Off")
color: root.entity.active ? Theme.warn : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 9
anchors.centerIn: parent
text: root.busy ? "\u{F0772}" : "\u{F0335}"
color: root.entity.active ? Theme.warn : Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 15
}
}
Text {
anchors.right: parent.right
anchors.rightMargin: 13
anchors.verticalCenter: bulb.verticalCenter
text: root.entity.dimmable ? root.displayedBrightness + "%" : "—"
color: root.entity.active || root.pendingBrightness >= 0
? Theme.warn
: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.features: Theme.tabularFigures
}
Text {
id: aliasLabel
anchors.left: parent.left
anchors.leftMargin: 13
anchors.right: parent.right
anchors.rightMargin: 13
anchors.top: bulb.bottom
anchors.topMargin: 8
text: root.entity.name
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
anchors.left: aliasLabel.left
anchors.right: aliasLabel.right
anchors.top: aliasLabel.bottom
anchors.topMargin: 2
text: root.secondaryText()
color: root.actionError !== ""
? Theme.warn
: (root.entity.active ? Theme.warn : Theme.fgMuted)
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
MouseArea {
id: tileMouse
anchors.fill: parent
enabled: root.entity.available && !root.busy
id: powerArea
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: brightnessSlider.top
anchors.bottomMargin: 1
enabled: root.powerEnabled
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.activated()
onPressed: root.forceActiveFocus()
onClicked: root.powerRequested()
}
function glyphFor(domain: string): string {
if (domain === "light")
return "\u{F0335}";
if (domain === "switch")
return "\u{F0521}";
if (domain === "scene")
return "\u{F0FCE}";
return "\u{F02DC}";
HomeBrightnessSlider {
id: brightnessSlider
anchors.left: parent.left
anchors.leftMargin: 13
anchors.right: parent.right
anchors.rightMargin: 13
anchors.bottom: parent.bottom
anchors.bottomMargin: 5
value: root.pendingBrightness >= 0
? root.pendingBrightness
: root.confirmedBrightness
enabled: root.dimmerEnabled
accessibleName: root.entity.name + " brightness"
onCommitted: value => root.brightnessRequested(value)
}
function clamp(candidate: real): int {
return Math.max(0, Math.min(100, Math.round(candidate)));
}
function secondaryText(): string {
if (root.actionError !== "")
return root.errorText(root.actionError);
if (!root.entity.available)
return "Unavailable";
if (root.busy && root.pendingBrightness >= 0)
return "Setting " + root.pendingBrightness + "%";
if (root.busy)
return "Updating";
return root.entity.active ? "On" : "Off";
}
function errorText(code: string): string {
if (code === "authentication-required")
return "Authentication required";
if (code === "entity-not-discovered")
return "Light is unavailable";
return "Couldnt update light";
}
}
@@ -0,0 +1,24 @@
import QtQuick
import qs.services
// Nonvisual owner of Phone Controls' stable action model and enablement rules.
// Keeping this logic free of delegates and icons makes it safe to exercise in
// disposable diagnostic engines.
QtObject {
id: root
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" },
{ id: "messages", glyph: "\u{F0365}", label: "Messages" }
]
function actionEnabled(action: string): bool {
if (action === "messages")
return SystemSettings.bluebubblesAvailable;
return KdeConnect.phoneReachable
&& !KdeConnect.transferActive
&& KdeConnect.supports(action);
}
}
@@ -10,11 +10,11 @@ Item {
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))
readonly property var actionModels: phoneActions.actionModels
PhoneActions {
id: phoneActions
}
implicitHeight: content.implicitHeight
@@ -120,9 +120,8 @@ Item {
Grid {
id: actionsGrid
width: parent.width
columns: Math.max(1, root.actionModels.length)
columns: 4
spacing: 7
visible: root.actionModels.length > 0
Repeater {
model: root.actionModels
@@ -136,8 +135,20 @@ Item {
color: actionMouse.containsMouse && actionMouse.enabled
? Theme.alpha(Theme.accent, 0.15)
: Theme.alpha(Theme.accent, 0.075)
border.width: actionMouse.activeFocus ? 2 : 0
border.color: Theme.accent
opacity: actionMouse.enabled ? 1 : 0.48
Accessible.role: Accessible.Button
Accessible.name: actionButton.modelData.label
Accessible.description: root.actionAccessibleDescription(actionButton.modelData.id)
Accessible.focusable: actionMouse.enabled
Accessible.focused: actionMouse.activeFocus
Accessible.onPressAction: {
if (actionMouse.enabled)
root.invoke(actionButton.modelData.id);
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
@@ -160,10 +171,13 @@ Item {
MouseArea {
id: actionMouse
anchors.fill: parent
enabled: KdeConnect.phoneReachable && !KdeConnect.transferActive
enabled: root.actionEnabled(actionButton.modelData.id)
hoverEnabled: true
activeFocusOnTab: enabled
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.invoke(actionButton.modelData.id)
Keys.onReturnPressed: root.invoke(actionButton.modelData.id)
Keys.onSpacePressed: root.invoke(actionButton.modelData.id)
}
}
}
@@ -172,7 +186,17 @@ Item {
Text {
width: parent.width
visible: root.phone && !KdeConnect.phoneReachable && root.actionModels.length > 0
text: "Actions become available when the iPhone reconnects"
text: "KDE Connect actions are unavailable until the iPhone reconnects"
color: Theme.fgMuted
horizontalAlignment: Text.AlignHCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
width: parent.width
visible: !SystemSettings.bluebubblesAvailable
text: "BlueBubbles is not installed"
color: Theme.fgMuted
horizontalAlignment: Text.AlignHCenter
font.family: Theme.fontFamily
@@ -254,6 +278,20 @@ Item {
KdeConnect.sendClipboard();
else if (action === "ring")
KdeConnect.ring();
else if (action === "messages") {
if (SystemSettings.bluebubblesAvailable && SystemSettings.openApplication("bluebubbles"))
ShellState.close();
}
}
function actionEnabled(action: string): bool {
return phoneActions.actionEnabled(action);
}
function actionAccessibleDescription(action: string): string {
if (action === "messages")
return SystemSettings.bluebubblesAvailable ? "Opens BlueBubbles" : "BlueBubbles is not installed";
return root.actionEnabled(action) ? "Available through KDE Connect" : "Unavailable through KDE Connect";
}
function localPath(selectedUrl: url): string {
@@ -0,0 +1,19 @@
module qs.modules.quicksettings
AudioDeviceList 1.0 AudioDeviceList.qml
AudioSlider 1.0 AudioSlider.qml
BluetoothList 1.0 BluetoothList.qml
BrightnessControl 1.0 BrightnessControl.qml
ControlSectionHeader 1.0 ControlSectionHeader.qml
HomeBrightnessSlider 1.0 HomeBrightnessSlider.qml
HomeControls 1.0 HomeControls.qml
HomeTile 1.0 HomeTile.qml
IconButton 1.0 IconButton.qml
PhoneActions 1.0 PhoneActions.qml
PhoneControls 1.0 PhoneControls.qml
QuickSettings 1.0 QuickSettings.qml
QuickSettingsPanel 1.0 QuickSettingsPanel.qml
RecentExchange 1.0 RecentExchange.qml
RowButton 1.0 RowButton.qml
ScrollColumn 1.0 ScrollColumn.qml
Section 1.0 Section.qml
WifiList 1.0 WifiList.qml
@@ -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,316 @@
import QtQuick
import qs.config
import qs.services
Item {
id: root
objectName: "home-phone-page"
property string lightQuery: searchInput.text.trim().toLowerCase()
readonly property var availableLights: HomeAssistant.catalog.filter(entity => {
if (HomeAssistant.selectedEntities.some(selected => selected.id === entity.id))
return false;
const haystack = (entity.sourceName + " " + entity.id).toLowerCase();
return root.lightQuery === "" || haystack.includes(root.lightQuery);
})
readonly property string availableEmptyText: root.availableLights.length > 0
? ""
: (root.lightQuery !== ""
? "No lights match that search"
: (HomeAssistant.catalog.length === 0
? "No lights discovered"
: "All discovered lights are already selected"))
readonly property var pageDiagnostics: ({
availableLightIds: root.availableLights.map(entity => entity.id),
availableEmptyText: root.availableEmptyText,
homeStatus: root.homeStatus()
})
function homeStatus(): string {
if (HomeAssistant.lastError === "authentication-required")
return "Authentication required";
if (HomeAssistant.lastError === "not-configured")
return "Home Assistant is not configured";
if (HomeAssistant.phase === "ready")
return `Connected · ${HomeAssistant.discoveredCount} lights discovered`;
if (HomeAssistant.phase === "degraded")
return "Last update unavailable · showing saved controls";
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.availableEmptyText
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")
}
}
}
}
}
}
}
@@ -6,6 +6,11 @@ Rectangle {
id: root
property var hostWindow: null
readonly property var homePhoneDiagnostics: pageLoader.status === Loader.Ready
&& pageLoader.item
&& pageLoader.item.objectName === "home-phone-page"
? pageLoader.item.pageDiagnostics
: ({})
color: Theme.bg
radius: 18
@@ -88,6 +93,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 +139,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}" },
@@ -6,6 +6,8 @@ import qs.services
FloatingWindow {
id: root
readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics
title: "Panama Settings"
visible: ShellState.settingsOpen
implicitWidth: 1120
@@ -23,6 +25,7 @@ FloatingWindow {
}
SettingsShell {
id: settingsShell
anchors.fill: parent
hostWindow: root
}
@@ -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