summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>2026-01-03 17:53:06 +1100
committerGitHub <noreply@github.com>2026-01-03 17:53:06 +1100
commitbdcd13222fc6edc77c779a396900ab909e7d5439 (patch)
treef9457f3c91c05ec852f974f239d06aca52a3918e
parent[CI] chore: update flake (diff)
parentMerge branch 'caelestia-dots:main' into main (diff)
downloadcaelestia-shell-bdcd13222fc6edc77c779a396900ab909e7d5439.tar.gz
caelestia-shell-bdcd13222fc6edc77c779a396900ab909e7d5439.tar.bz2
caelestia-shell-bdcd13222fc6edc77c779a396900ab909e7d5439.zip
Merge pull request #906 from atdma/main
controlcenter: many setting panes and minor features
-rw-r--r--components/ConnectionHeader.qml32
-rw-r--r--components/ConnectionInfoSection.qml60
-rw-r--r--components/PropertyRow.qml27
-rw-r--r--components/SectionContainer.qml35
-rw-r--r--components/SectionHeader.qml28
-rw-r--r--components/StateLayer.qml3
-rw-r--r--components/controls/CollapsibleSection.qml135
-rw-r--r--components/controls/CustomSpinBox.qml74
-rw-r--r--components/controls/FilledSlider.qml2
-rw-r--r--components/controls/IconTextButton.qml9
-rw-r--r--components/controls/SpinBoxRow.qml53
-rw-r--r--components/controls/StyledInputField.qml80
-rw-r--r--components/controls/StyledScrollBar.qml96
-rw-r--r--components/controls/StyledSlider.qml2
-rw-r--r--components/controls/StyledTextField.qml2
-rw-r--r--components/controls/SwitchRow.qml49
-rw-r--r--components/controls/ToggleButton.qml127
-rw-r--r--components/controls/ToggleRow.qml29
-rw-r--r--components/controls/Tooltip.qml172
-rw-r--r--config/Config.qml407
-rw-r--r--modules/bar/components/OsIcon.qml26
-rw-r--r--modules/bar/components/Settings.qml44
-rw-r--r--modules/bar/components/SettingsIcon.qml44
-rw-r--r--modules/bar/components/StatusIcons.qml14
-rw-r--r--modules/bar/components/Tray.qml2
-rw-r--r--modules/bar/popouts/Audio.qml44
-rw-r--r--modules/bar/popouts/Bluetooth.qml45
-rw-r--r--modules/bar/popouts/Content.qml59
-rw-r--r--modules/bar/popouts/Network.qml211
-rw-r--r--modules/bar/popouts/WirelessPassword.qml606
-rw-r--r--modules/bar/popouts/Wrapper.qml28
-rw-r--r--modules/controlcenter/ControlCenter.qml10
-rw-r--r--modules/controlcenter/NavRail.qml72
-rw-r--r--modules/controlcenter/PaneRegistry.qml87
-rw-r--r--modules/controlcenter/Panes.qml156
-rw-r--r--modules/controlcenter/Session.qml20
-rw-r--r--modules/controlcenter/WindowFactory.qml8
-rw-r--r--modules/controlcenter/appearance/AppearancePane.qml244
-rw-r--r--modules/controlcenter/appearance/sections/AnimationsSection.qml42
-rw-r--r--modules/controlcenter/appearance/sections/BackgroundSection.qml105
-rw-r--r--modules/controlcenter/appearance/sections/BorderSection.qml63
-rw-r--r--modules/controlcenter/appearance/sections/ColorSchemeSection.qml147
-rw-r--r--modules/controlcenter/appearance/sections/ColorVariantSection.qml92
-rw-r--r--modules/controlcenter/appearance/sections/FontsSection.qml286
-rw-r--r--modules/controlcenter/appearance/sections/ScalesSection.qml84
-rw-r--r--modules/controlcenter/appearance/sections/ThemeModeSection.qml24
-rw-r--r--modules/controlcenter/appearance/sections/TransparencySection.qml74
-rw-r--r--modules/controlcenter/audio/AudioPane.qml467
-rw-r--r--modules/controlcenter/bluetooth/BtPane.qml137
-rw-r--r--modules/controlcenter/bluetooth/Details.qml654
-rw-r--r--modules/controlcenter/bluetooth/DeviceList.qml307
-rw-r--r--modules/controlcenter/bluetooth/Settings.qml32
-rw-r--r--modules/controlcenter/components/DeviceDetails.qml71
-rw-r--r--modules/controlcenter/components/DeviceList.qml85
-rw-r--r--modules/controlcenter/components/PaneTransition.qml72
-rw-r--r--modules/controlcenter/components/SettingsHeader.qml38
-rw-r--r--modules/controlcenter/components/SliderInput.qml181
-rw-r--r--modules/controlcenter/components/SplitPaneLayout.qml112
-rw-r--r--modules/controlcenter/components/SplitPaneWithDetails.qml93
-rw-r--r--modules/controlcenter/components/WallpaperGrid.qml241
-rw-r--r--modules/controlcenter/launcher/LauncherPane.qml599
-rw-r--r--modules/controlcenter/launcher/Settings.qml218
-rw-r--r--modules/controlcenter/network/EthernetDetails.qml118
-rw-r--r--modules/controlcenter/network/EthernetList.qml177
-rw-r--r--modules/controlcenter/network/EthernetPane.qml50
-rw-r--r--modules/controlcenter/network/EthernetSettings.qml76
-rw-r--r--modules/controlcenter/network/NetworkSettings.qml98
-rw-r--r--modules/controlcenter/network/NetworkingPane.qml305
-rw-r--r--modules/controlcenter/network/WirelessDetails.qml212
-rw-r--r--modules/controlcenter/network/WirelessList.qml226
-rw-r--r--modules/controlcenter/network/WirelessPane.qml57
-rw-r--r--modules/controlcenter/network/WirelessPasswordDialog.qml512
-rw-r--r--modules/controlcenter/network/WirelessSettings.qml73
-rw-r--r--modules/controlcenter/state/BluetoothState.qml13
-rw-r--r--modules/controlcenter/state/EthernetState.qml8
-rw-r--r--modules/controlcenter/state/LauncherState.qml8
-rw-r--r--modules/controlcenter/state/NetworkState.qml10
-rw-r--r--modules/controlcenter/taskbar/ConnectedButtonGroup.qml109
-rw-r--r--modules/controlcenter/taskbar/TaskbarPane.qml637
-rw-r--r--modules/dashboard/Content.qml11
-rw-r--r--modules/drawers/Panels.qml1
-rw-r--r--modules/launcher/Content.qml2
-rw-r--r--modules/launcher/items/WallpaperItem.qml1
-rw-r--r--modules/utilities/Content.qml2
-rw-r--r--modules/utilities/Wrapper.qml2
-rw-r--r--modules/utilities/cards/Toggles.qml6
-rw-r--r--services/Network.qml384
-rw-r--r--services/Nmcli.qml1352
-rw-r--r--services/VPN.qml6
-rw-r--r--utils/Icons.qml37
-rw-r--r--utils/NetworkConnection.qml122
91 files changed, 10968 insertions, 1013 deletions
diff --git a/components/ConnectionHeader.qml b/components/ConnectionHeader.qml
new file mode 100644
index 0000000..3f77fd9
--- /dev/null
+++ b/components/ConnectionHeader.qml
@@ -0,0 +1,32 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property string icon
+ required property string title
+
+ spacing: Appearance.spacing.normal
+ Layout.alignment: Qt.AlignHCenter
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignHCenter
+ animate: true
+ text: root.icon
+ font.pointSize: Appearance.font.size.extraLarge * 3
+ font.bold: true
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ animate: true
+ text: root.title
+ font.pointSize: Appearance.font.size.large
+ font.bold: true
+ }
+}
+
diff --git a/components/ConnectionInfoSection.qml b/components/ConnectionInfoSection.qml
new file mode 100644
index 0000000..88c6b3a
--- /dev/null
+++ b/components/ConnectionInfoSection.qml
@@ -0,0 +1,60 @@
+import qs.components
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property var deviceDetails
+
+ spacing: Appearance.spacing.small / 2
+
+ StyledText {
+ text: qsTr("IP Address")
+ }
+
+ StyledText {
+ text: root.deviceDetails?.ipAddress || qsTr("Not available")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("Subnet Mask")
+ }
+
+ StyledText {
+ text: root.deviceDetails?.subnet || qsTr("Not available")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("Gateway")
+ }
+
+ StyledText {
+ text: root.deviceDetails?.gateway || qsTr("Not available")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("DNS Servers")
+ }
+
+ StyledText {
+ text: (root.deviceDetails && root.deviceDetails.dns && root.deviceDetails.dns.length > 0) ? root.deviceDetails.dns.join(", ") : qsTr("Not available")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ wrapMode: Text.Wrap
+ Layout.maximumWidth: parent.width
+ }
+}
+
diff --git a/components/PropertyRow.qml b/components/PropertyRow.qml
new file mode 100644
index 0000000..697830a
--- /dev/null
+++ b/components/PropertyRow.qml
@@ -0,0 +1,27 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property string label
+ required property string value
+ property bool showTopMargin: false
+
+ spacing: Appearance.spacing.small / 2
+
+ StyledText {
+ Layout.topMargin: root.showTopMargin ? Appearance.spacing.normal : 0
+ text: root.label
+ }
+
+ StyledText {
+ text: root.value
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+}
+
diff --git a/components/SectionContainer.qml b/components/SectionContainer.qml
new file mode 100644
index 0000000..f133e19
--- /dev/null
+++ b/components/SectionContainer.qml
@@ -0,0 +1,35 @@
+import qs.components
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ default property alias content: contentColumn.data
+ property real contentSpacing: Appearance.spacing.larger
+ property bool alignTop: false
+
+ Layout.fillWidth: true
+ implicitHeight: contentColumn.implicitHeight + Appearance.padding.large * 2
+
+ radius: Appearance.rounding.normal
+ color: Colours.transparency.enabled
+ ? Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ : Colours.palette.m3surfaceContainerHigh
+
+ ColumnLayout {
+ id: contentColumn
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: root.alignTop ? parent.top : undefined
+ anchors.verticalCenter: root.alignTop ? undefined : parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+
+ spacing: root.contentSpacing
+ }
+}
+
diff --git a/components/SectionHeader.qml b/components/SectionHeader.qml
new file mode 100644
index 0000000..897e63a
--- /dev/null
+++ b/components/SectionHeader.qml
@@ -0,0 +1,28 @@
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property string title
+ property string description: ""
+
+ spacing: 0
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.large
+ text: root.title
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ StyledText {
+ visible: root.description !== ""
+ text: root.description
+ color: Colours.palette.m3outline
+ }
+}
+
diff --git a/components/StateLayer.qml b/components/StateLayer.qml
index d86e782..a20e266 100644
--- a/components/StateLayer.qml
+++ b/components/StateLayer.qml
@@ -6,6 +6,7 @@ MouseArea {
id: root
property bool disabled
+ property bool showHoverBackground: true
property color color: Colours.palette.m3onSurface
property real radius: parent?.radius ?? 0
property alias rect: hoverLayer
@@ -75,7 +76,7 @@ MouseArea {
anchors.fill: parent
- color: Qt.alpha(root.color, root.disabled ? 0 : root.pressed ? 0.1 : root.containsMouse ? 0.08 : 0)
+ color: Qt.alpha(root.color, root.disabled ? 0 : root.pressed ? 0.12 : (root.showHoverBackground && root.containsMouse) ? 0.08 : 0)
radius: root.radius
StyledRect {
diff --git a/components/controls/CollapsibleSection.qml b/components/controls/CollapsibleSection.qml
new file mode 100644
index 0000000..8940884
--- /dev/null
+++ b/components/controls/CollapsibleSection.qml
@@ -0,0 +1,135 @@
+import ".."
+import qs.components
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property string title
+ property string description: ""
+ property bool expanded: false
+ property bool showBackground: false
+ property bool nested: false
+
+ signal toggleRequested
+
+ spacing: Appearance.spacing.small
+ Layout.fillWidth: true
+
+ Item {
+ id: sectionHeaderItem
+ Layout.fillWidth: true
+ Layout.preferredHeight: Math.max(titleRow.implicitHeight + Appearance.padding.normal * 2, 48)
+
+ RowLayout {
+ id: titleRow
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Appearance.padding.normal
+ anchors.rightMargin: Appearance.padding.normal
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: root.title
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ MaterialIcon {
+ text: "expand_more"
+ rotation: root.expanded ? 180 : 0
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.normal
+ Behavior on rotation {
+ Anim {
+ duration: Appearance.anim.durations.small
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+ }
+
+ StateLayer {
+ anchors.fill: parent
+ color: Colours.palette.m3onSurface
+ radius: Appearance.rounding.normal
+ showHoverBackground: false
+ function onClicked(): void {
+ root.toggleRequested();
+ root.expanded = !root.expanded;
+ }
+ }
+ }
+
+ default property alias content: contentColumn.data
+
+ Item {
+ id: contentWrapper
+ Layout.fillWidth: true
+ Layout.preferredHeight: root.expanded ? (contentColumn.implicitHeight + Appearance.spacing.small * 2) : 0
+ clip: true
+
+ Behavior on Layout.preferredHeight {
+ Anim {
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ StyledRect {
+ id: backgroundRect
+ anchors.fill: parent
+ radius: Appearance.rounding.normal
+ color: Colours.transparency.enabled
+ ? Colours.layer(Colours.palette.m3surfaceContainer, root.nested ? 3 : 2)
+ : (root.nested ? Colours.palette.m3surfaceContainerHigh : Colours.palette.m3surfaceContainer)
+ opacity: root.showBackground && root.expanded ? 1.0 : 0.0
+ visible: root.showBackground
+
+ Behavior on opacity {
+ Anim {
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+ }
+
+ ColumnLayout {
+ id: contentColumn
+ anchors.left: parent.left
+ anchors.right: parent.right
+ y: Appearance.spacing.small
+ anchors.leftMargin: Appearance.padding.normal
+ anchors.rightMargin: Appearance.padding.normal
+ anchors.bottomMargin: Appearance.spacing.small
+ spacing: Appearance.spacing.small
+ opacity: root.expanded ? 1.0 : 0.0
+
+ Behavior on opacity {
+ Anim {
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ }
+
+ StyledText {
+ id: descriptionText
+ Layout.fillWidth: true
+ Layout.topMargin: root.description !== "" ? Appearance.spacing.smaller : 0
+ Layout.bottomMargin: root.description !== "" ? Appearance.spacing.small : 0
+ visible: root.description !== ""
+ text: root.description
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+ wrapMode: Text.Wrap
+ }
+ }
+ }
+}
+
diff --git a/components/controls/CustomSpinBox.qml b/components/controls/CustomSpinBox.qml
index e2ed508..438dc08 100644
--- a/components/controls/CustomSpinBox.qml
+++ b/components/controls/CustomSpinBox.qml
@@ -9,19 +9,69 @@ import QtQuick.Layouts
RowLayout {
id: root
- property int value
+ property real value
property real max: Infinity
property real min: -Infinity
+ property real step: 1
property alias repeatRate: timer.interval
- signal valueModified(value: int)
+ signal valueModified(value: real)
spacing: Appearance.spacing.small
+ property bool isEditing: false
+ property string displayText: root.value.toString()
+
+ onValueChanged: {
+ if (!root.isEditing) {
+ root.displayText = root.value.toString();
+ }
+ }
+
StyledTextField {
+ id: textField
+
inputMethodHints: Qt.ImhFormattedNumbersOnly
- text: root.value
- onAccepted: root.valueModified(text)
+ text: root.isEditing ? text : root.displayText
+ validator: DoubleValidator {
+ bottom: root.min
+ top: root.max
+ decimals: root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0
+ }
+ onActiveFocusChanged: {
+ if (activeFocus) {
+ root.isEditing = true;
+ } else {
+ root.isEditing = false;
+ root.displayText = root.value.toString();
+ }
+ }
+ onAccepted: {
+ const numValue = parseFloat(text);
+ if (!isNaN(numValue)) {
+ const clampedValue = Math.max(root.min, Math.min(root.max, numValue));
+ root.value = clampedValue;
+ root.displayText = clampedValue.toString();
+ root.valueModified(clampedValue);
+ } else {
+ text = root.displayText;
+ }
+ root.isEditing = false;
+ }
+ onEditingFinished: {
+ if (text !== root.displayText) {
+ const numValue = parseFloat(text);
+ if (!isNaN(numValue)) {
+ const clampedValue = Math.max(root.min, Math.min(root.max, numValue));
+ root.value = clampedValue;
+ root.displayText = clampedValue.toString();
+ root.valueModified(clampedValue);
+ } else {
+ text = root.displayText;
+ }
+ }
+ root.isEditing = false;
+ }
padding: Appearance.padding.small
leftPadding: Appearance.padding.normal
@@ -50,7 +100,13 @@ RowLayout {
onReleased: timer.stop()
function onClicked(): void {
- root.valueModified(Math.min(root.max, root.value + 1));
+ let newValue = Math.min(root.max, root.value + root.step);
+ // Round to avoid floating point precision errors
+ const decimals = root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0;
+ newValue = Math.round(newValue * Math.pow(10, decimals)) / Math.pow(10, decimals);
+ root.value = newValue;
+ root.displayText = newValue.toString();
+ root.valueModified(newValue);
}
}
@@ -79,7 +135,13 @@ RowLayout {
onReleased: timer.stop()
function onClicked(): void {
- root.valueModified(Math.max(root.min, root.value - 1));
+ let newValue = Math.max(root.min, root.value - root.step);
+ // Round to avoid floating point precision errors
+ const decimals = root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0;
+ newValue = Math.round(newValue * Math.pow(10, decimals)) / Math.pow(10, decimals);
+ root.value = newValue;
+ root.displayText = newValue.toString();
+ root.valueModified(newValue);
}
}
diff --git a/components/controls/FilledSlider.qml b/components/controls/FilledSlider.qml
index 78b8a5c..80dd44c 100644
--- a/components/controls/FilledSlider.qml
+++ b/components/controls/FilledSlider.qml
@@ -15,7 +15,7 @@ Slider {
orientation: Qt.Vertical
background: StyledRect {
- color: Colours.tPalette.m3surfaceContainer
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
radius: Appearance.rounding.full
StyledRect {
diff --git a/components/controls/IconTextButton.qml b/components/controls/IconTextButton.qml
index 78e7c5b..0badd7a 100644
--- a/components/controls/IconTextButton.qml
+++ b/components/controls/IconTextButton.qml
@@ -2,6 +2,7 @@ import ".."
import qs.services
import qs.config
import QtQuick
+import QtQuick.Layouts
StyledRect {
id: root
@@ -53,7 +54,7 @@ StyledRect {
}
}
- Row {
+ RowLayout {
id: row
anchors.centerIn: parent
@@ -62,7 +63,8 @@ StyledRect {
MaterialIcon {
id: iconLabel
- anchors.verticalCenter: parent.verticalCenter
+ Layout.alignment: Qt.AlignVCenter
+ Layout.topMargin: Math.round(fontInfo.pointSize * 0.0575)
color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
fill: root.internalChecked ? 1 : 0
@@ -74,7 +76,8 @@ StyledRect {
StyledText {
id: label
- anchors.verticalCenter: parent.verticalCenter
+ Layout.alignment: Qt.AlignVCenter
+ Layout.topMargin: -Math.round(iconLabel.fontInfo.pointSize * 0.0575)
color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour
}
}
diff --git a/components/controls/SpinBoxRow.qml b/components/controls/SpinBoxRow.qml
new file mode 100644
index 0000000..4902627
--- /dev/null
+++ b/components/controls/SpinBoxRow.qml
@@ -0,0 +1,53 @@
+import ".."
+import qs.components
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ required property string label
+ required property real value
+ required property real min
+ required property real max
+ property real step: 1
+ property var onValueModified: function(value) {}
+
+ Layout.fillWidth: true
+ implicitHeight: row.implicitHeight + Appearance.padding.large * 2
+ radius: Appearance.rounding.normal
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+
+ RowLayout {
+ id: row
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: root.label
+ }
+
+ CustomSpinBox {
+ min: root.min
+ max: root.max
+ step: root.step
+ value: root.value
+ onValueModified: value => {
+ root.onValueModified(value);
+ }
+ }
+ }
+}
+
diff --git a/components/controls/StyledInputField.qml b/components/controls/StyledInputField.qml
new file mode 100644
index 0000000..fcd0a33
--- /dev/null
+++ b/components/controls/StyledInputField.qml
@@ -0,0 +1,80 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.components
+import qs.services
+import qs.config
+import QtQuick
+
+Item {
+ id: root
+
+ property string text: ""
+ property var validator: null
+ property bool readOnly: false
+ property int horizontalAlignment: TextInput.AlignHCenter
+ property int implicitWidth: 70
+ property bool enabled: true
+
+ // Expose activeFocus through alias to avoid FINAL property override
+ readonly property alias hasFocus: inputField.activeFocus
+
+ signal textEdited(string text)
+ signal editingFinished()
+
+ implicitHeight: inputField.implicitHeight + Appearance.padding.small * 2
+
+ StyledRect {
+ id: container
+
+ anchors.fill: parent
+ color: inputHover.containsMouse || inputField.activeFocus
+ ? Colours.layer(Colours.palette.m3surfaceContainer, 3)
+ : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: inputField.activeFocus
+ ? Colours.palette.m3primary
+ : Qt.alpha(Colours.palette.m3outline, 0.3)
+ opacity: root.enabled ? 1 : 0.5
+
+ Behavior on color { CAnim {} }
+ Behavior on border.color { CAnim {} }
+
+ MouseArea {
+ id: inputHover
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.IBeamCursor
+ acceptedButtons: Qt.NoButton
+ enabled: root.enabled
+ }
+
+ StyledTextField {
+ id: inputField
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: root.horizontalAlignment
+ validator: root.validator
+ readOnly: root.readOnly
+ enabled: root.enabled
+
+ Binding {
+ target: inputField
+ property: "text"
+ value: root.text
+ when: !inputField.activeFocus
+ }
+
+ onTextChanged: {
+ root.text = text;
+ root.textEdited(text);
+ }
+
+ onEditingFinished: {
+ root.editingFinished();
+ }
+ }
+ }
+}
+
diff --git a/components/controls/StyledScrollBar.qml b/components/controls/StyledScrollBar.qml
index fc641b5..de8b679 100644
--- a/components/controls/StyledScrollBar.qml
+++ b/components/controls/StyledScrollBar.qml
@@ -19,14 +19,51 @@ ScrollBar {
shouldBeActive = flickable.moving;
}
+ property bool _updatingFromFlickable: false
+ property bool _updatingFromUser: false
+
+ // Sync nonAnimPosition with Qt's automatic position binding
onPositionChanged: {
- if (position === nonAnimPosition)
+ if (_updatingFromUser) {
+ _updatingFromUser = false;
+ return;
+ }
+ if (position === nonAnimPosition) {
animating = false;
- else if (!animating)
+ return;
+ }
+ if (!animating && !_updatingFromFlickable && !fullMouse.pressed) {
nonAnimPosition = position;
+ }
}
- position: nonAnimPosition
+ // Sync nonAnimPosition with flickable when not animating
+ Connections {
+ target: flickable
+ function onContentYChanged() {
+ if (!animating && !fullMouse.pressed) {
+ _updatingFromFlickable = true;
+ const contentHeight = flickable.contentHeight;
+ const height = flickable.height;
+ if (contentHeight > height) {
+ nonAnimPosition = Math.max(0, Math.min(1, flickable.contentY / (contentHeight - height)));
+ } else {
+ nonAnimPosition = 0;
+ }
+ _updatingFromFlickable = false;
+ }
+ }
+ }
+
+ Component.onCompleted: {
+ if (flickable) {
+ const contentHeight = flickable.contentHeight;
+ const height = flickable.height;
+ if (contentHeight > height) {
+ nonAnimPosition = Math.max(0, Math.min(1, flickable.contentY / (contentHeight - height)));
+ }
+ }
+ }
implicitWidth: Appearance.padding.small
contentItem: StyledRect {
@@ -86,17 +123,62 @@ ScrollBar {
onPressed: event => {
root.animating = true;
- root.nonAnimPosition = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2));
+ root._updatingFromUser = true;
+ const newPos = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2));
+ root.nonAnimPosition = newPos;
+ // Update flickable position
+ // Map scrollbar position [0, 1-size] to contentY [0, maxContentY]
+ if (root.flickable) {
+ const contentHeight = root.flickable.contentHeight;
+ const height = root.flickable.height;
+ if (contentHeight > height) {
+ const maxContentY = contentHeight - height;
+ const maxPos = 1 - root.size;
+ const contentY = maxPos > 0 ? (newPos / maxPos) * maxContentY : 0;
+ root.flickable.contentY = Math.max(0, Math.min(maxContentY, contentY));
+ }
+ }
}
- onPositionChanged: event => root.nonAnimPosition = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2))
+ onPositionChanged: event => {
+ root._updatingFromUser = true;
+ const newPos = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2));
+ root.nonAnimPosition = newPos;
+ // Update flickable position
+ // Map scrollbar position [0, 1-size] to contentY [0, maxContentY]
+ if (root.flickable) {
+ const contentHeight = root.flickable.contentHeight;
+ const height = root.flickable.height;
+ if (contentHeight > height) {
+ const maxContentY = contentHeight - height;
+ const maxPos = 1 - root.size;
+ const contentY = maxPos > 0 ? (newPos / maxPos) * maxContentY : 0;
+ root.flickable.contentY = Math.max(0, Math.min(maxContentY, contentY));
+ }
+ }
+ }
function onWheel(event: WheelEvent): void {
root.animating = true;
+ root._updatingFromUser = true;
+ let newPos = root.nonAnimPosition;
if (event.angleDelta.y > 0)
- root.nonAnimPosition = Math.max(0, root.nonAnimPosition - 0.1);
+ newPos = Math.max(0, root.nonAnimPosition - 0.1);
else if (event.angleDelta.y < 0)
- root.nonAnimPosition = Math.min(1 - root.size, root.nonAnimPosition + 0.1);
+ newPos = Math.min(1 - root.size, root.nonAnimPosition + 0.1);
+ root.nonAnimPosition = newPos;
+ // Update flickable position
+ // Map scrollbar position [0, 1-size] to contentY [0, maxContentY]
+ if (root.flickable) {
+ const contentHeight = root.flickable.contentHeight;
+ const height = root.flickable.height;
+ if (contentHeight > height) {
+ const maxContentY = contentHeight - height;
+ const maxPos = 1 - root.size;
+ const contentY = maxPos > 0 ? (newPos / maxPos) * maxContentY : 0;
+ root.flickable.contentY = Math.max(0, Math.min(maxContentY, contentY));
+ }
+ }
}
}
diff --git a/components/controls/StyledSlider.qml b/components/controls/StyledSlider.qml
index 92c8aa8..0ef229d 100644
--- a/components/controls/StyledSlider.qml
+++ b/components/controls/StyledSlider.qml
@@ -32,7 +32,7 @@ Slider {
implicitWidth: parent.width - root.handle.x - root.handle.implicitWidth - root.implicitHeight / 6
- color: Colours.tPalette.m3surfaceContainer
+ color: Colours.palette.m3surfaceContainerHighest
radius: Appearance.rounding.full
topLeftRadius: root.implicitHeight / 15
bottomLeftRadius: root.implicitHeight / 15
diff --git a/components/controls/StyledTextField.qml b/components/controls/StyledTextField.qml
index 4db87e9..60bcff2 100644
--- a/components/controls/StyledTextField.qml
+++ b/components/controls/StyledTextField.qml
@@ -13,7 +13,7 @@ TextField {
placeholderTextColor: Colours.palette.m3outline
font.family: Appearance.font.family.sans
font.pointSize: Appearance.font.size.smaller
- renderType: TextField.NativeRendering
+ renderType: echoMode === TextField.Password ? TextField.QtRendering : TextField.NativeRendering
cursorVisible: !readOnly
background: null
diff --git a/components/controls/SwitchRow.qml b/components/controls/SwitchRow.qml
new file mode 100644
index 0000000..7fa3e1b
--- /dev/null
+++ b/components/controls/SwitchRow.qml
@@ -0,0 +1,49 @@
+import ".."
+import qs.components
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ required property string label
+ required property bool checked
+ property bool enabled: true
+ property var onToggled: function(checked) {}
+
+ Layout.fillWidth: true
+ implicitHeight: row.implicitHeight + Appearance.padding.large * 2
+ radius: Appearance.rounding.normal
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+
+ RowLayout {
+ id: row
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: root.label
+ }
+
+ StyledSwitch {
+ checked: root.checked
+ enabled: root.enabled
+ onToggled: {
+ root.onToggled(checked);
+ }
+ }
+ }
+}
+
diff --git a/components/controls/ToggleButton.qml b/components/controls/ToggleButton.qml
new file mode 100644
index 0000000..b2c2afe
--- /dev/null
+++ b/components/controls/ToggleButton.qml
@@ -0,0 +1,127 @@
+import ".."
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ required property bool toggled
+ property string icon
+ property string label
+ property string accent: "Secondary"
+ property real iconSize: Appearance.font.size.large
+ property real horizontalPadding: Appearance.padding.large
+ property real verticalPadding: Appearance.padding.normal
+ property string tooltip: ""
+
+ property bool hovered: false
+ signal clicked
+
+ Component.onCompleted: {
+ hovered = toggleStateLayer.containsMouse;
+ }
+
+ Connections {
+ target: toggleStateLayer
+ function onContainsMouseChanged() {
+ const newHovered = toggleStateLayer.containsMouse;
+ if (hovered !== newHovered) {
+ hovered = newHovered;
+ }
+ }
+ }
+
+ Layout.preferredWidth: implicitWidth + (toggleStateLayer.pressed ? Appearance.padding.normal * 2 : toggled ? Appearance.padding.small * 2 : 0)
+ implicitWidth: toggleBtnInner.implicitWidth + horizontalPadding * 2
+ implicitHeight: toggleBtnIcon.implicitHeight + verticalPadding * 2
+
+ radius: toggled || toggleStateLayer.pressed ? Appearance.rounding.small : Math.min(width, height) / 2 * Math.min(1, Appearance.rounding.scale)
+ color: toggled ? Colours.palette[`m3${accent.toLowerCase()}`] : Colours.palette[`m3${accent.toLowerCase()}Container`]
+
+ StateLayer {
+ id: toggleStateLayer
+
+ color: root.toggled ? Colours.palette[`m3on${root.accent}`] : Colours.palette[`m3on${root.accent}Container`]
+
+ function onClicked(): void {
+ root.clicked();
+ }
+ }
+
+ RowLayout {
+ id: toggleBtnInner
+
+ anchors.centerIn: parent
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ id: toggleBtnIcon
+
+ visible: !!text
+ fill: root.toggled ? 1 : 0
+ text: root.icon
+ color: root.toggled ? Colours.palette[`m3on${root.accent}`] : Colours.palette[`m3on${root.accent}Container`]
+ font.pointSize: root.iconSize
+
+ Behavior on fill {
+ Anim {}
+ }
+ }
+
+ Loader {
+ asynchronous: true
+ active: !!root.label
+ visible: active
+
+ sourceComponent: StyledText {
+ text: root.label
+ color: root.toggled ? Colours.palette[`m3on${root.accent}`] : Colours.palette[`m3on${root.accent}Container`]
+ }
+ }
+ }
+
+ Behavior on radius {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+
+ Behavior on Layout.preferredWidth {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+
+ // Tooltip - positioned absolutely, doesn't affect layout
+ Loader {
+ id: tooltipLoader
+ active: root.tooltip !== ""
+ asynchronous: true
+ z: 10000
+ width: 0
+ height: 0
+ sourceComponent: Component {
+ Tooltip {
+ target: root
+ text: root.tooltip
+ }
+ }
+ // Completely remove from layout
+ Layout.fillWidth: false
+ Layout.fillHeight: false
+ Layout.preferredWidth: 0
+ Layout.preferredHeight: 0
+ Layout.maximumWidth: 0
+ Layout.maximumHeight: 0
+ Layout.minimumWidth: 0
+ Layout.minimumHeight: 0
+ }
+}
+
diff --git a/components/controls/ToggleRow.qml b/components/controls/ToggleRow.qml
new file mode 100644
index 0000000..23dc2a2
--- /dev/null
+++ b/components/controls/ToggleRow.qml
@@ -0,0 +1,29 @@
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+RowLayout {
+ id: root
+
+ required property string label
+ property alias checked: toggle.checked
+ property alias toggle: toggle
+
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: root.label
+ }
+
+ StyledSwitch {
+ id: toggle
+
+ cLayer: 2
+ }
+}
+
diff --git a/components/controls/Tooltip.qml b/components/controls/Tooltip.qml
new file mode 100644
index 0000000..d665083
--- /dev/null
+++ b/components/controls/Tooltip.qml
@@ -0,0 +1,172 @@
+import ".."
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+Popup {
+ id: root
+
+ required property Item target
+ required property string text
+ property int delay: 500
+ property int timeout: 0
+
+ property bool tooltipVisible: false
+ property Timer showTimer: Timer {
+ interval: root.delay
+ onTriggered: root.tooltipVisible = true
+ }
+ property Timer hideTimer: Timer {
+ interval: root.timeout
+ onTriggered: root.tooltipVisible = false
+ }
+
+ // Popup properties - doesn't affect layout
+ parent: {
+ let p = target;
+ // Walk up to find the root Item (usually has anchors.fill: parent)
+ while (p && p.parent) {
+ const parentItem = p.parent;
+ // Check if this looks like a root pane Item
+ if (parentItem && parentItem.anchors && parentItem.anchors.fill !== undefined) {
+ return parentItem;
+ }
+ p = parentItem;
+ }
+ // Fallback
+ return target.parent?.parent?.parent ?? target.parent?.parent ?? target.parent ?? target;
+ }
+
+ visible: tooltipVisible
+ modal: false
+ closePolicy: Popup.NoAutoClose
+ padding: 0
+ margins: 0
+ background: Item {}
+
+ // Update position when target moves or tooltip becomes visible
+ onTooltipVisibleChanged: {
+ if (tooltipVisible) {
+ Qt.callLater(updatePosition);
+ }
+ }
+ Connections {
+ target: root.target
+ function onXChanged() { if (root.tooltipVisible) root.updatePosition(); }
+ function onYChanged() { if (root.tooltipVisible) root.updatePosition(); }
+ function onWidthChanged() { if (root.tooltipVisible) root.updatePosition(); }
+ function onHeightChanged() { if (root.tooltipVisible) root.updatePosition(); }
+ }
+
+ function updatePosition() {
+ if (!target || !parent) return;
+
+ // Wait for tooltipRect to have its size calculated
+ Qt.callLater(() => {
+ if (!target || !parent || !tooltipRect) return;
+
+ // Get target position in parent's coordinate system
+ const targetPos = target.mapToItem(parent, 0, 0);
+ const targetCenterX = targetPos.x + target.width / 2;
+
+ // Get tooltip size (use width/height if available, otherwise implicit)
+ const tooltipWidth = tooltipRect.width > 0 ? tooltipRect.width : tooltipRect.implicitWidth;
+ const tooltipHeight = tooltipRect.height > 0 ? tooltipRect.height : tooltipRect.implicitHeight;
+
+ // Center tooltip horizontally on target
+ let newX = targetCenterX - tooltipWidth / 2;
+
+ // Position tooltip above target
+ let newY = targetPos.y - tooltipHeight - Appearance.spacing.small;
+
+ // Keep within bounds
+ const padding = Appearance.padding.normal;
+ if (newX < padding) {
+ newX = padding;
+ } else if (newX + tooltipWidth > (parent.width - padding)) {
+ newX = parent.width - tooltipWidth - padding;
+ }
+
+ // Update popup position
+ x = newX;
+ y = newY;
+ });
+ }
+
+ enter: Transition {
+ Anim {
+ property: "opacity"
+ from: 0
+ to: 1
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+
+ exit: Transition {
+ Anim {
+ property: "opacity"
+ from: 1
+ to: 0
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+
+ // Monitor hover state
+ Connections {
+ target: root.target
+ function onHoveredChanged() {
+ if (target.hovered) {
+ showTimer.start();
+ if (timeout > 0) {
+ hideTimer.stop();
+ hideTimer.start();
+ }
+ } else {
+ showTimer.stop();
+ hideTimer.stop();
+ tooltipVisible = false;
+ }
+ }
+ }
+
+ contentItem: StyledRect {
+ id: tooltipRect
+
+ implicitWidth: tooltipText.implicitWidth + Appearance.padding.normal * 2
+ implicitHeight: tooltipText.implicitHeight + Appearance.padding.smaller * 2
+
+ color: Colours.palette.m3surfaceContainerHighest
+ radius: Appearance.rounding.small
+ antialiasing: true
+
+ // Add elevation for depth
+ Elevation {
+ anchors.fill: parent
+ radius: parent.radius
+ z: -1
+ level: 3
+ }
+
+ StyledText {
+ id: tooltipText
+
+ anchors.centerIn: parent
+
+ text: root.text
+ color: Colours.palette.m3onSurface
+ font.pointSize: Appearance.font.size.small
+ }
+ }
+
+ Component.onCompleted: {
+ if (tooltipVisible) {
+ updatePosition();
+ }
+ }
+}
+
diff --git a/config/Config.qml b/config/Config.qml
index 267a184..b875eef 100644
--- a/config/Config.qml
+++ b/config/Config.qml
@@ -4,6 +4,7 @@ import qs.utils
import Caelestia
import Quickshell
import Quickshell.Io
+import QtQuick
Singleton {
id: root
@@ -26,22 +27,420 @@ Singleton {
property alias services: adapter.services
property alias paths: adapter.paths
+ // Public save function - call this to persist config changes
+ function save(): void {
+ saveTimer.restart();
+ recentlySaved = true;
+ recentSaveCooldown.restart();
+ }
+
+ property bool recentlySaved: false
+
ElapsedTimer {
id: timer
}
+ Timer {
+ id: saveTimer
+
+ interval: 500
+ onTriggered: {
+ timer.restart();
+ try {
+ // Parse current config to preserve structure and comments if possible
+ let config = {};
+ try {
+ config = JSON.parse(fileView.text());
+ } catch (e) {
+ // If parsing fails, start with empty object
+ config = {};
+ }
+
+ // Update config with current values
+ config = serializeConfig();
+
+ // Save to file with pretty printing
+ fileView.setText(JSON.stringify(config, null, 2));
+ } catch (e) {
+ Toaster.toast(qsTr("Failed to serialize config"), e.message, "settings_alert", Toast.Error);
+ }
+ }
+ }
+
+ Timer {
+ id: recentSaveCooldown
+
+ interval: 2000
+ onTriggered: {
+ recentlySaved = false;
+ }
+ }
+
+ // Helper function to serialize the config object
+ function serializeConfig(): var {
+ return {
+ appearance: serializeAppearance(),
+ general: serializeGeneral(),
+ background: serializeBackground(),
+ bar: serializeBar(),
+ border: serializeBorder(),
+ dashboard: serializeDashboard(),
+ controlCenter: serializeControlCenter(),
+ launcher: serializeLauncher(),
+ notifs: serializeNotifs(),
+ osd: serializeOsd(),
+ session: serializeSession(),
+ winfo: serializeWinfo(),
+ lock: serializeLock(),
+ utilities: serializeUtilities(),
+ sidebar: serializeSidebar(),
+ services: serializeServices(),
+ paths: serializePaths()
+ };
+ }
+
+ function serializeAppearance(): var {
+ return {
+ rounding: { scale: appearance.rounding.scale },
+ spacing: { scale: appearance.spacing.scale },
+ padding: { scale: appearance.padding.scale },
+ font: {
+ family: {
+ sans: appearance.font.family.sans,
+ mono: appearance.font.family.mono,
+ material: appearance.font.family.material,
+ clock: appearance.font.family.clock
+ },
+ size: { scale: appearance.font.size.scale }
+ },
+ anim: {
+ durations: { scale: appearance.anim.durations.scale }
+ },
+ transparency: {
+ enabled: appearance.transparency.enabled,
+ base: appearance.transparency.base,
+ layers: appearance.transparency.layers
+ }
+ };
+ }
+
+ function serializeGeneral(): var {
+ return {
+ apps: {
+ terminal: general.apps.terminal,
+ audio: general.apps.audio,
+ playback: general.apps.playback,
+ explorer: general.apps.explorer
+ },
+ idle: {
+ lockBeforeSleep: general.idle.lockBeforeSleep,
+ inhibitWhenAudio: general.idle.inhibitWhenAudio,
+ timeouts: general.idle.timeouts
+ },
+ battery: {
+ warnLevels: general.battery.warnLevels,
+ criticalLevel: general.battery.criticalLevel
+ }
+ };
+ }
+
+ function serializeBackground(): var {
+ return {
+ enabled: background.enabled,
+ desktopClock: {
+ enabled: background.desktopClock.enabled
+ },
+ visualiser: {
+ enabled: background.visualiser.enabled,
+ autoHide: background.visualiser.autoHide,
+ blur: background.visualiser.blur,
+ rounding: background.visualiser.rounding,
+ spacing: background.visualiser.spacing
+ }
+ };
+ }
+
+ function serializeBar(): var {
+ return {
+ persistent: bar.persistent,
+ showOnHover: bar.showOnHover,
+ dragThreshold: bar.dragThreshold,
+ scrollActions: {
+ workspaces: bar.scrollActions.workspaces,
+ volume: bar.scrollActions.volume,
+ brightness: bar.scrollActions.brightness
+ },
+ popouts: {
+ activeWindow: bar.popouts.activeWindow,
+ tray: bar.popouts.tray,
+ statusIcons: bar.popouts.statusIcons
+ },
+ workspaces: {
+ shown: bar.workspaces.shown,
+ activeIndicator: bar.workspaces.activeIndicator,
+ occupiedBg: bar.workspaces.occupiedBg,
+ showWindows: bar.workspaces.showWindows,
+ showWindowsOnSpecialWorkspaces: bar.workspaces.showWindowsOnSpecialWorkspaces,
+ activeTrail: bar.workspaces.activeTrail,
+ perMonitorWorkspaces: bar.workspaces.perMonitorWorkspaces,
+ label: bar.workspaces.label,
+ occupiedLabel: bar.workspaces.occupiedLabel,
+ activeLabel: bar.workspaces.activeLabel,
+ capitalisation: bar.workspaces.capitalisation,
+ specialWorkspaceIcons: bar.workspaces.specialWorkspaceIcons
+ },
+ tray: {
+ background: bar.tray.background,
+ recolour: bar.tray.recolour,
+ compact: bar.tray.compact,
+ iconSubs: bar.tray.iconSubs
+ },
+ status: {
+ showAudio: bar.status.showAudio,
+ showMicrophone: bar.status.showMicrophone,
+ showKbLayout: bar.status.showKbLayout,
+ showNetwork: bar.status.showNetwork,
+ showBluetooth: bar.status.showBluetooth,
+ showBattery: bar.status.showBattery,
+ showLockStatus: bar.status.showLockStatus
+ },
+ clock: {
+ showIcon: bar.clock.showIcon
+ },
+ sizes: {
+ innerWidth: bar.sizes.innerWidth,
+ windowPreviewSize: bar.sizes.windowPreviewSize,
+ trayMenuWidth: bar.sizes.trayMenuWidth,
+ batteryWidth: bar.sizes.batteryWidth,
+ networkWidth: bar.sizes.networkWidth
+ },
+ entries: bar.entries
+ };
+ }
+
+ function serializeBorder(): var {
+ return {
+ thickness: border.thickness,
+ rounding: border.rounding
+ };
+ }
+
+ function serializeDashboard(): var {
+ return {
+ enabled: dashboard.enabled,
+ showOnHover: dashboard.showOnHover,
+ mediaUpdateInterval: dashboard.mediaUpdateInterval,
+ dragThreshold: dashboard.dragThreshold,
+ sizes: {
+ tabIndicatorHeight: dashboard.sizes.tabIndicatorHeight,
+ tabIndicatorSpacing: dashboard.sizes.tabIndicatorSpacing,
+ infoWidth: dashboard.sizes.infoWidth,
+ infoIconSize: dashboard.sizes.infoIconSize,
+ dateTimeWidth: dashboard.sizes.dateTimeWidth,
+ mediaWidth: dashboard.sizes.mediaWidth,
+ mediaProgressSweep: dashboard.sizes.mediaProgressSweep,
+ mediaProgressThickness: dashboard.sizes.mediaProgressThickness,
+ resourceProgessThickness: dashboard.sizes.resourceProgessThickness,
+ weatherWidth: dashboard.sizes.weatherWidth,
+ mediaCoverArtSize: dashboard.sizes.mediaCoverArtSize,
+ mediaVisualiserSize: dashboard.sizes.mediaVisualiserSize,
+ resourceSize: dashboard.sizes.resourceSize
+ }
+ };
+ }
+
+ function serializeControlCenter(): var {
+ return {
+ sizes: {
+ heightMult: controlCenter.sizes.heightMult,
+ ratio: controlCenter.sizes.ratio
+ }
+ };
+ }
+
+ function serializeLauncher(): var {
+ return {
+ enabled: launcher.enabled,
+ showOnHover: launcher.showOnHover,
+ maxShown: launcher.maxShown,
+ maxWallpapers: launcher.maxWallpapers,
+ specialPrefix: launcher.specialPrefix,
+ actionPrefix: launcher.actionPrefix,
+ enableDangerousActions: launcher.enableDangerousActions,
+ dragThreshold: launcher.dragThreshold,
+ vimKeybinds: launcher.vimKeybinds,
+ hiddenApps: launcher.hiddenApps,
+ useFuzzy: {
+ apps: launcher.useFuzzy.apps,
+ actions: launcher.useFuzzy.actions,
+ schemes: launcher.useFuzzy.schemes,
+ variants: launcher.useFuzzy.variants,
+ wallpapers: launcher.useFuzzy.wallpapers
+ },
+ sizes: {
+ itemWidth: launcher.sizes.itemWidth,
+ itemHeight: launcher.sizes.itemHeight,
+ wallpaperWidth: launcher.sizes.wallpaperWidth,
+ wallpaperHeight: launcher.sizes.wallpaperHeight
+ },
+ actions: launcher.actions
+ };
+ }
+
+ function serializeNotifs(): var {
+ return {
+ expire: notifs.expire,
+ defaultExpireTimeout: notifs.defaultExpireTimeout,
+ clearThreshold: notifs.clearThreshold,
+ expandThreshold: notifs.expandThreshold,
+ actionOnClick: notifs.actionOnClick,
+ groupPreviewNum: notifs.groupPreviewNum,
+ sizes: {
+ width: notifs.sizes.width,
+ image: notifs.sizes.image,
+ badge: notifs.sizes.badge
+ }
+ };
+ }
+
+ function serializeOsd(): var {
+ return {
+ enabled: osd.enabled,
+ hideDelay: osd.hideDelay,
+ enableBrightness: osd.enableBrightness,
+ enableMicrophone: osd.enableMicrophone,
+ sizes: {
+ sliderWidth: osd.sizes.sliderWidth,
+ sliderHeight: osd.sizes.sliderHeight
+ }
+ };
+ }
+
+ function serializeSession(): var {
+ return {
+ enabled: session.enabled,
+ dragThreshold: session.dragThreshold,
+ vimKeybinds: session.vimKeybinds,
+ commands: {
+ logout: session.commands.logout,
+ shutdown: session.commands.shutdown,
+ hibernate: session.commands.hibernate,
+ reboot: session.commands.reboot
+ },
+ sizes: {
+ button: session.sizes.button
+ }
+ };
+ }
+
+ function serializeWinfo(): var {
+ return {
+ sizes: {
+ heightMult: winfo.sizes.heightMult,
+ detailsWidth: winfo.sizes.detailsWidth
+ }
+ };
+ }
+
+ function serializeLock(): var {
+ return {
+ recolourLogo: lock.recolourLogo,
+ enableFprint: lock.enableFprint,
+ maxFprintTries: lock.maxFprintTries,
+ sizes: {
+ heightMult: lock.sizes.heightMult,
+ ratio: lock.sizes.ratio,
+ centerWidth: lock.sizes.centerWidth
+ }
+ };
+ }
+
+ function serializeUtilities(): var {
+ return {
+ enabled: utilities.enabled,
+ maxToasts: utilities.maxToasts,
+ sizes: {
+ width: utilities.sizes.width,
+ toastWidth: utilities.sizes.toastWidth
+ },
+ toasts: {
+ configLoaded: utilities.toasts.configLoaded,
+ chargingChanged: utilities.toasts.chargingChanged,
+ gameModeChanged: utilities.toasts.gameModeChanged,
+ dndChanged: utilities.toasts.dndChanged,
+ audioOutputChanged: utilities.toasts.audioOutputChanged,
+ audioInputChanged: utilities.toasts.audioInputChanged,
+ capsLockChanged: utilities.toasts.capsLockChanged,
+ numLockChanged: utilities.toasts.numLockChanged,
+ kbLayoutChanged: utilities.toasts.kbLayoutChanged,
+ vpnChanged: utilities.toasts.vpnChanged,
+ nowPlaying: utilities.toasts.nowPlaying
+ },
+ vpn: {
+ enabled: utilities.vpn.enabled,
+ provider: utilities.vpn.provider
+ }
+ };
+ }
+
+ function serializeSidebar(): var {
+ return {
+ enabled: sidebar.enabled,
+ dragThreshold: sidebar.dragThreshold,
+ sizes: {
+ width: sidebar.sizes.width
+ }
+ };
+ }
+
+ function serializeServices(): var {
+ return {
+ weatherLocation: services.weatherLocation,
+ useFahrenheit: services.useFahrenheit,
+ useTwelveHourClock: services.useTwelveHourClock,
+ gpuType: services.gpuType,
+ visualiserBars: services.visualiserBars,
+ audioIncrement: services.audioIncrement,
+ maxVolume: services.maxVolume,
+ smartScheme: services.smartScheme,
+ defaultPlayer: services.defaultPlayer,
+ playerAliases: services.playerAliases
+ };
+ }
+
+ function serializePaths(): var {
+ return {
+ wallpaperDir: paths.wallpaperDir,
+ sessionGif: paths.sessionGif,
+ mediaGif: paths.mediaGif
+ };
+ }
+
FileView {
+ id: fileView
+
path: `${Paths.config}/shell.json`
watchChanges: true
onFileChanged: {
- timer.restart();
- reload();
+ // Prevent reload loop - don't reload if we just saved
+ if (!recentlySaved) {
+ timer.restart();
+ reload();
+ } else {
+ // Self-initiated save - reload without toast
+ reload();
+ }
}
onLoaded: {
try {
JSON.parse(text());
- if (adapter.utilities.toasts.configLoaded)
- Toaster.toast(qsTr("Config loaded"), qsTr("Config loaded in %1ms").arg(timer.elapsedMs()), "rule_settings");
+ const elapsed = timer.elapsedMs();
+ // Only show toast for external changes (not our own saves) and when elapsed time is meaningful
+ if (adapter.utilities.toasts.configLoaded && !recentlySaved && elapsed > 0) {
+ Toaster.toast(qsTr("Config loaded"), qsTr("Config loaded in %1ms").arg(elapsed), "rule_settings");
+ } else if (adapter.utilities.toasts.configLoaded && recentlySaved && elapsed > 0) {
+ Toaster.toast(qsTr("Config saved"), qsTr("Config reloaded in %1ms").arg(elapsed), "rule_settings");
+ }
} catch (e) {
Toaster.toast(qsTr("Failed to load config"), e.message, "settings_alert", Toast.Error);
}
diff --git a/modules/bar/components/OsIcon.qml b/modules/bar/components/OsIcon.qml
index ed6da5d..2bc3864 100644
--- a/modules/bar/components/OsIcon.qml
+++ b/modules/bar/components/OsIcon.qml
@@ -2,9 +2,27 @@ import qs.components.effects
import qs.services
import qs.config
import qs.utils
+import QtQuick
-ColouredIcon {
- source: SysInfo.osLogo
- implicitSize: Appearance.font.size.large * 1.2
- colour: Colours.palette.m3tertiary
+Item {
+ id: root
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ const visibilities = Visibilities.getForActive();
+ visibilities.launcher = !visibilities.launcher;
+ }
+ }
+
+ ColouredIcon {
+ anchors.centerIn: parent
+ source: SysInfo.osLogo
+ implicitSize: Appearance.font.size.large * 1.2
+ colour: Colours.palette.m3tertiary
+ }
+
+ implicitWidth: Appearance.font.size.large * 1.2
+ implicitHeight: Appearance.font.size.large * 1.2
}
diff --git a/modules/bar/components/Settings.qml b/modules/bar/components/Settings.qml
new file mode 100644
index 0000000..7cd18be
--- /dev/null
+++ b/modules/bar/components/Settings.qml
@@ -0,0 +1,44 @@
+import qs.components
+import qs.modules.controlcenter
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ implicitWidth: icon.implicitHeight + Appearance.padding.small * 2
+ implicitHeight: icon.implicitHeight
+
+ StateLayer {
+ // Cursed workaround to make the height larger than the parent
+ anchors.fill: undefined
+ anchors.centerIn: parent
+ implicitWidth: implicitHeight
+ implicitHeight: icon.implicitHeight + Appearance.padding.small * 2
+
+ radius: Appearance.rounding.full
+
+ function onClicked(): void {
+ WindowFactory.create(null, {
+ active: "network"
+ });
+ }
+ }
+
+ MaterialIcon {
+ id: icon
+
+ anchors.centerIn: parent
+ anchors.horizontalCenterOffset: -1
+
+ text: "settings"
+ color: Colours.palette.m3onSurface
+ font.bold: true
+ font.pointSize: Appearance.font.size.normal
+ }
+}
+
+
+
diff --git a/modules/bar/components/SettingsIcon.qml b/modules/bar/components/SettingsIcon.qml
new file mode 100644
index 0000000..7cd18be
--- /dev/null
+++ b/modules/bar/components/SettingsIcon.qml
@@ -0,0 +1,44 @@
+import qs.components
+import qs.modules.controlcenter
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+
+Item {
+ id: root
+
+ implicitWidth: icon.implicitHeight + Appearance.padding.small * 2
+ implicitHeight: icon.implicitHeight
+
+ StateLayer {
+ // Cursed workaround to make the height larger than the parent
+ anchors.fill: undefined
+ anchors.centerIn: parent
+ implicitWidth: implicitHeight
+ implicitHeight: icon.implicitHeight + Appearance.padding.small * 2
+
+ radius: Appearance.rounding.full
+
+ function onClicked(): void {
+ WindowFactory.create(null, {
+ active: "network"
+ });
+ }
+ }
+
+ MaterialIcon {
+ id: icon
+
+ anchors.centerIn: parent
+ anchors.horizontalCenterOffset: -1
+
+ text: "settings"
+ color: Colours.palette.m3onSurface
+ font.bold: true
+ font.pointSize: Appearance.font.size.normal
+ }
+}
+
+
+
diff --git a/modules/bar/components/StatusIcons.qml b/modules/bar/components/StatusIcons.qml
index 27fd9f8..2f52596 100644
--- a/modules/bar/components/StatusIcons.qml
+++ b/modules/bar/components/StatusIcons.qml
@@ -147,7 +147,19 @@ StyledRect {
sourceComponent: MaterialIcon {
animate: true
- text: Network.active ? Icons.getNetworkIcon(Network.active.strength ?? 0) : "wifi_off"
+ text: Nmcli.active ? Icons.getNetworkIcon(Nmcli.active.strength ?? 0) : "wifi_off"
+ color: root.colour
+ }
+ }
+
+ // Ethernet icon
+ WrappedLoader {
+ name: "ethernet"
+ active: Config.bar.status.showNetwork && Nmcli.activeEthernet
+
+ sourceComponent: MaterialIcon {
+ animate: true
+ text: "cable"
color: root.colour
}
}
diff --git a/modules/bar/components/Tray.qml b/modules/bar/components/Tray.qml
index efd0c3a..96956f6 100644
--- a/modules/bar/components/Tray.qml
+++ b/modules/bar/components/Tray.qml
@@ -30,7 +30,7 @@ StyledRect {
implicitWidth: Config.bar.sizes.innerWidth
implicitHeight: nonAnimHeight
- color: Qt.alpha(Colours.tPalette.m3surfaceContainer, Config.bar.tray.background ? Colours.tPalette.m3surfaceContainer.a : 0)
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, (Config.bar.tray.background && items.count > 0) ? Colours.tPalette.m3surfaceContainer.a : 0)
radius: Appearance.rounding.full
Column {
diff --git a/modules/bar/popouts/Audio.qml b/modules/bar/popouts/Audio.qml
index 952dd6b..58b29ba 100644
--- a/modules/bar/popouts/Audio.qml
+++ b/modules/bar/popouts/Audio.qml
@@ -9,6 +9,7 @@ import Quickshell.Services.Pipewire
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
+import "../../controlcenter/network"
Item {
id: root
@@ -104,43 +105,16 @@ Item {
}
}
- StyledRect {
+ IconTextButton {
+ Layout.fillWidth: true
Layout.topMargin: Appearance.spacing.normal
- visible: Config.general.apps.audio.length > 0
-
- implicitWidth: expandBtn.implicitWidth + Appearance.padding.normal * 2
- implicitHeight: expandBtn.implicitHeight + Appearance.padding.small
-
- radius: Appearance.rounding.normal
- color: Colours.palette.m3primaryContainer
-
- StateLayer {
- color: Colours.palette.m3onPrimaryContainer
-
- function onClicked(): void {
- root.wrapper.hasCurrent = false;
- Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.audio]);
- }
- }
+ inactiveColour: Colours.palette.m3primaryContainer
+ inactiveOnColour: Colours.palette.m3onPrimaryContainer
+ verticalPadding: Appearance.padding.small
+ text: qsTr("Open settings")
+ icon: "settings"
- RowLayout {
- id: expandBtn
-
- anchors.centerIn: parent
- spacing: Appearance.spacing.small
-
- StyledText {
- Layout.leftMargin: Appearance.padding.smaller
- text: qsTr("Open settings")
- color: Colours.palette.m3onPrimaryContainer
- }
-
- MaterialIcon {
- text: "chevron_right"
- color: Colours.palette.m3onPrimaryContainer
- font.pointSize: Appearance.font.size.large
- }
- }
+ onClicked: root.wrapper.detach("audio")
}
}
}
diff --git a/modules/bar/popouts/Bluetooth.qml b/modules/bar/popouts/Bluetooth.qml
index 53d8b29..4674905 100644
--- a/modules/bar/popouts/Bluetooth.qml
+++ b/modules/bar/popouts/Bluetooth.qml
@@ -9,6 +9,7 @@ import Quickshell
import Quickshell.Bluetooth
import QtQuick
import QtQuick.Layouts
+import "../../controlcenter/network"
ColumnLayout {
id: root
@@ -20,7 +21,7 @@ ColumnLayout {
StyledText {
Layout.topMargin: Appearance.padding.normal
Layout.rightMargin: Appearance.padding.small
- text: qsTr("Bluetooth %1").arg(BluetoothAdapterState.toString(Bluetooth.defaultAdapter?.state).toLowerCase())
+ text: qsTr("Bluetooth")
font.weight: 500
}
@@ -164,40 +165,16 @@ ColumnLayout {
}
}
- StyledRect {
- Layout.topMargin: Appearance.spacing.small
- implicitWidth: expandBtn.implicitWidth + Appearance.padding.normal * 2
- implicitHeight: expandBtn.implicitHeight + Appearance.padding.small
-
- radius: Appearance.rounding.normal
- color: Colours.palette.m3primaryContainer
-
- StateLayer {
- color: Colours.palette.m3onPrimaryContainer
-
- function onClicked(): void {
- root.wrapper.detach("bluetooth");
- }
- }
-
- RowLayout {
- id: expandBtn
-
- anchors.centerIn: parent
- spacing: Appearance.spacing.small
-
- StyledText {
- Layout.leftMargin: Appearance.padding.smaller
- text: qsTr("Open panel")
- color: Colours.palette.m3onPrimaryContainer
- }
+ IconTextButton {
+ Layout.fillWidth: true
+ Layout.topMargin: Appearance.spacing.normal
+ inactiveColour: Colours.palette.m3primaryContainer
+ inactiveOnColour: Colours.palette.m3onPrimaryContainer
+ verticalPadding: Appearance.padding.small
+ text: qsTr("Open settings")
+ icon: "settings"
- MaterialIcon {
- text: "chevron_right"
- color: Colours.palette.m3onPrimaryContainer
- font.pointSize: Appearance.font.size.large
- }
- }
+ onClicked: root.wrapper.detach("bluetooth")
}
component Toggle: RowLayout {
diff --git a/modules/bar/popouts/Content.qml b/modules/bar/popouts/Content.qml
index e3f569d..da993fa 100644
--- a/modules/bar/popouts/Content.qml
+++ b/modules/bar/popouts/Content.qml
@@ -32,8 +32,65 @@ Item {
}
Popout {
+ id: networkPopout
name: "network"
- sourceComponent: Network {}
+ sourceComponent: Network {
+ wrapper: root.wrapper
+ view: "wireless"
+ }
+ }
+
+ Popout {
+ name: "ethernet"
+ sourceComponent: Network {
+ wrapper: root.wrapper
+ view: "ethernet"
+ }
+ }
+
+ Popout {
+ id: passwordPopout
+ name: "wirelesspassword"
+ sourceComponent: WirelessPassword {
+ id: passwordComponent
+ wrapper: root.wrapper
+ network: networkPopout.item?.passwordNetwork ?? null
+ }
+
+ Connections {
+ target: root.wrapper
+ function onCurrentNameChanged() {
+ // Update network immediately when password popout becomes active
+ if (root.wrapper.currentName === "wirelesspassword") {
+ // Set network immediately if available
+ if (networkPopout.item && networkPopout.item.passwordNetwork) {
+ if (passwordPopout.item) {
+ passwordPopout.item.network = networkPopout.item.passwordNetwork;
+ }
+ }
+ // Also try after a short delay in case networkPopout.item wasn't ready
+ Qt.callLater(() => {
+ if (passwordPopout.item && networkPopout.item && networkPopout.item.passwordNetwork) {
+ passwordPopout.item.network = networkPopout.item.passwordNetwork;
+ }
+ }, 100);
+ }
+ }
+ }
+
+ Connections {
+ target: networkPopout
+ function onItemChanged() {
+ // When network popout loads, update password popout if it's active
+ if (root.wrapper.currentName === "wirelesspassword" && passwordPopout.item) {
+ Qt.callLater(() => {
+ if (networkPopout.item && networkPopout.item.passwordNetwork) {
+ passwordPopout.item.network = networkPopout.item.passwordNetwork;
+ }
+ });
+ }
+ }
+ }
}
Popout {
diff --git a/modules/bar/popouts/Network.qml b/modules/bar/popouts/Network.qml
index f21a92d..0e99613 100644
--- a/modules/bar/popouts/Network.qml
+++ b/modules/bar/popouts/Network.qml
@@ -12,35 +12,48 @@ import QtQuick.Layouts
ColumnLayout {
id: root
+ required property Item wrapper
+
property string connectingToSsid: ""
+ property string view: "wireless" // "wireless" or "ethernet"
+ property var passwordNetwork: null
+ property bool showPasswordDialog: false
spacing: Appearance.spacing.small
width: Config.bar.sizes.networkWidth
+ // Wireless section
StyledText {
- Layout.topMargin: Appearance.padding.normal
+ visible: root.view === "wireless"
+ Layout.preferredHeight: visible ? implicitHeight : 0
+ Layout.topMargin: visible ? Appearance.padding.normal : 0
Layout.rightMargin: Appearance.padding.small
- text: qsTr("Wifi %1").arg(Network.wifiEnabled ? "enabled" : "disabled")
+ text: qsTr("Wireless")
font.weight: 500
}
Toggle {
+ visible: root.view === "wireless"
+ Layout.preferredHeight: visible ? implicitHeight : 0
label: qsTr("Enabled")
- checked: Network.wifiEnabled
- toggle.onToggled: Network.enableWifi(checked)
+ checked: Nmcli.wifiEnabled
+ toggle.onToggled: Nmcli.enableWifi(checked)
}
StyledText {
- Layout.topMargin: Appearance.spacing.small
+ visible: root.view === "wireless"
+ Layout.preferredHeight: visible ? implicitHeight : 0
+ Layout.topMargin: visible ? Appearance.spacing.small : 0
Layout.rightMargin: Appearance.padding.small
- text: qsTr("%1 networks available").arg(Network.networks.length)
+ text: qsTr("%1 networks available").arg(Nmcli.networks.length)
color: Colours.palette.m3onSurfaceVariant
font.pointSize: Appearance.font.size.small
}
Repeater {
+ visible: root.view === "wireless"
model: ScriptModel {
- values: [...Network.networks].sort((a, b) => {
+ values: [...Nmcli.networks].sort((a, b) => {
if (a.active !== b.active)
return b.active - a.active;
return b.strength - a.strength;
@@ -50,10 +63,12 @@ ColumnLayout {
RowLayout {
id: networkItem
- required property Network.AccessPoint modelData
+ required property Nmcli.AccessPoint modelData
readonly property bool isConnecting: root.connectingToSsid === modelData.ssid
readonly property bool loading: networkItem.isConnecting
+ visible: root.view === "wireless"
+ Layout.preferredHeight: visible ? implicitHeight : 0
Layout.fillWidth: true
Layout.rightMargin: Appearance.padding.small
spacing: Appearance.spacing.small
@@ -96,10 +111,8 @@ ColumnLayout {
}
StyledRect {
- id: connectBtn
-
implicitWidth: implicitHeight
- implicitHeight: connectIcon.implicitHeight + Appearance.padding.small
+ implicitHeight: wirelessConnectIcon.implicitHeight + Appearance.padding.small
radius: Appearance.rounding.full
color: Qt.alpha(Colours.palette.m3primary, networkItem.modelData.active ? 1 : 0)
@@ -111,20 +124,32 @@ ColumnLayout {
StateLayer {
color: networkItem.modelData.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
- disabled: networkItem.loading || !Network.wifiEnabled
+ disabled: networkItem.loading || !Nmcli.wifiEnabled
function onClicked(): void {
if (networkItem.modelData.active) {
- Network.disconnectFromNetwork();
+ Nmcli.disconnectFromNetwork();
} else {
root.connectingToSsid = networkItem.modelData.ssid;
- Network.connectToNetwork(networkItem.modelData.ssid, "");
+ NetworkConnection.handleConnect(
+ networkItem.modelData,
+ null,
+ (network) => {
+ // Password is required - show password dialog
+ root.passwordNetwork = network;
+ root.showPasswordDialog = true;
+ root.wrapper.currentName = "wirelesspassword";
+ }
+ );
+
+ // Clear connecting state if connection succeeds immediately (saved profile)
+ // This is handled by the onActiveChanged connection below
}
}
}
MaterialIcon {
- id: connectIcon
+ id: wirelessConnectIcon
anchors.centerIn: parent
animate: true
@@ -142,7 +167,9 @@ ColumnLayout {
}
StyledRect {
- Layout.topMargin: Appearance.spacing.small
+ visible: root.view === "wireless"
+ Layout.preferredHeight: visible ? implicitHeight : 0
+ Layout.topMargin: visible ? Appearance.spacing.small : 0
Layout.fillWidth: true
implicitHeight: rescanBtn.implicitHeight + Appearance.padding.small * 2
@@ -151,10 +178,10 @@ ColumnLayout {
StateLayer {
color: Colours.palette.m3onPrimaryContainer
- disabled: Network.scanning || !Network.wifiEnabled
+ disabled: Nmcli.scanning || !Nmcli.wifiEnabled
function onClicked(): void {
- Network.rescanWifi();
+ Nmcli.rescanWifi();
}
}
@@ -163,17 +190,19 @@ ColumnLayout {
anchors.centerIn: parent
spacing: Appearance.spacing.small
- opacity: Network.scanning ? 0 : 1
+ opacity: Nmcli.scanning ? 0 : 1
MaterialIcon {
id: scanIcon
+ Layout.topMargin: Math.round(fontInfo.pointSize * 0.0575)
animate: true
text: "wifi_find"
color: Colours.palette.m3onPrimaryContainer
}
StyledText {
+ Layout.topMargin: -Math.round(scanIcon.fontInfo.pointSize * 0.0575)
text: qsTr("Rescan networks")
color: Colours.palette.m3onPrimaryContainer
}
@@ -188,26 +217,160 @@ ColumnLayout {
strokeWidth: Appearance.padding.small / 2
bgColour: "transparent"
implicitHeight: parent.implicitHeight - Appearance.padding.smaller * 2
- running: Network.scanning
+ running: Nmcli.scanning
+ }
+ }
+
+ // Ethernet section
+ StyledText {
+ visible: root.view === "ethernet"
+ Layout.preferredHeight: visible ? implicitHeight : 0
+ Layout.topMargin: visible ? Appearance.padding.normal : 0
+ Layout.rightMargin: Appearance.padding.small
+ text: qsTr("Ethernet")
+ font.weight: 500
+ }
+
+ StyledText {
+ visible: root.view === "ethernet"
+ Layout.preferredHeight: visible ? implicitHeight : 0
+ Layout.topMargin: visible ? Appearance.spacing.small : 0
+ Layout.rightMargin: Appearance.padding.small
+ text: qsTr("%1 devices available").arg(Nmcli.ethernetDevices.length)
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+ }
+
+ Repeater {
+ visible: root.view === "ethernet"
+ model: ScriptModel {
+ values: [...Nmcli.ethernetDevices].sort((a, b) => {
+ if (a.connected !== b.connected)
+ return b.connected - a.connected;
+ return (a.interface || "").localeCompare(b.interface || "");
+ }).slice(0, 8)
+ }
+
+ RowLayout {
+ id: ethernetItem
+
+ required property var modelData
+ readonly property bool loading: false
+
+ visible: root.view === "ethernet"
+ Layout.preferredHeight: visible ? implicitHeight : 0
+ Layout.fillWidth: true
+ Layout.rightMargin: Appearance.padding.small
+ spacing: Appearance.spacing.small
+
+ opacity: 0
+ scale: 0.7
+
+ Component.onCompleted: {
+ opacity = 1;
+ scale = 1;
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {}
+ }
+
+ MaterialIcon {
+ text: "cable"
+ color: ethernetItem.modelData.connected ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant
+ }
+
+ StyledText {
+ Layout.leftMargin: Appearance.spacing.small / 2
+ Layout.rightMargin: Appearance.spacing.small / 2
+ Layout.fillWidth: true
+ text: ethernetItem.modelData.interface || qsTr("Unknown")
+ elide: Text.ElideRight
+ font.weight: ethernetItem.modelData.connected ? 500 : 400
+ color: ethernetItem.modelData.connected ? Colours.palette.m3primary : Colours.palette.m3onSurface
+ }
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: connectIcon.implicitHeight + Appearance.padding.small
+
+ radius: Appearance.rounding.full
+ color: Qt.alpha(Colours.palette.m3primary, ethernetItem.modelData.connected ? 1 : 0)
+
+ CircularIndicator {
+ anchors.fill: parent
+ running: ethernetItem.loading
+ }
+
+ StateLayer {
+ color: ethernetItem.modelData.connected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
+ disabled: ethernetItem.loading
+
+ function onClicked(): void {
+ if (ethernetItem.modelData.connected && ethernetItem.modelData.connection) {
+ Nmcli.disconnectEthernet(ethernetItem.modelData.connection, () => {});
+ } else {
+ Nmcli.connectEthernet(ethernetItem.modelData.connection || "", ethernetItem.modelData.interface || "", () => {});
+ }
+ }
+ }
+
+ MaterialIcon {
+ id: connectIcon
+
+ anchors.centerIn: parent
+ animate: true
+ text: ethernetItem.modelData.connected ? "link_off" : "link"
+ color: ethernetItem.modelData.connected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
+
+ opacity: ethernetItem.loading ? 0 : 1
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+ }
}
}
- // Reset connecting state when network changes
Connections {
- target: Network
+ target: Nmcli
function onActiveChanged(): void {
- if (Network.active && root.connectingToSsid === Network.active.ssid) {
+ if (Nmcli.active && root.connectingToSsid === Nmcli.active.ssid) {
root.connectingToSsid = "";
+ // Close password dialog if we successfully connected
+ if (root.showPasswordDialog && root.passwordNetwork && Nmcli.active.ssid === root.passwordNetwork.ssid) {
+ root.showPasswordDialog = false;
+ root.passwordNetwork = null;
+ if (root.wrapper.currentName === "wirelesspassword") {
+ root.wrapper.currentName = "network";
+ }
+ }
}
}
function onScanningChanged(): void {
- if (!Network.scanning)
+ if (!Nmcli.scanning)
scanIcon.rotation = 0;
}
}
+ Connections {
+ target: root.wrapper
+ function onCurrentNameChanged(): void {
+ // Clear password network when leaving password dialog
+ if (root.wrapper.currentName !== "wirelesspassword" && root.showPasswordDialog) {
+ root.showPasswordDialog = false;
+ root.passwordNetwork = null;
+ }
+ }
+ }
+
component Toggle: RowLayout {
required property string label
property alias checked: toggle.checked
diff --git a/modules/bar/popouts/WirelessPassword.qml b/modules/bar/popouts/WirelessPassword.qml
new file mode 100644
index 0000000..5da50b6
--- /dev/null
+++ b/modules/bar/popouts/WirelessPassword.qml
@@ -0,0 +1,606 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property Item wrapper
+ property var network: null
+ property bool isClosing: false
+
+ readonly property bool shouldBeVisible: root.wrapper.currentName === "wirelesspassword"
+
+ Connections {
+ target: root.wrapper
+ function onCurrentNameChanged() {
+ if (root.wrapper.currentName === "wirelesspassword") {
+ // Update network when popout becomes active
+ Qt.callLater(() => {
+ // Try to get network from parent Content's networkPopout
+ const content = root.parent?.parent?.parent;
+ if (content) {
+ const networkPopout = content.children.find(c => c.name === "network");
+ if (networkPopout && networkPopout.item) {
+ root.network = networkPopout.item.passwordNetwork;
+ }
+ }
+ // Force focus to password container when popout becomes active
+ // Use Timer for actual delay to ensure dialog is fully rendered
+ focusTimer.start();
+ });
+ }
+ }
+ }
+
+ Timer {
+ id: focusTimer
+ interval: 150
+ onTriggered: {
+ root.forceActiveFocus();
+ passwordContainer.forceActiveFocus();
+ }
+ }
+
+ spacing: Appearance.spacing.normal
+
+ implicitWidth: 400
+ implicitHeight: content.implicitHeight + Appearance.padding.large * 2
+
+ visible: shouldBeVisible || isClosing
+ enabled: shouldBeVisible && !isClosing
+ focus: enabled
+
+ Component.onCompleted: {
+ if (shouldBeVisible) {
+ // Use Timer for actual delay to ensure dialog is fully rendered
+ focusTimer.start();
+ }
+ }
+
+ onShouldBeVisibleChanged: {
+ if (shouldBeVisible) {
+ // Use Timer for actual delay to ensure dialog is fully rendered
+ focusTimer.start();
+ }
+ }
+
+ Keys.onEscapePressed: closeDialog()
+
+ StyledRect {
+ Layout.fillWidth: true
+ Layout.preferredWidth: 400
+ implicitHeight: content.implicitHeight + Appearance.padding.large * 2
+
+ radius: Appearance.rounding.normal
+ color: Colours.tPalette.m3surfaceContainer
+ visible: root.shouldBeVisible || root.isClosing
+ opacity: root.shouldBeVisible && !root.isClosing ? 1 : 0
+ scale: root.shouldBeVisible && !root.isClosing ? 1 : 0.7
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {}
+ }
+
+ ParallelAnimation {
+ running: root.isClosing
+ onFinished: {
+ if (root.isClosing) {
+ root.isClosing = false;
+ }
+ }
+
+ Anim {
+ target: parent
+ property: "opacity"
+ to: 0
+ }
+ Anim {
+ target: parent
+ property: "scale"
+ to: 0.7
+ }
+ }
+
+ Keys.onEscapePressed: root.closeDialog()
+
+ ColumnLayout {
+ id: content
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignHCenter
+ text: "lock"
+ font.pointSize: Appearance.font.size.extraLarge * 2
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("Enter password")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ StyledText {
+ id: networkNameText
+ Layout.alignment: Qt.AlignHCenter
+ text: {
+ if (root.network) {
+ const ssid = root.network.ssid;
+ if (ssid && ssid.length > 0) {
+ return qsTr("Network: %1").arg(ssid);
+ }
+ }
+ return qsTr("Network: Unknown");
+ }
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ Timer {
+ interval: 50
+ running: root.shouldBeVisible && (!root.network || !root.network.ssid)
+ repeat: true
+ property int attempts: 0
+ onTriggered: {
+ attempts++;
+ // Keep trying to get network from Network component
+ const content = root.parent?.parent?.parent;
+ if (content) {
+ const networkPopout = content.children.find(c => c.name === "network");
+ if (networkPopout && networkPopout.item && networkPopout.item.passwordNetwork) {
+ root.network = networkPopout.item.passwordNetwork;
+ }
+ }
+ // Stop if we got it or after 20 attempts (1 second)
+ if ((root.network && root.network.ssid) || attempts >= 20) {
+ stop();
+ attempts = 0;
+ }
+ }
+ onRunningChanged: {
+ if (!running) {
+ attempts = 0;
+ }
+ }
+ }
+
+ StyledText {
+ id: statusText
+
+ Layout.alignment: Qt.AlignHCenter
+ Layout.topMargin: Appearance.spacing.small
+ visible: connectButton.connecting || connectButton.hasError
+ text: {
+ if (connectButton.hasError) {
+ return qsTr("Connection failed. Please check your password and try again.");
+ }
+ if (connectButton.connecting) {
+ return qsTr("Connecting...");
+ }
+ return "";
+ }
+ color: connectButton.hasError ? Colours.palette.m3error : Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+ font.weight: 400
+ wrapMode: Text.WordWrap
+ Layout.maximumWidth: parent.width - Appearance.padding.large * 2
+ }
+
+ FocusScope {
+ id: passwordContainer
+ objectName: "passwordContainer"
+ Layout.topMargin: Appearance.spacing.large
+ Layout.fillWidth: true
+ implicitHeight: Math.max(48, charList.implicitHeight + Appearance.padding.normal * 2)
+
+ focus: true
+ activeFocusOnTab: true
+
+ property string passwordBuffer: ""
+
+ Keys.onPressed: event => {
+ // Ensure we have focus when receiving keyboard input
+ if (!activeFocus) {
+ forceActiveFocus();
+ }
+
+ // Clear error when user starts typing
+ if (connectButton.hasError && event.text && event.text.length > 0) {
+ connectButton.hasError = false;
+ }
+
+ if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) {
+ if (connectButton.enabled) {
+ connectButton.clicked();
+ }
+ event.accepted = true;
+ } else if (event.key === Qt.Key_Backspace) {
+ if (event.modifiers & Qt.ControlModifier) {
+ passwordBuffer = "";
+ } else {
+ passwordBuffer = passwordBuffer.slice(0, -1);
+ }
+ event.accepted = true;
+ } else if (event.text && event.text.length > 0) {
+ passwordBuffer += event.text;
+ event.accepted = true;
+ }
+ }
+
+ Connections {
+ target: root
+ function onShouldBeVisibleChanged(): void {
+ if (root.shouldBeVisible) {
+ // Use Timer for actual delay to ensure focus works correctly
+ passwordFocusTimer.start();
+ passwordContainer.passwordBuffer = "";
+ connectButton.hasError = false;
+ }
+ }
+ }
+
+ Timer {
+ id: passwordFocusTimer
+ interval: 50
+ onTriggered: {
+ passwordContainer.forceActiveFocus();
+ }
+ }
+
+ Component.onCompleted: {
+ if (root.shouldBeVisible) {
+ // Use Timer for actual delay to ensure focus works correctly
+ passwordFocusTimer.start();
+ }
+ }
+
+ StyledRect {
+ anchors.fill: parent
+ radius: Appearance.rounding.normal
+ color: passwordContainer.activeFocus ? Qt.lighter(Colours.tPalette.m3surfaceContainer, 1.05) : Colours.tPalette.m3surfaceContainer
+ border.width: passwordContainer.activeFocus || connectButton.hasError ? 4 : (root.shouldBeVisible ? 1 : 0)
+ border.color: {
+ if (connectButton.hasError) {
+ return Colours.palette.m3error;
+ }
+ if (passwordContainer.activeFocus) {
+ return Colours.palette.m3primary;
+ }
+ return root.shouldBeVisible ? Colours.palette.m3outline : "transparent";
+ }
+
+ Behavior on border.color {
+ CAnim {}
+ }
+
+ Behavior on border.width {
+ CAnim {}
+ }
+
+ Behavior on color {
+ CAnim {}
+ }
+ }
+
+ StateLayer {
+ hoverEnabled: false
+ cursorShape: Qt.IBeamCursor
+ radius: Appearance.rounding.normal
+
+ function onClicked(): void {
+ passwordContainer.forceActiveFocus();
+ }
+ }
+
+ StyledText {
+ id: placeholder
+
+ anchors.centerIn: parent
+ text: qsTr("Password")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.normal
+ font.family: Appearance.font.family.mono
+ opacity: passwordContainer.passwordBuffer ? 0 : 1
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ ListView {
+ id: charList
+
+ readonly property int fullWidth: count * (implicitHeight + spacing) - spacing
+
+ anchors.centerIn: parent
+ implicitWidth: fullWidth
+ implicitHeight: Appearance.font.size.normal
+
+ orientation: Qt.Horizontal
+ spacing: Appearance.spacing.small / 2
+ interactive: false
+
+ model: ScriptModel {
+ values: passwordContainer.passwordBuffer.split("")
+ }
+
+ delegate: StyledRect {
+ id: ch
+
+ implicitWidth: implicitHeight
+ implicitHeight: charList.implicitHeight
+
+ color: Colours.palette.m3onSurface
+ radius: Appearance.rounding.small / 2
+
+ opacity: 0
+ scale: 0
+ Component.onCompleted: {
+ opacity = 1;
+ scale = 1;
+ }
+ ListView.onRemove: removeAnim.start()
+
+ SequentialAnimation {
+ id: removeAnim
+
+ PropertyAction {
+ target: ch
+ property: "ListView.delayRemove"
+ value: true
+ }
+ ParallelAnimation {
+ Anim {
+ target: ch
+ property: "opacity"
+ to: 0
+ }
+ Anim {
+ target: ch
+ property: "scale"
+ to: 0.5
+ }
+ }
+ PropertyAction {
+ target: ch
+ property: "ListView.delayRemove"
+ value: false
+ }
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+ }
+
+ Behavior on implicitWidth {
+ Anim {}
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.topMargin: Appearance.spacing.normal
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ TextButton {
+ id: cancelButton
+
+ Layout.fillWidth: true
+ Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2
+ inactiveColour: Colours.palette.m3secondaryContainer
+ inactiveOnColour: Colours.palette.m3onSecondaryContainer
+ text: qsTr("Cancel")
+
+ onClicked: root.closeDialog()
+ }
+
+ TextButton {
+ id: connectButton
+
+ property bool connecting: false
+ property bool hasError: false
+
+ Layout.fillWidth: true
+ Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2
+ inactiveColour: Colours.palette.m3primary
+ inactiveOnColour: Colours.palette.m3onPrimary
+ text: qsTr("Connect")
+ enabled: passwordContainer.passwordBuffer.length > 0 && !connecting
+
+ onClicked: {
+ if (!root.network || connecting) {
+ return;
+ }
+
+ const password = passwordContainer.passwordBuffer;
+ if (!password || password.length === 0) {
+ return;
+ }
+
+ // Clear any previous error
+ hasError = false;
+
+ // Set connecting state
+ connecting = true;
+ enabled = false;
+ text = qsTr("Connecting...");
+
+ // Connect to network
+ NetworkConnection.connectWithPassword(root.network, password, result => {
+ if (result && result.success)
+ // Connection successful, monitor will handle the rest
+ {} else if (result && result.needsPassword) {
+ // Shouldn't happen since we provided password
+ connectionMonitor.stop();
+ connecting = false;
+ hasError = true;
+ enabled = true;
+ text = qsTr("Connect");
+ passwordContainer.passwordBuffer = "";
+ // Delete the failed connection
+ if (root.network && root.network.ssid) {
+ Nmcli.forgetNetwork(root.network.ssid);
+ }
+ } else {
+ // Connection failed immediately - show error
+ connectionMonitor.stop();
+ connecting = false;
+ hasError = true;
+ enabled = true;
+ text = qsTr("Connect");
+ passwordContainer.passwordBuffer = "";
+ // Delete the failed connection
+ if (root.network && root.network.ssid) {
+ Nmcli.forgetNetwork(root.network.ssid);
+ }
+ }
+ });
+
+ // Start monitoring connection
+ connectionMonitor.start();
+ }
+ }
+ }
+ }
+ }
+
+ function checkConnectionStatus(): void {
+ if (!root.shouldBeVisible || !connectButton.connecting) {
+ return;
+ }
+
+ // Check if we're connected to the target network (case-insensitive SSID comparison)
+ const isConnected = root.network && Nmcli.active && Nmcli.active.ssid && Nmcli.active.ssid.toLowerCase().trim() === root.network.ssid.toLowerCase().trim();
+
+ if (isConnected) {
+ // Successfully connected - give it a moment for network list to update
+ // Use Timer for actual delay
+ connectionSuccessTimer.start();
+ return;
+ }
+
+ // Check for connection failures - if pending connection was cleared but we're not connected
+ if (Nmcli.pendingConnection === null && connectButton.connecting) {
+ // Wait a bit more before giving up (allow time for connection to establish)
+ if (connectionMonitor.repeatCount > 10) {
+ connectionMonitor.stop();
+ connectButton.connecting = false;
+ connectButton.hasError = true;
+ connectButton.enabled = true;
+ connectButton.text = qsTr("Connect");
+ passwordContainer.passwordBuffer = "";
+ // Delete the failed connection
+ if (root.network && root.network.ssid) {
+ Nmcli.forgetNetwork(root.network.ssid);
+ }
+ }
+ }
+ }
+
+ Timer {
+ id: connectionMonitor
+ interval: 1000
+ repeat: true
+ triggeredOnStart: false
+ property int repeatCount: 0
+
+ onTriggered: {
+ repeatCount++;
+ root.checkConnectionStatus();
+ }
+
+ onRunningChanged: {
+ if (!running) {
+ repeatCount = 0;
+ }
+ }
+ }
+
+ Timer {
+ id: connectionSuccessTimer
+ interval: 500
+ onTriggered: {
+ // Double-check connection is still active
+ if (root.shouldBeVisible && Nmcli.active && Nmcli.active.ssid) {
+ const stillConnected = Nmcli.active.ssid.toLowerCase().trim() === root.network.ssid.toLowerCase().trim();
+ if (stillConnected) {
+ connectionMonitor.stop();
+ connectButton.connecting = false;
+ connectButton.text = qsTr("Connect");
+ // Return to network popout on successful connection
+ if (root.wrapper.currentName === "wirelesspassword") {
+ root.wrapper.currentName = "network";
+ }
+ closeDialog();
+ }
+ }
+ }
+ }
+
+ Connections {
+ target: Nmcli
+ function onActiveChanged() {
+ if (root.shouldBeVisible) {
+ root.checkConnectionStatus();
+ }
+ }
+ function onConnectionFailed(ssid: string) {
+ if (root.shouldBeVisible && root.network && root.network.ssid === ssid && connectButton.connecting) {
+ connectionMonitor.stop();
+ connectButton.connecting = false;
+ connectButton.hasError = true;
+ connectButton.enabled = true;
+ connectButton.text = qsTr("Connect");
+ passwordContainer.passwordBuffer = "";
+ // Delete the failed connection
+ Nmcli.forgetNetwork(ssid);
+ }
+ }
+ }
+
+ function closeDialog(): void {
+ if (isClosing) {
+ return;
+ }
+
+ isClosing = true;
+ passwordContainer.passwordBuffer = "";
+ connectButton.connecting = false;
+ connectButton.hasError = false;
+ connectButton.text = qsTr("Connect");
+ connectionMonitor.stop();
+
+ // Return to network popout
+ if (root.wrapper.currentName === "wirelesspassword") {
+ root.wrapper.currentName = "network";
+ }
+ }
+}
+
diff --git a/modules/bar/popouts/Wrapper.qml b/modules/bar/popouts/Wrapper.qml
index 4be47e4..5ef4f9d 100644
--- a/modules/bar/popouts/Wrapper.qml
+++ b/modules/bar/popouts/Wrapper.qml
@@ -55,7 +55,25 @@ Item {
implicitWidth: nonAnimWidth
implicitHeight: nonAnimHeight
- Keys.onEscapePressed: close()
+ focus: hasCurrent
+ Keys.onEscapePressed: {
+ // Forward escape to password popout if active, otherwise close
+ if (currentName === "wirelesspassword" && content.item) {
+ const passwordPopout = content.item.children.find(c => c.name === "wirelesspassword");
+ if (passwordPopout && passwordPopout.item) {
+ passwordPopout.item.closeDialog();
+ return;
+ }
+ }
+ close();
+ }
+
+ Keys.onPressed: event => {
+ // Don't intercept keys when password popout is active - let it handle them
+ if (currentName === "wirelesspassword") {
+ event.accepted = false;
+ }
+ }
HyprlandFocusGrab {
active: root.isDetached
@@ -70,6 +88,14 @@ Item {
property: "WlrLayershell.keyboardFocus"
value: WlrKeyboardFocus.OnDemand
}
+
+ Binding {
+ when: root.hasCurrent && root.currentName === "wirelesspassword"
+
+ target: QsWindow.window
+ property: "WlrLayershell.keyboardFocus"
+ value: WlrKeyboardFocus.OnDemand
+ }
Comp {
id: content
diff --git a/modules/controlcenter/ControlCenter.qml b/modules/controlcenter/ControlCenter.qml
index 8cdf01f..fdb824e 100644
--- a/modules/controlcenter/ControlCenter.qml
+++ b/modules/controlcenter/ControlCenter.qml
@@ -64,6 +64,11 @@ Item {
anchors.fill: parent
function onWheel(event: WheelEvent): void {
+ // Prevent tab switching during initial opening animation to avoid blank pages
+ if (!panes.initialOpeningComplete) {
+ return;
+ }
+
if (event.angleDelta.y < 0)
root.session.activeIndex = Math.min(root.session.activeIndex + 1, root.session.panes.length - 1);
else if (event.angleDelta.y > 0)
@@ -76,10 +81,13 @@ Item {
screen: root.screen
session: root.session
+ initialOpeningComplete: root.initialOpeningComplete
}
}
Panes {
+ id: panes
+
Layout.fillWidth: true
Layout.fillHeight: true
@@ -88,4 +96,6 @@ Item {
session: root.session
}
}
+
+ readonly property bool initialOpeningComplete: panes.initialOpeningComplete
}
diff --git a/modules/controlcenter/NavRail.qml b/modules/controlcenter/NavRail.qml
index 22c13a3..ef338b2 100644
--- a/modules/controlcenter/NavRail.qml
+++ b/modules/controlcenter/NavRail.qml
@@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound
import qs.components
import qs.services
import qs.config
+import qs.modules.controlcenter
import Quickshell
import QtQuick
import QtQuick.Layouts
@@ -12,6 +13,7 @@ Item {
required property ShellScreen screen
required property Session session
+ required property bool initialOpeningComplete
implicitWidth: layout.implicitWidth + Appearance.padding.larger * 4
implicitHeight: layout.implicitHeight + Appearance.padding.large * 2
@@ -30,57 +32,17 @@ Item {
PropertyChanges {
layout.spacing: Appearance.spacing.small
- menuIcon.opacity: 0
- menuIconExpanded.opacity: 1
- menuIcon.rotation: 180
- menuIconExpanded.rotation: 0
}
}
transitions: Transition {
Anim {
- properties: "spacing,opacity,rotation"
- }
- }
-
- Item {
- id: menuBtn
-
- Layout.topMargin: Appearance.spacing.large
- implicitWidth: menuIcon.implicitWidth + menuIcon.anchors.leftMargin * 2
- implicitHeight: menuIcon.implicitHeight + Appearance.padding.normal * 2
-
- StateLayer {
- radius: Appearance.rounding.small
-
- function onClicked(): void {
- root.session.navExpanded = !root.session.navExpanded;
- }
- }
-
- MaterialIcon {
- id: menuIcon
-
- anchors.left: parent.left
- anchors.verticalCenter: parent.verticalCenter
- anchors.leftMargin: Appearance.padding.large
-
- text: "menu"
- font.pointSize: Appearance.font.size.large
- }
-
- MaterialIcon {
- id: menuIconExpanded
-
- anchors.fill: menuIcon
- text: "menu_open"
- font.pointSize: menuIcon.font.pointSize
- opacity: 0
- rotation: -180
+ properties: "spacing"
}
}
Loader {
+ Layout.topMargin: Appearance.spacing.large
asynchronous: true
active: !root.session.floating
visible: active
@@ -102,7 +64,6 @@ Item {
function onClicked(): void {
root.session.root.close();
WindowFactory.create(null, {
- screen: root.screen,
active: root.session.active,
navExpanded: root.session.navExpanded
});
@@ -156,20 +117,15 @@ Item {
}
}
- NavItem {
- Layout.topMargin: Appearance.spacing.large * 2
- icon: "network_manage"
- label: "network"
- }
+ Repeater {
+ model: PaneRegistry.count
- NavItem {
- icon: "settings_bluetooth"
- label: "bluetooth"
- }
-
- NavItem {
- icon: "tune"
- label: "audio"
+ NavItem {
+ required property int index
+ Layout.topMargin: index === 0 ? Appearance.spacing.large * 2 : 0
+ icon: PaneRegistry.getByIndex(index).icon
+ label: PaneRegistry.getByIndex(index).label
+ }
}
}
@@ -222,6 +178,10 @@ Item {
color: item.active ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
function onClicked(): void {
+ // Prevent tab switching during initial opening animation to avoid blank pages
+ if (!root.initialOpeningComplete) {
+ return;
+ }
root.session.active = item.label;
}
}
diff --git a/modules/controlcenter/PaneRegistry.qml b/modules/controlcenter/PaneRegistry.qml
new file mode 100644
index 0000000..d8bf45e
--- /dev/null
+++ b/modules/controlcenter/PaneRegistry.qml
@@ -0,0 +1,87 @@
+pragma Singleton
+
+import QtQuick
+
+QtObject {
+ id: root
+
+ readonly property list<QtObject> panes: [
+ QtObject {
+ readonly property string id: "network"
+ readonly property string label: "network"
+ readonly property string icon: "router"
+ readonly property string component: "network/NetworkingPane.qml"
+ },
+ QtObject {
+ readonly property string id: "bluetooth"
+ readonly property string label: "bluetooth"
+ readonly property string icon: "settings_bluetooth"
+ readonly property string component: "bluetooth/BtPane.qml"
+ },
+ QtObject {
+ readonly property string id: "audio"
+ readonly property string label: "audio"
+ readonly property string icon: "volume_up"
+ readonly property string component: "audio/AudioPane.qml"
+ },
+ QtObject {
+ readonly property string id: "appearance"
+ readonly property string label: "appearance"
+ readonly property string icon: "palette"
+ readonly property string component: "appearance/AppearancePane.qml"
+ },
+ QtObject {
+ readonly property string id: "taskbar"
+ readonly property string label: "taskbar"
+ readonly property string icon: "task_alt"
+ readonly property string component: "taskbar/TaskbarPane.qml"
+ },
+ QtObject {
+ readonly property string id: "launcher"
+ readonly property string label: "launcher"
+ readonly property string icon: "apps"
+ readonly property string component: "launcher/LauncherPane.qml"
+ }
+ ]
+
+ readonly property int count: panes.length
+
+ readonly property var labels: {
+ const result = [];
+ for (let i = 0; i < panes.length; i++) {
+ result.push(panes[i].label);
+ }
+ return result;
+ }
+
+ function getByIndex(index: int): QtObject {
+ if (index >= 0 && index < panes.length) {
+ return panes[index];
+ }
+ return null;
+ }
+
+ function getIndexByLabel(label: string): int {
+ for (let i = 0; i < panes.length; i++) {
+ if (panes[i].label === label) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ function getByLabel(label: string): QtObject {
+ const index = getIndexByLabel(label);
+ return getByIndex(index);
+ }
+
+ function getById(id: string): QtObject {
+ for (let i = 0; i < panes.length; i++) {
+ if (panes[i].id === id) {
+ return panes[i];
+ }
+ }
+ return null;
+ }
+}
+
diff --git a/modules/controlcenter/Panes.qml b/modules/controlcenter/Panes.qml
index 2548c3d..833a411 100644
--- a/modules/controlcenter/Panes.qml
+++ b/modules/controlcenter/Panes.qml
@@ -1,9 +1,15 @@
pragma ComponentBehavior: Bound
import "bluetooth"
+import "network"
+import "audio"
+import "appearance"
+import "taskbar"
+import "launcher"
import qs.components
import qs.services
import qs.config
+import qs.modules.controlcenter
import Quickshell.Widgets
import QtQuick
import QtQuick.Layouts
@@ -13,74 +19,156 @@ ClippingRectangle {
required property Session session
+ readonly property bool initialOpeningComplete: layout.initialOpeningComplete
+
color: "transparent"
+ clip: true
+ focus: false
+ activeFocusOnTab: false
+
+ MouseArea {
+ anchors.fill: parent
+ z: -1
+ onPressed: function(mouse) {
+ root.focus = true;
+ mouse.accepted = false;
+ }
+ }
+
+ Connections {
+ target: root.session
+
+ function onActiveIndexChanged(): void {
+ root.focus = true;
+ }
+ }
ColumnLayout {
id: layout
spacing: 0
y: -root.session.activeIndex * root.height
+ clip: true
- Pane {
- index: 0
- sourceComponent: Item {
- StyledText {
- anchors.centerIn: parent
- text: qsTr("Work in progress")
- color: Colours.palette.m3outline
- font.pointSize: Appearance.font.size.extraLarge
- font.weight: 500
- }
+ property bool animationComplete: true
+ property bool initialOpeningComplete: false
+
+ Timer {
+ id: animationDelayTimer
+ interval: Appearance.anim.durations.normal
+ onTriggered: {
+ layout.animationComplete = true;
}
}
- Pane {
- index: 1
- sourceComponent: BtPane {
- session: root.session
+ Timer {
+ id: initialOpeningTimer
+ interval: Appearance.anim.durations.large
+ running: true
+ onTriggered: {
+ layout.initialOpeningComplete = true;
}
}
- Pane {
- index: 2
- sourceComponent: Item {
- StyledText {
- anchors.centerIn: parent
- text: qsTr("Work in progress")
- color: Colours.palette.m3outline
- font.pointSize: Appearance.font.size.extraLarge
- font.weight: 500
- }
+ Repeater {
+ model: PaneRegistry.count
+
+ Pane {
+ required property int index
+ paneIndex: index
+ componentPath: PaneRegistry.getByIndex(index).component
}
}
Behavior on y {
Anim {}
}
+
+ Connections {
+ target: root.session
+ function onActiveIndexChanged(): void {
+ layout.animationComplete = false;
+ animationDelayTimer.restart();
+ }
+ }
}
component Pane: Item {
id: pane
- required property int index
- property alias sourceComponent: loader.sourceComponent
+ required property int paneIndex
+ required property string componentPath
implicitWidth: root.width
implicitHeight: root.height
+ property bool hasBeenLoaded: false
+
+ function updateActive(): void {
+ const diff = Math.abs(root.session.activeIndex - pane.paneIndex);
+ const isActivePane = diff === 0;
+ let shouldBeActive = false;
+
+ if (!layout.initialOpeningComplete) {
+ shouldBeActive = isActivePane;
+ } else {
+ if (diff <= 1) {
+ shouldBeActive = true;
+ } else if (pane.hasBeenLoaded) {
+ shouldBeActive = true;
+ } else {
+ shouldBeActive = layout.animationComplete;
+ }
+ }
+
+ loader.active = shouldBeActive;
+ }
+
Loader {
id: loader
anchors.fill: parent
- clip: true
+ clip: false
asynchronous: true
- active: {
- if (root.session.activeIndex === pane.index)
- return true;
-
- const ly = -layout.y;
- const ty = pane.index * root.height;
- return ly + root.height > ty && ly < ty + root.height;
+ active: false
+
+ Component.onCompleted: {
+ pane.updateActive();
+ }
+
+ onActiveChanged: {
+ if (active && !pane.hasBeenLoaded) {
+ pane.hasBeenLoaded = true;
+ }
+
+ if (active && !item) {
+ loader.setSource(pane.componentPath, {
+ "session": root.session
+ });
+ }
+ }
+
+ onItemChanged: {
+ if (item) {
+ pane.hasBeenLoaded = true;
+ }
+ }
+ }
+
+ Connections {
+ target: root.session
+ function onActiveIndexChanged(): void {
+ pane.updateActive();
+ }
+ }
+
+ Connections {
+ target: layout
+ function onInitialOpeningCompleteChanged(): void {
+ pane.updateActive();
+ }
+ function onAnimationCompleteChanged(): void {
+ pane.updateActive();
}
}
}
diff --git a/modules/controlcenter/Session.qml b/modules/controlcenter/Session.qml
index a143470..0408a1a 100644
--- a/modules/controlcenter/Session.qml
+++ b/modules/controlcenter/Session.qml
@@ -1,25 +1,21 @@
-import Quickshell.Bluetooth
import QtQuick
+import "./state"
+import qs.modules.controlcenter
QtObject {
- readonly property list<string> panes: ["network", "bluetooth", "audio"]
+ readonly property list<string> panes: PaneRegistry.labels
required property var root
property bool floating: false
- property string active: panes[0]
+ property string active: "network"
property int activeIndex: 0
property bool navExpanded: false
- readonly property Bt bt: Bt {}
+ readonly property BluetoothState bt: BluetoothState {}
+ readonly property NetworkState network: NetworkState {}
+ readonly property EthernetState ethernet: EthernetState {}
+ readonly property LauncherState launcher: LauncherState {}
onActiveChanged: activeIndex = panes.indexOf(active)
onActiveIndexChanged: active = panes[activeIndex]
-
- component Bt: QtObject {
- property BluetoothDevice active
- property BluetoothAdapter currentAdapter: Bluetooth.defaultAdapter
- property bool editingAdapterName
- property bool fabMenuOpen
- property bool editingDeviceName
- }
}
diff --git a/modules/controlcenter/WindowFactory.qml b/modules/controlcenter/WindowFactory.qml
index c5b7535..abcf5df 100644
--- a/modules/controlcenter/WindowFactory.qml
+++ b/modules/controlcenter/WindowFactory.qml
@@ -32,12 +32,14 @@ Singleton {
destroy();
}
- minimumSize.width: 1000
- minimumSize.height: 600
-
implicitWidth: cc.implicitWidth
implicitHeight: cc.implicitHeight
+ minimumSize.width: implicitWidth
+ minimumSize.height: implicitHeight
+ maximumSize.width: implicitWidth
+ maximumSize.height: implicitHeight
+
title: qsTr("Caelestia Settings - %1").arg(cc.active.slice(0, 1).toUpperCase() + cc.active.slice(1))
ControlCenter {
diff --git a/modules/controlcenter/appearance/AppearancePane.qml b/modules/controlcenter/appearance/AppearancePane.qml
new file mode 100644
index 0000000..b6acbe5
--- /dev/null
+++ b/modules/controlcenter/appearance/AppearancePane.qml
@@ -0,0 +1,244 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import "./sections"
+import "../../launcher/services"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.components.images
+import qs.services
+import qs.config
+import qs.utils
+import Caelestia.Models
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Session session
+
+ property real animDurationsScale: Config.appearance.anim.durations.scale ?? 1
+ property string fontFamilyMaterial: Config.appearance.font.family.material ?? "Material Symbols Rounded"
+ property string fontFamilyMono: Config.appearance.font.family.mono ?? "CaskaydiaCove NF"
+ property string fontFamilySans: Config.appearance.font.family.sans ?? "Rubik"
+ property real fontSizeScale: Config.appearance.font.size.scale ?? 1
+ property real paddingScale: Config.appearance.padding.scale ?? 1
+ property real roundingScale: Config.appearance.rounding.scale ?? 1
+ property real spacingScale: Config.appearance.spacing.scale ?? 1
+ property bool transparencyEnabled: Config.appearance.transparency.enabled ?? false
+ property real transparencyBase: Config.appearance.transparency.base ?? 0.85
+ property real transparencyLayers: Config.appearance.transparency.layers ?? 0.4
+ property real borderRounding: Config.border.rounding ?? 1
+ property real borderThickness: Config.border.thickness ?? 1
+
+ property bool desktopClockEnabled: Config.background.desktopClock.enabled ?? false
+ property bool backgroundEnabled: Config.background.enabled ?? true
+ property bool visualiserEnabled: Config.background.visualiser.enabled ?? false
+ property bool visualiserAutoHide: Config.background.visualiser.autoHide ?? true
+ property real visualiserRounding: Config.background.visualiser.rounding ?? 1
+ property real visualiserSpacing: Config.background.visualiser.spacing ?? 1
+
+ anchors.fill: parent
+
+ function saveConfig() {
+ Config.appearance.anim.durations.scale = root.animDurationsScale;
+
+ Config.appearance.font.family.material = root.fontFamilyMaterial;
+ Config.appearance.font.family.mono = root.fontFamilyMono;
+ Config.appearance.font.family.sans = root.fontFamilySans;
+ Config.appearance.font.size.scale = root.fontSizeScale;
+
+ Config.appearance.padding.scale = root.paddingScale;
+ Config.appearance.rounding.scale = root.roundingScale;
+ Config.appearance.spacing.scale = root.spacingScale;
+
+ Config.appearance.transparency.enabled = root.transparencyEnabled;
+ Config.appearance.transparency.base = root.transparencyBase;
+ Config.appearance.transparency.layers = root.transparencyLayers;
+
+ Config.background.desktopClock.enabled = root.desktopClockEnabled;
+ Config.background.enabled = root.backgroundEnabled;
+
+ Config.background.visualiser.enabled = root.visualiserEnabled;
+ Config.background.visualiser.autoHide = root.visualiserAutoHide;
+ Config.background.visualiser.rounding = root.visualiserRounding;
+ Config.background.visualiser.spacing = root.visualiserSpacing;
+
+ Config.border.rounding = root.borderRounding;
+ Config.border.thickness = root.borderThickness;
+
+ Config.save();
+ }
+
+ Component {
+ id: appearanceRightContentComponent
+
+ Item {
+ id: rightAppearanceFlickable
+
+ ColumnLayout {
+ id: contentLayout
+
+ anchors.fill: parent
+ spacing: 0
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.bottomMargin: Appearance.spacing.normal
+ text: qsTr("Wallpaper")
+ font.pointSize: Appearance.font.size.extraLarge
+ font.weight: 600
+ }
+
+ Loader {
+ id: wallpaperLoader
+
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.bottomMargin: -Appearance.padding.large * 2
+
+ asynchronous: true
+ active: {
+ const isActive = root.session.activeIndex === 3;
+ const isAdjacent = Math.abs(root.session.activeIndex - 3) === 1;
+ const splitLayout = root.children[0];
+ const loader = splitLayout && splitLayout.rightLoader ? splitLayout.rightLoader : null;
+ const shouldActivate = loader && loader.item !== null && (isActive || isAdjacent);
+ return shouldActivate;
+ }
+
+ onStatusChanged: {
+ if (status === Loader.Error) {
+ console.error("[AppearancePane] Wallpaper loader error!");
+ }
+ }
+
+ sourceComponent: WallpaperGrid {
+ session: root.session
+ }
+ }
+ }
+ }
+ }
+
+ SplitPaneLayout {
+ anchors.fill: parent
+
+ leftContent: Component {
+
+ StyledFlickable {
+ id: sidebarFlickable
+ readonly property var rootPane: root
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: sidebarLayout.height
+
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: sidebarFlickable
+ }
+
+ ColumnLayout {
+ id: sidebarLayout
+ anchors.left: parent.left
+ anchors.right: parent.right
+ spacing: Appearance.spacing.small
+
+ readonly property var rootPane: sidebarFlickable.rootPane
+
+ readonly property bool allSectionsExpanded:
+ themeModeSection.expanded &&
+ colorVariantSection.expanded &&
+ colorSchemeSection.expanded &&
+ animationsSection.expanded &&
+ fontsSection.expanded &&
+ scalesSection.expanded &&
+ transparencySection.expanded &&
+ borderSection.expanded &&
+ backgroundSection.expanded
+
+ RowLayout {
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ text: qsTr("Appearance")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ IconButton {
+ icon: sidebarLayout.allSectionsExpanded ? "unfold_less" : "unfold_more"
+ type: IconButton.Text
+ label.animate: true
+ onClicked: {
+ const shouldExpand = !sidebarLayout.allSectionsExpanded;
+ themeModeSection.expanded = shouldExpand;
+ colorVariantSection.expanded = shouldExpand;
+ colorSchemeSection.expanded = shouldExpand;
+ animationsSection.expanded = shouldExpand;
+ fontsSection.expanded = shouldExpand;
+ scalesSection.expanded = shouldExpand;
+ transparencySection.expanded = shouldExpand;
+ borderSection.expanded = shouldExpand;
+ backgroundSection.expanded = shouldExpand;
+ }
+ }
+ }
+
+ ThemeModeSection {
+ id: themeModeSection
+ }
+
+ ColorVariantSection {
+ id: colorVariantSection
+ }
+
+ ColorSchemeSection {
+ id: colorSchemeSection
+ }
+
+ AnimationsSection {
+ id: animationsSection
+ rootPane: sidebarFlickable.rootPane
+ }
+
+ FontsSection {
+ id: fontsSection
+ rootPane: sidebarFlickable.rootPane
+ }
+
+ ScalesSection {
+ id: scalesSection
+ rootPane: sidebarFlickable.rootPane
+ }
+
+ TransparencySection {
+ id: transparencySection
+ rootPane: sidebarFlickable.rootPane
+ }
+
+ BorderSection {
+ id: borderSection
+ rootPane: sidebarFlickable.rootPane
+ }
+
+ BackgroundSection {
+ id: backgroundSection
+ rootPane: sidebarFlickable.rootPane
+ }
+ }
+ }
+ }
+
+ rightContent: appearanceRightContentComponent
+ }
+}
diff --git a/modules/controlcenter/appearance/sections/AnimationsSection.qml b/modules/controlcenter/appearance/sections/AnimationsSection.qml
new file mode 100644
index 0000000..03fc2b1
--- /dev/null
+++ b/modules/controlcenter/appearance/sections/AnimationsSection.qml
@@ -0,0 +1,42 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../../components"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+CollapsibleSection {
+ id: root
+
+ required property var rootPane
+
+ title: qsTr("Animations")
+ showBackground: true
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Animation duration scale")
+ value: rootPane.animDurationsScale
+ from: 0.1
+ to: 5.0
+ decimals: 1
+ suffix: "×"
+ validator: DoubleValidator { bottom: 0.1; top: 5.0 }
+
+ onValueModified: (newValue) => {
+ rootPane.animDurationsScale = newValue;
+ rootPane.saveConfig();
+ }
+ }
+ }
+}
+
diff --git a/modules/controlcenter/appearance/sections/BackgroundSection.qml b/modules/controlcenter/appearance/sections/BackgroundSection.qml
new file mode 100644
index 0000000..8754e73
--- /dev/null
+++ b/modules/controlcenter/appearance/sections/BackgroundSection.qml
@@ -0,0 +1,105 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../../components"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+CollapsibleSection {
+ id: root
+
+ required property var rootPane
+
+ title: qsTr("Background")
+ showBackground: true
+
+ SwitchRow {
+ label: qsTr("Desktop clock")
+ checked: rootPane.desktopClockEnabled
+ onToggled: checked => {
+ rootPane.desktopClockEnabled = checked;
+ rootPane.saveConfig();
+ }
+ }
+
+ SwitchRow {
+ label: qsTr("Background enabled")
+ checked: rootPane.backgroundEnabled
+ onToggled: checked => {
+ rootPane.backgroundEnabled = checked;
+ rootPane.saveConfig();
+ }
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("Visualiser")
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ SwitchRow {
+ label: qsTr("Visualiser enabled")
+ checked: rootPane.visualiserEnabled
+ onToggled: checked => {
+ rootPane.visualiserEnabled = checked;
+ rootPane.saveConfig();
+ }
+ }
+
+ SwitchRow {
+ label: qsTr("Visualiser auto hide")
+ checked: rootPane.visualiserAutoHide
+ onToggled: checked => {
+ rootPane.visualiserAutoHide = checked;
+ rootPane.saveConfig();
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Visualiser rounding")
+ value: rootPane.visualiserRounding
+ from: 0
+ to: 10
+ stepSize: 1
+ validator: IntValidator { bottom: 0; top: 10 }
+ formatValueFunction: (val) => Math.round(val).toString()
+ parseValueFunction: (text) => parseInt(text)
+
+ onValueModified: (newValue) => {
+ rootPane.visualiserRounding = Math.round(newValue);
+ rootPane.saveConfig();
+ }
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Visualiser spacing")
+ value: rootPane.visualiserSpacing
+ from: 0
+ to: 2
+ validator: DoubleValidator { bottom: 0; top: 2 }
+
+ onValueModified: (newValue) => {
+ rootPane.visualiserSpacing = newValue;
+ rootPane.saveConfig();
+ }
+ }
+ }
+}
+
diff --git a/modules/controlcenter/appearance/sections/BorderSection.qml b/modules/controlcenter/appearance/sections/BorderSection.qml
new file mode 100644
index 0000000..dae26c3
--- /dev/null
+++ b/modules/controlcenter/appearance/sections/BorderSection.qml
@@ -0,0 +1,63 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../../components"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+CollapsibleSection {
+ id: root
+
+ required property var rootPane
+
+ title: qsTr("Border")
+ showBackground: true
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Border rounding")
+ value: rootPane.borderRounding
+ from: 0.1
+ to: 100
+ decimals: 1
+ suffix: "px"
+ validator: DoubleValidator { bottom: 0.1; top: 100 }
+
+ onValueModified: (newValue) => {
+ rootPane.borderRounding = newValue;
+ rootPane.saveConfig();
+ }
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Border thickness")
+ value: rootPane.borderThickness
+ from: 0.1
+ to: 100
+ decimals: 1
+ suffix: "px"
+ validator: DoubleValidator { bottom: 0.1; top: 100 }
+
+ onValueModified: (newValue) => {
+ rootPane.borderThickness = newValue;
+ rootPane.saveConfig();
+ }
+ }
+ }
+}
+
diff --git a/modules/controlcenter/appearance/sections/ColorSchemeSection.qml b/modules/controlcenter/appearance/sections/ColorSchemeSection.qml
new file mode 100644
index 0000000..c0e5eb5
--- /dev/null
+++ b/modules/controlcenter/appearance/sections/ColorSchemeSection.qml
@@ -0,0 +1,147 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../../../launcher/services"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+CollapsibleSection {
+ title: qsTr("Color scheme")
+ description: qsTr("Available color schemes")
+ showBackground: true
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small / 2
+
+ Repeater {
+ model: Schemes.list
+
+ delegate: StyledRect {
+ required property var modelData
+
+ Layout.fillWidth: true
+
+ readonly property string schemeKey: `${modelData.name} ${modelData.flavour}`
+ readonly property bool isCurrent: schemeKey === Schemes.currentScheme
+
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, isCurrent ? Colours.tPalette.m3surfaceContainer.a : 0)
+ radius: Appearance.rounding.normal
+ border.width: isCurrent ? 1 : 0
+ border.color: Colours.palette.m3primary
+
+ StateLayer {
+ function onClicked(): void {
+ const name = modelData.name;
+ const flavour = modelData.flavour;
+ const schemeKey = `${name} ${flavour}`;
+
+ Schemes.currentScheme = schemeKey;
+ Quickshell.execDetached(["caelestia", "scheme", "set", "-n", name, "-f", flavour]);
+
+ Qt.callLater(() => {
+ reloadTimer.restart();
+ });
+ }
+ }
+
+ Timer {
+ id: reloadTimer
+ interval: 300
+ onTriggered: {
+ Schemes.reload();
+ }
+ }
+
+ RowLayout {
+ id: schemeRow
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ StyledRect {
+ id: preview
+
+ Layout.alignment: Qt.AlignVCenter
+
+ border.width: 1
+ border.color: Qt.alpha(`#${modelData.colours?.outline}`, 0.5)
+
+ color: `#${modelData.colours?.surface}`
+ radius: Appearance.rounding.full
+ implicitWidth: iconPlaceholder.implicitWidth
+ implicitHeight: iconPlaceholder.implicitWidth
+
+ MaterialIcon {
+ id: iconPlaceholder
+ visible: false
+ text: "circle"
+ font.pointSize: Appearance.font.size.large
+ }
+
+ Item {
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.right: parent.right
+
+ implicitWidth: parent.implicitWidth / 2
+ clip: true
+
+ StyledRect {
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.right: parent.right
+
+ implicitWidth: preview.implicitWidth
+ color: `#${modelData.colours?.primary}`
+ radius: Appearance.rounding.full
+ }
+ }
+ }
+
+ Column {
+ Layout.fillWidth: true
+ spacing: 0
+
+ StyledText {
+ text: modelData.flavour ?? ""
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ StyledText {
+ text: modelData.name ?? ""
+ font.pointSize: Appearance.font.size.small
+ color: Colours.palette.m3outline
+
+ elide: Text.ElideRight
+ anchors.left: parent.left
+ anchors.right: parent.right
+ }
+ }
+
+ Loader {
+ active: isCurrent
+ asynchronous: true
+
+ sourceComponent: MaterialIcon {
+ text: "check"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+ }
+
+ implicitHeight: schemeRow.implicitHeight + Appearance.padding.normal * 2
+ }
+ }
+ }
+}
+
diff --git a/modules/controlcenter/appearance/sections/ColorVariantSection.qml b/modules/controlcenter/appearance/sections/ColorVariantSection.qml
new file mode 100644
index 0000000..98c3d7c
--- /dev/null
+++ b/modules/controlcenter/appearance/sections/ColorVariantSection.qml
@@ -0,0 +1,92 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../../../launcher/services"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+CollapsibleSection {
+ title: qsTr("Color variant")
+ description: qsTr("Material theme variant")
+ showBackground: true
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small / 2
+
+ Repeater {
+ model: M3Variants.list
+
+ delegate: StyledRect {
+ required property var modelData
+
+ Layout.fillWidth: true
+
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, modelData.variant === Schemes.currentVariant ? Colours.tPalette.m3surfaceContainer.a : 0)
+ radius: Appearance.rounding.normal
+ border.width: modelData.variant === Schemes.currentVariant ? 1 : 0
+ border.color: Colours.palette.m3primary
+
+ StateLayer {
+ function onClicked(): void {
+ const variant = modelData.variant;
+
+ Schemes.currentVariant = variant;
+ Quickshell.execDetached(["caelestia", "scheme", "set", "-v", variant]);
+
+ Qt.callLater(() => {
+ reloadTimer.restart();
+ });
+ }
+ }
+
+ Timer {
+ id: reloadTimer
+ interval: 300
+ onTriggered: {
+ Schemes.reload();
+ }
+ }
+
+ RowLayout {
+ id: variantRow
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ text: modelData.icon
+ font.pointSize: Appearance.font.size.large
+ fill: modelData.variant === Schemes.currentVariant ? 1 : 0
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: modelData.name
+ font.weight: modelData.variant === Schemes.currentVariant ? 500 : 400
+ }
+
+ MaterialIcon {
+ visible: modelData.variant === Schemes.currentVariant
+ text: "check"
+ color: Colours.palette.m3primary
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+
+ implicitHeight: variantRow.implicitHeight + Appearance.padding.normal * 2
+ }
+ }
+ }
+}
+
diff --git a/modules/controlcenter/appearance/sections/FontsSection.qml b/modules/controlcenter/appearance/sections/FontsSection.qml
new file mode 100644
index 0000000..57b10ff
--- /dev/null
+++ b/modules/controlcenter/appearance/sections/FontsSection.qml
@@ -0,0 +1,286 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../../components"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+CollapsibleSection {
+ id: root
+
+ required property var rootPane
+
+ title: qsTr("Fonts")
+ showBackground: true
+
+ CollapsibleSection {
+ id: materialFontSection
+ title: qsTr("Material font family")
+ expanded: true
+ showBackground: true
+ nested: true
+
+ Loader {
+ id: materialFontLoader
+ Layout.fillWidth: true
+ Layout.preferredHeight: item ? Math.min(item.contentHeight, 300) : 0
+ asynchronous: true
+ active: materialFontSection.expanded
+
+ sourceComponent: StyledListView {
+ id: materialFontList
+ property alias contentHeight: materialFontList.contentHeight
+
+ clip: true
+ spacing: Appearance.spacing.small / 2
+ model: Qt.fontFamilies()
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: materialFontList
+ }
+
+ delegate: StyledRect {
+ required property string modelData
+ required property int index
+
+ width: ListView.view.width
+
+ readonly property bool isCurrent: modelData === rootPane.fontFamilyMaterial
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, isCurrent ? Colours.tPalette.m3surfaceContainer.a : 0)
+ radius: Appearance.rounding.normal
+ border.width: isCurrent ? 1 : 0
+ border.color: Colours.palette.m3primary
+
+ StateLayer {
+ function onClicked(): void {
+ rootPane.fontFamilyMaterial = modelData;
+ rootPane.saveConfig();
+ }
+ }
+
+ RowLayout {
+ id: fontFamilyMaterialRow
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: modelData
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ Loader {
+ active: isCurrent
+ asynchronous: true
+
+ sourceComponent: MaterialIcon {
+ text: "check"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+ }
+
+ implicitHeight: fontFamilyMaterialRow.implicitHeight + Appearance.padding.normal * 2
+ }
+ }
+ }
+ }
+
+ CollapsibleSection {
+ id: monoFontSection
+ title: qsTr("Monospace font family")
+ expanded: false
+ showBackground: true
+ nested: true
+
+ Loader {
+ Layout.fillWidth: true
+ Layout.preferredHeight: item ? Math.min(item.contentHeight, 300) : 0
+ asynchronous: true
+ active: monoFontSection.expanded
+
+ sourceComponent: StyledListView {
+ id: monoFontList
+ property alias contentHeight: monoFontList.contentHeight
+
+ clip: true
+ spacing: Appearance.spacing.small / 2
+ model: Qt.fontFamilies()
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: monoFontList
+ }
+
+ delegate: StyledRect {
+ required property string modelData
+ required property int index
+
+ width: ListView.view.width
+
+ readonly property bool isCurrent: modelData === rootPane.fontFamilyMono
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, isCurrent ? Colours.tPalette.m3surfaceContainer.a : 0)
+ radius: Appearance.rounding.normal
+ border.width: isCurrent ? 1 : 0
+ border.color: Colours.palette.m3primary
+
+ StateLayer {
+ function onClicked(): void {
+ rootPane.fontFamilyMono = modelData;
+ rootPane.saveConfig();
+ }
+ }
+
+ RowLayout {
+ id: fontFamilyMonoRow
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: modelData
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ Loader {
+ active: isCurrent
+ asynchronous: true
+
+ sourceComponent: MaterialIcon {
+ text: "check"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+ }
+
+ implicitHeight: fontFamilyMonoRow.implicitHeight + Appearance.padding.normal * 2
+ }
+ }
+ }
+ }
+
+ CollapsibleSection {
+ id: sansFontSection
+ title: qsTr("Sans-serif font family")
+ expanded: false
+ showBackground: true
+ nested: true
+
+ Loader {
+ Layout.fillWidth: true
+ Layout.preferredHeight: item ? Math.min(item.contentHeight, 300) : 0
+ asynchronous: true
+ active: sansFontSection.expanded
+
+ sourceComponent: StyledListView {
+ id: sansFontList
+ property alias contentHeight: sansFontList.contentHeight
+
+ clip: true
+ spacing: Appearance.spacing.small / 2
+ model: Qt.fontFamilies()
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: sansFontList
+ }
+
+ delegate: StyledRect {
+ required property string modelData
+ required property int index
+
+ width: ListView.view.width
+
+ readonly property bool isCurrent: modelData === rootPane.fontFamilySans
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, isCurrent ? Colours.tPalette.m3surfaceContainer.a : 0)
+ radius: Appearance.rounding.normal
+ border.width: isCurrent ? 1 : 0
+ border.color: Colours.palette.m3primary
+
+ StateLayer {
+ function onClicked(): void {
+ rootPane.fontFamilySans = modelData;
+ rootPane.saveConfig();
+ }
+ }
+
+ RowLayout {
+ id: fontFamilySansRow
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: modelData
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ Loader {
+ active: isCurrent
+ asynchronous: true
+
+ sourceComponent: MaterialIcon {
+ text: "check"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+ }
+
+ implicitHeight: fontFamilySansRow.implicitHeight + Appearance.padding.normal * 2
+ }
+ }
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Font size scale")
+ value: rootPane.fontSizeScale
+ from: 0.7
+ to: 1.5
+ decimals: 2
+ suffix: "×"
+ validator: DoubleValidator { bottom: 0.7; top: 1.5 }
+
+ onValueModified: (newValue) => {
+ rootPane.fontSizeScale = newValue;
+ rootPane.saveConfig();
+ }
+ }
+ }
+}
+
diff --git a/modules/controlcenter/appearance/sections/ScalesSection.qml b/modules/controlcenter/appearance/sections/ScalesSection.qml
new file mode 100644
index 0000000..f74923b
--- /dev/null
+++ b/modules/controlcenter/appearance/sections/ScalesSection.qml
@@ -0,0 +1,84 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../../components"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+CollapsibleSection {
+ id: root
+
+ required property var rootPane
+
+ title: qsTr("Scales")
+ showBackground: true
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Padding scale")
+ value: rootPane.paddingScale
+ from: 0.5
+ to: 2.0
+ decimals: 1
+ suffix: "×"
+ validator: DoubleValidator { bottom: 0.5; top: 2.0 }
+
+ onValueModified: (newValue) => {
+ rootPane.paddingScale = newValue;
+ rootPane.saveConfig();
+ }
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Rounding scale")
+ value: rootPane.roundingScale
+ from: 0.1
+ to: 5.0
+ decimals: 1
+ suffix: "×"
+ validator: DoubleValidator { bottom: 0.1; top: 5.0 }
+
+ onValueModified: (newValue) => {
+ rootPane.roundingScale = newValue;
+ rootPane.saveConfig();
+ }
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Spacing scale")
+ value: rootPane.spacingScale
+ from: 0.1
+ to: 2.0
+ decimals: 1
+ suffix: "×"
+ validator: DoubleValidator { bottom: 0.1; top: 2.0 }
+
+ onValueModified: (newValue) => {
+ rootPane.spacingScale = newValue;
+ rootPane.saveConfig();
+ }
+ }
+ }
+}
+
diff --git a/modules/controlcenter/appearance/sections/ThemeModeSection.qml b/modules/controlcenter/appearance/sections/ThemeModeSection.qml
new file mode 100644
index 0000000..c136437
--- /dev/null
+++ b/modules/controlcenter/appearance/sections/ThemeModeSection.qml
@@ -0,0 +1,24 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import QtQuick
+
+CollapsibleSection {
+ title: qsTr("Theme mode")
+ description: qsTr("Light or dark theme")
+ showBackground: true
+
+ SwitchRow {
+ label: qsTr("Dark mode")
+ checked: !Colours.currentLight
+ onToggled: checked => {
+ Colours.setMode(checked ? "dark" : "light");
+ }
+ }
+}
+
diff --git a/modules/controlcenter/appearance/sections/TransparencySection.qml b/modules/controlcenter/appearance/sections/TransparencySection.qml
new file mode 100644
index 0000000..c9dbfb8
--- /dev/null
+++ b/modules/controlcenter/appearance/sections/TransparencySection.qml
@@ -0,0 +1,74 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../../components"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+CollapsibleSection {
+ id: root
+
+ required property var rootPane
+
+ title: qsTr("Transparency")
+ showBackground: true
+
+ SwitchRow {
+ label: qsTr("Transparency enabled")
+ checked: rootPane.transparencyEnabled
+ onToggled: checked => {
+ rootPane.transparencyEnabled = checked;
+ rootPane.saveConfig();
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Transparency base")
+ value: rootPane.transparencyBase * 100
+ from: 0
+ to: 100
+ suffix: "%"
+ validator: IntValidator { bottom: 0; top: 100 }
+ formatValueFunction: (val) => Math.round(val).toString()
+ parseValueFunction: (text) => parseInt(text)
+
+ onValueModified: (newValue) => {
+ rootPane.transparencyBase = newValue / 100;
+ rootPane.saveConfig();
+ }
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Transparency layers")
+ value: rootPane.transparencyLayers * 100
+ from: 0
+ to: 100
+ suffix: "%"
+ validator: IntValidator { bottom: 0; top: 100 }
+ formatValueFunction: (val) => Math.round(val).toString()
+ parseValueFunction: (text) => parseInt(text)
+
+ onValueModified: (newValue) => {
+ rootPane.transparencyLayers = newValue / 100;
+ rootPane.saveConfig();
+ }
+ }
+ }
+}
+
diff --git a/modules/controlcenter/audio/AudioPane.qml b/modules/controlcenter/audio/AudioPane.qml
new file mode 100644
index 0000000..76122f9
--- /dev/null
+++ b/modules/controlcenter/audio/AudioPane.qml
@@ -0,0 +1,467 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.services
+import qs.config
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Session session
+
+ anchors.fill: parent
+
+ SplitPaneLayout {
+ anchors.fill: parent
+
+ leftContent: Component {
+
+ StyledFlickable {
+ id: leftAudioFlickable
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: leftContent.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: leftAudioFlickable
+ }
+
+ ColumnLayout {
+ id: leftContent
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ spacing: Appearance.spacing.normal
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ text: qsTr("Audio")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+ }
+
+ CollapsibleSection {
+ id: outputDevicesSection
+
+ Layout.fillWidth: true
+ title: qsTr("Output devices")
+ expanded: true
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ text: qsTr("Devices (%1)").arg(Audio.sinks.length)
+ font.pointSize: Appearance.font.size.normal
+ font.weight: 500
+ }
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("All available output devices")
+ color: Colours.palette.m3outline
+ }
+
+ Repeater {
+ Layout.fillWidth: true
+ model: Audio.sinks
+
+ delegate: StyledRect {
+ required property var modelData
+
+ Layout.fillWidth: true
+
+ color: Audio.sink?.id === modelData.id ? Colours.layer(Colours.palette.m3surfaceContainer, 2) : "transparent"
+ radius: Appearance.rounding.normal
+
+ StateLayer {
+ function onClicked(): void {
+ Audio.setAudioSink(modelData);
+ }
+ }
+
+ RowLayout {
+ id: outputRowLayout
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ text: Audio.sink?.id === modelData.id ? "speaker" : "speaker_group"
+ font.pointSize: Appearance.font.size.large
+ fill: Audio.sink?.id === modelData.id ? 1 : 0
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ elide: Text.ElideRight
+ maximumLineCount: 1
+
+ text: modelData.description || qsTr("Unknown")
+ font.weight: Audio.sink?.id === modelData.id ? 500 : 400
+ }
+ }
+
+ implicitHeight: outputRowLayout.implicitHeight + Appearance.padding.normal * 2
+ }
+ }
+ }
+ }
+
+ CollapsibleSection {
+ id: inputDevicesSection
+
+ Layout.fillWidth: true
+ title: qsTr("Input devices")
+ expanded: true
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ text: qsTr("Devices (%1)").arg(Audio.sources.length)
+ font.pointSize: Appearance.font.size.normal
+ font.weight: 500
+ }
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("All available input devices")
+ color: Colours.palette.m3outline
+ }
+
+ Repeater {
+ Layout.fillWidth: true
+ model: Audio.sources
+
+ delegate: StyledRect {
+ required property var modelData
+
+ Layout.fillWidth: true
+
+ color: Audio.source?.id === modelData.id ? Colours.layer(Colours.palette.m3surfaceContainer, 2) : "transparent"
+ radius: Appearance.rounding.normal
+
+ StateLayer {
+ function onClicked(): void {
+ Audio.setAudioSource(modelData);
+ }
+ }
+
+ RowLayout {
+ id: inputRowLayout
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ text: "mic"
+ font.pointSize: Appearance.font.size.large
+ fill: Audio.source?.id === modelData.id ? 1 : 0
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ elide: Text.ElideRight
+ maximumLineCount: 1
+
+ text: modelData.description || qsTr("Unknown")
+ font.weight: Audio.source?.id === modelData.id ? 500 : 400
+ }
+ }
+
+ implicitHeight: inputRowLayout.implicitHeight + Appearance.padding.normal * 2
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ rightContent: Component {
+ StyledFlickable {
+ id: rightAudioFlickable
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: contentLayout.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: rightAudioFlickable
+ }
+
+ ColumnLayout {
+ id: contentLayout
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ spacing: Appearance.spacing.normal
+
+ SettingsHeader {
+ icon: "volume_up"
+ title: qsTr("Audio Settings")
+ }
+
+ SectionHeader {
+ title: qsTr("Output volume")
+ description: qsTr("Control the volume of your output device")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Volume")
+ font.pointSize: Appearance.font.size.normal
+ font.weight: 500
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ StyledInputField {
+ id: outputVolumeInput
+ Layout.preferredWidth: 70
+ validator: IntValidator { bottom: 0; top: 100 }
+ enabled: !Audio.muted
+
+ Component.onCompleted: {
+ text = Math.round(Audio.volume * 100).toString();
+ }
+
+ Connections {
+ target: Audio
+ function onVolumeChanged() {
+ if (!outputVolumeInput.hasFocus) {
+ outputVolumeInput.text = Math.round(Audio.volume * 100).toString();
+ }
+ }
+ }
+
+ onTextEdited: (text) => {
+ if (hasFocus) {
+ const val = parseInt(text);
+ if (!isNaN(val) && val >= 0 && val <= 100) {
+ Audio.setVolume(val / 100);
+ }
+ }
+ }
+
+ onEditingFinished: {
+ const val = parseInt(text);
+ if (isNaN(val) || val < 0 || val > 100) {
+ text = Math.round(Audio.volume * 100).toString();
+ }
+ }
+ }
+
+ StyledText {
+ text: "%"
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.normal
+ opacity: Audio.muted ? 0.5 : 1
+ }
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: muteIcon.implicitHeight + Appearance.padding.normal * 2
+
+ radius: Appearance.rounding.normal
+ color: Audio.muted ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer
+
+ StateLayer {
+ function onClicked(): void {
+ if (Audio.sink?.audio) {
+ Audio.sink.audio.muted = !Audio.sink.audio.muted;
+ }
+ }
+ }
+
+ MaterialIcon {
+ id: muteIcon
+
+ anchors.centerIn: parent
+ text: Audio.muted ? "volume_off" : "volume_up"
+ color: Audio.muted ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer
+ }
+ }
+ }
+
+ StyledSlider {
+ id: outputVolumeSlider
+ Layout.fillWidth: true
+ implicitHeight: Appearance.padding.normal * 3
+
+ value: Audio.volume
+ enabled: !Audio.muted
+ opacity: enabled ? 1 : 0.5
+ onMoved: {
+ Audio.setVolume(value);
+ if (!outputVolumeInput.hasFocus) {
+ outputVolumeInput.text = Math.round(value * 100).toString();
+ }
+ }
+ }
+ }
+ }
+
+ SectionHeader {
+ title: qsTr("Input volume")
+ description: qsTr("Control the volume of your input device")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Volume")
+ font.pointSize: Appearance.font.size.normal
+ font.weight: 500
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ StyledInputField {
+ id: inputVolumeInput
+ Layout.preferredWidth: 70
+ validator: IntValidator { bottom: 0; top: 100 }
+ enabled: !Audio.sourceMuted
+
+ Component.onCompleted: {
+ text = Math.round(Audio.sourceVolume * 100).toString();
+ }
+
+ Connections {
+ target: Audio
+ function onSourceVolumeChanged() {
+ if (!inputVolumeInput.hasFocus) {
+ inputVolumeInput.text = Math.round(Audio.sourceVolume * 100).toString();
+ }
+ }
+ }
+
+ onTextEdited: (text) => {
+ if (hasFocus) {
+ const val = parseInt(text);
+ if (!isNaN(val) && val >= 0 && val <= 100) {
+ Audio.setSourceVolume(val / 100);
+ }
+ }
+ }
+
+ onEditingFinished: {
+ const val = parseInt(text);
+ if (isNaN(val) || val < 0 || val > 100) {
+ text = Math.round(Audio.sourceVolume * 100).toString();
+ }
+ }
+ }
+
+ StyledText {
+ text: "%"
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.normal
+ opacity: Audio.sourceMuted ? 0.5 : 1
+ }
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: muteInputIcon.implicitHeight + Appearance.padding.normal * 2
+
+ radius: Appearance.rounding.normal
+ color: Audio.sourceMuted ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer
+
+ StateLayer {
+ function onClicked(): void {
+ if (Audio.source?.audio) {
+ Audio.source.audio.muted = !Audio.source.audio.muted;
+ }
+ }
+ }
+
+ MaterialIcon {
+ id: muteInputIcon
+
+ anchors.centerIn: parent
+ text: "mic_off"
+ color: Audio.sourceMuted ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer
+ }
+ }
+ }
+
+ StyledSlider {
+ id: inputVolumeSlider
+ Layout.fillWidth: true
+ implicitHeight: Appearance.padding.normal * 3
+
+ value: Audio.sourceVolume
+ enabled: !Audio.sourceMuted
+ opacity: enabled ? 1 : 0.5
+ onMoved: {
+ Audio.setSourceVolume(value);
+ if (!inputVolumeInput.hasFocus) {
+ inputVolumeInput.text = Math.round(value * 100).toString();
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ } \ No newline at end of file
diff --git a/modules/controlcenter/bluetooth/BtPane.qml b/modules/controlcenter/bluetooth/BtPane.qml
index 96dc002..a987e75 100644
--- a/modules/controlcenter/bluetooth/BtPane.qml
+++ b/modules/controlcenter/bluetooth/BtPane.qml
@@ -1,134 +1,73 @@
pragma ComponentBehavior: Bound
import ".."
-import qs.components.effects
+import "../components"
+import "."
+import qs.components
+import qs.components.controls
import qs.components.containers
import qs.config
import Quickshell.Widgets
import Quickshell.Bluetooth
import QtQuick
-import QtQuick.Layouts
-RowLayout {
+SplitPaneWithDetails {
id: root
required property Session session
anchors.fill: parent
- spacing: 0
-
- Item {
- Layout.preferredWidth: Math.floor(parent.width * 0.4)
- Layout.minimumWidth: 420
- Layout.fillHeight: true
-
- DeviceList {
- anchors.fill: parent
- anchors.margins: Appearance.padding.large + Appearance.padding.normal
- anchors.leftMargin: Appearance.padding.large
- anchors.rightMargin: Appearance.padding.large + Appearance.padding.normal / 2
-
- session: root.session
- }
-
- InnerBorder {
- leftThickness: 0
- rightThickness: Appearance.padding.normal / 2
- }
+ activeItem: session.bt.active
+ paneIdGenerator: function(item) {
+ return item ? (item.address || "") : "";
}
- Item {
- Layout.fillWidth: true
- Layout.fillHeight: true
-
- ClippingRectangle {
- anchors.fill: parent
- anchors.margins: Appearance.padding.normal
- anchors.leftMargin: 0
- anchors.rightMargin: Appearance.padding.normal / 2
-
- radius: rightBorder.innerRadius
- color: "transparent"
+ leftContent: Component {
+ StyledFlickable {
+ id: leftFlickable
- Loader {
- id: loader
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: deviceList.height
- property BluetoothDevice pane: root.session.bt.active
-
- anchors.fill: parent
- anchors.margins: Appearance.padding.large * 2
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: leftFlickable
+ }
- asynchronous: true
- sourceComponent: pane ? details : settings
+ DeviceList {
+ id: deviceList
- Behavior on pane {
- SequentialAnimation {
- ParallelAnimation {
- Anim {
- property: "opacity"
- to: 0
- easing.bezierCurve: Appearance.anim.curves.standardAccel
- }
- Anim {
- property: "scale"
- to: 0.8
- easing.bezierCurve: Appearance.anim.curves.standardAccel
- }
- }
- PropertyAction {}
- ParallelAnimation {
- Anim {
- property: "opacity"
- to: 1
- easing.bezierCurve: Appearance.anim.curves.standardDecel
- }
- Anim {
- property: "scale"
- to: 1
- easing.bezierCurve: Appearance.anim.curves.standardDecel
- }
- }
- }
- }
+ anchors.left: parent.left
+ anchors.right: parent.right
+ session: root.session
}
}
+ }
- InnerBorder {
- id: rightBorder
-
- leftThickness: Appearance.padding.normal / 2
+ rightDetailsComponent: Component {
+ Details {
+ session: root.session
}
+ }
- Component {
- id: settings
-
- StyledFlickable {
- flickableDirection: Flickable.VerticalFlick
- contentHeight: settingsInner.height
-
- Settings {
- id: settingsInner
+ rightSettingsComponent: Component {
+ StyledFlickable {
+ id: settingsFlickable
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: settingsInner.height
- anchors.left: parent.left
- anchors.right: parent.right
- session: root.session
- }
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: settingsFlickable
}
- }
- Component {
- id: details
+ Settings {
+ id: settingsInner
- Details {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
session: root.session
}
}
}
-
- component Anim: NumberAnimation {
- target: loader
- duration: Appearance.anim.durations.normal / 2
- easing.type: Easing.BezierSpline
- }
}
diff --git a/modules/controlcenter/bluetooth/Details.qml b/modules/controlcenter/bluetooth/Details.qml
index 104f673..5299045 100644
--- a/modules/controlcenter/bluetooth/Details.qml
+++ b/modules/controlcenter/bluetooth/Details.qml
@@ -1,6 +1,7 @@
pragma ComponentBehavior: Bound
import ".."
+import "../components"
import qs.components
import qs.components.controls
import qs.components.effects
@@ -12,409 +13,430 @@ import Quickshell.Bluetooth
import QtQuick
import QtQuick.Layouts
-Item {
+StyledFlickable {
id: root
required property Session session
readonly property BluetoothDevice device: session.bt.active
- StyledFlickable {
- anchors.fill: parent
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: detailsWrapper.height
- flickableDirection: Flickable.VerticalFlick
- contentHeight: layout.height
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: root
+ }
+
+ Item {
+ id: detailsWrapper
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ implicitHeight: details.implicitHeight
- ColumnLayout {
- id: layout
+ DeviceDetails {
+ id: details
anchors.left: parent.left
anchors.right: parent.right
- spacing: Appearance.spacing.normal
+ anchors.top: parent.top
- MaterialIcon {
- Layout.alignment: Qt.AlignHCenter
- animate: true
- text: Icons.getBluetoothIcon(root.device.icon)
- font.pointSize: Appearance.font.size.extraLarge * 3
- font.bold: true
- }
+ session: root.session
+ device: root.device
- StyledText {
- Layout.alignment: Qt.AlignHCenter
- animate: true
- text: root.device?.name ?? ""
- font.pointSize: Appearance.font.size.large
- font.bold: true
+ headerComponent: Component {
+ SettingsHeader {
+ icon: Icons.getBluetoothIcon(root.device?.icon ?? "")
+ title: root.device?.name ?? ""
+ }
}
- StyledText {
- Layout.topMargin: Appearance.spacing.large
- text: qsTr("Connection status")
- font.pointSize: Appearance.font.size.larger
- font.weight: 500
- }
+ sections: [
+ Component {
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
- StyledText {
- text: qsTr("Connection settings for this device")
- color: Colours.palette.m3outline
- }
+ StyledText {
+ Layout.topMargin: Appearance.spacing.large
+ text: qsTr("Connection status")
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ StyledText {
+ text: qsTr("Connection settings for this device")
+ color: Colours.palette.m3outline
+ }
- StyledRect {
- Layout.fillWidth: true
- implicitHeight: deviceStatus.implicitHeight + Appearance.padding.large * 2
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: deviceStatus.implicitHeight + Appearance.padding.large * 2
- radius: Appearance.rounding.normal
- color: Colours.tPalette.m3surfaceContainer
+ radius: Appearance.rounding.normal
+ color: Colours.tPalette.m3surfaceContainer
- ColumnLayout {
- id: deviceStatus
+ ColumnLayout {
+ id: deviceStatus
- anchors.left: parent.left
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- anchors.margins: Appearance.padding.large
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
- spacing: Appearance.spacing.larger
+ spacing: Appearance.spacing.larger
- Toggle {
- label: qsTr("Connected")
- checked: root.device?.connected ?? false
- toggle.onToggled: root.device.connected = checked
- }
+ Toggle {
+ label: qsTr("Connected")
+ checked: root.device?.connected ?? false
+ toggle.onToggled: root.device.connected = checked
+ }
+
+ Toggle {
+ label: qsTr("Paired")
+ checked: root.device?.paired ?? false
+ toggle.onToggled: {
+ if (root.device.paired)
+ root.device.forget();
+ else
+ root.device.pair();
+ }
+ }
- Toggle {
- label: qsTr("Paired")
- checked: root.device?.paired ?? false
- toggle.onToggled: {
- if (root.device.paired)
- root.device.forget();
- else
- root.device.pair();
+ Toggle {
+ label: qsTr("Blocked")
+ checked: root.device?.blocked ?? false
+ toggle.onToggled: root.device.blocked = checked
+ }
+ }
}
}
+ },
+ Component {
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
- Toggle {
- label: qsTr("Blocked")
- checked: root.device?.blocked ?? false
- toggle.onToggled: root.device.blocked = checked
- }
- }
- }
+ StyledText {
+ Layout.topMargin: Appearance.spacing.large
+ text: qsTr("Device properties")
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
- StyledText {
- Layout.topMargin: Appearance.spacing.large
- text: qsTr("Device properties")
- font.pointSize: Appearance.font.size.larger
- font.weight: 500
- }
+ StyledText {
+ text: qsTr("Additional settings")
+ color: Colours.palette.m3outline
+ }
- StyledText {
- text: qsTr("Additional settings")
- color: Colours.palette.m3outline
- }
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: deviceProps.implicitHeight + Appearance.padding.large * 2
- StyledRect {
- Layout.fillWidth: true
- implicitHeight: deviceProps.implicitHeight + Appearance.padding.large * 2
+ radius: Appearance.rounding.normal
+ color: Colours.tPalette.m3surfaceContainer
- radius: Appearance.rounding.normal
- color: Colours.tPalette.m3surfaceContainer
+ ColumnLayout {
+ id: deviceProps
- ColumnLayout {
- id: deviceProps
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
- anchors.left: parent.left
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.larger
- spacing: Appearance.spacing.larger
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
- RowLayout {
- Layout.fillWidth: true
- spacing: Appearance.spacing.small
+ Item {
+ id: renameDevice
- Item {
- id: renameDevice
+ Layout.fillWidth: true
+ Layout.rightMargin: Appearance.spacing.small
- Layout.fillWidth: true
- Layout.rightMargin: Appearance.spacing.small
+ implicitHeight: renameLabel.implicitHeight + deviceNameEdit.implicitHeight
- implicitHeight: renameLabel.implicitHeight + deviceNameEdit.implicitHeight
+ states: State {
+ name: "editingDeviceName"
+ when: root.session.bt.editingDeviceName
- states: State {
- name: "editingDeviceName"
- when: root.session.bt.editingDeviceName
+ AnchorChanges {
+ target: deviceNameEdit
+ anchors.top: renameDevice.top
+ }
+ PropertyChanges {
+ renameDevice.implicitHeight: deviceNameEdit.implicitHeight
+ renameLabel.opacity: 0
+ deviceNameEdit.padding: Appearance.padding.normal
+ }
+ }
- AnchorChanges {
- target: deviceNameEdit
- anchors.top: renameDevice.top
- }
- PropertyChanges {
- renameDevice.implicitHeight: deviceNameEdit.implicitHeight
- renameLabel.opacity: 0
- deviceNameEdit.padding: Appearance.padding.normal
- }
- }
+ transitions: Transition {
+ AnchorAnimation {
+ duration: Appearance.anim.durations.normal
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standard
+ }
+ Anim {
+ properties: "implicitHeight,opacity,padding"
+ }
+ }
- transitions: Transition {
- AnchorAnimation {
- duration: Appearance.anim.durations.normal
- easing.type: Easing.BezierSpline
- easing.bezierCurve: Appearance.anim.curves.standard
- }
- Anim {
- properties: "implicitHeight,opacity,padding"
- }
- }
+ StyledText {
+ id: renameLabel
- StyledText {
- id: renameLabel
+ anchors.left: parent.left
- anchors.left: parent.left
+ text: qsTr("Device name")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
- text: qsTr("Device name")
- color: Colours.palette.m3outline
- font.pointSize: Appearance.font.size.small
- }
+ StyledTextField {
+ id: deviceNameEdit
- StyledTextField {
- id: deviceNameEdit
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: renameLabel.bottom
+ anchors.leftMargin: root.session.bt.editingDeviceName ? 0 : -Appearance.padding.normal
- anchors.left: parent.left
- anchors.right: parent.right
- anchors.top: renameLabel.bottom
- anchors.leftMargin: root.session.bt.editingDeviceName ? 0 : -Appearance.padding.normal
+ text: root.device?.name ?? ""
+ readOnly: !root.session.bt.editingDeviceName
+ onAccepted: {
+ root.session.bt.editingDeviceName = false;
+ root.device.name = text;
+ }
- text: root.device?.name ?? ""
- readOnly: !root.session.bt.editingDeviceName
- onAccepted: {
- root.session.bt.editingDeviceName = false;
- root.device.name = text;
- }
+ leftPadding: Appearance.padding.normal
+ rightPadding: Appearance.padding.normal
- leftPadding: Appearance.padding.normal
- rightPadding: Appearance.padding.normal
+ background: StyledRect {
+ radius: Appearance.rounding.small
+ border.width: 2
+ border.color: Colours.palette.m3primary
+ opacity: root.session.bt.editingDeviceName ? 1 : 0
- background: StyledRect {
- radius: Appearance.rounding.small
- border.width: 2
- border.color: Colours.palette.m3primary
- opacity: root.session.bt.editingDeviceName ? 1 : 0
+ Behavior on border.color {
+ CAnim {}
+ }
- Behavior on border.color {
- CAnim {}
- }
+ Behavior on opacity {
+ Anim {}
+ }
+ }
- Behavior on opacity {
- Anim {}
+ Behavior on anchors.leftMargin {
+ Anim {}
+ }
+ }
}
- }
- Behavior on anchors.leftMargin {
- Anim {}
- }
- }
- }
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: cancelEditIcon.implicitHeight + Appearance.padding.smaller * 2
- StyledRect {
- implicitWidth: implicitHeight
- implicitHeight: cancelEditIcon.implicitHeight + Appearance.padding.smaller * 2
+ radius: Appearance.rounding.small
+ color: Colours.palette.m3secondaryContainer
+ opacity: root.session.bt.editingDeviceName ? 1 : 0
+ scale: root.session.bt.editingDeviceName ? 1 : 0.5
- radius: Appearance.rounding.small
- color: Colours.palette.m3secondaryContainer
- opacity: root.session.bt.editingDeviceName ? 1 : 0
- scale: root.session.bt.editingDeviceName ? 1 : 0.5
+ StateLayer {
+ color: Colours.palette.m3onSecondaryContainer
+ disabled: !root.session.bt.editingDeviceName
- StateLayer {
- color: Colours.palette.m3onSecondaryContainer
- disabled: !root.session.bt.editingDeviceName
+ function onClicked(): void {
+ root.session.bt.editingDeviceName = false;
+ deviceNameEdit.text = Qt.binding(() => root.device?.name ?? "");
+ }
+ }
- function onClicked(): void {
- root.session.bt.editingDeviceName = false;
- deviceNameEdit.text = Qt.binding(() => root.device?.name ?? "");
- }
- }
+ MaterialIcon {
+ id: cancelEditIcon
- MaterialIcon {
- id: cancelEditIcon
+ anchors.centerIn: parent
+ animate: true
+ text: "cancel"
+ color: Colours.palette.m3onSecondaryContainer
+ }
- anchors.centerIn: parent
- animate: true
- text: "cancel"
- color: Colours.palette.m3onSecondaryContainer
- }
+ Behavior on opacity {
+ Anim {}
+ }
- Behavior on opacity {
- Anim {}
- }
+ Behavior on scale {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+ }
- Behavior on scale {
- Anim {
- duration: Appearance.anim.durations.expressiveFastSpatial
- easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
- }
- }
- }
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: editIcon.implicitHeight + Appearance.padding.smaller * 2
- StyledRect {
- implicitWidth: implicitHeight
- implicitHeight: editIcon.implicitHeight + Appearance.padding.smaller * 2
+ radius: root.session.bt.editingDeviceName ? Appearance.rounding.small : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale)
+ color: Qt.alpha(Colours.palette.m3primary, root.session.bt.editingDeviceName ? 1 : 0)
- radius: root.session.bt.editingDeviceName ? Appearance.rounding.small : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale)
- color: Qt.alpha(Colours.palette.m3primary, root.session.bt.editingDeviceName ? 1 : 0)
+ StateLayer {
+ color: root.session.bt.editingDeviceName ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
- StateLayer {
- color: root.session.bt.editingDeviceName ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
+ function onClicked(): void {
+ root.session.bt.editingDeviceName = !root.session.bt.editingDeviceName;
+ if (root.session.bt.editingDeviceName)
+ deviceNameEdit.forceActiveFocus();
+ else
+ deviceNameEdit.accepted();
+ }
+ }
- function onClicked(): void {
- root.session.bt.editingDeviceName = !root.session.bt.editingDeviceName;
- if (root.session.bt.editingDeviceName)
- deviceNameEdit.forceActiveFocus();
- else
- deviceNameEdit.accepted();
- }
- }
+ MaterialIcon {
+ id: editIcon
- MaterialIcon {
- id: editIcon
+ anchors.centerIn: parent
+ animate: true
+ text: root.session.bt.editingDeviceName ? "check_circle" : "edit"
+ color: root.session.bt.editingDeviceName ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
+ }
- anchors.centerIn: parent
- animate: true
- text: root.session.bt.editingDeviceName ? "check_circle" : "edit"
- color: root.session.bt.editingDeviceName ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface
- }
+ Behavior on radius {
+ Anim {}
+ }
+ }
+ }
- Behavior on radius {
- Anim {}
+ Toggle {
+ label: qsTr("Trusted")
+ checked: root.device?.trusted ?? false
+ toggle.onToggled: root.device.trusted = checked
+ }
+
+ Toggle {
+ label: qsTr("Wake allowed")
+ checked: root.device?.wakeAllowed ?? false
+ toggle.onToggled: root.device.wakeAllowed = checked
+ }
}
}
}
+ },
+ Component {
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
- Toggle {
- label: qsTr("Trusted")
- checked: root.device?.trusted ?? false
- toggle.onToggled: root.device.trusted = checked
- }
-
- Toggle {
- label: qsTr("Wake allowed")
- checked: root.device?.wakeAllowed ?? false
- toggle.onToggled: root.device.wakeAllowed = checked
- }
- }
- }
-
- StyledText {
- Layout.topMargin: Appearance.spacing.large
- text: qsTr("Device information")
- font.pointSize: Appearance.font.size.larger
- font.weight: 500
- }
+ StyledText {
+ Layout.topMargin: Appearance.spacing.large
+ text: qsTr("Device information")
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
- StyledText {
- text: qsTr("Information about this device")
- color: Colours.palette.m3outline
- }
+ StyledText {
+ text: qsTr("Information about this device")
+ color: Colours.palette.m3outline
+ }
- StyledRect {
- Layout.fillWidth: true
- implicitHeight: deviceInfo.implicitHeight + Appearance.padding.large * 2
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: deviceInfo.implicitHeight + Appearance.padding.large * 2
- radius: Appearance.rounding.normal
- color: Colours.tPalette.m3surfaceContainer
+ radius: Appearance.rounding.normal
+ color: Colours.tPalette.m3surfaceContainer
- ColumnLayout {
- id: deviceInfo
+ ColumnLayout {
+ id: deviceInfo
- anchors.left: parent.left
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- anchors.margins: Appearance.padding.large
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
- spacing: Appearance.spacing.small / 2
+ spacing: Appearance.spacing.small / 2
- StyledText {
- text: root.device?.batteryAvailable ? qsTr("Device battery (%1%)").arg(root.device.battery * 100) : qsTr("Battery unavailable")
- }
+ StyledText {
+ text: root.device?.batteryAvailable ? qsTr("Device battery (%1%)").arg(root.device.battery * 100) : qsTr("Battery unavailable")
+ }
- RowLayout {
- Layout.topMargin: Appearance.spacing.small / 2
- Layout.fillWidth: true
- Layout.preferredHeight: Appearance.padding.smaller
- spacing: Appearance.spacing.small / 2
+ RowLayout {
+ Layout.topMargin: Appearance.spacing.small / 2
+ Layout.fillWidth: true
+ Layout.preferredHeight: Appearance.padding.smaller
+ spacing: Appearance.spacing.small / 2
- StyledRect {
- Layout.fillHeight: true
- implicitWidth: root.device?.batteryAvailable ? parent.width * root.device.battery : 0
- radius: Appearance.rounding.full
- color: Colours.palette.m3primary
- }
+ StyledRect {
+ Layout.fillHeight: true
+ implicitWidth: root.device?.batteryAvailable ? parent.width * root.device.battery : 0
+ radius: Appearance.rounding.full
+ color: Colours.palette.m3primary
+ }
- StyledRect {
- Layout.fillWidth: true
- Layout.fillHeight: true
- radius: Appearance.rounding.full
- color: Colours.palette.m3secondaryContainer
+ StyledRect {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ radius: Appearance.rounding.full
+ color: Colours.palette.m3secondaryContainer
- StyledRect {
- anchors.right: parent.right
- anchors.top: parent.top
- anchors.bottom: parent.bottom
- anchors.margins: parent.height * 0.25
+ StyledRect {
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.margins: parent.height * 0.25
- implicitWidth: height
- radius: Appearance.rounding.full
- color: Colours.palette.m3primary
- }
- }
- }
+ implicitWidth: height
+ radius: Appearance.rounding.full
+ color: Colours.palette.m3primary
+ }
+ }
+ }
- StyledText {
- Layout.topMargin: Appearance.spacing.normal
- text: qsTr("Dbus path")
- }
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("Dbus path")
+ }
- StyledText {
- text: root.device?.dbusPath ?? ""
- color: Colours.palette.m3outline
- font.pointSize: Appearance.font.size.small
- }
+ StyledText {
+ text: root.device?.dbusPath ?? ""
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
- StyledText {
- Layout.topMargin: Appearance.spacing.normal
- text: qsTr("MAC address")
- }
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("MAC address")
+ }
- StyledText {
- text: root.device?.address ?? ""
- color: Colours.palette.m3outline
- font.pointSize: Appearance.font.size.small
- }
+ StyledText {
+ text: root.device?.address ?? ""
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
- StyledText {
- Layout.topMargin: Appearance.spacing.normal
- text: qsTr("Bonded")
- }
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("Bonded")
+ }
- StyledText {
- text: root.device?.bonded ? qsTr("Yes") : qsTr("No")
- color: Colours.palette.m3outline
- font.pointSize: Appearance.font.size.small
- }
+ StyledText {
+ text: root.device?.bonded ? qsTr("Yes") : qsTr("No")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
- StyledText {
- Layout.topMargin: Appearance.spacing.normal
- text: qsTr("System name")
- }
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("System name")
+ }
- StyledText {
- text: root.device?.deviceName ?? ""
- color: Colours.palette.m3outline
- font.pointSize: Appearance.font.size.small
+ StyledText {
+ text: root.device?.deviceName ?? ""
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+ }
+ }
}
}
- }
+ ]
}
}
@@ -562,11 +584,11 @@ Item {
Item {
id: fabRoot
- anchors.right: parent.right
- anchors.bottom: parent.bottom
-
- implicitWidth: 64
- implicitHeight: 64
+ x: root.contentX + root.width - width
+ y: root.contentY + root.height - height
+ width: 64
+ height: 64
+ z: 10000
StyledRect {
id: fabBg
diff --git a/modules/controlcenter/bluetooth/DeviceList.qml b/modules/controlcenter/bluetooth/DeviceList.qml
index 3831e4a..b978a2d 100644
--- a/modules/controlcenter/bluetooth/DeviceList.qml
+++ b/modules/controlcenter/bluetooth/DeviceList.qml
@@ -1,6 +1,7 @@
pragma ComponentBehavior: Bound
import ".."
+import "../components"
import qs.components
import qs.components.controls
import qs.components.containers
@@ -12,172 +13,142 @@ import Quickshell.Bluetooth
import QtQuick
import QtQuick.Layouts
-ColumnLayout {
+DeviceList {
id: root
required property Session session
readonly property bool smallDiscoverable: width <= 540
readonly property bool smallPairable: width <= 480
- spacing: Appearance.spacing.small
+ title: qsTr("Devices (%1)").arg(Bluetooth.devices.values.length)
+ description: qsTr("All available bluetooth devices")
+ activeItem: session.bt.active
- RowLayout {
- spacing: Appearance.spacing.smaller
+ model: ScriptModel {
+ id: deviceModel
- StyledText {
- text: qsTr("Settings")
- font.pointSize: Appearance.font.size.large
- font.weight: 500
- }
-
- Item {
- Layout.fillWidth: true
- }
+ values: [...Bluetooth.devices.values].sort((a, b) => (b.connected - a.connected) || (b.paired - a.paired))
+ }
- ToggleButton {
- toggled: Bluetooth.defaultAdapter?.enabled ?? false
- icon: "power"
- accent: "Tertiary"
+ headerComponent: Component {
+ RowLayout {
+ spacing: Appearance.spacing.smaller
- function onClicked(): void {
- const adapter = Bluetooth.defaultAdapter;
- if (adapter)
- adapter.enabled = !adapter.enabled;
+ StyledText {
+ text: qsTr("Bluetooth")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
}
- }
-
- ToggleButton {
- toggled: Bluetooth.defaultAdapter?.discoverable ?? false
- icon: root.smallDiscoverable ? "group_search" : ""
- label: root.smallDiscoverable ? "" : qsTr("Discoverable")
- function onClicked(): void {
- const adapter = Bluetooth.defaultAdapter;
- if (adapter)
- adapter.discoverable = !adapter.discoverable;
+ Item {
+ Layout.fillWidth: true
}
- }
- ToggleButton {
- toggled: Bluetooth.defaultAdapter?.pairable ?? false
- icon: "missing_controller"
- label: root.smallPairable ? "" : qsTr("Pairable")
+ ToggleButton {
+ toggled: Bluetooth.defaultAdapter?.enabled ?? false
+ icon: "power"
+ accent: "Tertiary"
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+ tooltip: qsTr("Toggle Bluetooth")
- function onClicked(): void {
- const adapter = Bluetooth.defaultAdapter;
- if (adapter)
- adapter.pairable = !adapter.pairable;
+ onClicked: {
+ const adapter = Bluetooth.defaultAdapter;
+ if (adapter)
+ adapter.enabled = !adapter.enabled;
+ }
}
- }
- ToggleButton {
- toggled: !root.session.bt.active
- icon: "settings"
- accent: "Primary"
+ ToggleButton {
+ toggled: Bluetooth.defaultAdapter?.discoverable ?? false
+ icon: root.smallDiscoverable ? "group_search" : ""
+ label: root.smallDiscoverable ? "" : qsTr("Discoverable")
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+ tooltip: qsTr("Make discoverable")
- function onClicked(): void {
- if (root.session.bt.active)
- root.session.bt.active = null;
- else {
- root.session.bt.active = deviceModel.values[0] ?? null;
+ onClicked: {
+ const adapter = Bluetooth.defaultAdapter;
+ if (adapter)
+ adapter.discoverable = !adapter.discoverable;
}
}
- }
- }
- RowLayout {
- Layout.topMargin: Appearance.spacing.large
- Layout.fillWidth: true
- spacing: Appearance.spacing.normal
+ ToggleButton {
+ toggled: Bluetooth.defaultAdapter?.pairable ?? false
+ icon: "missing_controller"
+ label: root.smallPairable ? "" : qsTr("Pairable")
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+ tooltip: qsTr("Make pairable")
- ColumnLayout {
- Layout.fillWidth: true
- spacing: Appearance.spacing.small
-
- StyledText {
- Layout.fillWidth: true
- text: qsTr("Devices (%1)").arg(Bluetooth.devices.values.length)
- font.pointSize: Appearance.font.size.large
- font.weight: 500
- }
-
- StyledText {
- Layout.fillWidth: true
- text: qsTr("All available bluetooth devices")
- color: Colours.palette.m3outline
+ onClicked: {
+ const adapter = Bluetooth.defaultAdapter;
+ if (adapter)
+ adapter.pairable = !adapter.pairable;
+ }
}
- }
-
- StyledRect {
- implicitWidth: implicitHeight
- implicitHeight: scanIcon.implicitHeight + Appearance.padding.normal * 2
- radius: Bluetooth.defaultAdapter?.discovering ? Appearance.rounding.normal : implicitHeight / 2 * Math.min(1, Appearance.rounding.scale)
- color: Bluetooth.defaultAdapter?.discovering ? Colours.palette.m3secondary : Colours.palette.m3secondaryContainer
+ ToggleButton {
+ toggled: Bluetooth.defaultAdapter?.discovering ?? false
+ icon: "bluetooth_searching"
+ accent: "Secondary"
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+ tooltip: qsTr("Scan for devices")
- StateLayer {
- color: Bluetooth.defaultAdapter?.discovering ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer
-
- function onClicked(): void {
+ onClicked: {
const adapter = Bluetooth.defaultAdapter;
if (adapter)
adapter.discovering = !adapter.discovering;
}
}
- MaterialIcon {
- id: scanIcon
-
- anchors.centerIn: parent
- animate: true
- text: "bluetooth_searching"
- color: Bluetooth.defaultAdapter?.discovering ? Colours.palette.m3onSecondary : Colours.palette.m3onSecondaryContainer
- fill: Bluetooth.defaultAdapter?.discovering ? 1 : 0
- }
+ ToggleButton {
+ toggled: !root.session.bt.active
+ icon: "settings"
+ accent: "Primary"
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+ tooltip: qsTr("Bluetooth settings")
- Behavior on radius {
- Anim {}
+ onClicked: {
+ if (root.session.bt.active)
+ root.session.bt.active = null;
+ else {
+ root.session.bt.active = root.model.values[0] ?? null;
+ }
+ }
}
}
}
- StyledListView {
- id: view
-
- model: ScriptModel {
- id: deviceModel
-
- values: [...Bluetooth.devices.values].sort((a, b) => (b.connected - a.connected) || (b.paired - a.paired))
- }
-
- Layout.fillWidth: true
- Layout.fillHeight: true
- clip: true
- spacing: Appearance.spacing.small / 2
-
- StyledScrollBar.vertical: StyledScrollBar {
- flickable: view
- }
- delegate: StyledRect {
+ delegate: Component {
+ StyledRect {
id: device
required property BluetoothDevice modelData
- readonly property bool loading: modelData.state === BluetoothDeviceState.Connecting || modelData.state === BluetoothDeviceState.Disconnecting
- readonly property bool connected: modelData.state === BluetoothDeviceState.Connected
+ readonly property bool loading: modelData && (modelData.state === BluetoothDeviceState.Connecting || modelData.state === BluetoothDeviceState.Disconnecting)
+ readonly property bool connected: modelData && modelData.state === BluetoothDeviceState.Connected
- anchors.left: parent.left
- anchors.right: parent.right
+ width: ListView.view ? ListView.view.width : undefined
implicitHeight: deviceInner.implicitHeight + Appearance.padding.normal * 2
- color: Qt.alpha(Colours.tPalette.m3surfaceContainer, root.session.bt.active === modelData ? Colours.tPalette.m3surfaceContainer.a : 0)
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, root.activeItem === modelData ? Colours.tPalette.m3surfaceContainer.a : 0)
radius: Appearance.rounding.normal
StateLayer {
id: stateLayer
function onClicked(): void {
- root.session.bt.active = device.modelData;
+ if (device.modelData)
+ root.session.bt.active = device.modelData;
}
}
@@ -194,20 +165,20 @@ ColumnLayout {
implicitHeight: icon.implicitHeight + Appearance.padding.normal * 2
radius: Appearance.rounding.normal
- color: device.connected ? Colours.palette.m3primaryContainer : device.modelData.bonded ? Colours.palette.m3secondaryContainer : Colours.tPalette.m3surfaceContainerHigh
+ color: device.connected ? Colours.palette.m3primaryContainer : (device.modelData && device.modelData.bonded) ? Colours.palette.m3secondaryContainer : Colours.tPalette.m3surfaceContainerHigh
StyledRect {
anchors.fill: parent
radius: parent.radius
- color: Qt.alpha(device.connected ? Colours.palette.m3onPrimaryContainer : device.modelData.bonded ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface, stateLayer.pressed ? 0.1 : stateLayer.containsMouse ? 0.08 : 0)
+ color: Qt.alpha(device.connected ? Colours.palette.m3onPrimaryContainer : (device.modelData && device.modelData.bonded) ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface, stateLayer.pressed ? 0.1 : stateLayer.containsMouse ? 0.08 : 0)
}
MaterialIcon {
id: icon
anchors.centerIn: parent
- text: Icons.getBluetoothIcon(device.modelData.icon)
- color: device.connected ? Colours.palette.m3onPrimaryContainer : device.modelData.bonded ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
+ text: Icons.getBluetoothIcon(device.modelData ? device.modelData.icon : "")
+ color: device.connected ? Colours.palette.m3onPrimaryContainer : (device.modelData && device.modelData.bonded) ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface
font.pointSize: Appearance.font.size.large
fill: device.connected ? 1 : 0
@@ -224,13 +195,13 @@ ColumnLayout {
StyledText {
Layout.fillWidth: true
- text: device.modelData.name
+ text: device.modelData ? device.modelData.name : qsTr("Unknown")
elide: Text.ElideRight
}
StyledText {
Layout.fillWidth: true
- text: device.modelData.address + (device.connected ? qsTr(" (Connected)") : device.modelData.bonded ? qsTr(" (Paired)") : "")
+ text: (device.modelData ? device.modelData.address : "") + (device.connected ? qsTr(" (Connected)") : (device.modelData && device.modelData.bonded) ? qsTr(" (Paired)") : "")
color: Colours.palette.m3outline
font.pointSize: Appearance.font.size.small
elide: Text.ElideRight
@@ -256,7 +227,18 @@ ColumnLayout {
disabled: device.loading
function onClicked(): void {
- device.modelData.connected = !device.modelData.connected;
+ if (device.loading)
+ return;
+
+ if (device.connected) {
+ device.modelData.connected = false;
+ } else {
+ if (device.modelData.bonded) {
+ device.modelData.connected = true;
+ } else {
+ device.modelData.pair();
+ }
+ }
}
}
@@ -265,7 +247,7 @@ ColumnLayout {
anchors.centerIn: parent
animate: true
- text: device.modelData.connected ? "link_off" : "link"
+ text: device.connected ? "link_off" : "link"
color: device.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
opacity: device.loading ? 0 : 1
@@ -279,78 +261,7 @@ ColumnLayout {
}
}
- component ToggleButton: StyledRect {
- id: toggleBtn
-
- required property bool toggled
- property string icon
- property string label
- property string accent: "Secondary"
-
- function onClicked(): void {
- }
-
- Layout.preferredWidth: implicitWidth + (toggleStateLayer.pressed ? Appearance.padding.normal * 2 : toggled ? Appearance.padding.small * 2 : 0)
- implicitWidth: toggleBtnInner.implicitWidth + Appearance.padding.large * 2
- implicitHeight: toggleBtnIcon.implicitHeight + Appearance.padding.normal * 2
-
- radius: toggled || toggleStateLayer.pressed ? Appearance.rounding.small : Math.min(width, height) / 2 * Math.min(1, Appearance.rounding.scale)
- color: toggled ? Colours.palette[`m3${accent.toLowerCase()}`] : Colours.palette[`m3${accent.toLowerCase()}Container`]
-
- StateLayer {
- id: toggleStateLayer
-
- color: toggleBtn.toggled ? Colours.palette[`m3on${toggleBtn.accent}`] : Colours.palette[`m3on${toggleBtn.accent}Container`]
-
- function onClicked(): void {
- toggleBtn.onClicked();
- }
- }
-
- RowLayout {
- id: toggleBtnInner
-
- anchors.centerIn: parent
- spacing: Appearance.spacing.normal
-
- MaterialIcon {
- id: toggleBtnIcon
-
- visible: !!text
- fill: toggleBtn.toggled ? 1 : 0
- text: toggleBtn.icon
- color: toggleBtn.toggled ? Colours.palette[`m3on${toggleBtn.accent}`] : Colours.palette[`m3on${toggleBtn.accent}Container`]
- font.pointSize: Appearance.font.size.large
-
- Behavior on fill {
- Anim {}
- }
- }
-
- Loader {
- asynchronous: true
- active: !!toggleBtn.label
- visible: active
-
- sourceComponent: StyledText {
- text: toggleBtn.label
- color: toggleBtn.toggled ? Colours.palette[`m3on${toggleBtn.accent}`] : Colours.palette[`m3on${toggleBtn.accent}Container`]
- }
- }
- }
-
- Behavior on radius {
- Anim {
- duration: Appearance.anim.durations.expressiveFastSpatial
- easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
- }
- }
-
- Behavior on Layout.preferredWidth {
- Anim {
- duration: Appearance.anim.durations.expressiveFastSpatial
- easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
- }
- }
+ onItemSelected: function(item) {
+ session.bt.active = item;
}
}
diff --git a/modules/controlcenter/bluetooth/Settings.qml b/modules/controlcenter/bluetooth/Settings.qml
index fb493ff..c547240 100644
--- a/modules/controlcenter/bluetooth/Settings.qml
+++ b/modules/controlcenter/bluetooth/Settings.qml
@@ -1,6 +1,7 @@
pragma ComponentBehavior: Bound
import ".."
+import "../components"
import qs.components
import qs.components.controls
import qs.components.effects
@@ -17,18 +18,9 @@ ColumnLayout {
spacing: Appearance.spacing.normal
- MaterialIcon {
- Layout.alignment: Qt.AlignHCenter
- text: "bluetooth"
- font.pointSize: Appearance.font.size.extraLarge * 3
- font.bold: true
- }
-
- StyledText {
- Layout.alignment: Qt.AlignHCenter
- text: qsTr("Bluetooth settings")
- font.pointSize: Appearance.font.size.large
- font.bold: true
+ SettingsHeader {
+ icon: "bluetooth"
+ title: qsTr("Bluetooth Settings")
}
StyledText {
@@ -284,8 +276,12 @@ ColumnLayout {
CustomSpinBox {
min: 0
- value: root.session.bt.currentAdapter.discoverableTimeout
- onValueModified: value => root.session.bt.currentAdapter.discoverableTimeout = value
+ value: root.session.bt.currentAdapter?.discoverableTimeout ?? 0
+ onValueModified: value => {
+ if (root.session.bt.currentAdapter) {
+ root.session.bt.currentAdapter.discoverableTimeout = value;
+ }
+ }
}
}
@@ -332,7 +328,7 @@ ColumnLayout {
anchors.left: parent.left
- text: qsTr("Rename adapter (currently does not work)") // FIXME: remove disclaimer when fixed
+ text: qsTr("Rename adapter (currently does not work)")
color: Colours.palette.m3outline
font.pointSize: Appearance.font.size.small
}
@@ -345,12 +341,10 @@ ColumnLayout {
anchors.top: renameLabel.bottom
anchors.leftMargin: root.session.bt.editingAdapterName ? 0 : -Appearance.padding.normal
- text: root.session.bt.currentAdapter.name
+ text: root.session.bt.currentAdapter?.name ?? ""
readOnly: !root.session.bt.editingAdapterName
onAccepted: {
root.session.bt.editingAdapterName = false;
- // Doesn't work for now, will be added to QS later
- // root.session.bt.currentAdapter.name = text;
}
leftPadding: Appearance.padding.normal
@@ -392,7 +386,7 @@ ColumnLayout {
function onClicked(): void {
root.session.bt.editingAdapterName = false;
- adapterNameEdit.text = Qt.binding(() => root.session.bt.currentAdapter.name);
+ adapterNameEdit.text = Qt.binding(() => root.session.bt.currentAdapter?.name ?? "");
}
}
diff --git a/modules/controlcenter/components/DeviceDetails.qml b/modules/controlcenter/components/DeviceDetails.qml
new file mode 100644
index 0000000..8cc9177
--- /dev/null
+++ b/modules/controlcenter/components/DeviceDetails.qml
@@ -0,0 +1,71 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ property Session session
+ property var device: null
+
+ property Component headerComponent: null
+ property list<Component> sections: []
+
+ property Component topContent: null
+ property Component bottomContent: null
+
+ implicitWidth: layout.implicitWidth
+ implicitHeight: layout.implicitHeight
+
+ ColumnLayout {
+ id: layout
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ spacing: Appearance.spacing.normal
+
+ Loader {
+ id: headerLoader
+
+ Layout.fillWidth: true
+ sourceComponent: root.headerComponent
+ visible: root.headerComponent !== null
+ }
+
+ Loader {
+ id: topContentLoader
+
+ Layout.fillWidth: true
+ sourceComponent: root.topContent
+ visible: root.topContent !== null
+ }
+
+ Repeater {
+ model: root.sections
+
+ Loader {
+ required property Component modelData
+
+ Layout.fillWidth: true
+ sourceComponent: modelData
+ }
+ }
+
+ Loader {
+ id: bottomContentLoader
+
+ Layout.fillWidth: true
+ sourceComponent: root.bottomContent
+ visible: root.bottomContent !== null
+ }
+ }
+}
+
diff --git a/modules/controlcenter/components/DeviceList.qml b/modules/controlcenter/components/DeviceList.qml
new file mode 100644
index 0000000..75dd913
--- /dev/null
+++ b/modules/controlcenter/components/DeviceList.qml
@@ -0,0 +1,85 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ property Session session: null
+ property var model: null
+ property Component delegate: null
+
+ property string title: ""
+ property string description: ""
+ property var activeItem: null
+ property Component headerComponent: null
+ property Component titleSuffix: null
+ property bool showHeader: true
+
+ signal itemSelected(var item)
+
+ spacing: Appearance.spacing.small
+
+ Loader {
+ id: headerLoader
+
+ Layout.fillWidth: true
+ sourceComponent: root.headerComponent
+ visible: root.headerComponent !== null && root.showHeader
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.topMargin: root.headerComponent ? 0 : 0
+ spacing: Appearance.spacing.small
+ visible: root.title !== "" || root.description !== ""
+
+ StyledText {
+ visible: root.title !== ""
+ text: root.title
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ Loader {
+ sourceComponent: root.titleSuffix
+ visible: root.titleSuffix !== null
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+ }
+
+ property alias view: view
+
+ StyledText {
+ visible: root.description !== ""
+ Layout.fillWidth: true
+ text: root.description
+ color: Colours.palette.m3outline
+ }
+
+ StyledListView {
+ id: view
+
+ Layout.fillWidth: true
+ implicitHeight: contentHeight
+
+ model: root.model
+ delegate: root.delegate
+
+ spacing: Appearance.spacing.small / 2
+ interactive: false
+ clip: false
+ }
+}
+
diff --git a/modules/controlcenter/components/PaneTransition.qml b/modules/controlcenter/components/PaneTransition.qml
new file mode 100644
index 0000000..d1814b5
--- /dev/null
+++ b/modules/controlcenter/components/PaneTransition.qml
@@ -0,0 +1,72 @@
+pragma ComponentBehavior: Bound
+
+import qs.config
+import QtQuick
+
+SequentialAnimation {
+ id: root
+
+ required property Item target
+ property list<PropertyAction> propertyActions
+
+ property real scaleFrom: 1.0
+ property real scaleTo: 0.8
+ property real opacityFrom: 1.0
+ property real opacityTo: 0.0
+
+ ParallelAnimation {
+ NumberAnimation {
+ target: root.target
+ property: "opacity"
+ from: root.opacityFrom
+ to: root.opacityTo
+ duration: Appearance.anim.durations.normal / 2
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standardAccel
+ }
+
+ NumberAnimation {
+ target: root.target
+ property: "scale"
+ from: root.scaleFrom
+ to: root.scaleTo
+ duration: Appearance.anim.durations.normal / 2
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standardAccel
+ }
+ }
+
+ ScriptAction {
+ script: {
+ for (let i = 0; i < root.propertyActions.length; i++) {
+ const action = root.propertyActions[i];
+ if (action.target && action.property !== undefined) {
+ action.target[action.property] = action.value;
+ }
+ }
+ }
+ }
+
+ ParallelAnimation {
+ NumberAnimation {
+ target: root.target
+ property: "opacity"
+ from: root.opacityTo
+ to: root.opacityFrom
+ duration: Appearance.anim.durations.normal / 2
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+
+ NumberAnimation {
+ target: root.target
+ property: "scale"
+ from: root.scaleTo
+ to: root.scaleFrom
+ duration: Appearance.anim.durations.normal / 2
+ easing.type: Easing.BezierSpline
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+}
+
diff --git a/modules/controlcenter/components/SettingsHeader.qml b/modules/controlcenter/components/SettingsHeader.qml
new file mode 100644
index 0000000..c1ba148
--- /dev/null
+++ b/modules/controlcenter/components/SettingsHeader.qml
@@ -0,0 +1,38 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property string icon
+ required property string title
+
+ Layout.fillWidth: true
+ implicitHeight: column.implicitHeight
+
+ ColumnLayout {
+ id: column
+
+ anchors.centerIn: parent
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignHCenter
+ text: root.icon
+ font.pointSize: Appearance.font.size.extraLarge * 3
+ font.bold: true
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: root.title
+ font.pointSize: Appearance.font.size.large
+ font.bold: true
+ }
+ }
+}
+
diff --git a/modules/controlcenter/components/SliderInput.qml b/modules/controlcenter/components/SliderInput.qml
new file mode 100644
index 0000000..7348368
--- /dev/null
+++ b/modules/controlcenter/components/SliderInput.qml
@@ -0,0 +1,181 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ property string label: ""
+ property real value: 0
+ property real from: 0
+ property real to: 100
+ property real stepSize: 0
+ property var validator: null
+ property string suffix: "" // Optional suffix text (e.g., "×", "px")
+ property int decimals: 1 // Number of decimal places to show (default: 1)
+ property var formatValueFunction: null // Optional custom format function
+ property var parseValueFunction: null // Optional custom parse function
+
+ function formatValue(val: real): string {
+ if (formatValueFunction) {
+ return formatValueFunction(val);
+ }
+ // Default format function
+ // Check if it's an IntValidator (IntValidator doesn't have a 'decimals' property)
+ if (validator && validator.bottom !== undefined && validator.decimals === undefined) {
+ return Math.round(val).toString();
+ }
+ // For DoubleValidator or no validator, use the decimals property
+ return val.toFixed(root.decimals);
+ }
+
+ function parseValue(text: string): real {
+ if (parseValueFunction) {
+ return parseValueFunction(text);
+ }
+ // Default parse function
+ if (validator && validator.bottom !== undefined) {
+ // Check if it's an integer validator
+ if (validator.top !== undefined && validator.top === Math.floor(validator.top)) {
+ return parseInt(text);
+ }
+ }
+ return parseFloat(text);
+ }
+
+ signal valueModified(real newValue)
+
+ property bool _initialized: false
+
+ spacing: Appearance.spacing.small
+
+ Component.onCompleted: {
+ // Set initialized flag after a brief delay to allow component to fully load
+ Qt.callLater(() => {
+ _initialized = true;
+ });
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ visible: root.label !== ""
+ text: root.label
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ StyledInputField {
+ id: inputField
+ Layout.preferredWidth: 70
+ validator: root.validator
+
+ Component.onCompleted: {
+ // Initialize text without triggering valueModified signal
+ text = root.formatValue(root.value);
+ }
+
+ onTextEdited: (text) => {
+ if (hasFocus) {
+ const val = root.parseValue(text);
+ if (!isNaN(val)) {
+ // Validate against validator bounds if available
+ let isValid = true;
+ if (root.validator) {
+ if (root.validator.bottom !== undefined && val < root.validator.bottom) {
+ isValid = false;
+ }
+ if (root.validator.top !== undefined && val > root.validator.top) {
+ isValid = false;
+ }
+ }
+
+ if (isValid) {
+ root.valueModified(val);
+ }
+ }
+ }
+ }
+
+ onEditingFinished: {
+ const val = root.parseValue(text);
+ let isValid = true;
+ if (root.validator) {
+ if (root.validator.bottom !== undefined && val < root.validator.bottom) {
+ isValid = false;
+ }
+ if (root.validator.top !== undefined && val > root.validator.top) {
+ isValid = false;
+ }
+ }
+
+ if (isNaN(val) || !isValid) {
+ text = root.formatValue(root.value);
+ }
+ }
+ }
+
+ StyledText {
+ visible: root.suffix !== ""
+ text: root.suffix
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.normal
+ }
+ }
+
+ StyledSlider {
+ id: slider
+
+ Layout.fillWidth: true
+ implicitHeight: Appearance.padding.normal * 3
+
+ from: root.from
+ to: root.to
+ stepSize: root.stepSize
+
+ // Use Binding to allow slider to move freely during dragging
+ Binding {
+ target: slider
+ property: "value"
+ value: root.value
+ when: !slider.pressed
+ }
+
+ onValueChanged: {
+ // Update input field text in real-time as slider moves during dragging
+ // Always update when slider value changes (during dragging or external updates)
+ if (!inputField.hasFocus) {
+ const newValue = root.stepSize > 0 ? Math.round(value / root.stepSize) * root.stepSize : value;
+ inputField.text = root.formatValue(newValue);
+ }
+ }
+
+ onMoved: {
+ const newValue = root.stepSize > 0 ? Math.round(value / root.stepSize) * root.stepSize : value;
+ root.valueModified(newValue);
+ if (!inputField.hasFocus) {
+ inputField.text = root.formatValue(newValue);
+ }
+ }
+ }
+
+ // Update input field when value changes externally (slider is already bound)
+ onValueChanged: {
+ // Only update if component is initialized to avoid issues during creation
+ if (root._initialized && !inputField.hasFocus) {
+ inputField.text = root.formatValue(root.value);
+ }
+ }
+}
+
diff --git a/modules/controlcenter/components/SplitPaneLayout.qml b/modules/controlcenter/components/SplitPaneLayout.qml
new file mode 100644
index 0000000..8b4f0d9
--- /dev/null
+++ b/modules/controlcenter/components/SplitPaneLayout.qml
@@ -0,0 +1,112 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.effects
+import qs.config
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+RowLayout {
+ id: root
+
+ spacing: 0
+
+ property Component leftContent: null
+ property Component rightContent: null
+
+ property real leftWidthRatio: 0.4
+ property int leftMinimumWidth: 420
+ property var leftLoaderProperties: ({})
+ property var rightLoaderProperties: ({})
+
+ property alias leftLoader: leftLoader
+ property alias rightLoader: rightLoader
+
+ Item {
+ id: leftPane
+
+ Layout.preferredWidth: Math.floor(parent.width * root.leftWidthRatio)
+ Layout.minimumWidth: root.leftMinimumWidth
+ Layout.fillHeight: true
+
+ ClippingRectangle {
+ id: leftClippingRect
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ anchors.leftMargin: 0
+ anchors.rightMargin: Appearance.padding.normal / 2
+
+ radius: leftBorder.innerRadius
+ color: "transparent"
+
+ Loader {
+ id: leftLoader
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large + Appearance.padding.normal
+ anchors.leftMargin: Appearance.padding.large
+ anchors.rightMargin: Appearance.padding.large + Appearance.padding.normal / 2
+
+ asynchronous: true
+ sourceComponent: root.leftContent
+
+ Component.onCompleted: {
+ for (const key in root.leftLoaderProperties) {
+ leftLoader[key] = root.leftLoaderProperties[key];
+ }
+ }
+ }
+ }
+
+ InnerBorder {
+ id: leftBorder
+
+ leftThickness: 0
+ rightThickness: Appearance.padding.normal / 2
+ }
+ }
+
+ Item {
+ id: rightPane
+
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ ClippingRectangle {
+ id: rightClippingRect
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ anchors.leftMargin: 0
+ anchors.rightMargin: Appearance.padding.normal / 2
+
+ radius: rightBorder.innerRadius
+ color: "transparent"
+
+ Loader {
+ id: rightLoader
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large * 2
+
+ asynchronous: true
+ sourceComponent: root.rightContent
+
+ Component.onCompleted: {
+ for (const key in root.rightLoaderProperties) {
+ rightLoader[key] = root.rightLoaderProperties[key];
+ }
+ }
+ }
+ }
+
+ InnerBorder {
+ id: rightBorder
+
+ leftThickness: Appearance.padding.normal / 2
+ }
+ }
+}
+
diff --git a/modules/controlcenter/components/SplitPaneWithDetails.qml b/modules/controlcenter/components/SplitPaneWithDetails.qml
new file mode 100644
index 0000000..e873923
--- /dev/null
+++ b/modules/controlcenter/components/SplitPaneWithDetails.qml
@@ -0,0 +1,93 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.components
+import qs.components.effects
+import qs.components.containers
+import qs.config
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Component leftContent
+ required property Component rightDetailsComponent
+ required property Component rightSettingsComponent
+
+ property var activeItem: null
+ property var paneIdGenerator: function(item) { return item ? String(item) : ""; }
+
+ property Component overlayComponent: null
+
+ SplitPaneLayout {
+ id: splitLayout
+
+ anchors.fill: parent
+
+ leftContent: root.leftContent
+
+ rightContent: Component {
+ Item {
+ id: rightPaneItem
+
+ property var pane: root.activeItem
+ property string paneId: root.paneIdGenerator(pane)
+ property Component targetComponent: root.rightSettingsComponent
+ property Component nextComponent: root.rightSettingsComponent
+
+ function getComponentForPane() {
+ return pane ? root.rightDetailsComponent : root.rightSettingsComponent;
+ }
+
+ Component.onCompleted: {
+ targetComponent = getComponentForPane();
+ nextComponent = targetComponent;
+ }
+
+ Loader {
+ id: rightLoader
+
+ anchors.fill: parent
+
+ opacity: 1
+ scale: 1
+ transformOrigin: Item.Center
+
+ clip: false
+ asynchronous: true
+ sourceComponent: rightPaneItem.targetComponent
+ }
+
+ Behavior on paneId {
+ PaneTransition {
+ target: rightLoader
+ propertyActions: [
+ PropertyAction {
+ target: rightPaneItem
+ property: "targetComponent"
+ value: rightPaneItem.nextComponent
+ }
+ ]
+ }
+ }
+
+ onPaneChanged: {
+ nextComponent = getComponentForPane();
+ paneId = root.paneIdGenerator(pane);
+ }
+ }
+ }
+ }
+
+ Loader {
+ id: overlayLoader
+
+ anchors.fill: parent
+ z: 1000
+ sourceComponent: root.overlayComponent
+ active: root.overlayComponent !== null
+ }
+}
+
diff --git a/modules/controlcenter/components/WallpaperGrid.qml b/modules/controlcenter/components/WallpaperGrid.qml
new file mode 100644
index 0000000..5eab5b8
--- /dev/null
+++ b/modules/controlcenter/components/WallpaperGrid.qml
@@ -0,0 +1,241 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.images
+import qs.services
+import qs.config
+import Caelestia.Models
+import QtQuick
+
+GridView {
+ id: root
+
+ required property Session session
+
+ readonly property int minCellWidth: 200 + Appearance.spacing.normal
+ readonly property int columnsCount: Math.max(1, Math.floor(width / minCellWidth))
+
+ cellWidth: width / columnsCount
+ cellHeight: 140 + Appearance.spacing.normal
+
+ model: Wallpapers.list
+
+ clip: true
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: root
+ }
+
+ delegate: Item {
+ required property var modelData
+ required property int index
+
+ width: root.cellWidth
+ height: root.cellHeight
+
+ readonly property bool isCurrent: modelData && modelData.path === Wallpapers.actualCurrent
+ readonly property real itemMargin: Appearance.spacing.normal / 2
+ readonly property real itemRadius: Appearance.rounding.normal
+
+ StateLayer {
+ anchors.fill: parent
+ anchors.leftMargin: itemMargin
+ anchors.rightMargin: itemMargin
+ anchors.topMargin: itemMargin
+ anchors.bottomMargin: itemMargin
+ radius: itemRadius
+
+ function onClicked(): void {
+ Wallpapers.setWallpaper(modelData.path);
+ }
+ }
+
+ StyledClippingRect {
+ id: image
+
+ anchors.fill: parent
+ anchors.leftMargin: itemMargin
+ anchors.rightMargin: itemMargin
+ anchors.topMargin: itemMargin
+ anchors.bottomMargin: itemMargin
+ color: Colours.tPalette.m3surfaceContainer
+ radius: itemRadius
+ antialiasing: true
+ layer.enabled: true
+ layer.smooth: true
+
+ CachingImage {
+ id: cachingImage
+
+ path: modelData.path
+ anchors.fill: parent
+ fillMode: Image.PreserveAspectCrop
+ cache: true
+ visible: opacity > 0
+ antialiasing: true
+ smooth: true
+ sourceSize: Qt.size(width, height)
+
+ opacity: status === Image.Ready ? 1 : 0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: 1000
+ easing.type: Easing.OutQuad
+ }
+ }
+ }
+
+ // Fallback if CachingImage fails to load
+ Image {
+ id: fallbackImage
+
+ anchors.fill: parent
+ source: fallbackTimer.triggered && cachingImage.status !== Image.Ready ? modelData.path : ""
+ asynchronous: true
+ fillMode: Image.PreserveAspectCrop
+ cache: true
+ visible: opacity > 0
+ antialiasing: true
+ smooth: true
+ sourceSize: Qt.size(width, height)
+
+ opacity: status === Image.Ready && cachingImage.status !== Image.Ready ? 1 : 0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: 1000
+ easing.type: Easing.OutQuad
+ }
+ }
+ }
+
+ Timer {
+ id: fallbackTimer
+
+ property bool triggered: false
+ interval: 800
+ running: cachingImage.status === Image.Loading || cachingImage.status === Image.Null
+ onTriggered: triggered = true
+ }
+
+ // Gradient overlay for filename
+ Rectangle {
+ id: filenameOverlay
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+
+ implicitHeight: filenameText.implicitHeight + Appearance.padding.normal * 1.5
+ radius: 0
+
+ gradient: Gradient {
+ GradientStop {
+ position: 0.0
+ color: Qt.rgba(Colours.palette.m3surface.r,
+ Colours.palette.m3surface.g,
+ Colours.palette.m3surface.b, 0)
+ }
+ GradientStop {
+ position: 0.3
+ color: Qt.rgba(Colours.palette.m3surface.r,
+ Colours.palette.m3surface.g,
+ Colours.palette.m3surface.b, 0.7)
+ }
+ GradientStop {
+ position: 0.6
+ color: Qt.rgba(Colours.palette.m3surface.r,
+ Colours.palette.m3surface.g,
+ Colours.palette.m3surface.b, 0.9)
+ }
+ GradientStop {
+ position: 1.0
+ color: Qt.rgba(Colours.palette.m3surface.r,
+ Colours.palette.m3surface.g,
+ Colours.palette.m3surface.b, 0.95)
+ }
+ }
+
+ opacity: 0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: 1000
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ Component.onCompleted: {
+ opacity = 1;
+ }
+ }
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.leftMargin: itemMargin
+ anchors.rightMargin: itemMargin
+ anchors.topMargin: itemMargin
+ anchors.bottomMargin: itemMargin
+ color: "transparent"
+ radius: itemRadius + border.width
+ border.width: isCurrent ? 2 : 0
+ border.color: Colours.palette.m3primary
+ antialiasing: true
+ smooth: true
+
+ Behavior on border.width {
+ NumberAnimation {
+ duration: 150
+ easing.type: Easing.OutQuad
+ }
+ }
+
+ MaterialIcon {
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: Appearance.padding.small
+
+ visible: isCurrent
+ text: "check_circle"
+ color: Colours.palette.m3primary
+ font.pointSize: Appearance.font.size.large
+ }
+ }
+
+ StyledText {
+ id: filenameText
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.leftMargin: Appearance.padding.normal + Appearance.spacing.normal / 2
+ anchors.rightMargin: Appearance.padding.normal + Appearance.spacing.normal / 2
+ anchors.bottomMargin: Appearance.padding.normal
+
+ text: modelData.name
+ font.pointSize: Appearance.font.size.smaller
+ font.weight: 500
+ color: isCurrent ? Colours.palette.m3primary : Colours.palette.m3onSurface
+ elide: Text.ElideMiddle
+ maximumLineCount: 1
+ horizontalAlignment: Text.AlignHCenter
+
+ opacity: 0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: 1000
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ Component.onCompleted: {
+ opacity = 1;
+ }
+ }
+ }
+ }
diff --git a/modules/controlcenter/launcher/LauncherPane.qml b/modules/controlcenter/launcher/LauncherPane.qml
new file mode 100644
index 0000000..47f87cc
--- /dev/null
+++ b/modules/controlcenter/launcher/LauncherPane.qml
@@ -0,0 +1,599 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import "../../launcher/services"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.services
+import qs.config
+import qs.utils
+import Caelestia
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+import "../../../utils/scripts/fuzzysort.js" as Fuzzy
+
+Item {
+ id: root
+
+ required property Session session
+
+ property var selectedApp: root.session.launcher.active
+ property bool hideFromLauncherChecked: false
+
+ anchors.fill: parent
+
+ onSelectedAppChanged: {
+ root.session.launcher.active = root.selectedApp;
+ updateToggleState();
+ }
+
+ Connections {
+ target: root.session.launcher
+ function onActiveChanged() {
+ root.selectedApp = root.session.launcher.active;
+ updateToggleState();
+ }
+ }
+
+ function updateToggleState() {
+ if (!root.selectedApp) {
+ root.hideFromLauncherChecked = false;
+ return;
+ }
+
+ const appId = root.selectedApp.id || root.selectedApp.entry?.id;
+
+ if (Config.launcher.hiddenApps && Config.launcher.hiddenApps.length > 0) {
+ root.hideFromLauncherChecked = Config.launcher.hiddenApps.includes(appId);
+ } else {
+ root.hideFromLauncherChecked = false;
+ }
+ }
+
+ function saveHiddenApps(isHidden) {
+ if (!root.selectedApp) {
+ return;
+ }
+
+ const appId = root.selectedApp.id || root.selectedApp.entry?.id;
+
+ const hiddenApps = Config.launcher.hiddenApps ? [...Config.launcher.hiddenApps] : [];
+
+ if (isHidden) {
+ if (!hiddenApps.includes(appId)) {
+ hiddenApps.push(appId);
+ }
+ } else {
+ const index = hiddenApps.indexOf(appId);
+ if (index !== -1) {
+ hiddenApps.splice(index, 1);
+ }
+ }
+
+ Config.launcher.hiddenApps = hiddenApps;
+ Config.save();
+ }
+
+
+ AppDb {
+ id: allAppsDb
+
+ path: `${Paths.state}/apps.sqlite`
+ entries: DesktopEntries.applications.values
+ }
+
+ property string searchText: ""
+
+ function filterApps(search: string): list<var> {
+ if (!search || search.trim() === "") {
+ const apps = [];
+ for (let i = 0; i < allAppsDb.apps.length; i++) {
+ apps.push(allAppsDb.apps[i]);
+ }
+ return apps;
+ }
+
+ if (!allAppsDb.apps || allAppsDb.apps.length === 0) {
+ return [];
+ }
+
+ const preparedApps = [];
+ for (let i = 0; i < allAppsDb.apps.length; i++) {
+ const app = allAppsDb.apps[i];
+ const name = app.name || app.entry?.name || "";
+ preparedApps.push({
+ _item: app,
+ name: Fuzzy.prepare(name)
+ });
+ }
+
+ const results = Fuzzy.go(search, preparedApps, {
+ all: true,
+ keys: ["name"],
+ scoreFn: r => r[0].score
+ });
+
+ return results
+ .sort((a, b) => b._score - a._score)
+ .map(r => r.obj._item);
+ }
+
+ property list<var> filteredApps: []
+
+ function updateFilteredApps() {
+ filteredApps = filterApps(searchText);
+ }
+
+ onSearchTextChanged: {
+ updateFilteredApps();
+ }
+
+ Component.onCompleted: {
+ updateFilteredApps();
+ }
+
+ Connections {
+ target: allAppsDb
+ function onAppsChanged() {
+ updateFilteredApps();
+ }
+ }
+
+ SplitPaneLayout {
+ anchors.fill: parent
+
+ leftContent: Component {
+
+ ColumnLayout {
+ id: leftLauncherLayout
+ anchors.fill: parent
+
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ text: qsTr("Launcher")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ ToggleButton {
+ toggled: !root.session.launcher.active
+ icon: "settings"
+ accent: "Primary"
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+ tooltip: qsTr("Launcher settings")
+
+ onClicked: {
+ if (root.session.launcher.active) {
+ root.session.launcher.active = null;
+ } else {
+ if (root.filteredApps.length > 0) {
+ root.session.launcher.active = root.filteredApps[0];
+ }
+ }
+ }
+ }
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.large
+ text: qsTr("Applications (%1)").arg(root.searchText ? root.filteredApps.length : allAppsDb.apps.length)
+ font.pointSize: Appearance.font.size.normal
+ font.weight: 500
+ }
+
+ StyledText {
+ text: qsTr("All applications available in the launcher")
+ color: Colours.palette.m3outline
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ Layout.topMargin: Appearance.spacing.normal
+ Layout.bottomMargin: Appearance.spacing.small
+
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.full
+
+ implicitHeight: Math.max(searchIcon.implicitHeight, searchField.implicitHeight, clearIcon.implicitHeight)
+
+ MaterialIcon {
+ id: searchIcon
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ anchors.leftMargin: Appearance.padding.normal
+
+ text: "search"
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ StyledTextField {
+ id: searchField
+
+ anchors.left: searchIcon.right
+ anchors.right: clearIcon.left
+ anchors.leftMargin: Appearance.spacing.small
+ anchors.rightMargin: Appearance.spacing.small
+
+ topPadding: Appearance.padding.normal
+ bottomPadding: Appearance.padding.normal
+
+ placeholderText: qsTr("Search applications...")
+
+ onTextChanged: {
+ root.searchText = text;
+ }
+ }
+
+ MaterialIcon {
+ id: clearIcon
+
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.right: parent.right
+ anchors.rightMargin: Appearance.padding.normal
+
+ width: searchField.text ? implicitWidth : implicitWidth / 2
+ opacity: {
+ if (!searchField.text)
+ return 0;
+ if (clearMouse.pressed)
+ return 0.7;
+ if (clearMouse.containsMouse)
+ return 0.8;
+ return 1;
+ }
+
+ text: "close"
+ color: Colours.palette.m3onSurfaceVariant
+
+ MouseArea {
+ id: clearMouse
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: searchField.text ? Qt.PointingHandCursor : undefined
+
+ onClicked: searchField.text = ""
+ }
+
+ Behavior on width {
+ Anim {
+ duration: Appearance.anim.durations.small
+ }
+ }
+
+ Behavior on opacity {
+ Anim {
+ duration: Appearance.anim.durations.small
+ }
+ }
+ }
+ }
+
+ Loader {
+ id: appsListLoader
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ asynchronous: true
+ active: true
+
+ sourceComponent: StyledListView {
+ id: appsListView
+
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ model: root.filteredApps
+ spacing: Appearance.spacing.small / 2
+ clip: true
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: parent
+ }
+
+ delegate: StyledRect {
+ required property var modelData
+
+ width: parent ? parent.width : 0
+
+ readonly property bool isSelected: root.selectedApp === modelData
+
+ color: isSelected ? Colours.layer(Colours.palette.m3surfaceContainer, 2) : "transparent"
+ radius: Appearance.rounding.normal
+
+ opacity: 0
+
+ Behavior on opacity {
+ NumberAnimation {
+ duration: 1000
+ easing.type: Easing.OutCubic
+ }
+ }
+
+ Component.onCompleted: {
+ opacity = 1;
+ }
+
+ StateLayer {
+ function onClicked(): void {
+ root.session.launcher.active = modelData;
+ }
+ }
+
+ RowLayout {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ IconImage {
+ Layout.alignment: Qt.AlignVCenter
+ implicitSize: 32
+ source: {
+ const entry = modelData.entry;
+ return entry ? Quickshell.iconPath(entry.icon, "image-missing") : "image-missing";
+ }
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: modelData.name || modelData.entry?.name || qsTr("Unknown")
+ font.pointSize: Appearance.font.size.normal
+ }
+ }
+
+ implicitHeight: 40
+ }
+ }
+ }
+ }
+ }
+
+ rightContent: Component {
+ Item {
+ id: rightLauncherPane
+
+ property var pane: root.session.launcher.active
+ property string paneId: pane ? (pane.id || pane.entry?.id || "") : ""
+ property Component targetComponent: settings
+ property Component nextComponent: settings
+ property var displayedApp: null
+
+ function getComponentForPane() {
+ return pane ? appDetails : settings;
+ }
+
+ Component.onCompleted: {
+ displayedApp = pane;
+ targetComponent = getComponentForPane();
+ nextComponent = targetComponent;
+ }
+
+ Loader {
+ id: rightLauncherLoader
+
+ anchors.fill: parent
+
+ opacity: 1
+ scale: 1
+ transformOrigin: Item.Center
+ clip: false
+
+ asynchronous: true
+ sourceComponent: rightLauncherPane.targetComponent
+ active: true
+
+ property var displayedApp: rightLauncherPane.displayedApp
+
+ onItemChanged: {
+ if (item && rightLauncherPane.pane && rightLauncherPane.displayedApp !== rightLauncherPane.pane) {
+ rightLauncherPane.displayedApp = rightLauncherPane.pane;
+ }
+ }
+ }
+
+ Behavior on paneId {
+ PaneTransition {
+ target: rightLauncherLoader
+ propertyActions: [
+ PropertyAction {
+ target: rightLauncherPane
+ property: "displayedApp"
+ value: rightLauncherPane.pane
+ },
+ PropertyAction {
+ target: rightLauncherLoader
+ property: "active"
+ value: false
+ },
+ PropertyAction {
+ target: rightLauncherPane
+ property: "targetComponent"
+ value: rightLauncherPane.nextComponent
+ },
+ PropertyAction {
+ target: rightLauncherLoader
+ property: "active"
+ value: true
+ }
+ ]
+ }
+ }
+
+ onPaneChanged: {
+ nextComponent = getComponentForPane();
+ paneId = pane ? (pane.id || pane.entry?.id || "") : "";
+ }
+
+ onDisplayedAppChanged: {
+ if (displayedApp) {
+ const appId = displayedApp.id || displayedApp.entry?.id;
+ if (Config.launcher.hiddenApps && Config.launcher.hiddenApps.length > 0) {
+ root.hideFromLauncherChecked = Config.launcher.hiddenApps.includes(appId);
+ } else {
+ root.hideFromLauncherChecked = false;
+ }
+ } else {
+ root.hideFromLauncherChecked = false;
+ }
+ }
+ }
+ }
+ }
+
+ Component {
+ id: settings
+
+ StyledFlickable {
+ id: settingsFlickable
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: settingsInner.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: settingsFlickable
+ }
+
+ Settings {
+ id: settingsInner
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ session: root.session
+ }
+ }
+ }
+
+ Component {
+ id: appDetails
+
+ ColumnLayout {
+ id: appDetailsLayout
+ anchors.fill: parent
+
+ readonly property var displayedApp: parent && parent.displayedApp !== undefined ? parent.displayedApp : null
+
+ spacing: Appearance.spacing.normal
+
+ SettingsHeader {
+ Layout.leftMargin: Appearance.padding.large * 2
+ Layout.rightMargin: Appearance.padding.large * 2
+ Layout.topMargin: Appearance.padding.large * 2
+ visible: displayedApp === null
+ icon: "apps"
+ title: qsTr("Launcher Applications")
+ }
+
+ Item {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.leftMargin: Appearance.padding.large * 2
+ Layout.rightMargin: Appearance.padding.large * 2
+ Layout.topMargin: Appearance.padding.large * 2
+ visible: displayedApp !== null
+ implicitWidth: Math.max(appIconImage.implicitWidth, appTitleText.implicitWidth)
+ implicitHeight: appIconImage.implicitHeight + Appearance.spacing.normal + appTitleText.implicitHeight
+
+ ColumnLayout {
+ anchors.centerIn: parent
+ spacing: Appearance.spacing.normal
+
+ IconImage {
+ id: appIconImage
+ Layout.alignment: Qt.AlignHCenter
+ implicitSize: Appearance.font.size.extraLarge * 3 * 2
+ source: {
+ const app = appDetailsLayout.displayedApp;
+ if (!app) return "image-missing";
+ const entry = app.entry;
+ if (entry && entry.icon) {
+ return Quickshell.iconPath(entry.icon, "image-missing");
+ }
+ return "image-missing";
+ }
+ }
+
+ StyledText {
+ id: appTitleText
+ Layout.alignment: Qt.AlignHCenter
+ text: displayedApp ? (displayedApp.name || displayedApp.entry?.name || qsTr("Application Details")) : ""
+ font.pointSize: Appearance.font.size.large
+ font.bold: true
+ }
+ }
+ }
+
+ Item {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.topMargin: Appearance.spacing.large
+ Layout.leftMargin: Appearance.padding.large * 2
+ Layout.rightMargin: Appearance.padding.large * 2
+
+ StyledFlickable {
+ id: detailsFlickable
+ anchors.fill: parent
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: debugLayout.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: parent
+ }
+
+ ColumnLayout {
+ id: debugLayout
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ spacing: Appearance.spacing.normal
+
+ SwitchRow {
+ Layout.topMargin: Appearance.spacing.normal
+ visible: appDetailsLayout.displayedApp !== null
+ label: qsTr("Hide from launcher")
+ checked: root.hideFromLauncherChecked
+ enabled: appDetailsLayout.displayedApp !== null
+ onToggled: checked => {
+ root.hideFromLauncherChecked = checked;
+ const app = appDetailsLayout.displayedApp;
+ if (app) {
+ const appId = app.id || app.entry?.id;
+ const hiddenApps = Config.launcher.hiddenApps ? [...Config.launcher.hiddenApps] : [];
+ if (checked) {
+ if (!hiddenApps.includes(appId)) {
+ hiddenApps.push(appId);
+ }
+ } else {
+ const index = hiddenApps.indexOf(appId);
+ if (index !== -1) {
+ hiddenApps.splice(index, 1);
+ }
+ }
+ Config.launcher.hiddenApps = hiddenApps;
+ Config.save();
+ }
+ }
+ }
+
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/modules/controlcenter/launcher/Settings.qml b/modules/controlcenter/launcher/Settings.qml
new file mode 100644
index 0000000..161221e
--- /dev/null
+++ b/modules/controlcenter/launcher/Settings.qml
@@ -0,0 +1,218 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property Session session
+
+ spacing: Appearance.spacing.normal
+
+ SettingsHeader {
+ icon: "apps"
+ title: qsTr("Launcher Settings")
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("General")
+ description: qsTr("General launcher settings")
+ }
+
+ SectionContainer {
+ ToggleRow {
+ label: qsTr("Enabled")
+ checked: Config.launcher.enabled
+ toggle.onToggled: {
+ Config.launcher.enabled = checked;
+ Config.save();
+ }
+ }
+
+ ToggleRow {
+ label: qsTr("Show on hover")
+ checked: Config.launcher.showOnHover
+ toggle.onToggled: {
+ Config.launcher.showOnHover = checked;
+ Config.save();
+ }
+ }
+
+ ToggleRow {
+ label: qsTr("Vim keybinds")
+ checked: Config.launcher.vimKeybinds
+ toggle.onToggled: {
+ Config.launcher.vimKeybinds = checked;
+ Config.save();
+ }
+ }
+
+ ToggleRow {
+ label: qsTr("Enable dangerous actions")
+ checked: Config.launcher.enableDangerousActions
+ toggle.onToggled: {
+ Config.launcher.enableDangerousActions = checked;
+ Config.save();
+ }
+ }
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("Display")
+ description: qsTr("Display and appearance settings")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small / 2
+
+ PropertyRow {
+ label: qsTr("Max shown items")
+ value: qsTr("%1").arg(Config.launcher.maxShown)
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Max wallpapers")
+ value: qsTr("%1").arg(Config.launcher.maxWallpapers)
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Drag threshold")
+ value: qsTr("%1 px").arg(Config.launcher.dragThreshold)
+ }
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("Prefixes")
+ description: qsTr("Command prefix settings")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small / 2
+
+ PropertyRow {
+ label: qsTr("Special prefix")
+ value: Config.launcher.specialPrefix || qsTr("None")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Action prefix")
+ value: Config.launcher.actionPrefix || qsTr("None")
+ }
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("Fuzzy search")
+ description: qsTr("Fuzzy search settings")
+ }
+
+ SectionContainer {
+ ToggleRow {
+ label: qsTr("Apps")
+ checked: Config.launcher.useFuzzy.apps
+ toggle.onToggled: {
+ Config.launcher.useFuzzy.apps = checked;
+ Config.save();
+ }
+ }
+
+ ToggleRow {
+ label: qsTr("Actions")
+ checked: Config.launcher.useFuzzy.actions
+ toggle.onToggled: {
+ Config.launcher.useFuzzy.actions = checked;
+ Config.save();
+ }
+ }
+
+ ToggleRow {
+ label: qsTr("Schemes")
+ checked: Config.launcher.useFuzzy.schemes
+ toggle.onToggled: {
+ Config.launcher.useFuzzy.schemes = checked;
+ Config.save();
+ }
+ }
+
+ ToggleRow {
+ label: qsTr("Variants")
+ checked: Config.launcher.useFuzzy.variants
+ toggle.onToggled: {
+ Config.launcher.useFuzzy.variants = checked;
+ Config.save();
+ }
+ }
+
+ ToggleRow {
+ label: qsTr("Wallpapers")
+ checked: Config.launcher.useFuzzy.wallpapers
+ toggle.onToggled: {
+ Config.launcher.useFuzzy.wallpapers = checked;
+ Config.save();
+ }
+ }
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("Sizes")
+ description: qsTr("Size settings for launcher items")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small / 2
+
+ PropertyRow {
+ label: qsTr("Item width")
+ value: qsTr("%1 px").arg(Config.launcher.sizes.itemWidth)
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Item height")
+ value: qsTr("%1 px").arg(Config.launcher.sizes.itemHeight)
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Wallpaper width")
+ value: qsTr("%1 px").arg(Config.launcher.sizes.wallpaperWidth)
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Wallpaper height")
+ value: qsTr("%1 px").arg(Config.launcher.sizes.wallpaperHeight)
+ }
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("Hidden apps")
+ description: qsTr("Applications hidden from launcher")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small / 2
+
+ PropertyRow {
+ label: qsTr("Total hidden")
+ value: qsTr("%1").arg(Config.launcher.hiddenApps ? Config.launcher.hiddenApps.length : 0)
+ }
+ }
+}
+
diff --git a/modules/controlcenter/network/EthernetDetails.qml b/modules/controlcenter/network/EthernetDetails.qml
new file mode 100644
index 0000000..1cd6c0a
--- /dev/null
+++ b/modules/controlcenter/network/EthernetDetails.qml
@@ -0,0 +1,118 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+DeviceDetails {
+ id: root
+
+ required property Session session
+ readonly property var ethernetDevice: root.session.ethernet.active
+
+ device: ethernetDevice
+
+ Component.onCompleted: {
+ if (ethernetDevice && ethernetDevice.interface) {
+ Nmcli.getEthernetDeviceDetails(ethernetDevice.interface, () => {});
+ }
+ }
+
+ onEthernetDeviceChanged: {
+ if (ethernetDevice && ethernetDevice.interface) {
+ Nmcli.getEthernetDeviceDetails(ethernetDevice.interface, () => {});
+ } else {
+ Nmcli.ethernetDeviceDetails = null;
+ }
+ }
+
+ headerComponent: Component {
+ ConnectionHeader {
+ icon: "cable"
+ title: root.ethernetDevice?.interface ?? qsTr("Unknown")
+ }
+ }
+
+ sections: [
+ Component {
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
+
+ SectionHeader {
+ title: qsTr("Connection status")
+ description: qsTr("Connection settings for this device")
+ }
+
+ SectionContainer {
+ ToggleRow {
+ label: qsTr("Connected")
+ checked: root.ethernetDevice?.connected ?? false
+ toggle.onToggled: {
+ if (checked) {
+ Nmcli.connectEthernet(root.ethernetDevice?.connection || "", root.ethernetDevice?.interface || "", () => {});
+ } else {
+ if (root.ethernetDevice?.connection) {
+ Nmcli.disconnectEthernet(root.ethernetDevice.connection, () => {});
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ Component {
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
+
+ SectionHeader {
+ title: qsTr("Device properties")
+ description: qsTr("Additional information")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small / 2
+
+ PropertyRow {
+ label: qsTr("Interface")
+ value: root.ethernetDevice?.interface ?? qsTr("Unknown")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Connection")
+ value: root.ethernetDevice?.connection || qsTr("Not connected")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("State")
+ value: root.ethernetDevice?.state ?? qsTr("Unknown")
+ }
+ }
+ }
+ },
+ Component {
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
+
+ SectionHeader {
+ title: qsTr("Connection information")
+ description: qsTr("Network connection details")
+ }
+
+ SectionContainer {
+ ConnectionInfoSection {
+ deviceDetails: Nmcli.ethernetDeviceDetails
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/modules/controlcenter/network/EthernetList.qml b/modules/controlcenter/network/EthernetList.qml
new file mode 100644
index 0000000..4f4dc8a
--- /dev/null
+++ b/modules/controlcenter/network/EthernetList.qml
@@ -0,0 +1,177 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+DeviceList {
+ id: root
+
+ required property Session session
+
+ title: qsTr("Devices (%1)").arg(Nmcli.ethernetDevices.length)
+ description: qsTr("All available ethernet devices")
+ activeItem: session.ethernet.active
+
+ model: Nmcli.ethernetDevices
+
+ headerComponent: Component {
+ RowLayout {
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ text: qsTr("Settings")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ ToggleButton {
+ toggled: !root.session.ethernet.active
+ icon: "settings"
+ accent: "Primary"
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+
+ onClicked: {
+ if (root.session.ethernet.active)
+ root.session.ethernet.active = null;
+ else {
+ root.session.ethernet.active = root.view.model.get(0)?.modelData ?? null;
+ }
+ }
+ }
+ }
+ }
+
+ delegate: Component {
+ StyledRect {
+ id: ethernetItem
+
+ required property var modelData
+ readonly property bool isActive: root.activeItem && modelData && root.activeItem.interface === modelData.interface
+
+ width: ListView.view ? ListView.view.width : undefined
+ implicitHeight: rowLayout.implicitHeight + Appearance.padding.normal * 2
+
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, ethernetItem.isActive ? Colours.tPalette.m3surfaceContainer.a : 0)
+ radius: Appearance.rounding.normal
+
+ StateLayer {
+ id: stateLayer
+
+ function onClicked(): void {
+ root.session.ethernet.active = modelData;
+ }
+ }
+
+ RowLayout {
+ id: rowLayout
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: icon.implicitHeight + Appearance.padding.normal * 2
+
+ radius: Appearance.rounding.normal
+ color: modelData.connected ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh
+
+ StyledRect {
+ anchors.fill: parent
+ radius: parent.radius
+ color: Qt.alpha(modelData.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface, stateLayer.pressed ? 0.1 : stateLayer.containsMouse ? 0.08 : 0)
+ }
+
+ MaterialIcon {
+ id: icon
+
+ anchors.centerIn: parent
+ text: "cable"
+ font.pointSize: Appearance.font.size.large
+ fill: modelData.connected ? 1 : 0
+ color: modelData.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
+
+ Behavior on fill {
+ Anim {}
+ }
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+
+ spacing: 0
+
+ StyledText {
+ Layout.fillWidth: true
+ text: modelData.interface || qsTr("Unknown")
+ elide: Text.ElideRight
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ Layout.fillWidth: true
+ text: modelData.connected ? qsTr("Connected") : qsTr("Disconnected")
+ color: modelData.connected ? Colours.palette.m3primary : Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ font.weight: modelData.connected ? 500 : 400
+ elide: Text.ElideRight
+ }
+ }
+ }
+
+ StyledRect {
+ id: connectBtn
+
+ implicitWidth: implicitHeight
+ implicitHeight: connectIcon.implicitHeight + Appearance.padding.smaller * 2
+
+ radius: Appearance.rounding.full
+ color: Qt.alpha(Colours.palette.m3primaryContainer, modelData.connected ? 1 : 0)
+
+ StateLayer {
+ color: modelData.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
+
+ function onClicked(): void {
+ if (modelData.connected && modelData.connection) {
+ Nmcli.disconnectEthernet(modelData.connection, () => {});
+ } else {
+ Nmcli.connectEthernet(modelData.connection || "", modelData.interface || "", () => {});
+ }
+ }
+ }
+
+ MaterialIcon {
+ id: connectIcon
+
+ anchors.centerIn: parent
+ animate: true
+ text: modelData.connected ? "link_off" : "link"
+ color: modelData.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
+ }
+ }
+ }
+ }
+ }
+
+ onItemSelected: function(item) {
+ session.ethernet.active = item;
+ }
+}
diff --git a/modules/controlcenter/network/EthernetPane.qml b/modules/controlcenter/network/EthernetPane.qml
new file mode 100644
index 0000000..126535a
--- /dev/null
+++ b/modules/controlcenter/network/EthernetPane.qml
@@ -0,0 +1,50 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.containers
+import qs.config
+import Quickshell.Widgets
+import QtQuick
+
+SplitPaneWithDetails {
+ id: root
+
+ required property Session session
+
+ anchors.fill: parent
+
+ activeItem: session.ethernet.active
+ paneIdGenerator: function(item) {
+ return item ? (item.interface || "") : "";
+ }
+
+ leftContent: Component {
+ EthernetList {
+ session: root.session
+ }
+ }
+
+ rightDetailsComponent: Component {
+ EthernetDetails {
+ session: root.session
+ }
+ }
+
+ rightSettingsComponent: Component {
+ StyledFlickable {
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: settingsInner.height
+ clip: true
+
+ EthernetSettings {
+ id: settingsInner
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ session: root.session
+ }
+ }
+ }
+}
diff --git a/modules/controlcenter/network/EthernetSettings.qml b/modules/controlcenter/network/EthernetSettings.qml
new file mode 100644
index 0000000..f0f66b4
--- /dev/null
+++ b/modules/controlcenter/network/EthernetSettings.qml
@@ -0,0 +1,76 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property Session session
+
+ spacing: Appearance.spacing.normal
+
+ SettingsHeader {
+ icon: "cable"
+ title: qsTr("Ethernet settings")
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.large
+ text: qsTr("Ethernet devices")
+ font.pointSize: Appearance.font.size.larger
+ font.weight: 500
+ }
+
+ StyledText {
+ text: qsTr("Available ethernet devices")
+ color: Colours.palette.m3outline
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: ethernetInfo.implicitHeight + Appearance.padding.large * 2
+
+ radius: Appearance.rounding.normal
+ color: Colours.tPalette.m3surfaceContainer
+
+ ColumnLayout {
+ id: ethernetInfo
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+
+ spacing: Appearance.spacing.small / 2
+
+ StyledText {
+ text: qsTr("Total devices")
+ }
+
+ StyledText {
+ text: qsTr("%1").arg(Nmcli.ethernetDevices.length)
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.normal
+ text: qsTr("Connected devices")
+ }
+
+ StyledText {
+ text: qsTr("%1").arg(Nmcli.ethernetDevices.filter(d => d.connected).length)
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/modules/controlcenter/network/NetworkSettings.qml b/modules/controlcenter/network/NetworkSettings.qml
new file mode 100644
index 0000000..22e07cb
--- /dev/null
+++ b/modules/controlcenter/network/NetworkSettings.qml
@@ -0,0 +1,98 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property Session session
+
+ spacing: Appearance.spacing.normal
+
+ SettingsHeader {
+ icon: "router"
+ title: qsTr("Network Settings")
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("Ethernet")
+ description: qsTr("Ethernet device information")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small / 2
+
+ PropertyRow {
+ label: qsTr("Total devices")
+ value: qsTr("%1").arg(Nmcli.ethernetDevices.length)
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Connected devices")
+ value: qsTr("%1").arg(Nmcli.ethernetDevices.filter(d => d.connected).length)
+ }
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("Wireless")
+ description: qsTr("WiFi network settings")
+ }
+
+ SectionContainer {
+ ToggleRow {
+ label: qsTr("WiFi enabled")
+ checked: Nmcli.wifiEnabled
+ toggle.onToggled: {
+ Nmcli.enableWifi(checked);
+ }
+ }
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("Current connection")
+ description: qsTr("Active network connection information")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small / 2
+
+ PropertyRow {
+ label: qsTr("Network")
+ value: Nmcli.active ? Nmcli.active.ssid : (Nmcli.activeEthernet ? Nmcli.activeEthernet.interface : qsTr("Not connected"))
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ visible: Nmcli.active !== null
+ label: qsTr("Signal strength")
+ value: Nmcli.active ? qsTr("%1%").arg(Nmcli.active.strength) : qsTr("N/A")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ visible: Nmcli.active !== null
+ label: qsTr("Security")
+ value: Nmcli.active ? (Nmcli.active.isSecure ? qsTr("Secured") : qsTr("Open")) : qsTr("N/A")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ visible: Nmcli.active !== null
+ label: qsTr("Frequency")
+ value: Nmcli.active ? qsTr("%1 MHz").arg(Nmcli.active.frequency) : qsTr("N/A")
+ }
+ }
+}
+
diff --git a/modules/controlcenter/network/NetworkingPane.qml b/modules/controlcenter/network/NetworkingPane.qml
new file mode 100644
index 0000000..b430cce
--- /dev/null
+++ b/modules/controlcenter/network/NetworkingPane.qml
@@ -0,0 +1,305 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import "."
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Session session
+
+ anchors.fill: parent
+
+ SplitPaneLayout {
+ id: splitLayout
+
+ anchors.fill: parent
+
+ leftContent: Component {
+ StyledFlickable {
+ id: leftFlickable
+
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: leftContent.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: leftFlickable
+ }
+
+ ColumnLayout {
+ id: leftContent
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ spacing: Appearance.spacing.normal
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ text: qsTr("Network")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ ToggleButton {
+ toggled: Nmcli.wifiEnabled
+ icon: "wifi"
+ accent: "Tertiary"
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+ tooltip: qsTr("Toggle WiFi")
+
+ onClicked: {
+ Nmcli.toggleWifi(null);
+ }
+ }
+
+ ToggleButton {
+ toggled: Nmcli.scanning
+ icon: "wifi_find"
+ accent: "Secondary"
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+ tooltip: qsTr("Scan for networks")
+
+ onClicked: {
+ Nmcli.rescanWifi();
+ }
+ }
+
+ ToggleButton {
+ toggled: !root.session.ethernet.active && !root.session.network.active
+ icon: "settings"
+ accent: "Primary"
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+ tooltip: qsTr("Network settings")
+
+ onClicked: {
+ if (root.session.ethernet.active || root.session.network.active) {
+ root.session.ethernet.active = null;
+ root.session.network.active = null;
+ } else {
+ if (Nmcli.ethernetDevices.length > 0) {
+ root.session.ethernet.active = Nmcli.ethernetDevices[0];
+ } else if (Nmcli.networks.length > 0) {
+ root.session.network.active = Nmcli.networks[0];
+ }
+ }
+ }
+ }
+ }
+
+ CollapsibleSection {
+ id: ethernetListSection
+
+ Layout.fillWidth: true
+ title: qsTr("Ethernet")
+ expanded: true
+
+ Loader {
+ Layout.fillWidth: true
+ sourceComponent: Component {
+ EthernetList {
+ session: root.session
+ showHeader: false
+ }
+ }
+ }
+ }
+
+ CollapsibleSection {
+ id: wirelessListSection
+
+ Layout.fillWidth: true
+ title: qsTr("Wireless")
+ expanded: true
+
+ Loader {
+ Layout.fillWidth: true
+ sourceComponent: Component {
+ WirelessList {
+ session: root.session
+ showHeader: false
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ rightContent: Component {
+ Item {
+ id: rightPaneItem
+
+ property var ethernetPane: root.session.ethernet.active
+ property var wirelessPane: root.session.network.active
+ property var pane: ethernetPane || wirelessPane
+ property string paneId: ethernetPane ? ("eth:" + (ethernetPane.interface || "")) : (wirelessPane ? ("wifi:" + (wirelessPane.ssid || wirelessPane.bssid || "")) : "settings")
+ property Component targetComponent: settingsComponent
+ property Component nextComponent: settingsComponent
+
+ function getComponentForPane() {
+ if (ethernetPane) return ethernetDetailsComponent;
+ if (wirelessPane) return wirelessDetailsComponent;
+ return settingsComponent;
+ }
+
+ Component.onCompleted: {
+ targetComponent = getComponentForPane();
+ nextComponent = targetComponent;
+ }
+
+ Connections {
+ target: root.session.ethernet
+ function onActiveChanged() {
+ // Clear wireless when ethernet is selected
+ if (root.session.ethernet.active && root.session.network.active) {
+ root.session.network.active = null;
+ return; // Let the network.onActiveChanged handle the update
+ }
+ rightPaneItem.nextComponent = rightPaneItem.getComponentForPane();
+ // paneId will automatically update via property binding
+ }
+ }
+
+ Connections {
+ target: root.session.network
+ function onActiveChanged() {
+ // Clear ethernet when wireless is selected
+ if (root.session.network.active && root.session.ethernet.active) {
+ root.session.ethernet.active = null;
+ return; // Let the ethernet.onActiveChanged handle the update
+ }
+ rightPaneItem.nextComponent = rightPaneItem.getComponentForPane();
+ // paneId will automatically update via property binding
+ }
+ }
+
+ Loader {
+ id: rightLoader
+
+ anchors.fill: parent
+
+ opacity: 1
+ scale: 1
+ transformOrigin: Item.Center
+ clip: false
+
+ asynchronous: true
+ sourceComponent: rightPaneItem.targetComponent
+ }
+
+ Behavior on paneId {
+ PaneTransition {
+ target: rightLoader
+ propertyActions: [
+ PropertyAction {
+ target: rightPaneItem
+ property: "targetComponent"
+ value: rightPaneItem.nextComponent
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+
+ Component {
+ id: settingsComponent
+
+ StyledFlickable {
+ id: settingsFlickable
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: settingsInner.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: settingsFlickable
+ }
+
+ NetworkSettings {
+ id: settingsInner
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ session: root.session
+ }
+ }
+ }
+
+ Component {
+ id: ethernetDetailsComponent
+
+ StyledFlickable {
+ id: ethernetFlickable
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: ethernetDetailsInner.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: ethernetFlickable
+ }
+
+ EthernetDetails {
+ id: ethernetDetailsInner
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ session: root.session
+ }
+ }
+ }
+
+ Component {
+ id: wirelessDetailsComponent
+
+ StyledFlickable {
+ id: wirelessFlickable
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: wirelessDetailsInner.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: wirelessFlickable
+ }
+
+ WirelessDetails {
+ id: wirelessDetailsInner
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ session: root.session
+ }
+ }
+ }
+
+ WirelessPasswordDialog {
+ anchors.fill: parent
+ session: root.session
+ z: 1000
+ }
+}
diff --git a/modules/controlcenter/network/WirelessDetails.qml b/modules/controlcenter/network/WirelessDetails.qml
new file mode 100644
index 0000000..47d42c2
--- /dev/null
+++ b/modules/controlcenter/network/WirelessDetails.qml
@@ -0,0 +1,212 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import "."
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.services
+import qs.config
+import qs.utils
+import QtQuick
+import QtQuick.Layouts
+
+DeviceDetails {
+ id: root
+
+ required property Session session
+ readonly property var network: root.session.network.active
+
+ device: network
+
+ Component.onCompleted: {
+ updateDeviceDetails();
+ checkSavedProfile();
+ }
+
+ onNetworkChanged: {
+ connectionUpdateTimer.stop();
+ if (network && network.ssid) {
+ connectionUpdateTimer.start();
+ }
+ updateDeviceDetails();
+ checkSavedProfile();
+ }
+
+ function checkSavedProfile(): void {
+ if (network && network.ssid) {
+ Nmcli.loadSavedConnections(() => {});
+ }
+ }
+
+ Connections {
+ target: Nmcli
+ function onActiveChanged() {
+ updateDeviceDetails();
+ }
+ function onWirelessDeviceDetailsChanged() {
+ if (network && network.ssid) {
+ const isActive = network.active || (Nmcli.active && Nmcli.active.ssid === network.ssid);
+ if (isActive && Nmcli.wirelessDeviceDetails && Nmcli.wirelessDeviceDetails !== null) {
+ connectionUpdateTimer.stop();
+ }
+ }
+ }
+ }
+
+ Timer {
+ id: connectionUpdateTimer
+ interval: 500
+ repeat: true
+ running: network && network.ssid
+ onTriggered: {
+ if (network) {
+ const isActive = network.active || (Nmcli.active && Nmcli.active.ssid === network.ssid);
+ if (isActive) {
+ if (!Nmcli.wirelessDeviceDetails || Nmcli.wirelessDeviceDetails === null) {
+ Nmcli.getWirelessDeviceDetails("", () => {
+ });
+ } else {
+ connectionUpdateTimer.stop();
+ }
+ } else {
+ if (Nmcli.wirelessDeviceDetails !== null) {
+ Nmcli.wirelessDeviceDetails = null;
+ }
+ }
+ }
+ }
+ }
+
+ function updateDeviceDetails(): void {
+ if (network && network.ssid) {
+ const isActive = network.active || (Nmcli.active && Nmcli.active.ssid === network.ssid);
+ if (isActive) {
+ Nmcli.getWirelessDeviceDetails("");
+ } else {
+ Nmcli.wirelessDeviceDetails = null;
+ }
+ } else {
+ Nmcli.wirelessDeviceDetails = null;
+ }
+ }
+
+ headerComponent: Component {
+ ConnectionHeader {
+ icon: root.network?.isSecure ? "lock" : "wifi"
+ title: root.network?.ssid ?? qsTr("Unknown")
+ }
+ }
+
+ sections: [
+ Component {
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
+
+ SectionHeader {
+ title: qsTr("Connection status")
+ description: qsTr("Connection settings for this network")
+ }
+
+ SectionContainer {
+ ToggleRow {
+ label: qsTr("Connected")
+ checked: root.network?.active ?? false
+ toggle.onToggled: {
+ if (checked) {
+ NetworkConnection.handleConnect(root.network, root.session, null);
+ } else {
+ Nmcli.disconnectFromNetwork();
+ }
+ }
+ }
+
+ TextButton {
+ Layout.fillWidth: true
+ Layout.topMargin: Appearance.spacing.normal
+ Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2
+ visible: {
+ if (!root.network || !root.network.ssid) {
+ return false;
+ }
+ return Nmcli.hasSavedProfile(root.network.ssid);
+ }
+ inactiveColour: Colours.palette.m3secondaryContainer
+ inactiveOnColour: Colours.palette.m3onSecondaryContainer
+ text: qsTr("Forget Network")
+
+ onClicked: {
+ if (root.network && root.network.ssid) {
+ if (root.network.active) {
+ Nmcli.disconnectFromNetwork();
+ }
+ Nmcli.forgetNetwork(root.network.ssid);
+ }
+ }
+ }
+ }
+ }
+ },
+ Component {
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
+
+ SectionHeader {
+ title: qsTr("Network properties")
+ description: qsTr("Additional information")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small / 2
+
+ PropertyRow {
+ label: qsTr("SSID")
+ value: root.network?.ssid ?? qsTr("Unknown")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("BSSID")
+ value: root.network?.bssid ?? qsTr("Unknown")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Signal strength")
+ value: root.network ? qsTr("%1%").arg(root.network.strength) : qsTr("N/A")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Frequency")
+ value: root.network ? qsTr("%1 MHz").arg(root.network.frequency) : qsTr("N/A")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Security")
+ value: root.network ? (root.network.isSecure ? root.network.security : qsTr("Open")) : qsTr("N/A")
+ }
+ }
+ }
+ },
+ Component {
+ ColumnLayout {
+ spacing: Appearance.spacing.normal
+
+ SectionHeader {
+ title: qsTr("Connection information")
+ description: qsTr("Network connection details")
+ }
+
+ SectionContainer {
+ ConnectionInfoSection {
+ deviceDetails: Nmcli.wirelessDeviceDetails
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/modules/controlcenter/network/WirelessList.qml b/modules/controlcenter/network/WirelessList.qml
new file mode 100644
index 0000000..8159291
--- /dev/null
+++ b/modules/controlcenter/network/WirelessList.qml
@@ -0,0 +1,226 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import "."
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.components.effects
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+DeviceList {
+ id: root
+
+ required property Session session
+
+ title: qsTr("Networks (%1)").arg(Nmcli.networks.length)
+ description: qsTr("All available WiFi networks")
+ activeItem: session.network.active
+
+ titleSuffix: Component {
+ StyledText {
+ visible: Nmcli.scanning
+ text: qsTr("Scanning...")
+ color: Colours.palette.m3primary
+ font.pointSize: Appearance.font.size.small
+ }
+ }
+
+ model: ScriptModel {
+ values: [...Nmcli.networks].sort((a, b) => {
+ if (a.active !== b.active)
+ return b.active - a.active;
+ return b.strength - a.strength;
+ })
+ }
+
+ headerComponent: Component {
+ RowLayout {
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ text: qsTr("Settings")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ ToggleButton {
+ toggled: Nmcli.wifiEnabled
+ icon: "wifi"
+ accent: "Tertiary"
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+
+ onClicked: {
+ Nmcli.toggleWifi(null);
+ }
+ }
+
+ ToggleButton {
+ toggled: Nmcli.scanning
+ icon: "wifi_find"
+ accent: "Secondary"
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+
+ onClicked: {
+ Nmcli.rescanWifi();
+ }
+ }
+
+ ToggleButton {
+ toggled: !root.session.network.active
+ icon: "settings"
+ accent: "Primary"
+ iconSize: Appearance.font.size.normal
+ horizontalPadding: Appearance.padding.normal
+ verticalPadding: Appearance.padding.smaller
+
+ onClicked: {
+ if (root.session.network.active)
+ root.session.network.active = null;
+ else {
+ root.session.network.active = root.view.model.get(0)?.modelData ?? null;
+ }
+ }
+ }
+ }
+ }
+
+ delegate: Component {
+ StyledRect {
+ required property var modelData
+
+ width: ListView.view ? ListView.view.width : undefined
+
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, root.activeItem === modelData ? Colours.tPalette.m3surfaceContainer.a : 0)
+ radius: Appearance.rounding.normal
+
+ StateLayer {
+ function onClicked(): void {
+ root.session.network.active = modelData;
+ if (modelData && modelData.ssid) {
+ root.checkSavedProfileForNetwork(modelData.ssid);
+ }
+ }
+ }
+
+ RowLayout {
+ id: rowLayout
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.normal
+
+ spacing: Appearance.spacing.normal
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: icon.implicitHeight + Appearance.padding.normal * 2
+
+ radius: Appearance.rounding.normal
+ color: modelData.active ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh
+
+ MaterialIcon {
+ id: icon
+
+ anchors.centerIn: parent
+ text: Icons.getNetworkIcon(modelData.strength, modelData.isSecure)
+ font.pointSize: Appearance.font.size.large
+ fill: modelData.active ? 1 : 0
+ color: modelData.active ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+
+ spacing: 0
+
+ StyledText {
+ Layout.fillWidth: true
+ elide: Text.ElideRight
+ maximumLineCount: 1
+
+ text: modelData.ssid || qsTr("Unknown")
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ Layout.fillWidth: true
+ text: {
+ if (modelData.active) return qsTr("Connected");
+ if (modelData.isSecure && modelData.security && modelData.security.length > 0) {
+ return modelData.security;
+ }
+ if (modelData.isSecure) return qsTr("Secured");
+ return qsTr("Open");
+ }
+ color: modelData.active ? Colours.palette.m3primary : Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ font.weight: modelData.active ? 500 : 400
+ elide: Text.ElideRight
+ }
+ }
+ }
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: connectIcon.implicitHeight + Appearance.padding.smaller * 2
+
+ radius: Appearance.rounding.full
+ color: Qt.alpha(Colours.palette.m3primaryContainer, modelData.active ? 1 : 0)
+
+ StateLayer {
+ function onClicked(): void {
+ if (modelData.active) {
+ Nmcli.disconnectFromNetwork();
+ } else {
+ NetworkConnection.handleConnect(modelData, root.session, null);
+ }
+ }
+ }
+
+ MaterialIcon {
+ id: connectIcon
+
+ anchors.centerIn: parent
+ text: modelData.active ? "link_off" : "link"
+ color: modelData.active ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
+ }
+ }
+ }
+
+ implicitHeight: rowLayout.implicitHeight + Appearance.padding.normal * 2
+ }
+ }
+
+ onItemSelected: function(item) {
+ session.network.active = item;
+ if (item && item.ssid) {
+ checkSavedProfileForNetwork(item.ssid);
+ }
+ }
+
+ function checkSavedProfileForNetwork(ssid: string): void {
+ if (ssid && ssid.length > 0) {
+ Nmcli.loadSavedConnections(() => {});
+ }
+ }
+}
diff --git a/modules/controlcenter/network/WirelessPane.qml b/modules/controlcenter/network/WirelessPane.qml
new file mode 100644
index 0000000..109d416
--- /dev/null
+++ b/modules/controlcenter/network/WirelessPane.qml
@@ -0,0 +1,57 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.containers
+import qs.config
+import Quickshell.Widgets
+import QtQuick
+
+SplitPaneWithDetails {
+ id: root
+
+ required property Session session
+
+ anchors.fill: parent
+
+ activeItem: session.network.active
+ paneIdGenerator: function(item) {
+ return item ? (item.ssid || item.bssid || "") : "";
+ }
+
+ leftContent: Component {
+ WirelessList {
+ session: root.session
+ }
+ }
+
+ rightDetailsComponent: Component {
+ WirelessDetails {
+ session: root.session
+ }
+ }
+
+ rightSettingsComponent: Component {
+ StyledFlickable {
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: settingsInner.height
+ clip: true
+
+ WirelessSettings {
+ id: settingsInner
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ session: root.session
+ }
+ }
+ }
+
+ overlayComponent: Component {
+ WirelessPasswordDialog {
+ anchors.fill: parent
+ session: root.session
+ }
+ }
+}
diff --git a/modules/controlcenter/network/WirelessPasswordDialog.qml b/modules/controlcenter/network/WirelessPasswordDialog.qml
new file mode 100644
index 0000000..7c046af
--- /dev/null
+++ b/modules/controlcenter/network/WirelessPasswordDialog.qml
@@ -0,0 +1,512 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "."
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Session session
+
+ readonly property var network: {
+ if (session.network.pendingNetwork) {
+ return session.network.pendingNetwork;
+ }
+ if (session.network.active) {
+ return session.network.active;
+ }
+ return null;
+ }
+
+ property bool isClosing: false
+ visible: session.network.showPasswordDialog || isClosing
+ enabled: session.network.showPasswordDialog && !isClosing
+ focus: enabled
+
+ Keys.onEscapePressed: {
+ closeDialog();
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ color: Qt.rgba(0, 0, 0, 0.5)
+ opacity: root.session.network.showPasswordDialog && !root.isClosing ? 1 : 0
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: closeDialog()
+ }
+ }
+
+ StyledRect {
+ id: dialog
+
+ anchors.centerIn: parent
+
+ implicitWidth: 400
+ implicitHeight: content.implicitHeight + Appearance.padding.large * 2
+
+ radius: Appearance.rounding.normal
+ color: Colours.tPalette.m3surface
+ opacity: root.session.network.showPasswordDialog && !root.isClosing ? 1 : 0
+ scale: root.session.network.showPasswordDialog && !root.isClosing ? 1 : 0.7
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {}
+ }
+
+ ParallelAnimation {
+ running: root.isClosing
+ onFinished: {
+ if (root.isClosing) {
+ root.session.network.showPasswordDialog = false;
+ root.isClosing = false;
+ }
+ }
+
+ Anim {
+ target: dialog
+ property: "opacity"
+ to: 0
+ }
+ Anim {
+ target: dialog
+ property: "scale"
+ to: 0.7
+ }
+ }
+
+ Keys.onEscapePressed: closeDialog()
+
+ ColumnLayout {
+ id: content
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignHCenter
+ text: "lock"
+ font.pointSize: Appearance.font.size.extraLarge * 2
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("Enter password")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: root.network ? qsTr("Network: %1").arg(root.network.ssid) : ""
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledText {
+ id: statusText
+
+ Layout.alignment: Qt.AlignHCenter
+ Layout.topMargin: Appearance.spacing.small
+ visible: connectButton.connecting || connectButton.hasError
+ text: {
+ if (connectButton.hasError) {
+ return qsTr("Connection failed. Please check your password and try again.");
+ }
+ if (connectButton.connecting) {
+ return qsTr("Connecting...");
+ }
+ return "";
+ }
+ color: connectButton.hasError ? Colours.palette.m3error : Colours.palette.m3onSurfaceVariant
+ font.pointSize: Appearance.font.size.small
+ font.weight: 400
+ wrapMode: Text.WordWrap
+ Layout.maximumWidth: parent.width - Appearance.padding.large * 2
+ }
+
+ Item {
+ id: passwordContainer
+ Layout.topMargin: Appearance.spacing.large
+ Layout.fillWidth: true
+ implicitHeight: Math.max(48, charList.implicitHeight + Appearance.padding.normal * 2)
+
+ focus: true
+ Keys.onPressed: event => {
+ if (!activeFocus) {
+ forceActiveFocus();
+ }
+
+ if (connectButton.hasError && event.text && event.text.length > 0) {
+ connectButton.hasError = false;
+ }
+
+ if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) {
+ if (connectButton.enabled) {
+ connectButton.clicked();
+ }
+ event.accepted = true;
+ } else if (event.key === Qt.Key_Backspace) {
+ if (event.modifiers & Qt.ControlModifier) {
+ passwordBuffer = "";
+ } else {
+ passwordBuffer = passwordBuffer.slice(0, -1);
+ }
+ event.accepted = true;
+ } else if (event.text && event.text.length > 0) {
+ passwordBuffer += event.text;
+ event.accepted = true;
+ }
+ }
+
+ property string passwordBuffer: ""
+
+ Connections {
+ target: root.session.network
+ function onShowPasswordDialogChanged(): void {
+ if (root.session.network.showPasswordDialog) {
+ Qt.callLater(() => {
+ passwordContainer.forceActiveFocus();
+ passwordContainer.passwordBuffer = "";
+ connectButton.hasError = false;
+ });
+ }
+ }
+ }
+
+ Connections {
+ target: root
+ function onVisibleChanged(): void {
+ if (root.visible) {
+ Qt.callLater(() => {
+ passwordContainer.forceActiveFocus();
+ });
+ }
+ }
+ }
+
+ StyledRect {
+ anchors.fill: parent
+ radius: Appearance.rounding.normal
+ color: passwordContainer.activeFocus ? Qt.lighter(Colours.tPalette.m3surfaceContainer, 1.05) : Colours.tPalette.m3surfaceContainer
+ border.width: passwordContainer.activeFocus || connectButton.hasError ? 4 : (root.visible ? 1 : 0)
+ border.color: {
+ if (connectButton.hasError) {
+ return Colours.palette.m3error;
+ }
+ if (passwordContainer.activeFocus) {
+ return Colours.palette.m3primary;
+ }
+ return root.visible ? Colours.palette.m3outline : "transparent";
+ }
+
+ Behavior on border.color {
+ CAnim {}
+ }
+
+ Behavior on border.width {
+ CAnim {}
+ }
+
+ Behavior on color {
+ CAnim {}
+ }
+ }
+
+ StateLayer {
+ hoverEnabled: false
+ cursorShape: Qt.IBeamCursor
+
+ function onClicked(): void {
+ passwordContainer.forceActiveFocus();
+ }
+ }
+
+ StyledText {
+ id: placeholder
+ anchors.centerIn: parent
+ text: qsTr("Password")
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.normal
+ font.family: Appearance.font.family.mono
+ opacity: passwordContainer.passwordBuffer ? 0 : 1
+
+ Behavior on opacity {
+ Anim {}
+ }
+ }
+
+ ListView {
+ id: charList
+
+ readonly property int fullWidth: count * (implicitHeight + spacing) - spacing
+
+ anchors.centerIn: parent
+ implicitWidth: fullWidth
+ implicitHeight: Appearance.font.size.normal
+
+ orientation: Qt.Horizontal
+ spacing: Appearance.spacing.small / 2
+ interactive: false
+
+ model: ScriptModel {
+ values: passwordContainer.passwordBuffer.split("")
+ }
+
+ delegate: StyledRect {
+ id: ch
+
+ implicitWidth: implicitHeight
+ implicitHeight: charList.implicitHeight
+
+ color: Colours.palette.m3onSurface
+ radius: Appearance.rounding.small / 2
+
+ opacity: 0
+ scale: 0
+ Component.onCompleted: {
+ opacity = 1;
+ scale = 1;
+ }
+ ListView.onRemove: removeAnim.start()
+
+ SequentialAnimation {
+ id: removeAnim
+
+ PropertyAction {
+ target: ch
+ property: "ListView.delayRemove"
+ value: true
+ }
+ ParallelAnimation {
+ Anim {
+ target: ch
+ property: "opacity"
+ to: 0
+ }
+ Anim {
+ target: ch
+ property: "scale"
+ to: 0.5
+ }
+ }
+ PropertyAction {
+ target: ch
+ property: "ListView.delayRemove"
+ value: false
+ }
+ }
+
+ Behavior on opacity {
+ Anim {}
+ }
+
+ Behavior on scale {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+ }
+
+ Behavior on implicitWidth {
+ Anim {}
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.topMargin: Appearance.spacing.normal
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ TextButton {
+ id: cancelButton
+
+ Layout.fillWidth: true
+ Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2
+ inactiveColour: Colours.palette.m3secondaryContainer
+ inactiveOnColour: Colours.palette.m3onSecondaryContainer
+ text: qsTr("Cancel")
+
+ onClicked: root.closeDialog()
+ }
+
+ TextButton {
+ id: connectButton
+
+ property bool connecting: false
+ property bool hasError: false
+
+ Layout.fillWidth: true
+ Layout.minimumHeight: Appearance.font.size.normal + Appearance.padding.normal * 2
+ inactiveColour: Colours.palette.m3primary
+ inactiveOnColour: Colours.palette.m3onPrimary
+ text: qsTr("Connect")
+ enabled: passwordContainer.passwordBuffer.length > 0 && !connecting
+
+ onClicked: {
+ if (!root.network || connecting) {
+ return;
+ }
+
+ const password = passwordContainer.passwordBuffer;
+ if (!password || password.length === 0) {
+ return;
+ }
+
+ hasError = false;
+ connecting = true;
+ enabled = false;
+ text = qsTr("Connecting...");
+
+ NetworkConnection.connectWithPassword(root.network, password, result => {
+ if (result && result.success) {
+ } else if (result && result.needsPassword) {
+ connectionMonitor.stop();
+ connecting = false;
+ hasError = true;
+ enabled = true;
+ text = qsTr("Connect");
+ passwordContainer.passwordBuffer = "";
+ if (root.network && root.network.ssid) {
+ Nmcli.forgetNetwork(root.network.ssid);
+ }
+ } else {
+ connectionMonitor.stop();
+ connecting = false;
+ hasError = true;
+ enabled = true;
+ text = qsTr("Connect");
+ passwordContainer.passwordBuffer = "";
+ if (root.network && root.network.ssid) {
+ Nmcli.forgetNetwork(root.network.ssid);
+ }
+ }
+ });
+
+ connectionMonitor.start();
+ }
+ }
+ }
+ }
+ }
+
+ function checkConnectionStatus(): void {
+ if (!root.visible || !connectButton.connecting) {
+ return;
+ }
+
+ const isConnected = root.network && Nmcli.active && Nmcli.active.ssid && Nmcli.active.ssid.toLowerCase().trim() === root.network.ssid.toLowerCase().trim();
+
+ if (isConnected) {
+ connectionSuccessTimer.start();
+ return;
+ }
+
+ if (Nmcli.pendingConnection === null && connectButton.connecting) {
+ if (connectionMonitor.repeatCount > 10) {
+ connectionMonitor.stop();
+ connectButton.connecting = false;
+ connectButton.hasError = true;
+ connectButton.enabled = true;
+ connectButton.text = qsTr("Connect");
+ passwordContainer.passwordBuffer = "";
+ if (root.network && root.network.ssid) {
+ Nmcli.forgetNetwork(root.network.ssid);
+ }
+ }
+ }
+ }
+
+ Timer {
+ id: connectionMonitor
+ interval: 1000
+ repeat: true
+ triggeredOnStart: false
+ property int repeatCount: 0
+
+ onTriggered: {
+ repeatCount++;
+ checkConnectionStatus();
+ }
+
+ onRunningChanged: {
+ if (!running) {
+ repeatCount = 0;
+ }
+ }
+ }
+
+ Timer {
+ id: connectionSuccessTimer
+ interval: 500
+ onTriggered: {
+ if (root.visible && Nmcli.active && Nmcli.active.ssid) {
+ const stillConnected = Nmcli.active.ssid.toLowerCase().trim() === root.network.ssid.toLowerCase().trim();
+ if (stillConnected) {
+ connectionMonitor.stop();
+ connectButton.connecting = false;
+ connectButton.text = qsTr("Connect");
+ closeDialog();
+ }
+ }
+ }
+ }
+
+ Connections {
+ target: Nmcli
+ function onActiveChanged() {
+ if (root.visible) {
+ checkConnectionStatus();
+ }
+ }
+ function onConnectionFailed(ssid: string) {
+ if (root.visible && root.network && root.network.ssid === ssid && connectButton.connecting) {
+ connectionMonitor.stop();
+ connectButton.connecting = false;
+ connectButton.hasError = true;
+ connectButton.enabled = true;
+ connectButton.text = qsTr("Connect");
+ passwordContainer.passwordBuffer = "";
+ Nmcli.forgetNetwork(ssid);
+ }
+ }
+ }
+
+ function closeDialog(): void {
+ if (isClosing) {
+ return;
+ }
+
+ isClosing = true;
+ passwordContainer.passwordBuffer = "";
+ connectButton.connecting = false;
+ connectButton.hasError = false;
+ connectButton.text = qsTr("Connect");
+ connectionMonitor.stop();
+ }
+}
diff --git a/modules/controlcenter/network/WirelessSettings.qml b/modules/controlcenter/network/WirelessSettings.qml
new file mode 100644
index 0000000..f87fe39
--- /dev/null
+++ b/modules/controlcenter/network/WirelessSettings.qml
@@ -0,0 +1,73 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property Session session
+
+ spacing: Appearance.spacing.normal
+
+ SettingsHeader {
+ icon: "wifi"
+ title: qsTr("Network settings")
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("WiFi status")
+ description: qsTr("General WiFi settings")
+ }
+
+ SectionContainer {
+ ToggleRow {
+ label: qsTr("WiFi enabled")
+ checked: Nmcli.wifiEnabled
+ toggle.onToggled: {
+ Nmcli.enableWifi(checked);
+ }
+ }
+ }
+
+ SectionHeader {
+ Layout.topMargin: Appearance.spacing.large
+ title: qsTr("Network information")
+ description: qsTr("Current network connection")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small / 2
+
+ PropertyRow {
+ label: qsTr("Connected network")
+ value: Nmcli.active ? Nmcli.active.ssid : qsTr("Not connected")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Signal strength")
+ value: Nmcli.active ? qsTr("%1%").arg(Nmcli.active.strength) : qsTr("N/A")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Security")
+ value: Nmcli.active ? (Nmcli.active.isSecure ? qsTr("Secured") : qsTr("Open")) : qsTr("N/A")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Frequency")
+ value: Nmcli.active ? qsTr("%1 MHz").arg(Nmcli.active.frequency) : qsTr("N/A")
+ }
+ }
+} \ No newline at end of file
diff --git a/modules/controlcenter/state/BluetoothState.qml b/modules/controlcenter/state/BluetoothState.qml
new file mode 100644
index 0000000..00497ce
--- /dev/null
+++ b/modules/controlcenter/state/BluetoothState.qml
@@ -0,0 +1,13 @@
+import Quickshell.Bluetooth
+import QtQuick
+
+QtObject {
+ id: root
+
+ property BluetoothDevice active: null
+ property BluetoothAdapter currentAdapter: Bluetooth.defaultAdapter
+ property bool editingAdapterName: false
+ property bool fabMenuOpen: false
+ property bool editingDeviceName: false
+}
+
diff --git a/modules/controlcenter/state/EthernetState.qml b/modules/controlcenter/state/EthernetState.qml
new file mode 100644
index 0000000..c5da7aa
--- /dev/null
+++ b/modules/controlcenter/state/EthernetState.qml
@@ -0,0 +1,8 @@
+import QtQuick
+
+QtObject {
+ id: root
+
+ property var active: null
+}
+
diff --git a/modules/controlcenter/state/LauncherState.qml b/modules/controlcenter/state/LauncherState.qml
new file mode 100644
index 0000000..c5da7aa
--- /dev/null
+++ b/modules/controlcenter/state/LauncherState.qml
@@ -0,0 +1,8 @@
+import QtQuick
+
+QtObject {
+ id: root
+
+ property var active: null
+}
+
diff --git a/modules/controlcenter/state/NetworkState.qml b/modules/controlcenter/state/NetworkState.qml
new file mode 100644
index 0000000..da13e65
--- /dev/null
+++ b/modules/controlcenter/state/NetworkState.qml
@@ -0,0 +1,10 @@
+import QtQuick
+
+QtObject {
+ id: root
+
+ property var active: null
+ property bool showPasswordDialog: false
+ property var pendingNetwork: null
+}
+
diff --git a/modules/controlcenter/taskbar/ConnectedButtonGroup.qml b/modules/controlcenter/taskbar/ConnectedButtonGroup.qml
new file mode 100644
index 0000000..bf3a97f
--- /dev/null
+++ b/modules/controlcenter/taskbar/ConnectedButtonGroup.qml
@@ -0,0 +1,109 @@
+import ".."
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+StyledRect {
+ id: root
+
+ property var options: [] // Array of {label: string, propertyName: string, onToggled: function}
+ property var rootItem: null // The root item that contains the properties we want to bind to
+ property string title: "" // Optional title text
+
+ Layout.fillWidth: true
+ implicitHeight: layout.implicitHeight + Appearance.padding.large * 2
+ radius: Appearance.rounding.normal
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ clip: true
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+
+ ColumnLayout {
+ id: layout
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ visible: root.title !== ""
+ text: root.title
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ RowLayout {
+ id: buttonRow
+ Layout.alignment: Qt.AlignHCenter
+ spacing: Appearance.spacing.small
+
+ Repeater {
+ id: repeater
+ model: root.options
+
+ delegate: TextButton {
+ id: button
+ required property int index
+ required property var modelData
+
+ Layout.fillWidth: true
+ text: modelData.label
+
+ property bool _checked: false
+
+ checked: _checked
+ toggle: false
+ type: TextButton.Tonal
+
+ // Create binding in Component.onCompleted
+ Component.onCompleted: {
+ if (root.rootItem && modelData.propertyName) {
+ const propName = modelData.propertyName;
+ const rootItem = root.rootItem;
+ _checked = Qt.binding(function() {
+ return rootItem[propName] ?? false;
+ });
+ }
+ }
+
+ // Match utilities Toggles radius styling
+ // Each button has full rounding (not connected) since they have spacing
+ radius: stateLayer.pressed ? Appearance.rounding.small / 2 : internalChecked ? Appearance.rounding.small : Appearance.rounding.normal
+
+ // Match utilities Toggles inactive color
+ inactiveColour: Colours.layer(Colours.palette.m3surfaceContainerHighest, 2)
+
+ // Adjust width similar to utilities toggles
+ Layout.preferredWidth: implicitWidth + (stateLayer.pressed ? Appearance.padding.large : internalChecked ? Appearance.padding.smaller : 0)
+
+ onClicked: {
+ if (modelData.onToggled && root.rootItem && modelData.propertyName) {
+ const currentValue = root.rootItem[modelData.propertyName] ?? false;
+ modelData.onToggled(!currentValue);
+ }
+ }
+
+ Behavior on Layout.preferredWidth {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+
+ Behavior on radius {
+ Anim {
+ duration: Appearance.anim.durations.expressiveFastSpatial
+ easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
diff --git a/modules/controlcenter/taskbar/TaskbarPane.qml b/modules/controlcenter/taskbar/TaskbarPane.qml
new file mode 100644
index 0000000..5d51c8c
--- /dev/null
+++ b/modules/controlcenter/taskbar/TaskbarPane.qml
@@ -0,0 +1,637 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Session session
+
+ property bool clockShowIcon: Config.bar.clock.showIcon ?? true
+ property bool persistent: Config.bar.persistent ?? true
+ property bool showOnHover: Config.bar.showOnHover ?? true
+ property int dragThreshold: Config.bar.dragThreshold ?? 20
+ property bool showAudio: Config.bar.status.showAudio ?? true
+ property bool showMicrophone: Config.bar.status.showMicrophone ?? true
+ property bool showKbLayout: Config.bar.status.showKbLayout ?? false
+ property bool showNetwork: Config.bar.status.showNetwork ?? true
+ property bool showBluetooth: Config.bar.status.showBluetooth ?? true
+ property bool showBattery: Config.bar.status.showBattery ?? true
+ property bool showLockStatus: Config.bar.status.showLockStatus ?? true
+ property bool trayBackground: Config.bar.tray.background ?? false
+ property bool trayCompact: Config.bar.tray.compact ?? false
+ property bool trayRecolour: Config.bar.tray.recolour ?? false
+ property int workspacesShown: Config.bar.workspaces.shown ?? 5
+ property bool workspacesActiveIndicator: Config.bar.workspaces.activeIndicator ?? true
+ property bool workspacesOccupiedBg: Config.bar.workspaces.occupiedBg ?? false
+ property bool workspacesShowWindows: Config.bar.workspaces.showWindows ?? false
+ property bool workspacesPerMonitor: Config.bar.workspaces.perMonitorWorkspaces ?? true
+ property bool scrollWorkspaces: Config.bar.scrollActions.workspaces ?? true
+ property bool scrollVolume: Config.bar.scrollActions.volume ?? true
+ property bool scrollBrightness: Config.bar.scrollActions.brightness ?? true
+ property bool popoutActiveWindow: Config.bar.popouts.activeWindow ?? true
+ property bool popoutTray: Config.bar.popouts.tray ?? true
+ property bool popoutStatusIcons: Config.bar.popouts.statusIcons ?? true
+
+ anchors.fill: parent
+
+ Component.onCompleted: {
+ if (Config.bar.entries) {
+ entriesModel.clear();
+ for (let i = 0; i < Config.bar.entries.length; i++) {
+ const entry = Config.bar.entries[i];
+ entriesModel.append({
+ id: entry.id,
+ enabled: entry.enabled !== false
+ });
+ }
+ }
+ }
+
+ function saveConfig(entryIndex, entryEnabled) {
+ Config.bar.clock.showIcon = root.clockShowIcon;
+ Config.bar.persistent = root.persistent;
+ Config.bar.showOnHover = root.showOnHover;
+ Config.bar.dragThreshold = root.dragThreshold;
+ Config.bar.status.showAudio = root.showAudio;
+ Config.bar.status.showMicrophone = root.showMicrophone;
+ Config.bar.status.showKbLayout = root.showKbLayout;
+ Config.bar.status.showNetwork = root.showNetwork;
+ Config.bar.status.showBluetooth = root.showBluetooth;
+ Config.bar.status.showBattery = root.showBattery;
+ Config.bar.status.showLockStatus = root.showLockStatus;
+ Config.bar.tray.background = root.trayBackground;
+ Config.bar.tray.compact = root.trayCompact;
+ Config.bar.tray.recolour = root.trayRecolour;
+ Config.bar.workspaces.shown = root.workspacesShown;
+ Config.bar.workspaces.activeIndicator = root.workspacesActiveIndicator;
+ Config.bar.workspaces.occupiedBg = root.workspacesOccupiedBg;
+ Config.bar.workspaces.showWindows = root.workspacesShowWindows;
+ Config.bar.workspaces.perMonitorWorkspaces = root.workspacesPerMonitor;
+ Config.bar.scrollActions.workspaces = root.scrollWorkspaces;
+ Config.bar.scrollActions.volume = root.scrollVolume;
+ Config.bar.scrollActions.brightness = root.scrollBrightness;
+ Config.bar.popouts.activeWindow = root.popoutActiveWindow;
+ Config.bar.popouts.tray = root.popoutTray;
+ Config.bar.popouts.statusIcons = root.popoutStatusIcons;
+
+ const entries = [];
+ for (let i = 0; i < entriesModel.count; i++) {
+ const entry = entriesModel.get(i);
+ let enabled = entry.enabled;
+ if (entryIndex !== undefined && i === entryIndex) {
+ enabled = entryEnabled;
+ }
+ entries.push({
+ id: entry.id,
+ enabled: enabled
+ });
+ }
+ Config.bar.entries = entries;
+ Config.save();
+ }
+
+ ListModel {
+ id: entriesModel
+ }
+
+ ClippingRectangle {
+ id: taskbarClippingRect
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ anchors.leftMargin: 0
+ anchors.rightMargin: Appearance.padding.normal
+
+ radius: taskbarBorder.innerRadius
+ color: "transparent"
+
+ Loader {
+ id: taskbarLoader
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large + Appearance.padding.normal
+ anchors.leftMargin: Appearance.padding.large
+ anchors.rightMargin: Appearance.padding.large
+
+ asynchronous: true
+ sourceComponent: taskbarContentComponent
+ }
+ }
+
+ InnerBorder {
+ id: taskbarBorder
+ leftThickness: 0
+ rightThickness: Appearance.padding.normal
+ }
+
+ Component {
+ id: taskbarContentComponent
+
+ StyledFlickable {
+ id: sidebarFlickable
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: sidebarLayout.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: sidebarFlickable
+ }
+
+ ColumnLayout {
+ id: sidebarLayout
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+
+ spacing: Appearance.spacing.normal
+
+ RowLayout {
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ text: qsTr("Taskbar")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+ }
+
+ SectionContainer {
+ Layout.fillWidth: true
+ alignTop: true
+
+ StyledText {
+ text: qsTr("Status Icons")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ ConnectedButtonGroup {
+ rootItem: root
+
+ options: [
+ {
+ label: qsTr("Speakers"),
+ propertyName: "showAudio",
+ onToggled: function(checked) {
+ root.showAudio = checked;
+ root.saveConfig();
+ }
+ },
+ {
+ label: qsTr("Microphone"),
+ propertyName: "showMicrophone",
+ onToggled: function(checked) {
+ root.showMicrophone = checked;
+ root.saveConfig();
+ }
+ },
+ {
+ label: qsTr("Keyboard"),
+ propertyName: "showKbLayout",
+ onToggled: function(checked) {
+ root.showKbLayout = checked;
+ root.saveConfig();
+ }
+ },
+ {
+ label: qsTr("Network"),
+ propertyName: "showNetwork",
+ onToggled: function(checked) {
+ root.showNetwork = checked;
+ root.saveConfig();
+ }
+ },
+ {
+ label: qsTr("Bluetooth"),
+ propertyName: "showBluetooth",
+ onToggled: function(checked) {
+ root.showBluetooth = checked;
+ root.saveConfig();
+ }
+ },
+ {
+ label: qsTr("Battery"),
+ propertyName: "showBattery",
+ onToggled: function(checked) {
+ root.showBattery = checked;
+ root.saveConfig();
+ }
+ },
+ {
+ label: qsTr("Capslock"),
+ propertyName: "showLockStatus",
+ onToggled: function(checked) {
+ root.showLockStatus = checked;
+ root.saveConfig();
+ }
+ }
+ ]
+ }
+ }
+
+ RowLayout {
+ id: mainRowLayout
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ ColumnLayout {
+ id: leftColumnLayout
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignTop
+ spacing: Appearance.spacing.normal
+
+ SectionContainer {
+ Layout.fillWidth: true
+ alignTop: true
+
+ StyledText {
+ text: qsTr("Workspaces")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: workspacesShownRow.implicitHeight + Appearance.padding.large * 2
+ radius: Appearance.rounding.normal
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+
+ RowLayout {
+ id: workspacesShownRow
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("Shown")
+ }
+
+ CustomSpinBox {
+ min: 1
+ max: 20
+ value: root.workspacesShown
+ onValueModified: value => {
+ root.workspacesShown = value;
+ root.saveConfig();
+ }
+ }
+ }
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: workspacesActiveIndicatorRow.implicitHeight + Appearance.padding.large * 2
+ radius: Appearance.rounding.normal
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+
+ RowLayout {
+ id: workspacesActiveIndicatorRow
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("Active indicator")
+ }
+
+ StyledSwitch {
+ checked: root.workspacesActiveIndicator
+ onToggled: {
+ root.workspacesActiveIndicator = checked;
+ root.saveConfig();
+ }
+ }
+ }
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: workspacesOccupiedBgRow.implicitHeight + Appearance.padding.large * 2
+ radius: Appearance.rounding.normal
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+
+ RowLayout {
+ id: workspacesOccupiedBgRow
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("Occupied background")
+ }
+
+ StyledSwitch {
+ checked: root.workspacesOccupiedBg
+ onToggled: {
+ root.workspacesOccupiedBg = checked;
+ root.saveConfig();
+ }
+ }
+ }
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: workspacesShowWindowsRow.implicitHeight + Appearance.padding.large * 2
+ radius: Appearance.rounding.normal
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+
+ RowLayout {
+ id: workspacesShowWindowsRow
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("Show windows")
+ }
+
+ StyledSwitch {
+ checked: root.workspacesShowWindows
+ onToggled: {
+ root.workspacesShowWindows = checked;
+ root.saveConfig();
+ }
+ }
+ }
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: workspacesPerMonitorRow.implicitHeight + Appearance.padding.large * 2
+ radius: Appearance.rounding.normal
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
+
+ Behavior on implicitHeight {
+ Anim {}
+ }
+
+ RowLayout {
+ id: workspacesPerMonitorRow
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("Per monitor workspaces")
+ }
+
+ StyledSwitch {
+ checked: root.workspacesPerMonitor
+ onToggled: {
+ root.workspacesPerMonitor = checked;
+ root.saveConfig();
+ }
+ }
+ }
+ }
+ }
+
+ SectionContainer {
+ Layout.fillWidth: true
+ alignTop: true
+
+ StyledText {
+ text: qsTr("Scroll Actions")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ ConnectedButtonGroup {
+ rootItem: root
+
+ options: [
+ {
+ label: qsTr("Workspaces"),
+ propertyName: "scrollWorkspaces",
+ onToggled: function(checked) {
+ root.scrollWorkspaces = checked;
+ root.saveConfig();
+ }
+ },
+ {
+ label: qsTr("Volume"),
+ propertyName: "scrollVolume",
+ onToggled: function(checked) {
+ root.scrollVolume = checked;
+ root.saveConfig();
+ }
+ },
+ {
+ label: qsTr("Brightness"),
+ propertyName: "scrollBrightness",
+ onToggled: function(checked) {
+ root.scrollBrightness = checked;
+ root.saveConfig();
+ }
+ }
+ ]
+ }
+ }
+ }
+
+ ColumnLayout {
+ id: middleColumnLayout
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignTop
+ spacing: Appearance.spacing.normal
+
+ SectionContainer {
+ Layout.fillWidth: true
+ alignTop: true
+
+ StyledText {
+ text: qsTr("Clock")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ SwitchRow {
+ label: qsTr("Show clock icon")
+ checked: root.clockShowIcon
+ onToggled: checked => {
+ root.clockShowIcon = checked;
+ root.saveConfig();
+ }
+ }
+ }
+
+ SectionContainer {
+ Layout.fillWidth: true
+ alignTop: true
+
+ StyledText {
+ text: qsTr("Bar Behavior")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ SwitchRow {
+ label: qsTr("Persistent")
+ checked: root.persistent
+ onToggled: checked => {
+ root.persistent = checked;
+ root.saveConfig();
+ }
+ }
+
+ SwitchRow {
+ label: qsTr("Show on hover")
+ checked: root.showOnHover
+ onToggled: checked => {
+ root.showOnHover = checked;
+ root.saveConfig();
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Drag threshold")
+ value: root.dragThreshold
+ from: 0
+ to: 100
+ suffix: "px"
+ validator: IntValidator { bottom: 0; top: 100 }
+ formatValueFunction: (val) => Math.round(val).toString()
+ parseValueFunction: (text) => parseInt(text)
+
+ onValueModified: (newValue) => {
+ root.dragThreshold = Math.round(newValue);
+ root.saveConfig();
+ }
+ }
+ }
+ }
+ }
+
+ ColumnLayout {
+ id: rightColumnLayout
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignTop
+ spacing: Appearance.spacing.normal
+
+ SectionContainer {
+ Layout.fillWidth: true
+ alignTop: true
+
+ StyledText {
+ text: qsTr("Popouts")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ SwitchRow {
+ label: qsTr("Active window")
+ checked: root.popoutActiveWindow
+ onToggled: checked => {
+ root.popoutActiveWindow = checked;
+ root.saveConfig();
+ }
+ }
+
+ SwitchRow {
+ label: qsTr("Tray")
+ checked: root.popoutTray
+ onToggled: checked => {
+ root.popoutTray = checked;
+ root.saveConfig();
+ }
+ }
+
+ SwitchRow {
+ label: qsTr("Status icons")
+ checked: root.popoutStatusIcons
+ onToggled: checked => {
+ root.popoutStatusIcons = checked;
+ root.saveConfig();
+ }
+ }
+ }
+
+ SectionContainer {
+ Layout.fillWidth: true
+ alignTop: true
+
+ StyledText {
+ text: qsTr("Tray Settings")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ ConnectedButtonGroup {
+ rootItem: root
+
+ options: [
+ {
+ label: qsTr("Background"),
+ propertyName: "trayBackground",
+ onToggled: function(checked) {
+ root.trayBackground = checked;
+ root.saveConfig();
+ }
+ },
+ {
+ label: qsTr("Compact"),
+ propertyName: "trayCompact",
+ onToggled: function(checked) {
+ root.trayCompact = checked;
+ root.saveConfig();
+ }
+ },
+ {
+ label: qsTr("Recolour"),
+ propertyName: "trayRecolour",
+ onToggled: function(checked) {
+ root.trayRecolour = checked;
+ root.saveConfig();
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+
+ }
+ }
+ }
+}
diff --git a/modules/dashboard/Content.qml b/modules/dashboard/Content.qml
index 8a9ed5f..707bee3 100644
--- a/modules/dashboard/Content.qml
+++ b/modules/dashboard/Content.qml
@@ -87,6 +87,7 @@ Item {
id: row
Pane {
+ index: 0
sourceComponent: Dash {
visibilities: root.visibilities
state: root.state
@@ -95,12 +96,14 @@ Item {
}
Pane {
+ index: 1
sourceComponent: Media {
visibilities: root.visibilities
}
}
Pane {
+ index: 2
sourceComponent: Performance {}
}
}
@@ -126,12 +129,14 @@ Item {
}
component Pane: Loader {
+ required property int index
+
Layout.alignment: Qt.AlignTop
Component.onCompleted: active = Qt.binding(() => {
- const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth);
- const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth);
- return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth);
+ const current = view.currentIndex;
+ // Activate current pane and adjacent panes for smooth scrolling
+ return Math.abs(index - current) <= 1;
})
}
}
diff --git a/modules/drawers/Panels.qml b/modules/drawers/Panels.qml
index 4ce1182..7705732 100644
--- a/modules/drawers/Panels.qml
+++ b/modules/drawers/Panels.qml
@@ -109,6 +109,7 @@ Item {
visibilities: root.visibilities
sidebar: sidebar
+ popouts: popouts
anchors.bottom: parent.bottom
anchors.right: parent.right
diff --git a/modules/launcher/Content.qml b/modules/launcher/Content.qml
index f674569..c085976 100644
--- a/modules/launcher/Content.qml
+++ b/modules/launcher/Content.qml
@@ -47,7 +47,7 @@ Item {
StyledRect {
id: searchWrapper
- color: Colours.tPalette.m3surfaceContainer
+ color: Colours.layer(Colours.palette.m3surfaceContainer, 2)
radius: Appearance.rounding.full
anchors.left: parent.left
diff --git a/modules/launcher/items/WallpaperItem.qml b/modules/launcher/items/WallpaperItem.qml
index 1128bad..9fdac3f 100644
--- a/modules/launcher/items/WallpaperItem.qml
+++ b/modules/launcher/items/WallpaperItem.qml
@@ -67,6 +67,7 @@ Item {
CachingImage {
path: root.modelData.path
smooth: !root.PathView.view.moving
+ cache: true
anchors.fill: parent
}
diff --git a/modules/utilities/Content.qml b/modules/utilities/Content.qml
index d5be824..902656d 100644
--- a/modules/utilities/Content.qml
+++ b/modules/utilities/Content.qml
@@ -8,6 +8,7 @@ Item {
required property var props
required property var visibilities
+ required property Item popouts
implicitWidth: layout.implicitWidth
implicitHeight: layout.implicitHeight
@@ -28,6 +29,7 @@ Item {
Toggles {
visibilities: root.visibilities
+ popouts: root.popouts
}
}
diff --git a/modules/utilities/Wrapper.qml b/modules/utilities/Wrapper.qml
index dd784bc..77178e3 100644
--- a/modules/utilities/Wrapper.qml
+++ b/modules/utilities/Wrapper.qml
@@ -10,6 +10,7 @@ Item {
required property var visibilities
required property Item sidebar
+ required property Item popouts
readonly property PersistentProperties props: PersistentProperties {
property bool recordingListExpanded: false
@@ -89,6 +90,7 @@ Item {
implicitWidth: root.implicitWidth - Appearance.padding.large * 2
props: root.props
visibilities: root.visibilities
+ popouts: root.popouts
}
}
}
diff --git a/modules/utilities/cards/Toggles.qml b/modules/utilities/cards/Toggles.qml
index 3d18e72..51e991e 100644
--- a/modules/utilities/cards/Toggles.qml
+++ b/modules/utilities/cards/Toggles.qml
@@ -12,6 +12,7 @@ StyledRect {
id: root
required property var visibilities
+ required property Item popouts
Layout.fillWidth: true
implicitHeight: layout.implicitHeight + Appearance.padding.large * 2
@@ -67,9 +68,7 @@ StyledRect {
toggle: false
onClicked: {
root.visibilities.utilities = false;
- WindowFactory.create(null, {
- screen: QsWindow.window?.screen ?? null
- });
+ root.popouts.detach("network");
}
}
@@ -92,6 +91,7 @@ StyledRect {
visible: VPN.enabled
onClicked: VPN.toggle()
}
+
}
}
diff --git a/services/Network.qml b/services/Network.qml
index 2c31065..fc16915 100644
--- a/services/Network.qml
+++ b/services/Network.qml
@@ -3,172 +3,205 @@ pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
+import qs.services
Singleton {
id: root
+ Component.onCompleted: {
+ // Trigger ethernet device detection after initialization
+ Qt.callLater(() => {
+ getEthernetDevices();
+ });
+ // Load saved connections on startup
+ Nmcli.loadSavedConnections(() => {
+ root.savedConnections = Nmcli.savedConnections;
+ root.savedConnectionSsids = Nmcli.savedConnectionSsids;
+ });
+ // Get initial WiFi status
+ Nmcli.getWifiStatus((enabled) => {
+ root.wifiEnabled = enabled;
+ });
+ // Sync networks from Nmcli on startup
+ Qt.callLater(() => {
+ syncNetworksFromNmcli();
+ }, 100);
+ }
+
readonly property list<AccessPoint> networks: []
readonly property AccessPoint active: networks.find(n => n.active) ?? null
property bool wifiEnabled: true
- readonly property bool scanning: rescanProc.running
+ readonly property bool scanning: Nmcli.scanning
+
+ property list<var> ethernetDevices: []
+ readonly property var activeEthernet: ethernetDevices.find(d => d.connected) ?? null
+ property int ethernetDeviceCount: 0
+ property bool ethernetProcessRunning: false
+ property var ethernetDeviceDetails: null
+ property var wirelessDeviceDetails: null
function enableWifi(enabled: bool): void {
- const cmd = enabled ? "on" : "off";
- enableWifiProc.exec(["nmcli", "radio", "wifi", cmd]);
+ Nmcli.enableWifi(enabled, (result) => {
+ if (result.success) {
+ root.getWifiStatus();
+ Nmcli.getNetworks(() => {
+ syncNetworksFromNmcli();
+ });
+ }
+ });
}
function toggleWifi(): void {
- const cmd = wifiEnabled ? "off" : "on";
- enableWifiProc.exec(["nmcli", "radio", "wifi", cmd]);
+ Nmcli.toggleWifi((result) => {
+ if (result.success) {
+ root.getWifiStatus();
+ Nmcli.getNetworks(() => {
+ syncNetworksFromNmcli();
+ });
+ }
+ });
}
function rescanWifi(): void {
- rescanProc.running = true;
+ Nmcli.rescanWifi();
}
- function connectToNetwork(ssid: string, password: string): void {
- // TODO: Implement password
- connectProc.exec(["nmcli", "conn", "up", ssid]);
- }
+ property var pendingConnection: null
+ signal connectionFailed(string ssid)
- function disconnectFromNetwork(): void {
- if (active) {
- disconnectProc.exec(["nmcli", "connection", "down", active.ssid]);
+ function connectToNetwork(ssid: string, password: string, bssid: string, callback: var): void {
+ // Set up pending connection tracking if callback provided
+ if (callback) {
+ const hasBssid = bssid !== undefined && bssid !== null && bssid.length > 0;
+ root.pendingConnection = { ssid: ssid, bssid: hasBssid ? bssid : "", callback: callback };
}
+
+ Nmcli.connectToNetwork(ssid, password, bssid, (result) => {
+ if (result && result.success) {
+ // Connection successful
+ if (callback) callback(result);
+ root.pendingConnection = null;
+ } else if (result && result.needsPassword) {
+ // Password needed - callback will handle showing dialog
+ if (callback) callback(result);
+ } else {
+ // Connection failed
+ if (result && result.error) {
+ root.connectionFailed(ssid);
+ }
+ if (callback) callback(result);
+ root.pendingConnection = null;
+ }
+ });
}
- function getWifiStatus(): void {
- wifiStatusProc.running = true;
+ function connectToNetworkWithPasswordCheck(ssid: string, isSecure: bool, callback: var, bssid: string): void {
+ // Set up pending connection tracking
+ const hasBssid = bssid !== undefined && bssid !== null && bssid.length > 0;
+ root.pendingConnection = { ssid: ssid, bssid: hasBssid ? bssid : "", callback: callback };
+
+ Nmcli.connectToNetworkWithPasswordCheck(ssid, isSecure, (result) => {
+ if (result && result.success) {
+ // Connection successful
+ if (callback) callback(result);
+ root.pendingConnection = null;
+ } else if (result && result.needsPassword) {
+ // Password needed - callback will handle showing dialog
+ if (callback) callback(result);
+ } else {
+ // Connection failed
+ if (result && result.error) {
+ root.connectionFailed(ssid);
+ }
+ if (callback) callback(result);
+ root.pendingConnection = null;
+ }
+ }, bssid);
}
- Process {
- running: true
- command: ["nmcli", "m"]
- stdout: SplitParser {
- onRead: getNetworks.running = true
- }
+ function disconnectFromNetwork(): void {
+ // Try to disconnect - use connection name if available, otherwise use device
+ Nmcli.disconnectFromNetwork();
+ // Refresh network list after disconnection
+ Qt.callLater(() => {
+ Nmcli.getNetworks(() => {
+ syncNetworksFromNmcli();
+ });
+ }, 500);
}
- Process {
- id: wifiStatusProc
-
- running: true
- command: ["nmcli", "radio", "wifi"]
- environment: ({
- LANG: "C.UTF-8",
- LC_ALL: "C.UTF-8"
- })
- stdout: StdioCollector {
- onStreamFinished: {
- root.wifiEnabled = text.trim() === "enabled";
+ function forgetNetwork(ssid: string): void {
+ // Delete the connection profile for this network
+ // This will remove the saved password and connection settings
+ Nmcli.forgetNetwork(ssid, (result) => {
+ if (result.success) {
+ // Refresh network list after deletion
+ Qt.callLater(() => {
+ Nmcli.getNetworks(() => {
+ syncNetworksFromNmcli();
+ });
+ }, 500);
}
- }
+ });
}
- Process {
- id: enableWifiProc
-
- onExited: {
- root.getWifiStatus();
- getNetworks.running = true;
- }
- }
- Process {
- id: rescanProc
+ property list<string> savedConnections: []
+ property list<string> savedConnectionSsids: []
- command: ["nmcli", "dev", "wifi", "list", "--rescan", "yes"]
- onExited: {
- getNetworks.running = true;
- }
- }
-
- Process {
- id: connectProc
-
- stdout: SplitParser {
- onRead: getNetworks.running = true
+ // Sync saved connections from Nmcli when they're updated
+ Connections {
+ target: Nmcli
+ function onSavedConnectionsChanged() {
+ root.savedConnections = Nmcli.savedConnections;
}
- stderr: StdioCollector {
- onStreamFinished: console.warn("Network connection error:", text)
+ function onSavedConnectionSsidsChanged() {
+ root.savedConnectionSsids = Nmcli.savedConnectionSsids;
}
}
- Process {
- id: disconnectProc
-
- stdout: SplitParser {
- onRead: getNetworks.running = true
+ function syncNetworksFromNmcli(): void {
+ const rNetworks = root.networks;
+ const nNetworks = Nmcli.networks;
+
+ // Build a map of existing networks by key
+ const existingMap = new Map();
+ for (const rn of rNetworks) {
+ const key = `${rn.frequency}:${rn.ssid}:${rn.bssid}`;
+ existingMap.set(key, rn);
}
- }
-
- Process {
- id: getNetworks
-
- running: true
- command: ["nmcli", "-g", "ACTIVE,SIGNAL,FREQ,SSID,BSSID,SECURITY", "d", "w"]
- environment: ({
- LANG: "C.UTF-8",
- LC_ALL: "C.UTF-8"
- })
- stdout: StdioCollector {
- onStreamFinished: {
- const PLACEHOLDER = "STRINGWHICHHOPEFULLYWONTBEUSED";
- const rep = new RegExp("\\\\:", "g");
- const rep2 = new RegExp(PLACEHOLDER, "g");
-
- const allNetworks = text.trim().split("\n").map(n => {
- const net = n.replace(rep, PLACEHOLDER).split(":");
- return {
- active: net[0] === "yes",
- strength: parseInt(net[1]),
- frequency: parseInt(net[2]),
- ssid: net[3]?.replace(rep2, ":") ?? "",
- bssid: net[4]?.replace(rep2, ":") ?? "",
- security: net[5] ?? ""
- };
- }).filter(n => n.ssid && n.ssid.length > 0);
-
- // Group networks by SSID and prioritize connected ones
- const networkMap = new Map();
- for (const network of allNetworks) {
- const existing = networkMap.get(network.ssid);
- if (!existing) {
- networkMap.set(network.ssid, network);
- } else {
- // Prioritize active/connected networks
- if (network.active && !existing.active) {
- networkMap.set(network.ssid, network);
- } else if (!network.active && !existing.active) {
- // If both are inactive, keep the one with better signal
- if (network.strength > existing.strength) {
- networkMap.set(network.ssid, network);
- }
- }
- // If existing is active and new is not, keep existing
- }
- }
-
- const networks = Array.from(networkMap.values());
-
- const rNetworks = root.networks;
-
- const destroyed = rNetworks.filter(rn => !networks.find(n => n.frequency === rn.frequency && n.ssid === rn.ssid && n.bssid === rn.bssid));
- for (const network of destroyed)
- rNetworks.splice(rNetworks.indexOf(network), 1).forEach(n => n.destroy());
-
- for (const network of networks) {
- const match = rNetworks.find(n => n.frequency === network.frequency && n.ssid === network.ssid && n.bssid === network.bssid);
- if (match) {
- match.lastIpcObject = network;
- } else {
- rNetworks.push(apComp.createObject(root, {
- lastIpcObject: network
- }));
- }
+
+ // Build a map of new networks by key
+ const newMap = new Map();
+ for (const nn of nNetworks) {
+ const key = `${nn.frequency}:${nn.ssid}:${nn.bssid}`;
+ newMap.set(key, nn);
+ }
+
+ // Remove networks that no longer exist
+ for (const [key, network] of existingMap) {
+ if (!newMap.has(key)) {
+ const index = rNetworks.indexOf(network);
+ if (index >= 0) {
+ rNetworks.splice(index, 1);
+ network.destroy();
}
}
}
+
+ // Add or update networks from Nmcli
+ for (const [key, nNetwork] of newMap) {
+ const existing = existingMap.get(key);
+ if (existing) {
+ // Update existing network's lastIpcObject
+ existing.lastIpcObject = nNetwork.lastIpcObject;
+ } else {
+ // Create new AccessPoint from Nmcli's data
+ rNetworks.push(apComp.createObject(root, {
+ lastIpcObject: nNetwork.lastIpcObject
+ }));
+ }
+ }
}
component AccessPoint: QtObject {
@@ -184,7 +217,100 @@ Singleton {
Component {
id: apComp
-
AccessPoint {}
}
+
+ function hasSavedProfile(ssid: string): bool {
+ // Use Nmcli's hasSavedProfile which has the same logic
+ return Nmcli.hasSavedProfile(ssid);
+ }
+
+ function getWifiStatus(): void {
+ Nmcli.getWifiStatus((enabled) => {
+ root.wifiEnabled = enabled;
+ });
+ }
+
+ function getEthernetDevices(): void {
+ root.ethernetProcessRunning = true;
+ Nmcli.getEthernetInterfaces((interfaces) => {
+ root.ethernetDevices = Nmcli.ethernetDevices;
+ root.ethernetDeviceCount = Nmcli.ethernetDevices.length;
+ root.ethernetProcessRunning = false;
+ });
+ }
+
+
+ function connectEthernet(connectionName: string, interfaceName: string): void {
+ Nmcli.connectEthernet(connectionName, interfaceName, (result) => {
+ if (result.success) {
+ getEthernetDevices();
+ // Refresh device details after connection
+ Qt.callLater(() => {
+ const activeDevice = root.ethernetDevices.find(function(d) { return d.connected; });
+ if (activeDevice && activeDevice.interface) {
+ updateEthernetDeviceDetails(activeDevice.interface);
+ }
+ }, 1000);
+ }
+ });
+ }
+
+ function disconnectEthernet(connectionName: string): void {
+ Nmcli.disconnectEthernet(connectionName, (result) => {
+ if (result.success) {
+ getEthernetDevices();
+ // Clear device details after disconnection
+ Qt.callLater(() => {
+ root.ethernetDeviceDetails = null;
+ });
+ }
+ });
+ }
+
+ function updateEthernetDeviceDetails(interfaceName: string): void {
+ Nmcli.getEthernetDeviceDetails(interfaceName, (details) => {
+ root.ethernetDeviceDetails = details;
+ });
+ }
+
+ function updateWirelessDeviceDetails(): void {
+ // Find the wireless interface by looking for wifi devices
+ // Pass empty string to let Nmcli find the active interface automatically
+ Nmcli.getWirelessDeviceDetails("", (details) => {
+ root.wirelessDeviceDetails = details;
+ });
+ }
+
+ function cidrToSubnetMask(cidr: string): string {
+ // Convert CIDR notation (e.g., "24") to subnet mask (e.g., "255.255.255.0")
+ const cidrNum = parseInt(cidr);
+ if (isNaN(cidrNum) || cidrNum < 0 || cidrNum > 32) {
+ return "";
+ }
+
+ const mask = (0xffffffff << (32 - cidrNum)) >>> 0;
+ const octets = [
+ (mask >>> 24) & 0xff,
+ (mask >>> 16) & 0xff,
+ (mask >>> 8) & 0xff,
+ mask & 0xff
+ ];
+
+ return octets.join(".");
+ }
+
+ Process {
+ running: true
+ command: ["nmcli", "m"]
+ stdout: SplitParser {
+ onRead: {
+ Nmcli.getNetworks(() => {
+ syncNetworksFromNmcli();
+ });
+ getEthernetDevices();
+ }
+ }
+ }
+
}
diff --git a/services/Nmcli.qml b/services/Nmcli.qml
new file mode 100644
index 0000000..36bd3e6
--- /dev/null
+++ b/services/Nmcli.qml
@@ -0,0 +1,1352 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+Singleton {
+ id: root
+
+ property var deviceStatus: null
+ property var wirelessInterfaces: []
+ property var ethernetInterfaces: []
+ property bool isConnected: false
+ property string activeInterface: ""
+ property string activeConnection: ""
+ property bool wifiEnabled: true
+ readonly property bool scanning: rescanProc.running
+ readonly property list<AccessPoint> networks: []
+ readonly property AccessPoint active: networks.find(n => n.active) ?? null
+ property list<string> savedConnections: []
+ property list<string> savedConnectionSsids: []
+
+ property var wifiConnectionQueue: []
+ property int currentSsidQueryIndex: 0
+ property var pendingConnection: null
+ signal connectionFailed(string ssid)
+ property var wirelessDeviceDetails: null
+ property var ethernetDeviceDetails: null
+ property list<var> ethernetDevices: []
+ readonly property var activeEthernet: ethernetDevices.find(d => d.connected) ?? null
+
+ property list<var> activeProcesses: []
+
+ // Constants
+ readonly property string deviceTypeWifi: "wifi"
+ readonly property string deviceTypeEthernet: "ethernet"
+ readonly property string connectionTypeWireless: "802-11-wireless"
+ readonly property string nmcliCommandDevice: "device"
+ readonly property string nmcliCommandConnection: "connection"
+ readonly property string nmcliCommandWifi: "wifi"
+ readonly property string nmcliCommandRadio: "radio"
+ readonly property string deviceStatusFields: "DEVICE,TYPE,STATE,CONNECTION"
+ readonly property string connectionListFields: "NAME,TYPE"
+ readonly property string wirelessSsidField: "802-11-wireless.ssid"
+ readonly property string networkListFields: "SSID,SIGNAL,SECURITY"
+ readonly property string networkDetailFields: "ACTIVE,SIGNAL,FREQ,SSID,BSSID,SECURITY"
+ readonly property string securityKeyMgmt: "802-11-wireless-security.key-mgmt"
+ readonly property string securityPsk: "802-11-wireless-security.psk"
+ readonly property string keyMgmtWpaPsk: "wpa-psk"
+ readonly property string connectionParamType: "type"
+ readonly property string connectionParamConName: "con-name"
+ readonly property string connectionParamIfname: "ifname"
+ readonly property string connectionParamSsid: "ssid"
+ readonly property string connectionParamPassword: "password"
+ readonly property string connectionParamBssid: "802-11-wireless.bssid"
+
+ function detectPasswordRequired(error: string): bool {
+ if (!error || error.length === 0) {
+ return false;
+ }
+
+ return (error.includes("Secrets were required") || error.includes("Secrets were required, but not provided") || error.includes("No secrets provided") || error.includes("802-11-wireless-security.psk") || error.includes("password for") || (error.includes("password") && !error.includes("Connection activated") && !error.includes("successfully")) || (error.includes("Secrets") && !error.includes("Connection activated") && !error.includes("successfully")) || (error.includes("802.11") && !error.includes("Connection activated") && !error.includes("successfully"))) && !error.includes("Connection activated") && !error.includes("successfully");
+ }
+
+ function parseNetworkOutput(output: string): list<var> {
+ if (!output || output.length === 0) {
+ return [];
+ }
+
+ const PLACEHOLDER = "STRINGWHICHHOPEFULLYWONTBEUSED";
+ const rep = new RegExp("\\\\:", "g");
+ const rep2 = new RegExp(PLACEHOLDER, "g");
+
+ const allNetworks = output.trim().split("\n").filter(line => line && line.length > 0).map(n => {
+ const net = n.replace(rep, PLACEHOLDER).split(":");
+ return {
+ active: net[0] === "yes",
+ strength: parseInt(net[1] || "0", 10) || 0,
+ frequency: parseInt(net[2] || "0", 10) || 0,
+ ssid: (net[3]?.replace(rep2, ":") ?? "").trim(),
+ bssid: (net[4]?.replace(rep2, ":") ?? "").trim(),
+ security: (net[5] ?? "").trim()
+ };
+ }).filter(n => n.ssid && n.ssid.length > 0);
+
+ return allNetworks;
+ }
+
+ function deduplicateNetworks(networks: list<var>): list<var> {
+ if (!networks || networks.length === 0) {
+ return [];
+ }
+
+ const networkMap = new Map();
+ for (const network of networks) {
+ const existing = networkMap.get(network.ssid);
+ if (!existing) {
+ networkMap.set(network.ssid, network);
+ } else {
+ if (network.active && !existing.active) {
+ networkMap.set(network.ssid, network);
+ } else if (!network.active && !existing.active) {
+ if (network.strength > existing.strength) {
+ networkMap.set(network.ssid, network);
+ }
+ }
+ }
+ }
+
+ return Array.from(networkMap.values());
+ }
+
+ function isConnectionCommand(command: list<string>): bool {
+ if (!command || command.length === 0) {
+ return false;
+ }
+
+ return command.includes(root.nmcliCommandWifi) || command.includes(root.nmcliCommandConnection);
+ }
+
+ function parseDeviceStatusOutput(output: string, filterType: string): list<var> {
+ if (!output || output.length === 0) {
+ return [];
+ }
+
+ const interfaces = [];
+ const lines = output.trim().split("\n");
+
+ for (const line of lines) {
+ const parts = line.split(":");
+ if (parts.length >= 2) {
+ const deviceType = parts[1];
+ let shouldInclude = false;
+
+ if (filterType === root.deviceTypeWifi && deviceType === root.deviceTypeWifi) {
+ shouldInclude = true;
+ } else if (filterType === root.deviceTypeEthernet && deviceType === root.deviceTypeEthernet) {
+ shouldInclude = true;
+ } else if (filterType === "both" && (deviceType === root.deviceTypeWifi || deviceType === root.deviceTypeEthernet)) {
+ shouldInclude = true;
+ }
+
+ if (shouldInclude) {
+ interfaces.push({
+ device: parts[0] || "",
+ type: parts[1] || "",
+ state: parts[2] || "",
+ connection: parts[3] || ""
+ });
+ }
+ }
+ }
+
+ return interfaces;
+ }
+
+ function isConnectedState(state: string): bool {
+ if (!state || state.length === 0) {
+ return false;
+ }
+
+ return state === "100 (connected)" || state === "connected" || state.startsWith("connected");
+ }
+
+ function executeCommand(args: list<string>, callback: var): void {
+ const proc = commandProc.createObject(root);
+ proc.command = ["nmcli", ...args];
+ proc.callback = callback;
+
+ activeProcesses.push(proc);
+
+ proc.processFinished.connect(() => {
+ const index = activeProcesses.indexOf(proc);
+ if (index >= 0) {
+ activeProcesses.splice(index, 1);
+ }
+ });
+
+ Qt.callLater(() => {
+ proc.exec(proc.command);
+ });
+ }
+
+ function getDeviceStatus(callback: var): void {
+ executeCommand(["-t", "-f", root.deviceStatusFields, root.nmcliCommandDevice, "status"], result => {
+ if (callback)
+ callback(result.output);
+ });
+ }
+
+ function getWirelessInterfaces(callback: var): void {
+ executeCommand(["-t", "-f", root.deviceStatusFields, root.nmcliCommandDevice, "status"], result => {
+ const interfaces = parseDeviceStatusOutput(result.output, root.deviceTypeWifi);
+ root.wirelessInterfaces = interfaces;
+ if (callback)
+ callback(interfaces);
+ });
+ }
+
+ function getEthernetInterfaces(callback: var): void {
+ executeCommand(["-t", "-f", root.deviceStatusFields, root.nmcliCommandDevice, "status"], result => {
+ const interfaces = parseDeviceStatusOutput(result.output, root.deviceTypeEthernet);
+ const devices = [];
+
+ for (const iface of interfaces) {
+ const connected = isConnectedState(iface.state);
+
+ devices.push({
+ interface: iface.device,
+ type: iface.type,
+ state: iface.state,
+ connection: iface.connection,
+ connected: connected,
+ ipAddress: "",
+ gateway: "",
+ dns: [],
+ subnet: "",
+ macAddress: "",
+ speed: ""
+ });
+ }
+
+ root.ethernetInterfaces = interfaces;
+ root.ethernetDevices = devices;
+ if (callback)
+ callback(interfaces);
+ });
+ }
+
+ function connectEthernet(connectionName: string, interfaceName: string, callback: var): void {
+ if (connectionName && connectionName.length > 0) {
+ executeCommand([root.nmcliCommandConnection, "up", connectionName], result => {
+ if (result.success) {
+ Qt.callLater(() => {
+ getEthernetInterfaces(() => {});
+ if (interfaceName && interfaceName.length > 0) {
+ Qt.callLater(() => {
+ getEthernetDeviceDetails(interfaceName, () => {});
+ }, 1000);
+ }
+ }, 500);
+ }
+ if (callback)
+ callback(result);
+ });
+ } else if (interfaceName && interfaceName.length > 0) {
+ executeCommand([root.nmcliCommandDevice, "connect", interfaceName], result => {
+ if (result.success) {
+ Qt.callLater(() => {
+ getEthernetInterfaces(() => {});
+ Qt.callLater(() => {
+ getEthernetDeviceDetails(interfaceName, () => {});
+ }, 1000);
+ }, 500);
+ }
+ if (callback)
+ callback(result);
+ });
+ } else {
+ if (callback)
+ callback({
+ success: false,
+ output: "",
+ error: "No connection name or interface specified",
+ exitCode: -1
+ });
+ }
+ }
+
+ function disconnectEthernet(connectionName: string, callback: var): void {
+ if (!connectionName || connectionName.length === 0) {
+ if (callback)
+ callback({
+ success: false,
+ output: "",
+ error: "No connection name specified",
+ exitCode: -1
+ });
+ return;
+ }
+
+ executeCommand([root.nmcliCommandConnection, "down", connectionName], result => {
+ if (result.success) {
+ root.ethernetDeviceDetails = null;
+ Qt.callLater(() => {
+ getEthernetInterfaces(() => {});
+ }, 500);
+ }
+ if (callback)
+ callback(result);
+ });
+ }
+
+ function getAllInterfaces(callback: var): void {
+ executeCommand(["-t", "-f", root.deviceStatusFields, root.nmcliCommandDevice, "status"], result => {
+ const interfaces = parseDeviceStatusOutput(result.output, "both");
+ if (callback)
+ callback(interfaces);
+ });
+ }
+
+ function isInterfaceConnected(interfaceName: string, callback: var): void {
+ executeCommand([root.nmcliCommandDevice, "status"], result => {
+ const lines = result.output.trim().split("\n");
+ for (const line of lines) {
+ const parts = line.split(/\s+/);
+ if (parts.length >= 3 && parts[0] === interfaceName) {
+ const connected = isConnectedState(parts[2]);
+ if (callback)
+ callback(connected);
+ return;
+ }
+ }
+ if (callback)
+ callback(false);
+ });
+ }
+
+ function connectToNetworkWithPasswordCheck(ssid: string, isSecure: bool, callback: var, bssid: string): void {
+ if (isSecure) {
+ const hasBssid = bssid !== undefined && bssid !== null && bssid.length > 0;
+ connectWireless(ssid, "", bssid, result => {
+ if (result.success) {
+ if (callback)
+ callback({
+ success: true,
+ usedSavedPassword: true,
+ output: result.output,
+ error: "",
+ exitCode: 0
+ });
+ } else if (result.needsPassword) {
+ if (callback)
+ callback({
+ success: false,
+ needsPassword: true,
+ output: result.output,
+ error: result.error,
+ exitCode: result.exitCode
+ });
+ } else {
+ if (callback)
+ callback(result);
+ }
+ });
+ } else {
+ connectWireless(ssid, "", bssid, callback);
+ }
+ }
+
+ function connectToNetwork(ssid: string, password: string, bssid: string, callback: var): void {
+ connectWireless(ssid, password, bssid, callback);
+ }
+
+ function connectWireless(ssid: string, password: string, bssid: string, callback: var, retryCount: int): void {
+ const hasBssid = bssid !== undefined && bssid !== null && bssid.length > 0;
+ const retries = retryCount !== undefined ? retryCount : 0;
+ const maxRetries = 2;
+
+ if (callback) {
+ root.pendingConnection = {
+ ssid: ssid,
+ bssid: hasBssid ? bssid : "",
+ callback: callback,
+ retryCount: retries
+ };
+ connectionCheckTimer.start();
+ immediateCheckTimer.checkCount = 0;
+ immediateCheckTimer.start();
+ }
+
+ if (password && password.length > 0 && hasBssid) {
+ const bssidUpper = bssid.toUpperCase();
+ createConnectionWithPassword(ssid, bssidUpper, password, callback);
+ return;
+ }
+
+ let cmd = [root.nmcliCommandDevice, root.nmcliCommandWifi, "connect", ssid];
+ if (password && password.length > 0) {
+ cmd.push(root.connectionParamPassword, password);
+ }
+ executeCommand(cmd, result => {
+ if (result.needsPassword && callback) {
+ if (callback)
+ callback(result);
+ return;
+ }
+
+ if (!result.success && root.pendingConnection && retries < maxRetries) {
+ console.warn("[NMCLI] Connection failed, retrying... (attempt " + (retries + 1) + "/" + maxRetries + ")");
+ Qt.callLater(() => {
+ connectWireless(ssid, password, bssid, callback, retries + 1);
+ }, 1000);
+ } else if (!result.success && root.pendingConnection) {} else if (result.success && callback) {} else if (!result.success && !root.pendingConnection) {
+ if (callback)
+ callback(result);
+ }
+ });
+ }
+
+ function createConnectionWithPassword(ssid: string, bssidUpper: string, password: string, callback: var): void {
+ checkAndDeleteConnection(ssid, () => {
+ const cmd = [root.nmcliCommandConnection, "add", root.connectionParamType, root.deviceTypeWifi, root.connectionParamConName, ssid, root.connectionParamIfname, "*", root.connectionParamSsid, ssid, root.connectionParamBssid, bssidUpper, root.securityKeyMgmt, root.keyMgmtWpaPsk, root.securityPsk, password];
+
+ executeCommand(cmd, result => {
+ if (result.success) {
+ loadSavedConnections(() => {});
+ activateConnection(ssid, callback);
+ } else {
+ const hasDuplicateWarning = result.error && (result.error.includes("another connection with the name") || result.error.includes("Reference the connection by its uuid"));
+
+ if (hasDuplicateWarning || (result.exitCode > 0 && result.exitCode < 10)) {
+ loadSavedConnections(() => {});
+ activateConnection(ssid, callback);
+ } else {
+ console.warn("[NMCLI] Connection profile creation failed, trying fallback...");
+ let fallbackCmd = [root.nmcliCommandDevice, root.nmcliCommandWifi, "connect", ssid, root.connectionParamPassword, password];
+ executeCommand(fallbackCmd, fallbackResult => {
+ if (callback)
+ callback(fallbackResult);
+ });
+ }
+ }
+ });
+ });
+ }
+
+ function checkAndDeleteConnection(ssid: string, callback: var): void {
+ executeCommand([root.nmcliCommandConnection, "show", ssid], result => {
+ if (result.success) {
+ executeCommand([root.nmcliCommandConnection, "delete", ssid], deleteResult => {
+ Qt.callLater(() => {
+ if (callback)
+ callback();
+ }, 300);
+ });
+ } else {
+ if (callback)
+ callback();
+ }
+ });
+ }
+
+ function activateConnection(connectionName: string, callback: var): void {
+ executeCommand([root.nmcliCommandConnection, "up", connectionName], result => {
+ if (callback)
+ callback(result);
+ });
+ }
+
+ function loadSavedConnections(callback: var): void {
+ executeCommand(["-t", "-f", root.connectionListFields, root.nmcliCommandConnection, "show"], result => {
+ if (!result.success) {
+ root.savedConnections = [];
+ root.savedConnectionSsids = [];
+ if (callback)
+ callback([]);
+ return;
+ }
+
+ parseConnectionList(result.output, callback);
+ });
+ }
+
+ function parseConnectionList(output: string, callback: var): void {
+ const lines = output.trim().split("\n").filter(line => line.length > 0);
+ const wifiConnections = [];
+ const connections = [];
+
+ for (const line of lines) {
+ const parts = line.split(":");
+ if (parts.length >= 2) {
+ const name = parts[0];
+ const type = parts[1];
+ connections.push(name);
+
+ if (type === root.connectionTypeWireless) {
+ wifiConnections.push(name);
+ }
+ }
+ }
+
+ root.savedConnections = connections;
+
+ if (wifiConnections.length > 0) {
+ root.wifiConnectionQueue = wifiConnections;
+ root.currentSsidQueryIndex = 0;
+ root.savedConnectionSsids = [];
+ queryNextSsid(callback);
+ } else {
+ root.savedConnectionSsids = [];
+ root.wifiConnectionQueue = [];
+ if (callback)
+ callback(root.savedConnectionSsids);
+ }
+ }
+
+ function queryNextSsid(callback: var): void {
+ if (root.currentSsidQueryIndex < root.wifiConnectionQueue.length) {
+ const connectionName = root.wifiConnectionQueue[root.currentSsidQueryIndex];
+ root.currentSsidQueryIndex++;
+
+ executeCommand(["-t", "-f", root.wirelessSsidField, root.nmcliCommandConnection, "show", connectionName], result => {
+ if (result.success) {
+ processSsidOutput(result.output);
+ }
+ queryNextSsid(callback);
+ });
+ } else {
+ root.wifiConnectionQueue = [];
+ root.currentSsidQueryIndex = 0;
+ if (callback)
+ callback(root.savedConnectionSsids);
+ }
+ }
+
+ function processSsidOutput(output: string): void {
+ const lines = output.trim().split("\n");
+ for (const line of lines) {
+ if (line.startsWith("802-11-wireless.ssid:")) {
+ const ssid = line.substring("802-11-wireless.ssid:".length).trim();
+ if (ssid && ssid.length > 0) {
+ const ssidLower = ssid.toLowerCase();
+ const exists = root.savedConnectionSsids.some(s => s && s.toLowerCase() === ssidLower);
+ if (!exists) {
+ const newList = root.savedConnectionSsids.slice();
+ newList.push(ssid);
+ root.savedConnectionSsids = newList;
+ }
+ }
+ }
+ }
+ }
+
+ function hasSavedProfile(ssid: string): bool {
+ if (!ssid || ssid.length === 0) {
+ return false;
+ }
+ const ssidLower = ssid.toLowerCase().trim();
+
+ if (root.active && root.active.ssid) {
+ const activeSsidLower = root.active.ssid.toLowerCase().trim();
+ if (activeSsidLower === ssidLower) {
+ return true;
+ }
+ }
+
+ const hasSsid = root.savedConnectionSsids.some(savedSsid => savedSsid && savedSsid.toLowerCase().trim() === ssidLower);
+
+ if (hasSsid) {
+ return true;
+ }
+
+ const hasConnectionName = root.savedConnections.some(connName => connName && connName.toLowerCase().trim() === ssidLower);
+
+ return hasConnectionName;
+ }
+
+ function forgetNetwork(ssid: string, callback: var): void {
+ if (!ssid || ssid.length === 0) {
+ if (callback)
+ callback({
+ success: false,
+ output: "",
+ error: "No SSID specified",
+ exitCode: -1
+ });
+ return;
+ }
+
+ const connectionName = root.savedConnections.find(conn => conn && conn.toLowerCase().trim() === ssid.toLowerCase().trim()) || ssid;
+
+ executeCommand([root.nmcliCommandConnection, "delete", connectionName], result => {
+ if (result.success) {
+ Qt.callLater(() => {
+ loadSavedConnections(() => {});
+ }, 500);
+ }
+ if (callback)
+ callback(result);
+ });
+ }
+
+ function disconnect(interfaceName: string, callback: var): void {
+ if (interfaceName && interfaceName.length > 0) {
+ executeCommand([root.nmcliCommandDevice, "disconnect", interfaceName], result => {
+ if (callback)
+ callback(result.success ? result.output : "");
+ });
+ } else {
+ executeCommand([root.nmcliCommandDevice, "disconnect", root.deviceTypeWifi], result => {
+ if (callback)
+ callback(result.success ? result.output : "");
+ });
+ }
+ }
+
+ function disconnectFromNetwork(): void {
+ if (active && active.ssid) {
+ executeCommand([root.nmcliCommandConnection, "down", active.ssid], result => {
+ if (result.success) {
+ getNetworks(() => {});
+ }
+ });
+ } else {
+ executeCommand([root.nmcliCommandDevice, "disconnect", root.deviceTypeWifi], result => {
+ if (result.success) {
+ getNetworks(() => {});
+ }
+ });
+ }
+ }
+
+ function getDeviceDetails(interfaceName: string, callback: var): void {
+ executeCommand([root.nmcliCommandDevice, "show", interfaceName], result => {
+ if (callback)
+ callback(result.output);
+ });
+ }
+
+ function refreshStatus(callback: var): void {
+ getDeviceStatus(output => {
+ const lines = output.trim().split("\n");
+ let connected = false;
+ let activeIf = "";
+ let activeConn = "";
+
+ for (const line of lines) {
+ const parts = line.split(":");
+ if (parts.length >= 4) {
+ const state = parts[2] || "";
+ if (isConnectedState(state)) {
+ connected = true;
+ activeIf = parts[0] || "";
+ activeConn = parts[3] || "";
+ break;
+ }
+ }
+ }
+
+ root.isConnected = connected;
+ root.activeInterface = activeIf;
+ root.activeConnection = activeConn;
+
+ if (callback)
+ callback({
+ connected,
+ interface: activeIf,
+ connection: activeConn
+ });
+ });
+ }
+
+ function bringInterfaceUp(interfaceName: string, callback: var): void {
+ if (interfaceName && interfaceName.length > 0) {
+ executeCommand([root.nmcliCommandDevice, "connect", interfaceName], result => {
+ if (callback) {
+ callback(result);
+ }
+ });
+ } else {
+ if (callback)
+ callback({
+ success: false,
+ output: "",
+ error: "No interface specified",
+ exitCode: -1
+ });
+ }
+ }
+
+ function bringInterfaceDown(interfaceName: string, callback: var): void {
+ if (interfaceName && interfaceName.length > 0) {
+ executeCommand([root.nmcliCommandDevice, "disconnect", interfaceName], result => {
+ if (callback) {
+ callback(result);
+ }
+ });
+ } else {
+ if (callback)
+ callback({
+ success: false,
+ output: "",
+ error: "No interface specified",
+ exitCode: -1
+ });
+ }
+ }
+
+ function scanWirelessNetworks(interfaceName: string, callback: var): void {
+ let cmd = [root.nmcliCommandDevice, root.nmcliCommandWifi, "rescan"];
+ if (interfaceName && interfaceName.length > 0) {
+ cmd.push(root.connectionParamIfname, interfaceName);
+ }
+ executeCommand(cmd, result => {
+ if (callback) {
+ callback(result);
+ }
+ });
+ }
+
+ function rescanWifi(): void {
+ rescanProc.running = true;
+ }
+
+ function enableWifi(enabled: bool, callback: var): void {
+ const cmd = enabled ? "on" : "off";
+ executeCommand([root.nmcliCommandRadio, root.nmcliCommandWifi, cmd], result => {
+ if (result.success) {
+ getWifiStatus(status => {
+ root.wifiEnabled = status;
+ if (callback)
+ callback(result);
+ });
+ } else {
+ if (callback)
+ callback(result);
+ }
+ });
+ }
+
+ function toggleWifi(callback: var): void {
+ const newState = !root.wifiEnabled;
+ enableWifi(newState, callback);
+ }
+
+ function getWifiStatus(callback: var): void {
+ executeCommand([root.nmcliCommandRadio, root.nmcliCommandWifi], result => {
+ if (result.success) {
+ const enabled = result.output.trim() === "enabled";
+ root.wifiEnabled = enabled;
+ if (callback)
+ callback(enabled);
+ } else {
+ if (callback)
+ callback(root.wifiEnabled);
+ }
+ });
+ }
+
+ function getNetworks(callback: var): void {
+ executeCommand(["-g", root.networkDetailFields, "d", "w"], result => {
+ if (!result.success) {
+ if (callback)
+ callback([]);
+ return;
+ }
+
+ const allNetworks = parseNetworkOutput(result.output);
+ const networks = deduplicateNetworks(allNetworks);
+ const rNetworks = root.networks;
+
+ const destroyed = rNetworks.filter(rn => !networks.find(n => n.frequency === rn.frequency && n.ssid === rn.ssid && n.bssid === rn.bssid));
+ for (const network of destroyed) {
+ const index = rNetworks.indexOf(network);
+ if (index >= 0) {
+ rNetworks.splice(index, 1);
+ network.destroy();
+ }
+ }
+
+ for (const network of networks) {
+ const match = rNetworks.find(n => n.frequency === network.frequency && n.ssid === network.ssid && n.bssid === network.bssid);
+ if (match) {
+ match.lastIpcObject = network;
+ } else {
+ rNetworks.push(apComp.createObject(root, {
+ lastIpcObject: network
+ }));
+ }
+ }
+
+ if (callback)
+ callback(root.networks);
+ checkPendingConnection();
+ });
+ }
+
+ function getWirelessSSIDs(interfaceName: string, callback: var): void {
+ let cmd = ["-t", "-f", root.networkListFields, root.nmcliCommandDevice, root.nmcliCommandWifi, "list"];
+ if (interfaceName && interfaceName.length > 0) {
+ cmd.push(root.connectionParamIfname, interfaceName);
+ }
+ executeCommand(cmd, result => {
+ if (!result.success) {
+ if (callback)
+ callback([]);
+ return;
+ }
+
+ const ssids = [];
+ const lines = result.output.trim().split("\n");
+ const seenSSIDs = new Set();
+
+ for (const line of lines) {
+ if (!line || line.length === 0)
+ continue;
+
+ const parts = line.split(":");
+ if (parts.length >= 1) {
+ const ssid = parts[0].trim();
+ if (ssid && ssid.length > 0 && !seenSSIDs.has(ssid)) {
+ seenSSIDs.add(ssid);
+ const signalStr = parts.length >= 2 ? parts[1].trim() : "";
+ const signal = signalStr ? parseInt(signalStr, 10) : 0;
+ const security = parts.length >= 3 ? parts[2].trim() : "";
+ ssids.push({
+ ssid: ssid,
+ signal: signalStr,
+ signalValue: isNaN(signal) ? 0 : signal,
+ security: security
+ });
+ }
+ }
+ }
+
+ ssids.sort((a, b) => {
+ return b.signalValue - a.signalValue;
+ });
+
+ if (callback)
+ callback(ssids);
+ });
+ }
+
+ function handlePasswordRequired(proc: var, error: string, output: string, exitCode: int): bool {
+ if (!proc || !error || error.length === 0) {
+ return false;
+ }
+
+ if (!isConnectionCommand(proc.command) || !root.pendingConnection || !root.pendingConnection.callback) {
+ return false;
+ }
+
+ const needsPassword = detectPasswordRequired(error);
+
+ if (needsPassword && !proc.callbackCalled && root.pendingConnection) {
+ connectionCheckTimer.stop();
+ immediateCheckTimer.stop();
+ immediateCheckTimer.checkCount = 0;
+ const pending = root.pendingConnection;
+ root.pendingConnection = null;
+ proc.callbackCalled = true;
+ const result = {
+ success: false,
+ output: output || "",
+ error: error,
+ exitCode: exitCode,
+ needsPassword: true
+ };
+ if (pending.callback) {
+ pending.callback(result);
+ }
+ if (proc.callback && proc.callback !== pending.callback) {
+ proc.callback(result);
+ }
+ return true;
+ }
+
+ return false;
+ }
+
+ component CommandProcess: Process {
+ id: proc
+
+ property var callback: null
+ property list<string> command: []
+ property bool callbackCalled: false
+ property int exitCode: 0
+
+ signal processFinished
+
+ environment: ({
+ LANG: "C.UTF-8",
+ LC_ALL: "C.UTF-8"
+ })
+
+ stdout: StdioCollector {
+ id: stdoutCollector
+ }
+
+ stderr: StdioCollector {
+ id: stderrCollector
+
+ onStreamFinished: {
+ const error = text.trim();
+ if (error && error.length > 0) {
+ const output = (stdoutCollector && stdoutCollector.text) ? stdoutCollector.text : "";
+ root.handlePasswordRequired(proc, error, output, -1);
+ }
+ }
+ }
+
+ onExited: code => {
+ exitCode = code;
+
+ Qt.callLater(() => {
+ if (callbackCalled) {
+ processFinished();
+ return;
+ }
+
+ if (proc.callback) {
+ const output = (stdoutCollector && stdoutCollector.text) ? stdoutCollector.text : "";
+ const error = (stderrCollector && stderrCollector.text) ? stderrCollector.text : "";
+ const success = exitCode === 0;
+ const cmdIsConnection = isConnectionCommand(proc.command);
+
+ if (root.handlePasswordRequired(proc, error, output, exitCode)) {
+ processFinished();
+ return;
+ }
+
+ const needsPassword = cmdIsConnection && root.detectPasswordRequired(error);
+
+ if (!success && cmdIsConnection && root.pendingConnection) {
+ const failedSsid = root.pendingConnection.ssid;
+ root.connectionFailed(failedSsid);
+ }
+
+ callbackCalled = true;
+ callback({
+ success: success,
+ output: output,
+ error: error,
+ exitCode: proc.exitCode,
+ needsPassword: needsPassword || false
+ });
+ processFinished();
+ } else {
+ processFinished();
+ }
+ });
+ }
+ }
+
+ Component {
+ id: commandProc
+
+ CommandProcess {}
+ }
+
+ component AccessPoint: QtObject {
+ required property var lastIpcObject
+ readonly property string ssid: lastIpcObject.ssid
+ readonly property string bssid: lastIpcObject.bssid
+ readonly property int strength: lastIpcObject.strength
+ readonly property int frequency: lastIpcObject.frequency
+ readonly property bool active: lastIpcObject.active
+ readonly property string security: lastIpcObject.security
+ readonly property bool isSecure: security.length > 0
+ }
+
+ Component {
+ id: apComp
+
+ AccessPoint {}
+ }
+
+ Timer {
+ id: connectionCheckTimer
+
+ interval: 4000
+ onTriggered: {
+ if (root.pendingConnection) {
+ const connected = root.active && root.active.ssid === root.pendingConnection.ssid;
+
+ if (!connected && root.pendingConnection.callback) {
+ let foundPasswordError = false;
+ for (let i = 0; i < root.activeProcesses.length; i++) {
+ const proc = root.activeProcesses[i];
+ if (proc && proc.stderr && proc.stderr.text) {
+ const error = proc.stderr.text.trim();
+ if (error && error.length > 0) {
+ if (root.isConnectionCommand(proc.command)) {
+ const needsPassword = root.detectPasswordRequired(error);
+
+ if (needsPassword && !proc.callbackCalled && root.pendingConnection) {
+ const pending = root.pendingConnection;
+ root.pendingConnection = null;
+ immediateCheckTimer.stop();
+ immediateCheckTimer.checkCount = 0;
+ proc.callbackCalled = true;
+ const result = {
+ success: false,
+ output: (proc.stdout && proc.stdout.text) ? proc.stdout.text : "",
+ error: error,
+ exitCode: -1,
+ needsPassword: true
+ };
+ if (pending.callback) {
+ pending.callback(result);
+ }
+ if (proc.callback && proc.callback !== pending.callback) {
+ proc.callback(result);
+ }
+ foundPasswordError = true;
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ if (!foundPasswordError) {
+ const pending = root.pendingConnection;
+ const failedSsid = pending.ssid;
+ root.pendingConnection = null;
+ immediateCheckTimer.stop();
+ immediateCheckTimer.checkCount = 0;
+ root.connectionFailed(failedSsid);
+ pending.callback({
+ success: false,
+ output: "",
+ error: "Connection timeout",
+ exitCode: -1,
+ needsPassword: false
+ });
+ }
+ } else if (connected) {
+ root.pendingConnection = null;
+ immediateCheckTimer.stop();
+ immediateCheckTimer.checkCount = 0;
+ }
+ }
+ }
+ }
+
+ Timer {
+ id: immediateCheckTimer
+
+ property int checkCount: 0
+
+ interval: 500
+ repeat: true
+ triggeredOnStart: false
+
+ onTriggered: {
+ if (root.pendingConnection) {
+ checkCount++;
+ const connected = root.active && root.active.ssid === root.pendingConnection.ssid;
+
+ if (connected) {
+ connectionCheckTimer.stop();
+ immediateCheckTimer.stop();
+ immediateCheckTimer.checkCount = 0;
+ if (root.pendingConnection.callback) {
+ root.pendingConnection.callback({
+ success: true,
+ output: "Connected",
+ error: "",
+ exitCode: 0
+ });
+ }
+ root.pendingConnection = null;
+ } else {
+ for (let i = 0; i < root.activeProcesses.length; i++) {
+ const proc = root.activeProcesses[i];
+ if (proc && proc.stderr && proc.stderr.text) {
+ const error = proc.stderr.text.trim();
+ if (error && error.length > 0) {
+ if (root.isConnectionCommand(proc.command)) {
+ const needsPassword = root.detectPasswordRequired(error);
+
+ if (needsPassword && !proc.callbackCalled && root.pendingConnection && root.pendingConnection.callback) {
+ connectionCheckTimer.stop();
+ immediateCheckTimer.stop();
+ immediateCheckTimer.checkCount = 0;
+ const pending = root.pendingConnection;
+ root.pendingConnection = null;
+ proc.callbackCalled = true;
+ const result = {
+ success: false,
+ output: (proc.stdout && proc.stdout.text) ? proc.stdout.text : "",
+ error: error,
+ exitCode: -1,
+ needsPassword: true
+ };
+ if (pending.callback) {
+ pending.callback(result);
+ }
+ if (proc.callback && proc.callback !== pending.callback) {
+ proc.callback(result);
+ }
+ return;
+ }
+ }
+ }
+ }
+ }
+
+ if (checkCount >= 6) {
+ immediateCheckTimer.stop();
+ immediateCheckTimer.checkCount = 0;
+ }
+ }
+ } else {
+ immediateCheckTimer.stop();
+ immediateCheckTimer.checkCount = 0;
+ }
+ }
+ }
+
+ function checkPendingConnection(): void {
+ if (root.pendingConnection) {
+ Qt.callLater(() => {
+ const connected = root.active && root.active.ssid === root.pendingConnection.ssid;
+ if (connected) {
+ connectionCheckTimer.stop();
+ immediateCheckTimer.stop();
+ immediateCheckTimer.checkCount = 0;
+ if (root.pendingConnection.callback) {
+ root.pendingConnection.callback({
+ success: true,
+ output: "Connected",
+ error: "",
+ exitCode: 0
+ });
+ }
+ root.pendingConnection = null;
+ } else {
+ if (!immediateCheckTimer.running) {
+ immediateCheckTimer.start();
+ }
+ }
+ });
+ }
+ }
+
+ function cidrToSubnetMask(cidr: string): string {
+ const cidrNum = parseInt(cidr, 10);
+ if (isNaN(cidrNum) || cidrNum < 0 || cidrNum > 32) {
+ return "";
+ }
+
+ const mask = (0xffffffff << (32 - cidrNum)) >>> 0;
+ const octet1 = (mask >>> 24) & 0xff;
+ const octet2 = (mask >>> 16) & 0xff;
+ const octet3 = (mask >>> 8) & 0xff;
+ const octet4 = mask & 0xff;
+
+ return `${octet1}.${octet2}.${octet3}.${octet4}`;
+ }
+
+ function getWirelessDeviceDetails(interfaceName: string, callback: var): void {
+ if (!interfaceName || interfaceName.length === 0) {
+ const activeInterface = root.wirelessInterfaces.find(iface => {
+ return isConnectedState(iface.state);
+ });
+ if (activeInterface && activeInterface.device) {
+ interfaceName = activeInterface.device;
+ } else {
+ if (callback)
+ callback(null);
+ return;
+ }
+ }
+
+ executeCommand(["device", "show", interfaceName], result => {
+ if (!result.success || !result.output) {
+ root.wirelessDeviceDetails = null;
+ if (callback)
+ callback(null);
+ return;
+ }
+
+ const details = parseDeviceDetails(result.output, false);
+ root.wirelessDeviceDetails = details;
+ if (callback)
+ callback(details);
+ });
+ }
+
+ function getEthernetDeviceDetails(interfaceName: string, callback: var): void {
+ if (!interfaceName || interfaceName.length === 0) {
+ const activeInterface = root.ethernetInterfaces.find(iface => {
+ return isConnectedState(iface.state);
+ });
+ if (activeInterface && activeInterface.device) {
+ interfaceName = activeInterface.device;
+ } else {
+ if (callback)
+ callback(null);
+ return;
+ }
+ }
+
+ executeCommand(["device", "show", interfaceName], result => {
+ if (!result.success || !result.output) {
+ root.ethernetDeviceDetails = null;
+ if (callback)
+ callback(null);
+ return;
+ }
+
+ const details = parseDeviceDetails(result.output, true);
+ root.ethernetDeviceDetails = details;
+ if (callback)
+ callback(details);
+ });
+ }
+
+ function parseDeviceDetails(output: string, isEthernet: bool): var {
+ const details = {
+ ipAddress: "",
+ gateway: "",
+ dns: [],
+ subnet: "",
+ macAddress: "",
+ speed: ""
+ };
+
+ if (!output || output.length === 0) {
+ return details;
+ }
+
+ const lines = output.trim().split("\n");
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const parts = line.split(":");
+ if (parts.length >= 2) {
+ const key = parts[0].trim();
+ const value = parts.slice(1).join(":").trim();
+
+ if (key.startsWith("IP4.ADDRESS")) {
+ const ipParts = value.split("/");
+ details.ipAddress = ipParts[0] || "";
+ if (ipParts[1]) {
+ details.subnet = cidrToSubnetMask(ipParts[1]);
+ } else {
+ details.subnet = "";
+ }
+ } else if (key === "IP4.GATEWAY") {
+ if (value !== "--") {
+ details.gateway = value;
+ }
+ } else if (key.startsWith("IP4.DNS")) {
+ if (value !== "--" && value.length > 0) {
+ details.dns.push(value);
+ }
+ } else if (isEthernet && key === "WIRED-PROPERTIES.MAC") {
+ details.macAddress = value;
+ } else if (isEthernet && key === "WIRED-PROPERTIES.SPEED") {
+ details.speed = value;
+ } else if (!isEthernet && key === "GENERAL.HWADDR") {
+ details.macAddress = value;
+ }
+ }
+ }
+
+ return details;
+ }
+
+ Process {
+ id: rescanProc
+
+ command: ["nmcli", "dev", root.nmcliCommandWifi, "list", "--rescan", "yes"]
+ onExited: root.getNetworks()
+ }
+
+ Process {
+ id: monitorProc
+
+ running: true
+ command: ["nmcli", "monitor"]
+ environment: ({
+ LANG: "C.UTF-8",
+ LC_ALL: "C.UTF-8"
+ })
+ stdout: SplitParser {
+ onRead: root.refreshOnConnectionChange()
+ }
+ onExited: monitorRestartTimer.start()
+ }
+
+ Timer {
+ id: monitorRestartTimer
+ interval: 2000
+ onTriggered: {
+ monitorProc.running = true;
+ }
+ }
+
+ function refreshOnConnectionChange(): void {
+ getNetworks(networks => {
+ const newActive = root.active;
+
+ if (newActive && newActive.active) {
+ Qt.callLater(() => {
+ if (root.wirelessInterfaces.length > 0) {
+ const activeWireless = root.wirelessInterfaces.find(iface => {
+ return isConnectedState(iface.state);
+ });
+ if (activeWireless && activeWireless.device) {
+ getWirelessDeviceDetails(activeWireless.device, () => {});
+ }
+ }
+
+ if (root.ethernetInterfaces.length > 0) {
+ const activeEthernet = root.ethernetInterfaces.find(iface => {
+ return isConnectedState(iface.state);
+ });
+ if (activeEthernet && activeEthernet.device) {
+ getEthernetDeviceDetails(activeEthernet.device, () => {});
+ }
+ }
+ }, 500);
+ } else {
+ root.wirelessDeviceDetails = null;
+ root.ethernetDeviceDetails = null;
+ }
+
+ getWirelessInterfaces(() => {});
+ getEthernetInterfaces(() => {
+ if (root.activeEthernet && root.activeEthernet.connected) {
+ Qt.callLater(() => {
+ getEthernetDeviceDetails(root.activeEthernet.interface, () => {});
+ }, 500);
+ }
+ });
+ });
+ }
+
+ Component.onCompleted: {
+ getWifiStatus(() => {});
+ getNetworks(() => {});
+ loadSavedConnections(() => {});
+ getEthernetInterfaces(() => {});
+
+ Qt.callLater(() => {
+ if (root.wirelessInterfaces.length > 0) {
+ const activeWireless = root.wirelessInterfaces.find(iface => {
+ return isConnectedState(iface.state);
+ });
+ if (activeWireless && activeWireless.device) {
+ getWirelessDeviceDetails(activeWireless.device, () => {});
+ }
+ }
+
+ if (root.ethernetInterfaces.length > 0) {
+ const activeEthernet = root.ethernetInterfaces.find(iface => {
+ return isConnectedState(iface.state);
+ });
+ if (activeEthernet && activeEthernet.device) {
+ getEthernetDeviceDetails(activeEthernet.device, () => {});
+ }
+ }
+ }, 2000);
+ }
+}
diff --git a/services/VPN.qml b/services/VPN.qml
index 10e5e7e..412bda4 100644
--- a/services/VPN.qml
+++ b/services/VPN.qml
@@ -21,7 +21,7 @@ Singleton {
const name = providerName;
const iface = interfaceName;
const defaults = getBuiltinDefaults(name, iface);
-
+
if (isCustomProvider) {
const custom = providerInput;
return {
@@ -31,7 +31,7 @@ Singleton {
displayName: custom.displayName || defaults.displayName
};
}
-
+
return defaults;
}
@@ -62,7 +62,7 @@ Singleton {
displayName: "Tailscale"
}
};
-
+
return builtins[name] || {
connectCmd: [name, "up"],
disconnectCmd: [name, "down"],
diff --git a/utils/Icons.qml b/utils/Icons.qml
index e946c4f..b3f00c5 100644
--- a/utils/Icons.qml
+++ b/utils/Icons.qml
@@ -3,6 +3,7 @@ pragma Singleton
import qs.config
import Quickshell
import Quickshell.Services.Notifications
+import QtQuick
Singleton {
id: root
@@ -115,16 +116,28 @@ Singleton {
return fallback;
}
- function getNetworkIcon(strength: int): string {
- if (strength >= 80)
- return "signal_wifi_4_bar";
- if (strength >= 60)
- return "network_wifi_3_bar";
- if (strength >= 40)
- return "network_wifi_2_bar";
- if (strength >= 20)
- return "network_wifi_1_bar";
- return "signal_wifi_0_bar";
+ function getNetworkIcon(strength: int, isSecure = false): string {
+ if (isSecure) {
+ if (strength >= 80)
+ return "network_wifi_locked";
+ if (strength >= 60)
+ return "network_wifi_3_bar_locked";
+ if (strength >= 40)
+ return "network_wifi_2_bar_locked";
+ if (strength >= 20)
+ return "network_wifi_1_bar_locked";
+ return "signal_wifi_0_bar";
+ } else {
+ if (strength >= 80)
+ return "network_wifi";
+ if (strength >= 60)
+ return "network_wifi_3_bar";
+ if (strength >= 40)
+ return "network_wifi_2_bar";
+ if (strength >= 20)
+ return "network_wifi_1_bar";
+ return "signal_wifi_0_bar";
+ }
}
function getBluetoothIcon(icon: string): string {
@@ -194,13 +207,13 @@ Singleton {
function getSpecialWsIcon(name: string): string {
name = name.toLowerCase().slice("special:".length);
-
+
for (const iconConfig of Config.bar.workspaces.specialWorkspaceIcons) {
if (iconConfig.name === name) {
return iconConfig.icon;
}
}
-
+
if (name === "special")
return "star";
if (name === "communication")
diff --git a/utils/NetworkConnection.qml b/utils/NetworkConnection.qml
new file mode 100644
index 0000000..c7595b1
--- /dev/null
+++ b/utils/NetworkConnection.qml
@@ -0,0 +1,122 @@
+pragma Singleton
+
+import qs.services
+import QtQuick
+
+/**
+ * NetworkConnection
+ *
+ * Centralized utility for network connection logic. Provides a single source of truth
+ * for connecting to wireless networks, eliminating code duplication across
+ * controlcenter components and bar popouts.
+ *
+ * Usage:
+ * ```qml
+ * import qs.utils
+ *
+ * // With Session object (controlcenter)
+ * NetworkConnection.handleConnect(network, session);
+ *
+ * // Without Session object (bar popouts) - provide password dialog callback
+ * NetworkConnection.handleConnect(network, null, (network) => {
+ * // Show password dialog
+ * root.passwordNetwork = network;
+ * root.showPasswordDialog = true;
+ * });
+ * ```
+ */
+QtObject {
+ id: root
+
+ /**
+ * Handle network connection with automatic disconnection if needed.
+ * If there's an active network different from the target, disconnects first,
+ * then connects to the target network.
+ *
+ * @param network The network object to connect to (must have ssid property)
+ * @param session Optional Session object (for controlcenter - must have network property with showPasswordDialog and pendingNetwork)
+ * @param onPasswordNeeded Optional callback function(network) called when password is needed (for bar popouts)
+ */
+ function handleConnect(network, session, onPasswordNeeded): void {
+ if (!network) {
+ return;
+ }
+
+ if (Nmcli.active && Nmcli.active.ssid !== network.ssid) {
+ Nmcli.disconnectFromNetwork();
+ Qt.callLater(() => {
+ root.connectToNetwork(network, session, onPasswordNeeded);
+ });
+ } else {
+ root.connectToNetwork(network, session, onPasswordNeeded);
+ }
+ }
+
+ /**
+ * Connect to a wireless network.
+ * Handles both secured and open networks, checks for saved profiles,
+ * and shows password dialog if needed.
+ *
+ * @param network The network object to connect to (must have ssid, isSecure, bssid properties)
+ * @param session Optional Session object (for controlcenter - must have network property with showPasswordDialog and pendingNetwork)
+ * @param onPasswordNeeded Optional callback function(network) called when password is needed (for bar popouts)
+ */
+ function connectToNetwork(network, session, onPasswordNeeded): void {
+ if (!network) {
+ return;
+ }
+
+ if (network.isSecure) {
+ const hasSavedProfile = Nmcli.hasSavedProfile(network.ssid);
+
+ if (hasSavedProfile) {
+ Nmcli.connectToNetwork(network.ssid, "", network.bssid, null);
+ } else {
+ // Use password check with callback
+ Nmcli.connectToNetworkWithPasswordCheck(
+ network.ssid,
+ network.isSecure,
+ (result) => {
+ if (result.needsPassword) {
+ // Clear pending connection if exists
+ if (Nmcli.pendingConnection) {
+ Nmcli.connectionCheckTimer.stop();
+ Nmcli.immediateCheckTimer.stop();
+ Nmcli.immediateCheckTimer.checkCount = 0;
+ Nmcli.pendingConnection = null;
+ }
+
+ // Handle password dialog - use session if available, otherwise use callback
+ if (session && session.network) {
+ session.network.showPasswordDialog = true;
+ session.network.pendingNetwork = network;
+ } else if (onPasswordNeeded) {
+ onPasswordNeeded(network);
+ }
+ }
+ },
+ network.bssid
+ );
+ }
+ } else {
+ Nmcli.connectToNetwork(network.ssid, "", network.bssid, null);
+ }
+ }
+
+ /**
+ * Connect to a wireless network with a provided password.
+ * Used by password dialogs when the user has already entered a password.
+ *
+ * @param network The network object to connect to (must have ssid, bssid properties)
+ * @param password The password to use for connection
+ * @param onResult Optional callback function(result) called with connection result
+ */
+ function connectWithPassword(network, password, onResult): void {
+ if (!network) {
+ return;
+ }
+
+ Nmcli.connectToNetwork(network.ssid, password || "", network.bssid || "", onResult || null);
+ }
+}
+