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,168 @@
pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
property alias initialized: values.initialized
property alias favorites: values.favorites
property string saveError: ""
FileView {
id: preferencesFile
path: Quickshell.stateDir + "/panama-home.json"
blockLoading: true
printErrors: false
atomicWrites: true
onSaved: root.saveError = ""
onSaveFailed: error => root.saveError = "Could not save Home favourites."
JsonAdapter {
id: values
property bool initialized: false
property var favorites: []
}
}
Connections {
target: values
function onInitializedChanged(): void { persistTimer.restart(); }
function onFavoritesChanged(): void { persistTimer.restart(); }
}
Component.onCompleted: preferencesFile.reload()
Timer {
id: persistTimer
interval: 180
repeat: false
onTriggered: preferencesFile.writeAdapter()
}
function cloneFavorites(): var {
var clone = [];
for (var index = 0; index < values.favorites.length; index++) {
var favorite = values.favorites[index];
clone.push({
id: favorite.id,
alias: favorite.alias
});
}
return clone;
}
function isValidEntityId(entityId: var): bool {
return typeof entityId === "string" && /^light\.[a-z0-9_]+$/.test(entityId);
}
function initialize(legacyIds: var): void {
if (values.initialized) {
return;
}
var seededFavorites = [];
var seen = {};
if (legacyIds && typeof legacyIds.length === "number") {
for (var index = 0; index < legacyIds.length; index++) {
var entityId = legacyIds[index];
if (!isValidEntityId(entityId) || seen[entityId]) {
continue;
}
seen[entityId] = true;
seededFavorites.push({ id: entityId, alias: "" });
}
}
values.favorites = seededFavorites;
values.initialized = true;
}
function isSelected(entityId: string): bool {
for (var index = 0; index < values.favorites.length; index++) {
if (values.favorites[index].id === entityId) {
return true;
}
}
return false;
}
function aliasFor(entityId: string, sourceName: string): string {
for (var index = 0; index < values.favorites.length; index++) {
var favorite = values.favorites[index];
if (favorite.id === entityId && favorite.alias !== "") {
return favorite.alias;
}
}
return sourceName;
}
function add(entityId: string): void {
if (!isValidEntityId(entityId) || isSelected(entityId)) {
return;
}
var nextFavorites = cloneFavorites();
nextFavorites.push({ id: entityId, alias: "" });
values.favorites = nextFavorites;
}
function remove(entityId: string): void {
var nextFavorites = [];
var removed = false;
for (var index = 0; index < values.favorites.length; index++) {
var favorite = values.favorites[index];
if (favorite.id === entityId) {
removed = true;
continue;
}
nextFavorites.push({ id: favorite.id, alias: favorite.alias });
}
if (removed) {
values.favorites = nextFavorites;
}
}
function setAlias(entityId: string, alias: string): void {
var nextFavorites = cloneFavorites();
var updated = false;
var trimmedAlias = String(alias).trim();
for (var index = 0; index < nextFavorites.length; index++) {
if (nextFavorites[index].id === entityId) {
nextFavorites[index] = { id: entityId, alias: trimmedAlias };
updated = true;
break;
}
}
if (updated) {
values.favorites = nextFavorites;
}
}
function move(entityId: string, targetIndex: int): void {
var nextFavorites = cloneFavorites();
var currentIndex = -1;
for (var index = 0; index < nextFavorites.length; index++) {
if (nextFavorites[index].id === entityId) {
currentIndex = index;
break;
}
}
if (currentIndex === -1) {
return;
}
var favorite = nextFavorites.splice(currentIndex, 1)[0];
var clampedIndex = Math.max(0, Math.min(targetIndex, nextFavorites.length));
nextFavorites.splice(clampedIndex, 0, favorite);
values.favorites = nextFavorites;
}
function retrySave(): void {
preferencesFile.writeAdapter();
}
}
+1
View File
@@ -1,5 +1,6 @@
module qs.config
singleton DesktopPreferences 1.0 DesktopPreferences.qml
singleton PreferenceSchema 1.0 PreferenceSchema.qml
singleton HomePreferences 1.0 HomePreferences.qml
singleton Settings 1.0 Settings.qml
singleton Theme 1.0 Theme.qml
@@ -0,0 +1,54 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.modules.quicksettings
ShellRoot {
id: root
property int confirmedValue: 30
property int commitCount: 0
property int lastCommit: -1
HomeBrightnessSlider {
id: slider
width: 200
value: root.confirmedValue
accessibleName: "Desk lamp brightness"
onCommitted: value => {
root.commitCount += 1;
root.lastCommit = value;
}
}
IpcHandler {
target: "home-brightness-slider-test"
function reset(value: int): void {
root.confirmedValue = value;
root.commitCount = 0;
root.lastCommit = -1;
slider.cancelPointerInteraction();
}
function external(value: int): void { root.confirmedValue = value; }
function press(position: int): void { slider.beginPointerInteraction(position); }
function move(position: int): void { slider.movePointerInteraction(position); }
function release(): void { slider.releasePointerInteraction(); }
function cancel(): void { slider.cancelPointerInteraction(); }
function wheel(delta: int): void { slider.commitWheel(delta); }
function status(): string {
return JSON.stringify({
confirmedValue: root.confirmedValue,
previewValue: slider.previewValue,
interactionActive: slider.interactionActive,
commitCount: root.commitCount,
lastCommit: root.lastCommit,
accessibleRoleIsSlider: slider.Accessible.role === Accessible.Slider,
accessibleName: slider.Accessible.name,
accessibleDescription: slider.Accessible.description,
accessibleFocusable: slider.Accessible.focusable
});
}
}
}
@@ -0,0 +1,25 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
ShellRoot {
IpcHandler {
target: "home-pref-test"
function initialize(idsJson: string): void { HomePreferences.initialize(JSON.parse(idsJson)); }
function add(id: string): void { HomePreferences.add(id); }
function alias(id: string, value: string): void { HomePreferences.setAlias(id, value); }
function move(id: string, index: int): void { HomePreferences.move(id, index); }
function remove(id: string): void { HomePreferences.remove(id); }
function status(): string {
return JSON.stringify({
initialized: HomePreferences.initialized,
favorites: HomePreferences.favorites,
saveError: HomePreferences.saveError,
stateDir: Quickshell.stateDir
});
}
}
}
@@ -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
@@ -0,0 +1,68 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.modules.quicksettings
import qs.services
// Component-only diagnostic surface. The contract replaces the two services in
// its copied configuration with fixture singletons before this file is run.
ShellRoot {
PhoneActions {
id: phoneActions
}
IpcHandler {
target: "phone-controls-test"
function fixture(name: string): void {
KdeConnect.available = false;
KdeConnect.transferActive = false;
KdeConnect.lastError = "";
if (name === "offline") {
KdeConnect.devices = [{
id: "fixture-phone",
name: "Fixture iPhone",
type: "phone",
paired: true,
reachable: false,
actions: []
}];
} else if (name === "unsupported") {
KdeConnect.available = true;
KdeConnect.devices = [{
id: "fixture-phone",
name: "Fixture iPhone",
type: "phone",
paired: true,
reachable: true,
actions: ["share"]
}];
} else if (name === "transfer") {
KdeConnect.available = true;
KdeConnect.transferActive = true;
KdeConnect.devices = [{
id: "fixture-phone",
name: "Fixture iPhone",
type: "phone",
paired: true,
reachable: true,
actions: ["share", "clipboard", "ring"]
}];
}
}
function status(): string {
return JSON.stringify({
actions: phoneActions.actionModels.map(action => ({
id: action.id,
enabled: phoneActions.actionEnabled(action.id)
})),
bluebubblesAvailable: SystemSettings.bluebubblesAvailable,
appLaunches: SystemSettings.launchCount,
phoneActions: KdeConnect.actionCount
});
}
}
}
@@ -18,7 +18,6 @@ import urllib.parse
import urllib.request
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import Any
ENV_KEYS = (
@@ -49,7 +48,7 @@ class Config:
@property
def configured(self) -> bool:
return bool(self.base_url and self.token and self.entity_ids)
return bool(self.base_url and self.token)
def compact_json(value: dict[str, object]) -> str:
@@ -226,7 +225,7 @@ def request_json(
config: Config,
method: str,
path: str,
payload: dict[str, str] | None = None,
payload: Mapping[str, object] | None = None,
) -> object:
if not config.base_url or not config.token:
raise BridgeError("not-configured")
@@ -266,71 +265,74 @@ def fallback_name(entity_id: str) -> str:
return entity_id.split(".", 1)[1].replace("_", " ").title()
def normalize_entities(
raw: list[dict[str, Any]],
configured: Sequence[str],
) -> list[dict[str, object]]:
by_id = {
str(item.get("entity_id", "")): item
for item in raw
if isinstance(item, dict)
}
def normalize_catalog(raw: Sequence[object]) -> list[dict[str, object]]:
result: list[dict[str, object]] = []
for entity_id in configured:
item = by_id.get(entity_id)
if not item:
for item in raw:
if not isinstance(item, dict):
continue
entity_id = item.get("entity_id")
attributes = item.get("attributes")
if not isinstance(attributes, dict):
if not isinstance(entity_id, str) or not entity_id.startswith("light."):
continue
if not ENTITY_ID.fullmatch(entity_id) or not isinstance(attributes, dict):
continue
state = str(item.get("state", "unavailable"))
available = state not in {"unknown", "unavailable"}
friendly_name = attributes.get("friendly_name")
name = (
friendly_name.strip()
if isinstance(friendly_name, str) and friendly_name.strip()
else fallback_name(entity_id)
active = available and state == "on"
raw_brightness = attributes.get("brightness")
brightness_pct = (
round(max(0, min(255, raw_brightness)) * 100 / 255)
if active
and isinstance(raw_brightness, (int, float))
and not isinstance(raw_brightness, bool)
else 0
)
modes = attributes.get("supported_color_modes", [])
dimmable = (
isinstance(modes, list) and any(mode != "onoff" for mode in modes)
) or isinstance(raw_brightness, (int, float))
source_name = attributes.get("friendly_name")
result.append(
{
"id": entity_id,
"name": name,
"domain": entity_id.split(".", 1)[0],
"sourceName": (
source_name.strip()
if isinstance(source_name, str) and source_name.strip()
else fallback_name(entity_id)
),
"state": state,
"available": available,
"active": available
and state not in {"off", "closed", "idle", "standby"},
"active": active,
"dimmable": dimmable,
"brightnessPct": brightness_pct,
}
)
return result
def ensure_configured(entity_id: str, configured: Sequence[str]) -> str:
if entity_id not in configured:
raise ValueError("entity-not-configured")
return entity_id
def collect_snapshot(config: Config) -> dict[str, object]:
def collect_catalog(config: Config) -> dict[str, object]:
legacy_entity_ids = list(config.entity_ids)
if not config.configured:
return {
"ok": False,
"configured": False,
"generatedAt": int(time.time()),
"entities": [],
"legacyEntityIds": legacy_entity_ids,
"error": "not-configured",
}
try:
raw = request_json(config, "GET", "/api/states")
if not isinstance(raw, list):
raise BridgeError("invalid-response")
entities = normalize_entities(raw, config.entity_ids)
entities = normalize_catalog(raw)
except BridgeError as error:
return {
"ok": False,
"configured": True,
"generatedAt": int(time.time()),
"entities": [],
"legacyEntityIds": legacy_entity_ids,
"error": str(error),
}
return {
@@ -338,10 +340,28 @@ def collect_snapshot(config: Config) -> dict[str, object]:
"configured": True,
"generatedAt": int(time.time()),
"entities": entities,
"legacyEntityIds": legacy_entity_ids,
"error": "",
}
def collect_snapshot(config: Config) -> dict[str, object]:
return collect_catalog(config)
def discovered_light_ids(config: Config) -> set[str]:
raw = request_json(config, "GET", "/api/states")
if not isinstance(raw, list):
raise BridgeError("invalid-response")
return {item["id"] for item in normalize_catalog(raw) if isinstance(item["id"], str)}
def ensure_discovered(config: Config, entity_id: str) -> str:
if entity_id not in discovered_light_ids(config):
raise ValueError("entity-not-discovered")
return entity_id
def probe(config: Config) -> dict[str, object]:
if not config.configured:
return {
@@ -368,7 +388,7 @@ def probe(config: Config) -> dict[str, object]:
def toggle(config: Config, entity_id: str) -> dict[str, object]:
entity_id = ensure_configured(entity_id, config.entity_ids)
entity_id = ensure_discovered(config, entity_id)
request_json(
config,
"POST",
@@ -378,6 +398,31 @@ def toggle(config: Config, entity_id: str) -> dict[str, object]:
return {"ok": True, "entityId": entity_id, "error": ""}
def set_brightness(
config: Config, entity_id: str, percent: int
) -> dict[str, object]:
if isinstance(percent, bool) or not isinstance(percent, int) or not 0 <= percent <= 100:
raise ValueError("invalid-brightness")
ensure_discovered(config, entity_id)
if percent == 0:
path = "/api/services/light/turn_off"
payload = {"entity_id": entity_id}
else:
path = "/api/services/light/turn_on"
payload = {"entity_id": entity_id, "brightness_pct": percent}
request_json(config, "POST", path, payload)
return {"ok": True, "entityId": entity_id, "brightnessPct": percent, "error": ""}
def parse_brightness(value: str) -> int:
if not re.fullmatch(r"(?:0|[1-9][0-9]{0,2})", value):
raise ValueError("invalid-brightness")
percent = int(value)
if percent > 100:
raise ValueError("invalid-brightness")
return percent
def open_home(config: Config) -> dict[str, object]:
if not config.base_url:
return {"ok": False, "error": "not-configured"}
@@ -409,8 +454,8 @@ def main(argv: list[str]) -> int:
if command == "probe" and len(argv) <= 1:
result = probe(config)
success = bool(result["reachable"])
elif command == "snapshot" and len(argv) == 1:
result = collect_snapshot(config)
elif command in {"catalog", "snapshot"} and len(argv) == 1:
result = collect_catalog(config)
success = bool(result["ok"])
elif command == "toggle" and len(argv) == 2:
try:
@@ -420,6 +465,14 @@ def main(argv: list[str]) -> int:
except BridgeError as error:
result = {"ok": False, "error": str(error)}
success = bool(result["ok"])
elif command == "brightness" and len(argv) == 3:
try:
result = set_brightness(config, argv[1], parse_brightness(argv[2]))
except ValueError as error:
result = {"ok": False, "error": str(error)}
except BridgeError as error:
result = {"ok": False, "error": str(error)}
success = bool(result["ok"])
elif command == "open" and len(argv) == 1:
result = open_home(config)
success = bool(result["ok"])
+476 -59
View File
@@ -1,11 +1,12 @@
pragma Singleton
// Home Assistant state for the Control Center. Credentials and REST details
// remain behind the helper; QML receives configured favourites only.
// remain behind the helper; QML composes its catalog with Panama preferences.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
@@ -14,26 +15,53 @@ Singleton {
// "loading" | "ready" | "degraded" | "unavailable"
property string phase: "loading"
property var entities: []
property var catalog: []
property var selectedEntities: []
property bool stale: false
property string busyEntityId: ""
property string lastError: ""
property bool fixtureMode: false
property var fixtureFavorites: []
property string fixtureProcessMode: ""
readonly property var visibleEntities: root.entities.slice(0, 4)
readonly property int configuredCount: root.entities.length
property var actionQueue: []
property var activeAction: null
property var busyEntityIds: []
property var pendingBrightness: ({})
property var entityErrors: ({})
property string actionResponseText: ""
property bool actionStreamFinished: false
property bool actionExited: false
property int actionExitCode: 0
property bool fixtureTransitionDraining: false
property bool fixtureTransitionStreamFinished: false
property bool fixtureTransitionExited: false
property var pendingFixtureTarget: null
property int delayedFixtureExitCode: 0
readonly property var visibleEntities: root.selectedEntities.slice(0, 4)
readonly property int discoveredCount: root.catalog.length
readonly property int configuredCount: root.selectedEntities.length
readonly property bool actionProcessRunning: actionProc.running
// Temporary aliases keep the current Home controls usable until the shelf
// switches to the selected-entity and per-entity action interfaces.
readonly property var entities: root.selectedEntities
readonly property string busyEntityId: root.busyEntityIds.length > 0
? root.busyEntityIds[0]
: ""
function refresh(): void {
if (root.fixtureMode || refreshProc.running)
return;
if (root.entities.length === 0)
if (root.catalog.length === 0)
root.phase = "loading";
refreshProc.running = true;
}
function consumeSnapshot(text: string): void {
function consumeCatalog(text: string): void {
if (root.fixtureMode)
return;
let result = null;
try {
result = JSON.parse(text);
@@ -42,7 +70,11 @@ Singleton {
}
if (result.ok === true) {
root.entities = Array.isArray(result.entities) ? result.entities : [];
root.catalog = Array.isArray(result.entities) ? result.entities : [];
HomePreferences.initialize(Array.isArray(result.legacyEntityIds)
? result.legacyEntityIds
: []);
root.rebuildSelection();
root.phase = "ready";
root.stale = false;
root.lastError = "";
@@ -50,7 +82,7 @@ Singleton {
}
root.lastError = String(result.error || "unreachable");
if (root.entities.length > 0) {
if (root.catalog.length > 0) {
root.phase = "degraded";
root.stale = true;
} else {
@@ -59,50 +91,261 @@ Singleton {
}
}
function toggleEntity(entityId: string): void {
if (entityId === "" || root.busyEntityId !== "")
return;
if (root.fixtureMode) {
root.entities = root.entities.map(entity => {
if (entity.id !== entityId)
return entity;
return Object.assign({}, entity, {
active: !entity.active,
state: entity.active ? "off" : "on"
function rebuildSelection(): void {
const favorites = root.fixtureMode
? root.fixtureFavorites
: HomePreferences.favorites;
const catalogById = {};
for (let index = 0; index < root.catalog.length; index++) {
const entity = root.catalog[index];
catalogById[entity.id] = entity;
}
const nextSelection = [];
for (let index = 0; index < favorites.length; index++) {
const favorite = favorites[index];
const entity = catalogById[favorite.id];
const alias = String(favorite.alias || "").trim();
if (entity) {
nextSelection.push({
id: entity.id,
sourceName: entity.sourceName,
name: alias || entity.sourceName,
state: entity.state,
available: entity.available,
active: entity.active,
dimmable: entity.dimmable,
brightnessPct: entity.brightnessPct
});
continue;
}
const sourceName = favorite.id.split(".")[1].split("_").join(" ");
nextSelection.push({
id: favorite.id,
sourceName,
name: alias || sourceName,
state: "unavailable",
available: false,
active: false,
dimmable: false,
brightnessPct: 0
});
}
root.selectedEntities = nextSelection;
}
Connections {
target: HomePreferences
function onFavoritesChanged(): void {
if (!root.fixtureMode)
root.rebuildSelection();
}
}
function isBusy(entityId: string): bool {
return root.busyEntityIds.indexOf(entityId) >= 0;
}
function pendingFor(entityId: string): int {
return Object.prototype.hasOwnProperty.call(root.pendingBrightness, entityId)
? root.pendingBrightness[entityId]
: -1;
}
function errorFor(entityId: string): string {
return String(root.entityErrors[entityId] || "");
}
function toggleEntity(entityId: string): void {
root.enqueueAction({ kind: "toggle", entityId });
}
function setBrightness(entityId: string, percent: int): void {
if (!Number.isInteger(percent) || percent < 0 || percent > 100)
return;
root.enqueueAction({ kind: "brightness", entityId, percent });
}
function enqueueAction(action: var): void {
if (root.fixtureTransitionDraining)
return;
if (!action || (action.kind !== "toggle" && action.kind !== "brightness"))
return;
if (typeof action.entityId !== "string" || action.entityId === "" || root.isBusy(action.entityId))
return;
const entity = root.catalog.find(candidate => candidate.id === action.entityId);
if (!entity || !entity.available || (action.kind === "brightness" && !entity.dimmable))
return;
const nextErrors = Object.assign({}, root.entityErrors);
delete nextErrors[action.entityId];
root.entityErrors = nextErrors;
root.busyEntityIds = root.busyEntityIds.concat([action.entityId]);
if (action.kind === "brightness") {
const nextPending = Object.assign({}, root.pendingBrightness);
nextPending[action.entityId] = action.percent;
root.pendingBrightness = nextPending;
}
if (root.fixtureMode && root.fixtureProcessMode === "") {
root.applyFixtureAction(action);
root.finishActionState(action.entityId);
return;
}
if (!root.entities.some(entity => entity.id === entityId))
root.actionQueue = root.actionQueue.concat([action]);
root.startNextAction();
}
function startNextAction(): void {
if (root.fixtureTransitionDraining
|| actionProc.running
|| root.activeAction !== null
|| root.actionQueue.length === 0) {
return;
root.busyEntityId = entityId;
actionProc.command = [root.helperPath, "toggle", entityId];
}
root.activeAction = root.actionQueue[0];
root.actionResponseText = "";
root.actionStreamFinished = false;
root.actionExited = false;
root.actionExitCode = 0;
if (root.fixtureProcessMode !== "") {
actionProc.command = root.fixtureActionCommand(root.activeAction);
} else {
actionProc.command = root.activeAction.kind === "brightness"
? [root.helperPath, "brightness", root.activeAction.entityId, String(root.activeAction.percent)]
: [root.helperPath, "toggle", root.activeAction.entityId];
}
actionProc.running = true;
}
function consumeAction(text: string): void {
if (root.busyEntityId === "")
function fixtureActionCommand(action: var): var {
if (root.fixtureProcessMode === "delayed-exit")
return ["/usr/bin/sh", "-c", "printf '%s' '{\"ok\":true}'; exit 7"];
if (root.fixtureProcessMode === "no-output"
&& action.entityId === "light.fixture_kitchen") {
return ["/usr/bin/sh", "-c", "sleep 0.5; exit 7"];
}
if (root.fixtureProcessMode === "slow-success")
return ["/usr/bin/sh", "-c", "sleep 2; printf '%s' '{\"ok\":true}'"];
return ["/usr/bin/sh", "-c", "sleep 0.5; printf '%s' '{\"ok\":true}'"];
}
function handleActionStreamFinished(text: string): void {
if (root.fixtureTransitionDraining) {
root.fixtureTransitionStreamFinished = true;
fixtureTransitionTimer.restart();
return;
}
if (root.activeAction === null || root.actionStreamFinished)
return;
root.actionResponseText = text;
root.actionStreamFinished = true;
actionCompletionTimer.restart();
}
function handleActionExited(exitCode: int): void {
// This fixture-only delay gives the contract a deterministic window
// where the process stopped but its exit bookkeeping is still pending.
if (root.fixtureMode
&& root.fixtureProcessMode === "delayed-exit"
&& root.activeAction !== null
&& !root.fixtureTransitionDraining) {
root.delayedFixtureExitCode = exitCode;
delayedFixtureExitTimer.restart();
return;
}
root.recordActionExited(exitCode);
}
function recordActionExited(exitCode: int): void {
if (root.fixtureTransitionDraining) {
root.fixtureTransitionExited = true;
fixtureTransitionTimer.restart();
return;
}
if (root.activeAction === null || root.actionExited)
return;
root.actionExited = true;
root.actionExitCode = exitCode;
actionCompletionTimer.restart();
}
function tryCompleteAction(): void {
if (root.activeAction === null)
return;
if (actionProc.running) {
actionCompletionTimer.restart();
return;
}
if (!root.actionExited || !root.actionStreamFinished)
return;
root.consumeAction(root.actionResponseText, root.actionExitCode);
}
function consumeAction(text: string, exitCode: int): void {
if (root.activeAction === null)
return;
const completedAction = root.activeAction;
let ok = false;
let errorCode = "action-failed";
let errorCode = text === "" ? "action-failed" : "invalid-response";
try {
const result = JSON.parse(text);
ok = result.ok === true;
errorCode = String(result.error || errorCode);
ok = exitCode === 0 && result.ok === true;
errorCode = String(result.error || "action-failed");
} catch (error) {
errorCode = "invalid-response";
// Empty output from a failed process is an action failure, while
// malformed non-empty output remains an invalid response.
}
root.busyEntityId = "";
actionCompletionTimer.stop();
root.actionQueue = root.actionQueue.slice(1);
root.activeAction = null;
root.finishActionState(completedAction.entityId);
const nextErrors = Object.assign({}, root.entityErrors);
if (ok) {
root.lastError = "";
refreshDelay.restart();
delete nextErrors[completedAction.entityId];
if (root.fixtureMode && root.fixtureProcessMode !== "")
root.applyFixtureAction(completedAction);
else
refreshDelay.restart();
} else {
root.lastError = errorCode;
if (root.entities.length > 0) {
root.phase = "degraded";
root.stale = true;
}
nextErrors[completedAction.entityId] = errorCode;
}
root.entityErrors = nextErrors;
queueAdvanceTimer.restart();
}
function finishActionState(entityId: string): void {
root.busyEntityIds = root.busyEntityIds.filter(id => id !== entityId);
const nextPending = Object.assign({}, root.pendingBrightness);
delete nextPending[entityId];
root.pendingBrightness = nextPending;
}
function applyFixtureAction(action: var): void {
root.catalog = root.catalog.map(entity => {
if (entity.id !== action.entityId)
return entity;
if (action.kind === "brightness") {
return Object.assign({}, entity, {
state: action.percent === 0 ? "off" : "on",
active: action.percent > 0,
brightnessPct: action.percent
});
}
return Object.assign({}, entity, {
active: !entity.active,
state: entity.active ? "off" : "on"
});
});
root.rebuildSelection();
}
function open(): void {
@@ -111,63 +354,237 @@ Singleton {
function fixtureEntities(): var {
return [
{ id: "light.fixture_all", name: "All lights", domain: "light", state: "on", available: true, active: true },
{ id: "light.fixture_kitchen", name: "Kitchen", domain: "light", state: "on", available: true, active: true },
{ id: "light.fixture_living", name: "Living room", domain: "light", state: "off", available: true, active: false },
{ id: "light.fixture_bedroom", name: "Bedroom", domain: "light", state: "on", available: true, active: true },
{ id: "light.fixture_hall", name: "Hall", domain: "light", state: "off", available: true, active: false },
{ id: "light.fixture_desk", name: "Desk", domain: "light", state: "off", available: true, active: false },
{ id: "light.fixture_corner", name: "Corner lamp", domain: "light", state: "unavailable", available: false, active: false }
{ id: "light.fixture_all", sourceName: "All lights", state: "on", available: true, active: true, dimmable: true, brightnessPct: 82 },
{ id: "light.fixture_kitchen", sourceName: "Kitchen", state: "on", available: true, active: true, dimmable: true, brightnessPct: 71 },
{ id: "light.fixture_living", sourceName: "Living room", state: "off", available: true, active: false, dimmable: true, brightnessPct: 36 },
{ id: "light.fixture_bedroom", sourceName: "Bedroom", state: "on", available: true, active: true, dimmable: true, brightnessPct: 48 },
{ id: "light.fixture_hall", sourceName: "Hall", state: "off", available: true, active: false, dimmable: false, brightnessPct: 0 },
{ id: "light.fixture_desk", sourceName: "Desk", state: "on", available: true, active: true, dimmable: true, brightnessPct: 24 },
{ id: "light.fixture_corner", sourceName: "Corner lamp", state: "unavailable", available: false, active: false, dimmable: false, brightnessPct: 0 }
];
}
function applyFixture(name: string): void {
if (["ready", "stale", "unavailable"].indexOf(name) < 0)
function fixturePreferenceRecords(): var {
return [
{ id: "light.fixture_all", alias: " Whole home " },
{ id: "light.fixture_kitchen", alias: "Kitchen island" },
{ id: "light.fixture_living", alias: "" },
{ id: "light.fixture_bedroom", alias: "" },
{ id: "light.fixture_hall", alias: "" },
{ id: "light.fixture_desk", alias: "Office" },
{ id: "light.fixture_corner", alias: "" }
];
}
function resetActionState(): void {
actionCompletionTimer.stop();
queueAdvanceTimer.stop();
root.actionQueue = [];
root.activeAction = null;
root.busyEntityIds = [];
root.pendingBrightness = {};
root.entityErrors = {};
root.actionResponseText = "";
root.actionStreamFinished = false;
root.actionExited = false;
root.actionExitCode = 0;
}
function beginFixtureTransition(): void {
if (root.fixtureTransitionDraining)
return;
const shouldDrain = root.fixtureMode
&& root.fixtureProcessMode !== ""
&& (actionProc.running || root.activeAction !== null);
const streamAlreadyFinished = root.actionStreamFinished;
const exitAlreadyObserved = root.actionExited;
fixtureTransitionTimer.stop();
root.fixtureTransitionDraining = shouldDrain;
root.fixtureTransitionStreamFinished = shouldDrain && streamAlreadyFinished;
root.fixtureTransitionExited = shouldDrain && exitAlreadyObserved;
root.resetActionState();
if (!shouldDrain)
return;
if (actionProc.running)
actionProc.running = false;
fixtureTransitionTimer.restart();
}
function tryFinishFixtureTransition(): void {
if (!root.fixtureTransitionDraining)
return;
if (actionProc.running) {
fixtureTransitionTimer.restart();
return;
}
if (!root.fixtureTransitionStreamFinished || !root.fixtureTransitionExited)
return;
const target = root.pendingFixtureTarget;
root.pendingFixtureTarget = null;
root.fixtureTransitionDraining = false;
root.fixtureTransitionStreamFinished = false;
root.fixtureTransitionExited = false;
if (target !== null)
root.installFixtureTarget(target);
queueAdvanceTimer.restart();
}
function applyFixture(name: string): void {
if (["ready", "available-extra", "stale", "stale-authentication", "stale-not-configured",
"unavailable", "missing-selected", "action-error",
"process-actions", "process-no-output", "process-delayed-exit",
"process-slow-actions"].indexOf(name) < 0)
return;
root.requestFixtureTarget({ fixture: true, name });
}
function requestFixtureTarget(target: var): void {
if (root.fixtureTransitionDraining) {
// Preserve the old process's callback guard and keep only the
// latest replacement requested during that drain.
root.pendingFixtureTarget = target;
root.resetActionState();
return;
}
root.pendingFixtureTarget = target;
root.beginFixtureTransition();
if (root.fixtureTransitionDraining)
return;
root.pendingFixtureTarget = null;
root.installFixtureTarget(target);
}
function installFixtureTarget(target: var): void {
if (target.fixture)
root.installFixture(target.name);
else
root.installLiveState();
}
function installFixture(name: string): void {
root.fixtureMode = true;
root.busyEntityId = "";
root.fixtureProcessMode = name === "process-actions"
? "success"
: (name === "process-no-output"
? "no-output"
: (name === "process-delayed-exit"
? "delayed-exit"
: (name === "process-slow-actions" ? "slow-success" : "")));
if (name === "unavailable") {
root.entities = [];
root.catalog = [];
root.fixtureFavorites = [];
root.rebuildSelection();
root.phase = "unavailable";
root.stale = false;
root.lastError = "not-configured";
return;
}
root.entities = root.fixtureEntities();
root.phase = name === "stale" ? "degraded" : "ready";
root.stale = name === "stale";
root.lastError = name === "stale" ? "unreachable" : "";
root.catalog = root.fixtureEntities();
root.fixtureFavorites = root.fixturePreferenceRecords();
if (name === "available-extra") {
root.catalog = root.catalog.concat([{
id: "light.fixture_guest",
sourceName: "Guest lamp",
state: "off",
available: true,
active: false,
dimmable: true,
brightnessPct: 0
}]);
}
if (name === "missing-selected") {
root.fixtureFavorites = root.fixtureFavorites.concat([
{ id: "light.fixture_missing", alias: "Porch" }
]);
}
root.rebuildSelection();
const isStale = ["stale", "stale-authentication", "stale-not-configured"].indexOf(name) >= 0;
root.phase = isStale ? "degraded" : "ready";
root.stale = isStale;
root.lastError = name === "stale-authentication"
? "authentication-required"
: (name === "stale-not-configured"
? "not-configured"
: (name === "stale" ? "unreachable" : ""));
if (name === "action-error") {
root.entityErrors = {
"light.fixture_kitchen": "request-failed"
};
}
}
function clearFixture(): void {
root.requestFixtureTarget({ fixture: false, name: "" });
}
function installLiveState(): void {
root.fixtureMode = false;
root.fixtureProcessMode = "";
root.phase = "loading";
root.entities = [];
root.catalog = [];
root.selectedEntities = [];
root.fixtureFavorites = [];
root.stale = false;
root.busyEntityId = "";
root.lastError = "";
root.refresh();
}
Process {
id: refreshProc
command: [root.helperPath, "snapshot"]
command: [root.helperPath, "catalog"]
stdout: StdioCollector {
onStreamFinished: root.consumeSnapshot(this.text)
onStreamFinished: root.consumeCatalog(this.text)
}
}
Process {
id: actionProc
stdout: StdioCollector {
onStreamFinished: root.consumeAction(this.text)
onStreamFinished: root.handleActionStreamFinished(this.text)
}
onExited: (code, status) => {
if (root.busyEntityId !== "")
root.consumeAction("");
onExited: (code, status) => root.handleActionExited(code)
}
Timer {
id: actionCompletionTimer
interval: 0
onTriggered: root.tryCompleteAction()
}
Timer {
id: queueAdvanceTimer
interval: 0
onTriggered: {
if (root.fixtureTransitionDraining)
return;
if (actionProc.running) {
queueAdvanceTimer.restart();
return;
}
root.startNextAction();
}
}
Timer {
id: fixtureTransitionTimer
interval: 0
onTriggered: root.tryFinishFixtureTransition()
}
Timer {
id: delayedFixtureExitTimer
interval: 1000
onTriggered: root.recordActionExited(root.delayedFixtureExitCode)
}
Timer {
interval: 60000
repeat: true
@@ -92,7 +92,7 @@ Singleton {
}
function openSettings(page: string): void {
const allowed = ["home", "appearance", "displays", "connectivity", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "services", "about"];
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "services", "about"];
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
DesktopPreferences.set("lastPage", root.settingsPage);
root.settingsOpen = true;
@@ -27,13 +27,16 @@ Singleton {
property bool hyprpaperActive: false
property bool hypridleActive: false
property bool vicinaeActive: false
property bool bluebubblesDetected: false
property string hyprlandVersion: ""
property string quickshellVersion: "0.3.0"
property string lastError: ""
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|| configWrite.running || configVerify.running
|| configWrite.running || configVerify.running || bluebubblesQuery.running
readonly property bool bluebubblesAvailable: root.bluebubblesDetected
readonly property bool autoHdr: DesktopPreferences.get("autoHdr")
readonly property int vrrPolicy: DesktopPreferences.get("vrrPolicy")
@@ -120,6 +123,12 @@ Singleton {
}
}
Process {
id: bluebubblesQuery
command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]
onExited: (exitCode, exitStatus) => root.bluebubblesDetected = exitCode === 0
}
// Reads the written options back out of the compositor. This is the only
// thing that decides whether a write succeeded.
Process {
@@ -148,6 +157,8 @@ Singleton {
serviceQuery.running = true;
if (!versionQuery.running && !root.hyprlandVersion)
versionQuery.running = true;
if (!bluebubblesQuery.running)
bluebubblesQuery.running = true;
}
function parseMonitors(text: string): void {
@@ -371,7 +382,8 @@ Singleton {
"nextcloud": ["nextcloud"],
"rustdesk": ["rustdesk"],
"kdeconnect": ["kdeconnect-app"],
"mission-center": ["flatpak", "run", "io.missioncenter.MissionCenter"]
"mission-center": ["flatpak", "run", "io.missioncenter.MissionCenter"],
"bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"]
};
const command = commands[id];
if (!command) {
+23 -3
View File
@@ -85,7 +85,7 @@ ShellRoot {
IntelligenceResult {}
ActivityPanel {}
PowerMenu {}
SettingsWindow {}
SettingsWindow { id: settingsWindow }
// Toasts are their own always-on layer; they must be able to appear
// without any overlay being open.
@@ -258,14 +258,27 @@ ShellRoot {
function fixture(name: string): void { HomeAssistant.applyFixture(name); }
function reset(): void { HomeAssistant.clearFixture(); }
function refresh(): void { HomeAssistant.refresh(); }
function brightness(id: string, percent: int): void { HomeAssistant.setBrightness(id, percent); }
function toggle(id: string): void { HomeAssistant.toggleEntity(id); }
function status(): string {
return JSON.stringify({
fixture: HomeAssistant.fixtureMode,
phase: HomeAssistant.phase,
discoveredCount: HomeAssistant.discoveredCount,
configuredCount: HomeAssistant.configuredCount,
visibleCount: HomeAssistant.visibleEntities.length,
selectedIds: HomeAssistant.selectedEntities.map(entity => entity.id),
entities: HomeAssistant.selectedEntities,
stale: HomeAssistant.stale,
busy: HomeAssistant.busyEntityId !== "",
busy: HomeAssistant.busyEntityIds.length > 0,
busyEntityIds: HomeAssistant.busyEntityIds,
pendingBrightness: HomeAssistant.pendingBrightness,
entityErrors: HomeAssistant.entityErrors,
actionProcessRunning: HomeAssistant.actionProcessRunning,
actionStreamFinished: HomeAssistant.actionStreamFinished,
fixtureTransitionDraining: HomeAssistant.fixtureTransitionDraining,
queuedActionCount: HomeAssistant.actionQueue.length,
actionActive: HomeAssistant.activeAction !== null,
lastError: HomeAssistant.lastError
});
}
@@ -278,7 +291,13 @@ ShellRoot {
function close(): void { ShellState.closeSettings(); }
function page(name: string): void { ShellState.openSettings(name); }
function status(): string {
return JSON.stringify({ open: ShellState.settingsOpen, page: ShellState.settingsPage });
return JSON.stringify({
open: ShellState.settingsOpen,
page: ShellState.settingsPage,
discoveredCount: HomeAssistant.discoveredCount,
selectedCount: HomeAssistant.configuredCount,
homePhone: settingsWindow.homePhoneDiagnostics
});
}
}
@@ -293,6 +312,7 @@ ShellRoot {
autoHdr: SystemSettings.autoHdr,
vrrPolicy: SystemSettings.vrrPolicy,
directScanoutPolicy: SystemSettings.directScanoutPolicy,
bluebubblesAvailable: SystemSettings.bluebubblesAvailable,
busy: SystemSettings.busy,
lastError: SystemSettings.lastError
});