summaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
Diffstat (limited to 'modules')
-rw-r--r--modules/background/Background.qml1
-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.qml219
-rw-r--r--modules/bar/popouts/WirelessPassword.qml605
-rw-r--r--modules/bar/popouts/Wrapper.qml28
-rw-r--r--modules/controlcenter/ControlCenter.qml11
-rw-r--r--modules/controlcenter/NavRail.qml70
-rw-r--r--modules/controlcenter/Panes.qml178
-rw-r--r--modules/controlcenter/Session.qml16
-rw-r--r--modules/controlcenter/WindowFactory.qml8
-rw-r--r--modules/controlcenter/appearance/AppearancePane.qml2455
-rw-r--r--modules/controlcenter/audio/AudioPane.qml594
-rw-r--r--modules/controlcenter/bluetooth/BtPane.qml44
-rw-r--r--modules/controlcenter/bluetooth/Details.qml40
-rw-r--r--modules/controlcenter/bluetooth/DeviceList.qml32
-rw-r--r--modules/controlcenter/bluetooth/Settings.qml14
-rw-r--r--modules/controlcenter/launcher/LauncherPane.qml519
-rw-r--r--modules/controlcenter/network/EthernetDetails.qml108
-rw-r--r--modules/controlcenter/network/EthernetList.qml168
-rw-r--r--modules/controlcenter/network/EthernetPane.qml162
-rw-r--r--modules/controlcenter/network/EthernetSettings.qml84
-rw-r--r--modules/controlcenter/network/NetworkSettings.qml106
-rw-r--r--modules/controlcenter/network/NetworkingPane.qml706
-rw-r--r--modules/controlcenter/network/WirelessDetails.qml245
-rw-r--r--modules/controlcenter/network/WirelessList.qml261
-rw-r--r--modules/controlcenter/network/WirelessPane.qml167
-rw-r--r--modules/controlcenter/network/WirelessPasswordDialog.qml534
-rw-r--r--modules/controlcenter/network/WirelessSettings.qml81
-rw-r--r--modules/controlcenter/taskbar/ConnectedButtonGroup.qml170
-rw-r--r--modules/controlcenter/taskbar/TaskbarPane.qml643
-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
44 files changed, 8349 insertions, 223 deletions
diff --git a/modules/background/Background.qml b/modules/background/Background.qml
index fbacfab..3e6d933 100644
--- a/modules/background/Background.qml
+++ b/modules/background/Background.qml
@@ -9,7 +9,6 @@ import Quickshell.Wayland
import QtQuick
Loader {
- asynchronous: true
active: Config.background.enabled
sourceComponent: Variants {
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..b9f66c4 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,40 @@ 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, "");
+ // Check if network is secure
+ if (networkItem.modelData.isSecure) {
+ // Try to connect first - will show password dialog if password is needed
+ Nmcli.connectToNetwork(networkItem.modelData.ssid, "", networkItem.modelData.bssid, result => {
+ if (result && result.needsPassword) {
+ // Password is required - show password dialog
+ root.passwordNetwork = networkItem.modelData;
+ root.showPasswordDialog = true;
+ root.wrapper.currentName = "wirelesspassword";
+ } else if (result && result.success) {
+ // Connection successful with saved password
+ root.connectingToSsid = "";
+ } else {
+ // Connection failed for other reasons
+ root.connectingToSsid = "";
+ }
+ });
+ } else {
+ // Open network, no password needed
+ Nmcli.connectToNetwork(networkItem.modelData.ssid, "", networkItem.modelData.bssid, null);
+ }
}
}
}
MaterialIcon {
- id: connectIcon
+ id: wirelessConnectIcon
anchors.centerIn: parent
animate: true
@@ -142,7 +175,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 +186,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 +198,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 +225,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..d91c87c
--- /dev/null
+++ b/modules/bar/popouts/WirelessPassword.qml
@@ -0,0 +1,605 @@
+pragma ComponentBehavior: Bound
+
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.config
+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
+ Nmcli.connectToNetwork(root.network.ssid, password, root.network.bssid || "", 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..3642a33 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,7 @@ Item {
session: root.session
}
}
+
+ // Expose initialOpeningComplete for NavRail to prevent tab switching during opening animation
+ readonly property bool initialOpeningComplete: panes.initialOpeningComplete
}
diff --git a/modules/controlcenter/NavRail.qml b/modules/controlcenter/NavRail.qml
index 22c13a3..1de1a9e 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
});
@@ -158,7 +119,7 @@ Item {
NavItem {
Layout.topMargin: Appearance.spacing.large * 2
- icon: "network_manage"
+ icon: "router"
label: "network"
}
@@ -168,9 +129,24 @@ Item {
}
NavItem {
- icon: "tune"
+ icon: "volume_up"
label: "audio"
}
+
+ NavItem {
+ icon: "palette"
+ label: "appearance"
+ }
+
+ NavItem {
+ icon: "task_alt"
+ label: "taskbar"
+ }
+
+ NavItem {
+ icon: "apps"
+ label: "launcher"
+ }
}
component NavItem: Item {
@@ -222,6 +198,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/Panes.qml b/modules/controlcenter/Panes.qml
index 2548c3d..756d73a 100644
--- a/modules/controlcenter/Panes.qml
+++ b/modules/controlcenter/Panes.qml
@@ -1,6 +1,11 @@
pragma ComponentBehavior: Bound
import "bluetooth"
+import "network"
+import "audio"
+import "appearance"
+import "taskbar"
+import "launcher"
import qs.components
import qs.services
import qs.config
@@ -13,24 +18,68 @@ ClippingRectangle {
required property Session session
+ // Expose initialOpeningComplete so parent can check if opening animation is done
+ readonly property bool initialOpeningComplete: layout.initialOpeningComplete
+
color: "transparent"
+ clip: true
+ focus: false
+ activeFocusOnTab: false
+
+ // Clear focus when clicking anywhere in the panes area
+ MouseArea {
+ anchors.fill: parent
+ z: -1
+ onPressed: function(mouse) {
+ root.focus = true;
+ mouse.accepted = false;
+ }
+ }
+
+ // Clear focus when switching panes
+ Connections {
+ target: root.session
+
+ function onActiveIndexChanged(): void {
+ root.focus = true;
+ }
+ }
ColumnLayout {
id: layout
spacing: 0
y: -root.session.activeIndex * root.height
+ clip: true
+
+ property bool animationComplete: true
+ // Track if initial opening animation has completed
+ // During initial opening, only the active pane loads to avoid hiccups
+ property bool initialOpeningComplete: false
+
+ Timer {
+ id: animationDelayTimer
+ interval: Appearance.anim.durations.normal
+ onTriggered: {
+ layout.animationComplete = true;
+ }
+ }
+
+ // Timer to detect when initial opening animation completes
+ // Uses large duration to cover both normal and detached opening cases
+ Timer {
+ id: initialOpeningTimer
+ interval: Appearance.anim.durations.large
+ running: true
+ onTriggered: {
+ layout.initialOpeningComplete = 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
- }
+ sourceComponent: NetworkingPane {
+ session: root.session
}
}
@@ -43,20 +92,44 @@ ClippingRectangle {
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
- }
+ sourceComponent: AudioPane {
+ session: root.session
+ }
+ }
+
+ Pane {
+ index: 3
+ sourceComponent: AppearancePane {
+ session: root.session
+ }
+ }
+
+ Pane {
+ index: 4
+ sourceComponent: TaskbarPane {
+ session: root.session
+ }
+ }
+
+ Pane {
+ index: 5
+ sourceComponent: LauncherPane {
+ session: root.session
}
}
Behavior on y {
Anim {}
}
+
+ Connections {
+ target: root.session
+ function onActiveIndexChanged(): void {
+ // Mark animation as incomplete and start delay timer
+ layout.animationComplete = false;
+ animationDelayTimer.restart();
+ }
+ }
}
component Pane: Item {
@@ -68,19 +141,76 @@ ClippingRectangle {
implicitWidth: root.width
implicitHeight: root.height
+ // Track if this pane has ever been loaded to enable caching
+ property bool hasBeenLoaded: false
+
+ // Function to compute if this pane should be active
+ function updateActive(): void {
+ const diff = Math.abs(root.session.activeIndex - pane.index);
+ const isActivePane = diff === 0;
+ let shouldBeActive = false;
+
+ // During initial opening animation, only load the active pane
+ // This prevents hiccups from multiple panes loading simultaneously
+ if (!layout.initialOpeningComplete) {
+ shouldBeActive = isActivePane;
+ } else {
+ // After initial opening, allow current and adjacent panes for smooth transitions
+ if (diff <= 1) {
+ shouldBeActive = true;
+ } else if (pane.hasBeenLoaded) {
+ // For distant panes that have been loaded before, keep them active to preserve cached data
+ shouldBeActive = true;
+ } else {
+ // For new distant panes, wait until animation completes to avoid heavy loading during transition
+ 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: {
+ // Mark pane as loaded when it becomes active
+ if (active && !pane.hasBeenLoaded) {
+ pane.hasBeenLoaded = true;
+ }
+ }
+
+ onItemChanged: {
+ // Mark pane as loaded when item is created
+ 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..efd360d 100644
--- a/modules/controlcenter/Session.qml
+++ b/modules/controlcenter/Session.qml
@@ -2,15 +2,17 @@ import Quickshell.Bluetooth
import QtQuick
QtObject {
- readonly property list<string> panes: ["network", "bluetooth", "audio"]
+ readonly property list<string> panes: ["network", "bluetooth", "audio", "appearance", "taskbar", "launcher"]
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 Network network: Network {}
+ readonly property Ethernet ethernet: Ethernet {}
onActiveChanged: activeIndex = panes.indexOf(active)
onActiveIndexChanged: active = panes[activeIndex]
@@ -22,4 +24,14 @@ QtObject {
property bool fabMenuOpen
property bool editingDeviceName
}
+
+ component Network: QtObject {
+ property var active
+ property bool showPasswordDialog: false
+ property var pendingNetwork
+ }
+
+ component Ethernet: QtObject {
+ property var active
+ }
}
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..891f64b
--- /dev/null
+++ b/modules/controlcenter/appearance/AppearancePane.qml
@@ -0,0 +1,2455 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+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
+
+RowLayout {
+ id: root
+
+ required property Session session
+
+ // Appearance settings
+ 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
+
+ // Background settings
+ 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
+
+ spacing: 0
+
+
+ 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();
+ }
+
+ Item {
+ id: leftAppearanceItem
+ Layout.preferredWidth: Math.floor(parent.width * 0.4)
+ Layout.minimumWidth: 420
+ Layout.fillHeight: true
+
+ ClippingRectangle {
+ id: leftAppearanceClippingRect
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ anchors.leftMargin: 0
+ anchors.rightMargin: Appearance.padding.normal / 2
+ radius: leftAppearanceBorder.innerRadius
+ color: "transparent"
+
+ Loader {
+ id: leftAppearanceLoader
+ 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: appearanceLeftContentComponent
+ property var rootPane: root
+ }
+ }
+
+ InnerBorder {
+ id: leftAppearanceBorder
+ leftThickness: 0
+ rightThickness: Appearance.padding.normal / 2
+ }
+
+ Component {
+ id: appearanceLeftContentComponent
+
+ StyledFlickable {
+ id: sidebarFlickable
+ readonly property var rootPane: leftAppearanceLoader.rootPane
+ 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 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;
+ }
+ }
+ }
+
+ CollapsibleSection {
+ id: themeModeSection
+ 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");
+ }
+ }
+ }
+
+ CollapsibleSection {
+ id: colorVariantSection
+ 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;
+
+ // Optimistic update - set immediately for responsive UI
+ Schemes.currentVariant = variant;
+ Quickshell.execDetached(["caelestia", "scheme", "set", "-v", variant]);
+
+ // Reload after a delay to confirm changes
+ 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
+ }
+ }
+ }
+ }
+
+ CollapsibleSection {
+ id: colorSchemeSection
+ 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}`;
+
+ // Optimistic update - set immediately for responsive UI
+ Schemes.currentScheme = schemeKey;
+ Quickshell.execDetached(["caelestia", "scheme", "set", "-n", name, "-f", flavour]);
+
+ // Reload after a delay to confirm changes
+ 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
+ }
+ }
+ }
+ }
+
+ CollapsibleSection {
+ id: animationsSection
+ title: qsTr("Animations")
+ showBackground: true
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Animation duration scale")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ StyledRect {
+ Layout.preferredWidth: 70
+ implicitHeight: animDurationsInput.implicitHeight + Appearance.padding.small * 2
+ color: animDurationsInputHover.containsMouse || animDurationsInput.activeFocus
+ ? Colours.layer(Colours.palette.m3surfaceContainer, 3)
+ : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: animDurationsInput.activeFocus
+ ? Colours.palette.m3primary
+ : Qt.alpha(Colours.palette.m3outline, 0.3)
+
+ Behavior on color { CAnim {} }
+ Behavior on border.color { CAnim {} }
+
+ MouseArea {
+ id: animDurationsInputHover
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.IBeamCursor
+ acceptedButtons: Qt.NoButton
+ }
+
+ StyledTextField {
+ id: animDurationsInput
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: TextInput.AlignHCenter
+ validator: DoubleValidator { bottom: 0.1; top: 5.0 }
+
+ Component.onCompleted: {
+ text = (rootPane.animDurationsScale).toFixed(1);
+ }
+
+ onTextChanged: {
+ if (activeFocus) {
+ const val = parseFloat(text);
+ if (!isNaN(val) && val >= 0.1 && val <= 5.0) {
+ rootPane.animDurationsScale = val;
+ rootPane.saveConfig();
+ }
+ }
+ }
+ onEditingFinished: {
+ const val = parseFloat(text);
+ if (isNaN(val) || val < 0.1 || val > 5.0) {
+ text = (rootPane.animDurationsScale).toFixed(1);
+ }
+ }
+ }
+ }
+
+ StyledText {
+ text: "×"
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.normal
+ }
+ }
+
+ StyledSlider {
+ id: animDurationsSlider
+
+ Layout.fillWidth: true
+ implicitHeight: Appearance.padding.normal * 3
+
+ from: 0.1
+ to: 5.0
+ value: rootPane.animDurationsScale
+ onMoved: {
+ rootPane.animDurationsScale = animDurationsSlider.value;
+ if (!animDurationsInput.activeFocus) {
+ animDurationsInput.text = (animDurationsSlider.value).toFixed(1);
+ }
+ rootPane.saveConfig();
+ }
+ }
+ }
+ }
+ }
+
+ CollapsibleSection {
+ id: fontsSection
+ title: qsTr("Fonts")
+ showBackground: true
+
+ CollapsibleSection {
+ id: materialFontSection
+ title: qsTr("Material font family")
+ expanded: true
+ showBackground: 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
+
+ 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
+
+ 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
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Font size scale")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ StyledRect {
+ Layout.preferredWidth: 70
+ implicitHeight: fontSizeInput.implicitHeight + Appearance.padding.small * 2
+ color: fontSizeInputHover.containsMouse || fontSizeInput.activeFocus
+ ? Colours.layer(Colours.palette.m3surfaceContainer, 3)
+ : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: fontSizeInput.activeFocus
+ ? Colours.palette.m3primary
+ : Qt.alpha(Colours.palette.m3outline, 0.3)
+
+ Behavior on color { CAnim {} }
+ Behavior on border.color { CAnim {} }
+
+ MouseArea {
+ id: fontSizeInputHover
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.IBeamCursor
+ acceptedButtons: Qt.NoButton
+ }
+
+ StyledTextField {
+ id: fontSizeInput
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: TextInput.AlignHCenter
+ validator: DoubleValidator { bottom: 0.7; top: 1.5 }
+
+ Component.onCompleted: {
+ text = (rootPane.fontSizeScale).toFixed(1);
+ }
+
+ onTextChanged: {
+ if (activeFocus) {
+ const val = parseFloat(text);
+ if (!isNaN(val) && val >= 0.7 && val <= 1.5) {
+ rootPane.fontSizeScale = val;
+ rootPane.saveConfig();
+ }
+ }
+ }
+ onEditingFinished: {
+ const val = parseFloat(text);
+ if (isNaN(val) || val < 0.7 || val > 1.5) {
+ text = (rootPane.fontSizeScale).toFixed(1);
+ }
+ }
+ }
+ }
+
+ StyledText {
+ text: "×"
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.normal
+ }
+ }
+
+ StyledSlider {
+ id: fontSizeSlider
+
+ Layout.fillWidth: true
+ implicitHeight: Appearance.padding.normal * 3
+
+ from: 0.7
+ to: 1.5
+ value: rootPane.fontSizeScale
+ onMoved: {
+ rootPane.fontSizeScale = fontSizeSlider.value;
+ if (!fontSizeInput.activeFocus) {
+ fontSizeInput.text = (fontSizeSlider.value).toFixed(1);
+ }
+ rootPane.saveConfig();
+ }
+ }
+ }
+ }
+ }
+
+ CollapsibleSection {
+ id: scalesSection
+ title: qsTr("Scales")
+ showBackground: true
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Padding scale")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ StyledRect {
+ Layout.preferredWidth: 70
+ implicitHeight: paddingInput.implicitHeight + Appearance.padding.small * 2
+ color: paddingInputHover.containsMouse || paddingInput.activeFocus
+ ? Colours.layer(Colours.palette.m3surfaceContainer, 3)
+ : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: paddingInput.activeFocus
+ ? Colours.palette.m3primary
+ : Qt.alpha(Colours.palette.m3outline, 0.3)
+
+ Behavior on color { CAnim {} }
+ Behavior on border.color { CAnim {} }
+
+ MouseArea {
+ id: paddingInputHover
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.IBeamCursor
+ acceptedButtons: Qt.NoButton
+ }
+
+ StyledTextField {
+ id: paddingInput
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: TextInput.AlignHCenter
+ validator: DoubleValidator { bottom: 0.5; top: 2.0 }
+
+ Component.onCompleted: {
+ text = (rootPane.paddingScale).toFixed(1);
+ }
+
+ onTextChanged: {
+ if (activeFocus) {
+ const val = parseFloat(text);
+ if (!isNaN(val) && val >= 0.5 && val <= 2.0) {
+ rootPane.paddingScale = val;
+ rootPane.saveConfig();
+ }
+ }
+ }
+ onEditingFinished: {
+ const val = parseFloat(text);
+ if (isNaN(val) || val < 0.5 || val > 2.0) {
+ text = (rootPane.paddingScale).toFixed(1);
+ }
+ }
+ }
+ }
+
+ StyledText {
+ text: "×"
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.normal
+ }
+ }
+
+ StyledSlider {
+ id: paddingSlider
+
+ Layout.fillWidth: true
+ implicitHeight: Appearance.padding.normal * 3
+
+ from: 0.5
+ to: 2.0
+ value: rootPane.paddingScale
+ onMoved: {
+ rootPane.paddingScale = paddingSlider.value;
+ if (!paddingInput.activeFocus) {
+ paddingInput.text = (paddingSlider.value).toFixed(1);
+ }
+ rootPane.saveConfig();
+ }
+ }
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Rounding scale")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ StyledRect {
+ Layout.preferredWidth: 70
+ implicitHeight: roundingInput.implicitHeight + Appearance.padding.small * 2
+ color: roundingInputHover.containsMouse || roundingInput.activeFocus
+ ? Colours.layer(Colours.palette.m3surfaceContainer, 3)
+ : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: roundingInput.activeFocus
+ ? Colours.palette.m3primary
+ : Qt.alpha(Colours.palette.m3outline, 0.3)
+
+ Behavior on color { CAnim {} }
+ Behavior on border.color { CAnim {} }
+
+ MouseArea {
+ id: roundingInputHover
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.IBeamCursor
+ acceptedButtons: Qt.NoButton
+ }
+
+ StyledTextField {
+ id: roundingInput
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: TextInput.AlignHCenter
+ validator: DoubleValidator { bottom: 0.1; top: 5.0 }
+
+ Component.onCompleted: {
+ text = (rootPane.roundingScale).toFixed(1);
+ }
+
+ onTextChanged: {
+ if (activeFocus) {
+ const val = parseFloat(text);
+ if (!isNaN(val) && val >= 0.1 && val <= 5.0) {
+ rootPane.roundingScale = val;
+ rootPane.saveConfig();
+ }
+ }
+ }
+ onEditingFinished: {
+ const val = parseFloat(text);
+ if (isNaN(val) || val < 0.1 || val > 5.0) {
+ text = (rootPane.roundingScale).toFixed(1);
+ }
+ }
+ }
+ }
+
+ StyledText {
+ text: "×"
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.normal
+ }
+ }
+
+ StyledSlider {
+ id: roundingSlider
+
+ Layout.fillWidth: true
+ implicitHeight: Appearance.padding.normal * 3
+
+ from: 0.1
+ to: 5.0
+ value: rootPane.roundingScale
+ onMoved: {
+ rootPane.roundingScale = roundingSlider.value;
+ if (!roundingInput.activeFocus) {
+ roundingInput.text = (roundingSlider.value).toFixed(1);
+ }
+ rootPane.saveConfig();
+ }
+ }
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Spacing scale")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ StyledRect {
+ Layout.preferredWidth: 70
+ implicitHeight: spacingInput.implicitHeight + Appearance.padding.small * 2
+ color: spacingInputHover.containsMouse || spacingInput.activeFocus
+ ? Colours.layer(Colours.palette.m3surfaceContainer, 3)
+ : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: spacingInput.activeFocus
+ ? Colours.palette.m3primary
+ : Qt.alpha(Colours.palette.m3outline, 0.3)
+
+ Behavior on color { CAnim {} }
+ Behavior on border.color { CAnim {} }
+
+ MouseArea {
+ id: spacingInputHover
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.IBeamCursor
+ acceptedButtons: Qt.NoButton
+ }
+
+ StyledTextField {
+ id: spacingInput
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: TextInput.AlignHCenter
+ validator: DoubleValidator { bottom: 0.1; top: 2.0 }
+
+ Component.onCompleted: {
+ text = (rootPane.spacingScale).toFixed(1);
+ }
+
+ onTextChanged: {
+ if (activeFocus) {
+ const val = parseFloat(text);
+ if (!isNaN(val) && val >= 0.1 && val <= 2.0) {
+ rootPane.spacingScale = val;
+ rootPane.saveConfig();
+ }
+ }
+ }
+ onEditingFinished: {
+ const val = parseFloat(text);
+ if (isNaN(val) || val < 0.1 || val > 2.0) {
+ text = (rootPane.spacingScale).toFixed(1);
+ }
+ }
+ }
+ }
+
+ StyledText {
+ text: "×"
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.normal
+ }
+ }
+
+ StyledSlider {
+ id: spacingSlider
+
+ Layout.fillWidth: true
+ implicitHeight: Appearance.padding.normal * 3
+
+ from: 0.1
+ to: 2.0
+ value: rootPane.spacingScale
+ onMoved: {
+ rootPane.spacingScale = spacingSlider.value;
+ if (!spacingInput.activeFocus) {
+ spacingInput.text = (spacingSlider.value).toFixed(1);
+ }
+ rootPane.saveConfig();
+ }
+ }
+ }
+ }
+ }
+
+ CollapsibleSection {
+ id: transparencySection
+ 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
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Transparency base")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ StyledRect {
+ Layout.preferredWidth: 70
+ implicitHeight: transparencyBaseInput.implicitHeight + Appearance.padding.small * 2
+ color: transparencyBaseInputHover.containsMouse || transparencyBaseInput.activeFocus
+ ? Colours.layer(Colours.palette.m3surfaceContainer, 3)
+ : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: transparencyBaseInput.activeFocus
+ ? Colours.palette.m3primary
+ : Qt.alpha(Colours.palette.m3outline, 0.3)
+
+ Behavior on color { CAnim {} }
+ Behavior on border.color { CAnim {} }
+
+ MouseArea {
+ id: transparencyBaseInputHover
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.IBeamCursor
+ acceptedButtons: Qt.NoButton
+ }
+
+ StyledTextField {
+ id: transparencyBaseInput
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: TextInput.AlignHCenter
+ validator: IntValidator { bottom: 0; top: 100 }
+
+ Component.onCompleted: {
+ text = Math.round(rootPane.transparencyBase * 100).toString();
+ }
+
+ onTextChanged: {
+ if (activeFocus) {
+ const val = parseInt(text);
+ if (!isNaN(val) && val >= 0 && val <= 100) {
+ rootPane.transparencyBase = val / 100;
+ rootPane.saveConfig();
+ }
+ }
+ }
+ onEditingFinished: {
+ const val = parseInt(text);
+ if (isNaN(val) || val < 0 || val > 100) {
+ text = Math.round(rootPane.transparencyBase * 100).toString();
+ }
+ }
+ }
+ }
+
+ StyledText {
+ text: "%"
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.normal
+ }
+ }
+
+ StyledSlider {
+ id: baseSlider
+
+ Layout.fillWidth: true
+ implicitHeight: Appearance.padding.normal * 3
+
+ from: 0
+ to: 100
+ value: rootPane.transparencyBase * 100
+ onMoved: {
+ rootPane.transparencyBase = baseSlider.value / 100;
+ if (!transparencyBaseInput.activeFocus) {
+ transparencyBaseInput.text = Math.round(baseSlider.value).toString();
+ }
+ rootPane.saveConfig();
+ }
+ }
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Transparency layers")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ StyledRect {
+ Layout.preferredWidth: 70
+ implicitHeight: transparencyLayersInput.implicitHeight + Appearance.padding.small * 2
+ color: transparencyLayersInputHover.containsMouse || transparencyLayersInput.activeFocus
+ ? Colours.layer(Colours.palette.m3surfaceContainer, 3)
+ : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: transparencyLayersInput.activeFocus
+ ? Colours.palette.m3primary
+ : Qt.alpha(Colours.palette.m3outline, 0.3)
+
+ Behavior on color { CAnim {} }
+ Behavior on border.color { CAnim {} }
+
+ MouseArea {
+ id: transparencyLayersInputHover
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.IBeamCursor
+ acceptedButtons: Qt.NoButton
+ }
+
+ StyledTextField {
+ id: transparencyLayersInput
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: TextInput.AlignHCenter
+ validator: IntValidator { bottom: 0; top: 100 }
+
+ Component.onCompleted: {
+ text = Math.round(rootPane.transparencyLayers * 100).toString();
+ }
+
+ onTextChanged: {
+ if (activeFocus) {
+ const val = parseInt(text);
+ if (!isNaN(val) && val >= 0 && val <= 100) {
+ rootPane.transparencyLayers = val / 100;
+ rootPane.saveConfig();
+ }
+ }
+ }
+ onEditingFinished: {
+ const val = parseInt(text);
+ if (isNaN(val) || val < 0 || val > 100) {
+ text = Math.round(rootPane.transparencyLayers * 100).toString();
+ }
+ }
+ }
+ }
+
+ StyledText {
+ text: "%"
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.normal
+ }
+ }
+
+ StyledSlider {
+ id: layersSlider
+
+ Layout.fillWidth: true
+ implicitHeight: Appearance.padding.normal * 3
+
+ from: 0
+ to: 100
+ value: rootPane.transparencyLayers * 100
+ onMoved: {
+ rootPane.transparencyLayers = layersSlider.value / 100;
+ if (!transparencyLayersInput.activeFocus) {
+ transparencyLayersInput.text = Math.round(layersSlider.value).toString();
+ }
+ rootPane.saveConfig();
+ }
+ }
+ }
+ }
+ }
+
+ CollapsibleSection {
+ id: borderSection
+ title: qsTr("Border")
+ showBackground: true
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Border rounding")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ StyledRect {
+ Layout.preferredWidth: 70
+ implicitHeight: borderRoundingInput.implicitHeight + Appearance.padding.small * 2
+ color: borderRoundingInputHover.containsMouse || borderRoundingInput.activeFocus
+ ? Colours.layer(Colours.palette.m3surfaceContainer, 3)
+ : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: borderRoundingInput.activeFocus
+ ? Colours.palette.m3primary
+ : Qt.alpha(Colours.palette.m3outline, 0.3)
+
+ Behavior on color { CAnim {} }
+ Behavior on border.color { CAnim {} }
+
+ MouseArea {
+ id: borderRoundingInputHover
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.IBeamCursor
+ acceptedButtons: Qt.NoButton
+ }
+
+ StyledTextField {
+ id: borderRoundingInput
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: TextInput.AlignHCenter
+ validator: DoubleValidator { bottom: 0.1; top: 100 }
+
+ Component.onCompleted: {
+ text = (rootPane.borderRounding).toFixed(1);
+ }
+
+ onTextChanged: {
+ if (activeFocus) {
+ const val = parseFloat(text);
+ if (!isNaN(val) && val >= 0.1 && val <= 100) {
+ rootPane.borderRounding = val;
+ rootPane.saveConfig();
+ }
+ }
+ }
+ onEditingFinished: {
+ const val = parseFloat(text);
+ if (isNaN(val) || val < 0.1 || val > 100) {
+ text = (rootPane.borderRounding).toFixed(1);
+ }
+ }
+ }
+ }
+ }
+
+ StyledSlider {
+ id: borderRoundingSlider
+
+ Layout.fillWidth: true
+ implicitHeight: Appearance.padding.normal * 3
+
+ from: 0.1
+ to: 100
+ value: rootPane.borderRounding
+ onMoved: {
+ rootPane.borderRounding = borderRoundingSlider.value;
+ if (!borderRoundingInput.activeFocus) {
+ borderRoundingInput.text = (borderRoundingSlider.value).toFixed(1);
+ }
+ rootPane.saveConfig();
+ }
+ }
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Border thickness")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ StyledRect {
+ Layout.preferredWidth: 70
+ implicitHeight: borderThicknessInput.implicitHeight + Appearance.padding.small * 2
+ color: borderThicknessInputHover.containsMouse || borderThicknessInput.activeFocus
+ ? Colours.layer(Colours.palette.m3surfaceContainer, 3)
+ : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: borderThicknessInput.activeFocus
+ ? Colours.palette.m3primary
+ : Qt.alpha(Colours.palette.m3outline, 0.3)
+
+ Behavior on color { CAnim {} }
+ Behavior on border.color { CAnim {} }
+
+ MouseArea {
+ id: borderThicknessInputHover
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.IBeamCursor
+ acceptedButtons: Qt.NoButton
+ }
+
+ StyledTextField {
+ id: borderThicknessInput
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: TextInput.AlignHCenter
+ validator: DoubleValidator { bottom: 0.1; top: 100 }
+
+ Component.onCompleted: {
+ text = (rootPane.borderThickness).toFixed(1);
+ }
+
+ onTextChanged: {
+ if (activeFocus) {
+ const val = parseFloat(text);
+ if (!isNaN(val) && val >= 0.1 && val <= 100) {
+ rootPane.borderThickness = val;
+ rootPane.saveConfig();
+ }
+ }
+ }
+ onEditingFinished: {
+ const val = parseFloat(text);
+ if (isNaN(val) || val < 0.1 || val > 100) {
+ text = (rootPane.borderThickness).toFixed(1);
+ }
+ }
+ }
+ }
+ }
+
+ StyledSlider {
+ id: borderThicknessSlider
+
+ Layout.fillWidth: true
+ implicitHeight: Appearance.padding.normal * 3
+
+ from: 0.1
+ to: 100
+ value: rootPane.borderThickness
+ onMoved: {
+ rootPane.borderThickness = borderThicknessSlider.value;
+ if (!borderThicknessInput.activeFocus) {
+ borderThicknessInput.text = (borderThicknessSlider.value).toFixed(1);
+ }
+ rootPane.saveConfig();
+ }
+ }
+ }
+ }
+ }
+
+ CollapsibleSection {
+ id: backgroundSection
+ 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
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Visualiser rounding")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ StyledRect {
+ Layout.preferredWidth: 70
+ implicitHeight: visualiserRoundingInput.implicitHeight + Appearance.padding.small * 2
+ color: visualiserRoundingInputHover.containsMouse || visualiserRoundingInput.activeFocus
+ ? Colours.layer(Colours.palette.m3surfaceContainer, 3)
+ : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: visualiserRoundingInput.activeFocus
+ ? Colours.palette.m3primary
+ : Qt.alpha(Colours.palette.m3outline, 0.3)
+
+ Behavior on color { CAnim {} }
+ Behavior on border.color { CAnim {} }
+
+ MouseArea {
+ id: visualiserRoundingInputHover
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.IBeamCursor
+ acceptedButtons: Qt.NoButton
+ }
+
+ StyledTextField {
+ id: visualiserRoundingInput
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: TextInput.AlignHCenter
+ validator: IntValidator { bottom: 0; top: 10 }
+
+ Component.onCompleted: {
+ text = Math.round(rootPane.visualiserRounding).toString();
+ }
+
+ onTextChanged: {
+ if (activeFocus) {
+ const val = parseInt(text);
+ if (!isNaN(val) && val >= 0 && val <= 10) {
+ rootPane.visualiserRounding = val;
+ rootPane.saveConfig();
+ }
+ }
+ }
+ onEditingFinished: {
+ const val = parseInt(text);
+ if (isNaN(val) || val < 0 || val > 10) {
+ text = Math.round(rootPane.visualiserRounding).toString();
+ }
+ }
+ }
+ }
+ }
+
+ StyledSlider {
+ id: visualiserRoundingSlider
+
+ Layout.fillWidth: true
+ implicitHeight: Appearance.padding.normal * 3
+
+ from: 0
+ to: 10
+ stepSize: 1
+ value: rootPane.visualiserRounding
+ onMoved: {
+ rootPane.visualiserRounding = Math.round(visualiserRoundingSlider.value);
+ if (!visualiserRoundingInput.activeFocus) {
+ visualiserRoundingInput.text = Math.round(visualiserRoundingSlider.value).toString();
+ }
+ rootPane.saveConfig();
+ }
+ }
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Visualiser spacing")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ StyledRect {
+ Layout.preferredWidth: 70
+ implicitHeight: visualiserSpacingInput.implicitHeight + Appearance.padding.small * 2
+ color: visualiserSpacingInputHover.containsMouse || visualiserSpacingInput.activeFocus
+ ? Colours.layer(Colours.palette.m3surfaceContainer, 3)
+ : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: visualiserSpacingInput.activeFocus
+ ? Colours.palette.m3primary
+ : Qt.alpha(Colours.palette.m3outline, 0.3)
+
+ Behavior on color { CAnim {} }
+ Behavior on border.color { CAnim {} }
+
+ MouseArea {
+ id: visualiserSpacingInputHover
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.IBeamCursor
+ acceptedButtons: Qt.NoButton
+ }
+
+ StyledTextField {
+ id: visualiserSpacingInput
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: TextInput.AlignHCenter
+ validator: DoubleValidator { bottom: 0; top: 2 }
+
+ Component.onCompleted: {
+ text = (rootPane.visualiserSpacing).toFixed(1);
+ }
+
+ onTextChanged: {
+ if (activeFocus) {
+ const val = parseFloat(text);
+ if (!isNaN(val) && val >= 0 && val <= 2) {
+ rootPane.visualiserSpacing = val;
+ rootPane.saveConfig();
+ }
+ }
+ }
+ onEditingFinished: {
+ const val = parseFloat(text);
+ if (isNaN(val) || val < 0 || val > 2) {
+ text = (rootPane.visualiserSpacing).toFixed(1);
+ }
+ }
+ }
+ }
+ }
+
+ StyledSlider {
+ id: visualiserSpacingSlider
+
+ Layout.fillWidth: true
+ implicitHeight: Appearance.padding.normal * 3
+
+ from: 0
+ to: 2
+ value: rootPane.visualiserSpacing
+ onMoved: {
+ rootPane.visualiserSpacing = visualiserSpacingSlider.value;
+ if (!visualiserSpacingInput.activeFocus) {
+ visualiserSpacingInput.text = (visualiserSpacingSlider.value).toFixed(1);
+ }
+ rootPane.saveConfig();
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Item {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ ClippingRectangle {
+ id: rightAppearanceClippingRect
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ anchors.leftMargin: 0
+ anchors.rightMargin: Appearance.padding.normal / 2
+ radius: rightAppearanceBorder.innerRadius
+ color: "transparent"
+
+ Loader {
+ id: rightAppearanceLoader
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large * 2
+ asynchronous: true
+ sourceComponent: appearanceRightContentComponent
+ property var rootPane: root
+
+ onStatusChanged: {
+ if (status === Loader.Error) {
+ console.error("[AppearancePane] Right appearance loader error!");
+ }
+ }
+ }
+ }
+
+ InnerBorder {
+ id: rightAppearanceBorder
+ leftThickness: Appearance.padding.normal / 2
+ }
+
+ Component {
+ id: appearanceRightContentComponent
+
+ StyledFlickable {
+ id: rightAppearanceFlickable
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: contentLayout.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: rightAppearanceFlickable
+ }
+
+ ColumnLayout {
+ id: contentLayout
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ spacing: Appearance.spacing.normal
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignHCenter | Qt.AlignTop
+ Layout.topMargin: 0
+ text: "palette"
+ font.pointSize: Appearance.font.size.extraLarge * 3
+ font.bold: true
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("Appearance Settings")
+ font.pointSize: Appearance.font.size.large
+ font.bold: true
+ }
+
+ StyledText {
+ Layout.topMargin: Appearance.spacing.large
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("Wallpaper")
+ font.pointSize: Appearance.font.size.extraLarge
+ font.weight: 600
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("Select a wallpaper")
+ font.pointSize: Appearance.font.size.normal
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ Item {
+ Layout.fillWidth: true
+ Layout.topMargin: Appearance.spacing.large
+ Layout.preferredHeight: wallpaperLoader.item ? wallpaperLoader.item.layoutPreferredHeight : 0
+
+ Loader {
+ id: wallpaperLoader
+ anchors.fill: parent
+ asynchronous: true
+ active: {
+ // Lazy load: only activate when:
+ // 1. Right pane is loaded AND
+ // 2. Appearance pane is active (index 3) or adjacent (for smooth transitions)
+ // This prevents loading all wallpapers when control center opens but appearance pane isn't visible
+ const isActive = root.session.activeIndex === 3;
+ const isAdjacent = Math.abs(root.session.activeIndex - 3) === 1;
+ const shouldActivate = rightAppearanceLoader.item !== null && (isActive || isAdjacent);
+ return shouldActivate;
+ }
+
+ onStatusChanged: {
+ if (status === Loader.Error) {
+ console.error("[AppearancePane] Wallpaper loader error!");
+ }
+ }
+
+ // Stop lazy loading when loader becomes inactive
+ onActiveChanged: {
+ if (!active && wallpaperLoader.item) {
+ const container = wallpaperLoader.item;
+ // Access timer through wallpaperGrid
+ if (container && container.wallpaperGrid) {
+ if (container.wallpaperGrid.scrollCheckTimer) {
+ container.wallpaperGrid.scrollCheckTimer.stop();
+ }
+ container.wallpaperGrid._expansionInProgress = false;
+ }
+ }
+ }
+
+ sourceComponent: Item {
+ id: wallpaperGridContainer
+ property alias layoutPreferredHeight: wallpaperGrid.layoutPreferredHeight
+
+ // Find and store reference to parent Flickable for scroll monitoring
+ property var parentFlickable: {
+ let item = parent;
+ while (item) {
+ if (item.flickableDirection !== undefined) {
+ return item;
+ }
+ item = item.parent;
+ }
+ return null;
+ }
+
+ // Cleanup when component is destroyed
+ Component.onDestruction: {
+ if (wallpaperGrid) {
+ if (wallpaperGrid.scrollCheckTimer) {
+ wallpaperGrid.scrollCheckTimer.stop();
+ }
+ wallpaperGrid._expansionInProgress = false;
+ }
+ }
+
+ // Lazy loading model: loads one image at a time, only when touching bottom
+ // This prevents GridView from creating all delegates at once
+ QtObject {
+ id: lazyModel
+
+ property var sourceList: null
+ property int loadedCount: 0 // Total items available to load
+ property int visibleCount: 0 // Items actually exposed to GridView (only visible + buffer)
+ property int totalCount: 0
+
+ function initialize(list) {
+ sourceList = list;
+ totalCount = list ? list.length : 0;
+ // Start with enough items to fill the initial viewport (~3 rows)
+ const initialRows = 3;
+ const cols = wallpaperGrid.columnsCount > 0 ? wallpaperGrid.columnsCount : 3;
+ const initialCount = Math.min(initialRows * cols, totalCount);
+ loadedCount = initialCount;
+ visibleCount = initialCount;
+ }
+
+ function loadOneRow() {
+ if (loadedCount < totalCount) {
+ const cols = wallpaperGrid.columnsCount > 0 ? wallpaperGrid.columnsCount : 1;
+ const itemsToLoad = Math.min(cols, totalCount - loadedCount);
+ loadedCount += itemsToLoad;
+ }
+ }
+
+ function updateVisibleCount(neededCount) {
+ // Always round up to complete rows to avoid incomplete rows in the grid
+ const cols = wallpaperGrid.columnsCount > 0 ? wallpaperGrid.columnsCount : 1;
+ const maxVisible = Math.min(neededCount, loadedCount);
+ const rows = Math.ceil(maxVisible / cols);
+ const newVisibleCount = Math.min(rows * cols, loadedCount);
+
+ if (newVisibleCount > visibleCount) {
+ visibleCount = newVisibleCount;
+ }
+ }
+ }
+
+ GridView {
+ id: wallpaperGrid
+ anchors.fill: parent
+
+ property int _delegateCount: 0
+
+ readonly property int minCellWidth: 200 + Appearance.spacing.normal
+ readonly property int columnsCount: Math.max(1, Math.floor(parent.width / minCellWidth))
+
+ // Height based on visible items only - prevents GridView from creating all delegates
+ readonly property int layoutPreferredHeight: {
+ if (!lazyModel || lazyModel.visibleCount === 0 || columnsCount === 0) {
+ return 0;
+ }
+ const calculated = Math.ceil(lazyModel.visibleCount / columnsCount) * cellHeight;
+ return calculated;
+ }
+
+ height: layoutPreferredHeight
+ cellWidth: width / columnsCount
+ cellHeight: 140 + Appearance.spacing.normal
+
+ leftMargin: 0
+ rightMargin: 0
+ topMargin: 0
+ bottomMargin: 0
+
+ // Use ListModel for incremental updates to prevent flashing when new items are added
+ ListModel {
+ id: wallpaperListModel
+ }
+
+ model: wallpaperListModel
+
+ Connections {
+ target: lazyModel
+ function onVisibleCountChanged(): void {
+ if (!lazyModel || !lazyModel.sourceList) return;
+
+ const newCount = lazyModel.visibleCount;
+ const currentCount = wallpaperListModel.count;
+
+ // Only append new items - never remove or replace existing ones
+ if (newCount > currentCount) {
+ const flickable = wallpaperGridContainer.parentFlickable;
+ const oldScrollY = flickable ? flickable.contentY : 0;
+
+ for (let i = currentCount; i < newCount; i++) {
+ wallpaperListModel.append({modelData: lazyModel.sourceList[i]});
+ }
+
+ // Preserve scroll position after model update
+ if (flickable) {
+ Qt.callLater(function() {
+ if (Math.abs(flickable.contentY - oldScrollY) < 1) {
+ flickable.contentY = oldScrollY;
+ }
+ });
+ }
+ }
+ }
+ }
+
+ Component.onCompleted: {
+ Qt.callLater(function() {
+ const isActive = root.session.activeIndex === 3;
+ if (width > 0 && parent && parent.visible && isActive && Wallpapers.list) {
+ lazyModel.initialize(Wallpapers.list);
+ wallpaperListModel.clear();
+ for (let i = 0; i < lazyModel.visibleCount; i++) {
+ wallpaperListModel.append({modelData: lazyModel.sourceList[i]});
+ }
+ }
+ });
+ }
+
+ Connections {
+ target: root.session
+ function onActiveIndexChanged(): void {
+ const isActive = root.session.activeIndex === 3;
+
+ // Stop lazy loading when switching away from appearance pane
+ if (!isActive) {
+ if (scrollCheckTimer) {
+ scrollCheckTimer.stop();
+ }
+ if (wallpaperGrid) {
+ wallpaperGrid._expansionInProgress = false;
+ }
+ return;
+ }
+
+ // Initialize if needed when switching to appearance pane
+ if (isActive && width > 0 && !lazyModel.sourceList && parent && parent.visible && Wallpapers.list) {
+ lazyModel.initialize(Wallpapers.list);
+ wallpaperListModel.clear();
+ for (let i = 0; i < lazyModel.visibleCount; i++) {
+ wallpaperListModel.append({modelData: lazyModel.sourceList[i]});
+ }
+ }
+ }
+ }
+
+ onWidthChanged: {
+ const isActive = root.session.activeIndex === 3;
+ if (width > 0 && !lazyModel.sourceList && parent && parent.visible && isActive && Wallpapers.list) {
+ lazyModel.initialize(Wallpapers.list);
+ wallpaperListModel.clear();
+ for (let i = 0; i < lazyModel.visibleCount; i++) {
+ wallpaperListModel.append({modelData: lazyModel.sourceList[i]});
+ }
+ }
+ }
+
+ // Force true lazy loading: only create delegates for visible items
+ displayMarginBeginning: 0
+ displayMarginEnd: 0
+ cacheBuffer: 0
+
+ // Debounce expansion to avoid too frequent checks
+ property bool _expansionInProgress: false
+
+ Connections {
+ target: wallpaperGridContainer.parentFlickable
+ function onContentYChanged(): void {
+ // Don't process scroll events if appearance pane is not active
+ const isActive = root.session.activeIndex === 3;
+ if (!isActive) return;
+
+ if (!lazyModel || !lazyModel.sourceList || lazyModel.loadedCount >= lazyModel.totalCount || wallpaperGrid._expansionInProgress) {
+ return;
+ }
+
+ const flickable = wallpaperGridContainer.parentFlickable;
+ if (!flickable) return;
+
+ const gridY = wallpaperGridContainer.y;
+ const scrollY = flickable.contentY;
+ const viewportHeight = flickable.height;
+
+ const topY = scrollY - gridY;
+ const bottomY = scrollY + viewportHeight - gridY;
+
+ if (bottomY < 0) return;
+
+ const topRow = Math.max(0, Math.floor(topY / wallpaperGrid.cellHeight));
+ const bottomRow = Math.floor(bottomY / wallpaperGrid.cellHeight);
+
+ // Update visible count with 1 row buffer ahead
+ const bufferRows = 1;
+ const neededBottomRow = bottomRow + bufferRows;
+ const neededCount = Math.min((neededBottomRow + 1) * wallpaperGrid.columnsCount, lazyModel.loadedCount);
+ lazyModel.updateVisibleCount(neededCount);
+
+ // Load more when we're within 1 row of running out of loaded items
+ const loadedRows = Math.ceil(lazyModel.loadedCount / wallpaperGrid.columnsCount);
+ const rowsRemaining = loadedRows - (bottomRow + 1);
+
+ if (rowsRemaining <= 1 && lazyModel.loadedCount < lazyModel.totalCount) {
+ if (!wallpaperGrid._expansionInProgress) {
+ wallpaperGrid._expansionInProgress = true;
+ lazyModel.loadOneRow();
+ Qt.callLater(function() {
+ wallpaperGrid._expansionInProgress = false;
+ });
+ }
+ }
+ }
+ }
+
+ // Fallback timer to check scroll position periodically
+ Timer {
+ id: scrollCheckTimer
+ interval: 100
+ running: {
+ const isActive = root.session.activeIndex === 3;
+ return isActive && lazyModel && lazyModel.sourceList && lazyModel.loadedCount < lazyModel.totalCount;
+ }
+ repeat: true
+ onTriggered: {
+ // Double-check that appearance pane is still active
+ const isActive = root.session.activeIndex === 3;
+ if (!isActive) {
+ stop();
+ return;
+ }
+
+ const flickable = wallpaperGridContainer.parentFlickable;
+ if (!flickable || !lazyModel || !lazyModel.sourceList) return;
+
+ const gridY = wallpaperGridContainer.y;
+ const scrollY = flickable.contentY;
+ const viewportHeight = flickable.height;
+
+ const topY = scrollY - gridY;
+ const bottomY = scrollY + viewportHeight - gridY;
+ if (bottomY < 0) return;
+
+ const topRow = Math.max(0, Math.floor(topY / wallpaperGrid.cellHeight));
+ const bottomRow = Math.floor(bottomY / wallpaperGrid.cellHeight);
+
+ const bufferRows = 1;
+ const neededBottomRow = bottomRow + bufferRows;
+ const neededCount = Math.min((neededBottomRow + 1) * wallpaperGrid.columnsCount, lazyModel.loadedCount);
+ lazyModel.updateVisibleCount(neededCount);
+
+ // Load more when we're within 1 row of running out of loaded items
+ const loadedRows = Math.ceil(lazyModel.loadedCount / wallpaperGrid.columnsCount);
+ const rowsRemaining = loadedRows - (bottomRow + 1);
+
+ if (rowsRemaining <= 1 && lazyModel.loadedCount < lazyModel.totalCount) {
+ if (!wallpaperGrid._expansionInProgress) {
+ wallpaperGrid._expansionInProgress = true;
+ lazyModel.loadOneRow();
+ Qt.callLater(function() {
+ wallpaperGrid._expansionInProgress = false;
+ });
+ }
+ }
+ }
+ }
+
+
+ // Parent Flickable handles scrolling
+ interactive: false
+
+
+ delegate: Item {
+ required property var modelData
+
+ width: wallpaperGrid.cellWidth
+ height: wallpaperGrid.cellHeight
+
+ readonly property bool isCurrent: modelData.path === Wallpapers.actualCurrent
+ readonly property real itemMargin: Appearance.spacing.normal / 2
+ readonly property real itemRadius: Appearance.rounding.normal
+
+ Component.onCompleted: {
+ wallpaperGrid._delegateCount++;
+ }
+
+ 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
+
+ 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
+
+ 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.m3surfaceContainer.r,
+ Colours.palette.m3surfaceContainer.g,
+ Colours.palette.m3surfaceContainer.b, 0)
+ }
+ GradientStop {
+ position: 0.3
+ color: Qt.rgba(Colours.palette.m3surfaceContainer.r,
+ Colours.palette.m3surfaceContainer.g,
+ Colours.palette.m3surfaceContainer.b, 0.7)
+ }
+ GradientStop {
+ position: 0.6
+ color: Qt.rgba(Colours.palette.m3surfaceContainer.r,
+ Colours.palette.m3surfaceContainer.g,
+ Colours.palette.m3surfaceContainer.b, 0.9)
+ }
+ GradientStop {
+ position: 1.0
+ color: Qt.rgba(Colours.palette.m3surfaceContainer.r,
+ Colours.palette.m3surfaceContainer.g,
+ Colours.palette.m3surfaceContainer.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
+
+ readonly property string fileName: {
+ const path = modelData.relativePath || "";
+ const parts = path.split("/");
+ return parts.length > 0 ? parts[parts.length - 1] : path;
+ }
+
+ text: fileName
+ 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/audio/AudioPane.qml b/modules/controlcenter/audio/AudioPane.qml
new file mode 100644
index 0000000..c2d60d8
--- /dev/null
+++ b/modules/controlcenter/audio/AudioPane.qml
@@ -0,0 +1,594 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+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
+
+RowLayout {
+ id: root
+
+ required property Session session
+
+ anchors.fill: parent
+
+ spacing: 0
+
+ Item {
+ id: leftAudioItem
+ Layout.preferredWidth: Math.floor(parent.width * 0.4)
+ Layout.minimumWidth: 420
+ Layout.fillHeight: true
+
+ ClippingRectangle {
+ id: leftAudioClippingRect
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ anchors.leftMargin: 0
+ anchors.rightMargin: Appearance.padding.normal / 2
+
+ radius: leftAudioBorder.innerRadius
+ color: "transparent"
+
+ Loader {
+ id: leftAudioLoader
+
+ 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: audioLeftContentComponent
+ }
+ }
+
+ InnerBorder {
+ id: leftAudioBorder
+ leftThickness: 0
+ rightThickness: Appearance.padding.normal / 2
+ }
+
+ Component {
+ id: audioLeftContentComponent
+
+ 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
+
+ // Audio header above the collapsible sections
+ 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
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Item {
+ id: rightAudioItem
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ ClippingRectangle {
+ id: rightAudioClippingRect
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ anchors.leftMargin: 0
+ anchors.rightMargin: Appearance.padding.normal / 2
+
+ radius: rightAudioBorder.innerRadius
+ color: "transparent"
+
+ Loader {
+ id: rightAudioLoader
+
+ anchors.fill: parent
+ anchors.topMargin: Appearance.padding.large * 2
+ anchors.bottomMargin: Appearance.padding.large * 2
+ anchors.leftMargin: 0
+ anchors.rightMargin: 0
+
+ asynchronous: true
+ sourceComponent: audioRightContentComponent
+ }
+ }
+
+ InnerBorder {
+ id: rightAudioBorder
+ leftThickness: Appearance.padding.normal / 2
+ }
+
+ Component {
+ id: audioRightContentComponent
+
+ 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.leftMargin: Appearance.padding.large * 2
+ anchors.rightMargin: Appearance.padding.large * 2
+ spacing: Appearance.spacing.normal
+
+ ConnectionHeader {
+ 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
+ }
+
+ StyledRect {
+ Layout.preferredWidth: 70
+ implicitHeight: outputVolumeInput.implicitHeight + Appearance.padding.small * 2
+ color: outputVolumeInputHover.containsMouse || outputVolumeInput.activeFocus
+ ? Colours.layer(Colours.palette.m3surfaceContainer, 3)
+ : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: outputVolumeInput.activeFocus
+ ? Colours.palette.m3primary
+ : Qt.alpha(Colours.palette.m3outline, 0.3)
+ enabled: !Audio.muted
+ opacity: enabled ? 1 : 0.5
+
+ Behavior on color { CAnim {} }
+ Behavior on border.color { CAnim {} }
+
+ MouseArea {
+ id: outputVolumeInputHover
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.IBeamCursor
+ acceptedButtons: Qt.NoButton
+ }
+
+ StyledTextField {
+ id: outputVolumeInput
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: TextInput.AlignHCenter
+ 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.activeFocus) {
+ outputVolumeInput.text = Math.round(Audio.volume * 100).toString();
+ }
+ }
+ }
+
+ onTextChanged: {
+ if (activeFocus) {
+ 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.activeFocus) {
+ 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
+ }
+
+ StyledRect {
+ Layout.preferredWidth: 70
+ implicitHeight: inputVolumeInput.implicitHeight + Appearance.padding.small * 2
+ color: inputVolumeInputHover.containsMouse || inputVolumeInput.activeFocus
+ ? Colours.layer(Colours.palette.m3surfaceContainer, 3)
+ : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: inputVolumeInput.activeFocus
+ ? Colours.palette.m3primary
+ : Qt.alpha(Colours.palette.m3outline, 0.3)
+ enabled: !Audio.sourceMuted
+ opacity: enabled ? 1 : 0.5
+
+ Behavior on color { CAnim {} }
+ Behavior on border.color { CAnim {} }
+
+ MouseArea {
+ id: inputVolumeInputHover
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.IBeamCursor
+ acceptedButtons: Qt.NoButton
+ }
+
+ StyledTextField {
+ id: inputVolumeInput
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: TextInput.AlignHCenter
+ 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.activeFocus) {
+ inputVolumeInput.text = Math.round(Audio.sourceVolume * 100).toString();
+ }
+ }
+ }
+
+ onTextChanged: {
+ if (activeFocus) {
+ 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.activeFocus) {
+ 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..8ad4b1f 100644
--- a/modules/controlcenter/bluetooth/BtPane.qml
+++ b/modules/controlcenter/bluetooth/BtPane.qml
@@ -1,6 +1,7 @@
pragma ComponentBehavior: Bound
import ".."
+import qs.components.controls
import qs.components.effects
import qs.components.containers
import qs.config
@@ -19,30 +20,57 @@ RowLayout {
spacing: 0
Item {
+ id: leftBtItem
Layout.preferredWidth: Math.floor(parent.width * 0.4)
Layout.minimumWidth: 420
Layout.fillHeight: true
- DeviceList {
+ ClippingRectangle {
+ id: leftBtClippingRect
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
+ anchors.margins: Appearance.padding.normal
+ anchors.leftMargin: 0
+ anchors.rightMargin: Appearance.padding.normal / 2
+
+ radius: leftBtBorder.innerRadius
+ color: "transparent"
- session: root.session
+ Loader {
+ id: leftBtLoader
+
+ 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: btDeviceListComponent
+ }
}
InnerBorder {
+ id: leftBtBorder
leftThickness: 0
rightThickness: Appearance.padding.normal / 2
}
+
+ Component {
+ id: btDeviceListComponent
+
+ DeviceList {
+ anchors.fill: parent
+ session: root.session
+ }
+ }
}
Item {
+ id: rightBtItem
Layout.fillWidth: true
Layout.fillHeight: true
ClippingRectangle {
+ id: btClippingRect
anchors.fill: parent
anchors.margins: Appearance.padding.normal
anchors.leftMargin: 0
@@ -104,14 +132,20 @@ RowLayout {
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
}
}
diff --git a/modules/controlcenter/bluetooth/Details.qml b/modules/controlcenter/bluetooth/Details.qml
index 104f673..c9d10cd 100644
--- a/modules/controlcenter/bluetooth/Details.qml
+++ b/modules/controlcenter/bluetooth/Details.qml
@@ -12,29 +12,39 @@ 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: layoutWrapper.height
- flickableDirection: Flickable.VerticalFlick
- contentHeight: layout.height
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: root
+ }
- ColumnLayout {
- id: layout
+ Item {
+ id: layoutWrapper
anchors.left: parent.left
anchors.right: parent.right
- spacing: Appearance.spacing.normal
+ anchors.top: parent.top
+ implicitHeight: layout.height
+
+ ColumnLayout {
+ id: layout
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ spacing: Appearance.spacing.normal
MaterialIcon {
Layout.alignment: Qt.AlignHCenter
animate: true
- text: Icons.getBluetoothIcon(root.device.icon)
+ text: Icons.getBluetoothIcon(root.device?.icon ?? "")
font.pointSize: Appearance.font.size.extraLarge * 3
font.bold: true
}
@@ -415,8 +425,8 @@ Item {
}
}
}
+ }
}
- }
ColumnLayout {
anchors.right: fabRoot.right
@@ -562,11 +572,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..06700e8 100644
--- a/modules/controlcenter/bluetooth/DeviceList.qml
+++ b/modules/controlcenter/bluetooth/DeviceList.qml
@@ -25,7 +25,7 @@ ColumnLayout {
spacing: Appearance.spacing.smaller
StyledText {
- text: qsTr("Settings")
+ text: qsTr("Bluetooth")
font.pointSize: Appearance.font.size.large
font.weight: 500
}
@@ -97,7 +97,7 @@ ColumnLayout {
StyledText {
Layout.fillWidth: true
text: qsTr("Devices (%1)").arg(Bluetooth.devices.values.length)
- font.pointSize: Appearance.font.size.large
+ font.pointSize: Appearance.font.size.normal
font.weight: 500
}
@@ -163,11 +163,11 @@ ColumnLayout {
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
+ anchors.left: view.contentItem.left
+ anchors.right: view.contentItem.right
implicitHeight: deviceInner.implicitHeight + Appearance.padding.normal * 2
color: Qt.alpha(Colours.tPalette.m3surfaceContainer, root.session.bt.active === modelData ? Colours.tPalette.m3surfaceContainer.a : 0)
@@ -177,7 +177,8 @@ ColumnLayout {
id: stateLayer
function onClicked(): void {
- root.session.bt.active = device.modelData;
+ if (device.modelData)
+ root.session.bt.active = device.modelData;
}
}
@@ -194,20 +195,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 +225,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 +257,8 @@ ColumnLayout {
disabled: device.loading
function onClicked(): void {
- device.modelData.connected = !device.modelData.connected;
+ if (device.modelData)
+ device.modelData.connected = !device.modelData.connected;
}
}
@@ -265,7 +267,7 @@ ColumnLayout {
anchors.centerIn: parent
animate: true
- text: device.modelData.connected ? "link_off" : "link"
+ text: (device.modelData && device.modelData.connected) ? "link_off" : "link"
color: device.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
opacity: device.loading ? 0 : 1
diff --git a/modules/controlcenter/bluetooth/Settings.qml b/modules/controlcenter/bluetooth/Settings.qml
index fb493ff..c8453b6 100644
--- a/modules/controlcenter/bluetooth/Settings.qml
+++ b/modules/controlcenter/bluetooth/Settings.qml
@@ -26,7 +26,7 @@ ColumnLayout {
StyledText {
Layout.alignment: Qt.AlignHCenter
- text: qsTr("Bluetooth settings")
+ text: qsTr("Bluetooth Settings")
font.pointSize: Appearance.font.size.large
font.bold: true
}
@@ -284,8 +284,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;
+ }
+ }
}
}
@@ -345,7 +349,7 @@ 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;
@@ -392,7 +396,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/launcher/LauncherPane.qml b/modules/controlcenter/launcher/LauncherPane.qml
new file mode 100644
index 0000000..300117a
--- /dev/null
+++ b/modules/controlcenter/launcher/LauncherPane.qml
@@ -0,0 +1,519 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+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
+
+RowLayout {
+ id: root
+
+ required property Session session
+
+ property var selectedApp: null
+ property bool hideFromLauncherChecked: false
+
+ anchors.fill: parent
+
+ spacing: 0
+
+ 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;
+
+ // Create a new array to ensure change detection
+ const hiddenApps = Config.launcher.hiddenApps ? [...Config.launcher.hiddenApps] : [];
+
+ if (isHidden) {
+ // Add to hiddenApps if not already there
+ if (!hiddenApps.includes(appId)) {
+ hiddenApps.push(appId);
+ }
+ } else {
+ // Remove from hiddenApps
+ const index = hiddenApps.indexOf(appId);
+ if (index !== -1) {
+ hiddenApps.splice(index, 1);
+ }
+ }
+
+ // Update Config
+ Config.launcher.hiddenApps = hiddenApps;
+
+ // Persist changes to disk
+ Config.save();
+ }
+
+ onSelectedAppChanged: {
+ updateToggleState();
+ }
+
+ AppDb {
+ id: allAppsDb
+
+ path: `${Paths.state}/apps.sqlite`
+ entries: DesktopEntries.applications.values // No filter - show all apps
+ }
+
+ property string searchText: ""
+
+ function filterApps(search: string): list<var> {
+ // If search is empty, return all apps directly
+ if (!search || search.trim() === "") {
+ // Convert QQmlListProperty to array
+ 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 [];
+ }
+
+ // Prepare apps for fuzzy search
+ 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)
+ });
+ }
+
+ // Perform fuzzy search
+ const results = Fuzzy.go(search, preparedApps, {
+ all: true,
+ keys: ["name"],
+ scoreFn: r => r[0].score
+ });
+
+ // Return sorted by score (highest first)
+ 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();
+ }
+ }
+
+ Item {
+ id: leftLauncherItem
+ Layout.preferredWidth: Math.floor(parent.width * 0.4)
+ Layout.minimumWidth: 420
+ Layout.fillHeight: true
+
+ ClippingRectangle {
+ id: leftLauncherClippingRect
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ anchors.leftMargin: 0
+ anchors.rightMargin: Appearance.padding.normal / 2
+
+ radius: leftLauncherBorder.innerRadius
+ color: "transparent"
+
+ Loader {
+ id: leftLauncherLoader
+
+ 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: leftContentComponent
+ }
+ }
+
+ InnerBorder {
+ id: leftLauncherBorder
+ leftThickness: 0
+ rightThickness: Appearance.padding.normal / 2
+ }
+
+ Component {
+ id: leftContentComponent
+
+ 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
+ }
+ }
+
+ 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: {
+ // Lazy load: activate when left pane is loaded
+ // The ListView will load asynchronously, and search will work because filteredApps
+ // is updated regardless of whether the ListView is loaded
+ return leftLauncherLoader.item !== null;
+ }
+
+ 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.selectedApp = 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
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Item {
+ id: rightLauncherItem
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ ClippingRectangle {
+ id: rightLauncherClippingRect
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ anchors.leftMargin: 0
+ anchors.rightMargin: Appearance.padding.normal / 2
+
+ radius: rightLauncherBorder.innerRadius
+ color: "transparent"
+
+ Loader {
+ id: rightLauncherLoader
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large * 2
+
+ asynchronous: true
+ sourceComponent: rightContentComponent
+ }
+ }
+
+ InnerBorder {
+ id: rightLauncherBorder
+
+ leftThickness: Appearance.padding.normal / 2
+ }
+
+ Component {
+ id: rightContentComponent
+
+ ColumnLayout {
+ anchors.fill: parent
+
+ spacing: Appearance.spacing.normal
+
+ Item {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.leftMargin: Appearance.padding.large * 2
+ Layout.rightMargin: Appearance.padding.large * 2
+ Layout.topMargin: Appearance.padding.large * 2
+ implicitWidth: iconLoader.implicitWidth
+ implicitHeight: iconLoader.implicitHeight
+
+ Loader {
+ id: iconLoader
+ sourceComponent: root.selectedApp ? appIconComponent : defaultIconComponent
+ }
+
+ Component {
+ id: appIconComponent
+ IconImage {
+ implicitSize: Appearance.font.size.extraLarge * 3 * 2
+ source: {
+ if (!root.selectedApp) return "image-missing";
+ const entry = root.selectedApp.entry;
+ if (entry && entry.icon) {
+ return Quickshell.iconPath(entry.icon, "image-missing");
+ }
+ return "image-missing";
+ }
+ }
+ }
+
+ Component {
+ id: defaultIconComponent
+ MaterialIcon {
+ text: "apps"
+ font.pointSize: Appearance.font.size.extraLarge * 3
+ font.bold: true
+ }
+ }
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.leftMargin: Appearance.padding.large * 2
+ Layout.rightMargin: Appearance.padding.large * 2
+ text: root.selectedApp ? (root.selectedApp.name || root.selectedApp.entry?.name || qsTr("Application Details")) : qsTr("Launcher Applications")
+ 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: root.selectedApp !== null
+ label: qsTr("Hide from launcher")
+ checked: root.hideFromLauncherChecked
+ enabled: root.selectedApp !== null
+ onToggled: checked => {
+ root.hideFromLauncherChecked = checked;
+ root.saveHiddenApps(checked);
+ }
+ }
+
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/modules/controlcenter/network/EthernetDetails.qml b/modules/controlcenter/network/EthernetDetails.qml
new file mode 100644
index 0000000..7c2534a
--- /dev/null
+++ b/modules/controlcenter/network/EthernetDetails.qml
@@ -0,0 +1,108 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+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
+
+Item {
+ id: root
+
+ required property Session session
+ readonly property var device: session.ethernet.active
+
+ implicitWidth: layout.implicitWidth
+ implicitHeight: layout.implicitHeight
+
+ Component.onCompleted: {
+ if (device && device.interface) {
+ Nmcli.getEthernetDeviceDetails(device.interface, () => {});
+ }
+ }
+
+ onDeviceChanged: {
+ if (device && device.interface) {
+ Nmcli.getEthernetDeviceDetails(device.interface, () => {});
+ } else {
+ Nmcli.ethernetDeviceDetails = null;
+ }
+ }
+
+ ColumnLayout {
+ id: layout
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ spacing: Appearance.spacing.normal
+
+ ConnectionHeader {
+ icon: "cable"
+ title: root.device?.interface ?? qsTr("Unknown")
+ }
+
+ SectionHeader {
+ title: qsTr("Connection status")
+ description: qsTr("Connection settings for this device")
+ }
+
+ SectionContainer {
+ ToggleRow {
+ label: qsTr("Connected")
+ checked: root.device?.connected ?? false
+ toggle.onToggled: {
+ if (checked) {
+ Nmcli.connectEthernet(root.device?.connection || "", root.device?.interface || "", () => {});
+ } else {
+ if (root.device?.connection) {
+ Nmcli.disconnectEthernet(root.device.connection, () => {});
+ }
+ }
+ }
+ }
+ }
+
+ SectionHeader {
+ title: qsTr("Device properties")
+ description: qsTr("Additional information")
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.small / 2
+
+ PropertyRow {
+ label: qsTr("Interface")
+ value: root.device?.interface ?? qsTr("Unknown")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("Connection")
+ value: root.device?.connection || qsTr("Not connected")
+ }
+
+ PropertyRow {
+ showTopMargin: true
+ label: qsTr("State")
+ value: root.device?.state ?? qsTr("Unknown")
+ }
+ }
+
+ SectionHeader {
+ title: qsTr("Connection information")
+ description: qsTr("Network connection details")
+ }
+
+ SectionContainer {
+ ConnectionInfoSection {
+ deviceDetails: Nmcli.ethernetDeviceDetails
+ }
+ }
+ }
+
+} \ No newline at end of file
diff --git a/modules/controlcenter/network/EthernetList.qml b/modules/controlcenter/network/EthernetList.qml
new file mode 100644
index 0000000..45c9481
--- /dev/null
+++ b/modules/controlcenter/network/EthernetList.qml
@@ -0,0 +1,168 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property Session session
+
+ spacing: Appearance.spacing.small
+
+ 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"
+
+ onClicked: {
+ if (root.session.ethernet.active)
+ root.session.ethernet.active = null;
+ else {
+ root.session.ethernet.active = view.model.get(0)?.modelData ?? null;
+ }
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ text: qsTr("Devices (%1)").arg(Nmcli.ethernetDevices.length)
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+ }
+
+ StyledText {
+ text: qsTr("All available ethernet devices")
+ color: Colours.palette.m3outline
+ }
+
+ StyledListView {
+ id: view
+
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ model: Nmcli.ethernetDevices
+
+ spacing: Appearance.spacing.small / 2
+ clip: true
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: view
+ }
+
+ delegate: StyledRect {
+ required property var modelData
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, root.session.ethernet.active === modelData ? Colours.tPalette.m3surfaceContainer.a : 0)
+ radius: Appearance.rounding.normal
+ border.width: root.session.ethernet.active === modelData ? 1 : 0
+ border.color: Colours.palette.m3primary
+
+ StateLayer {
+ function onClicked(): void {
+ root.session.ethernet.active = modelData;
+ }
+ }
+
+ 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.connected ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh
+
+ 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
+ }
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ elide: Text.ElideRight
+ maximumLineCount: 1
+
+ text: modelData.interface || qsTr("Unknown")
+ }
+
+ StyledText {
+ 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
+ }
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: connectIcon.implicitHeight + Appearance.padding.smaller * 2
+
+ radius: Appearance.rounding.full
+ color: Qt.alpha(Colours.palette.m3primaryContainer, modelData.connected ? 1 : 0)
+
+ StateLayer {
+ 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
+ text: modelData.connected ? "link_off" : "link"
+ color: modelData.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
+ }
+ }
+ }
+
+ implicitHeight: rowLayout.implicitHeight + Appearance.padding.normal * 2
+ }
+ }
+} \ No newline at end of file
diff --git a/modules/controlcenter/network/EthernetPane.qml b/modules/controlcenter/network/EthernetPane.qml
new file mode 100644
index 0000000..6a50cde
--- /dev/null
+++ b/modules/controlcenter/network/EthernetPane.qml
@@ -0,0 +1,162 @@
+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
+
+RowLayout {
+ 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
+
+ EthernetList {
+ 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
+ }
+ }
+
+ 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"
+ clip: true
+
+ Loader {
+ id: loader
+
+ property var pane: root.session.ethernet.active
+ property string paneId: pane ? (pane.interface || "") : ""
+ property Component targetComponent: settings
+ property Component nextComponent: settings
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large * 2
+
+ opacity: 1
+ scale: 1
+ transformOrigin: Item.Center
+
+ clip: true
+ asynchronous: true
+ sourceComponent: loader.targetComponent
+
+ Component.onCompleted: {
+ targetComponent = pane ? details : settings;
+ nextComponent = targetComponent;
+ }
+
+ Behavior on paneId {
+ SequentialAnimation {
+ ParallelAnimation {
+ Anim {
+ target: loader
+ property: "opacity"
+ to: 0
+ easing.bezierCurve: Appearance.anim.curves.standardAccel
+ }
+ Anim {
+ target: loader
+ property: "scale"
+ to: 0.8
+ easing.bezierCurve: Appearance.anim.curves.standardAccel
+ }
+ }
+ PropertyAction {
+ target: loader
+ property: "targetComponent"
+ value: loader.nextComponent
+ }
+ ParallelAnimation {
+ Anim {
+ target: loader
+ property: "opacity"
+ to: 1
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ Anim {
+ target: loader
+ property: "scale"
+ to: 1
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+ }
+ }
+
+ onPaneChanged: {
+ nextComponent = pane ? details : settings;
+ paneId = pane ? (pane.interface || "") : "";
+ }
+ }
+ }
+
+ InnerBorder {
+ id: rightBorder
+
+ leftThickness: Appearance.padding.normal / 2
+ }
+
+ Component {
+ id: settings
+
+ StyledFlickable {
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: settingsInner.height
+ clip: true
+
+ EthernetSettings {
+ id: settingsInner
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ session: root.session
+ }
+ }
+ }
+
+ Component {
+ id: details
+
+ EthernetDetails {
+ session: root.session
+ }
+ }
+ }
+
+ component Anim: NumberAnimation {
+ target: loader
+ duration: Appearance.anim.durations.normal / 2
+ easing.type: Easing.BezierSpline
+ }
+} \ No newline at end of file
diff --git a/modules/controlcenter/network/EthernetSettings.qml b/modules/controlcenter/network/EthernetSettings.qml
new file mode 100644
index 0000000..161492c
--- /dev/null
+++ b/modules/controlcenter/network/EthernetSettings.qml
@@ -0,0 +1,84 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+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
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignHCenter
+ text: "cable"
+ font.pointSize: Appearance.font.size.extraLarge * 3
+ font.bold: true
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("Ethernet settings")
+ font.pointSize: Appearance.font.size.large
+ font.bold: true
+ }
+
+ 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..75a7660
--- /dev/null
+++ b/modules/controlcenter/network/NetworkSettings.qml
@@ -0,0 +1,106 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+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
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignHCenter
+ text: "router"
+ font.pointSize: Appearance.font.size.extraLarge * 3
+ font.bold: true
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("Network Settings")
+ font.pointSize: Appearance.font.size.large
+ font.bold: true
+ }
+
+ 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..d0ea852
--- /dev/null
+++ b/modules/controlcenter/network/NetworkingPane.qml
@@ -0,0 +1,706 @@
+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 Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Session session
+
+ anchors.fill: parent
+
+ RowLayout {
+ id: contentLayout
+
+ anchors.fill: parent
+ spacing: 0
+
+ Item {
+ id: leftNetworkItem
+ Layout.preferredWidth: Math.floor(parent.width * 0.4)
+ Layout.minimumWidth: 420
+ Layout.fillHeight: true
+
+ ClippingRectangle {
+ id: leftNetworkClippingRect
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ anchors.leftMargin: 0
+ anchors.rightMargin: Appearance.padding.normal / 2
+
+ radius: leftNetworkBorder.innerRadius
+ color: "transparent"
+
+ Loader {
+ id: leftNetworkLoader
+
+ 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: networkListComponent
+ }
+ }
+
+ InnerBorder {
+ id: leftNetworkBorder
+ leftThickness: 0
+ rightThickness: Appearance.padding.normal / 2
+ }
+
+ Component {
+ id: networkListComponent
+
+ 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
+
+ // Network header above the collapsible sections
+ 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"
+
+ onClicked: {
+ Nmcli.toggleWifi(null);
+ }
+ }
+
+ ToggleButton {
+ toggled: Nmcli.scanning
+ icon: "wifi_find"
+ accent: "Secondary"
+
+ onClicked: {
+ Nmcli.rescanWifi();
+ }
+ }
+
+ ToggleButton {
+ toggled: !root.session.ethernet.active && !root.session.network.active
+ icon: "settings"
+ accent: "Primary"
+
+ onClicked: {
+ if (root.session.ethernet.active || root.session.network.active) {
+ root.session.ethernet.active = null;
+ root.session.network.active = null;
+ } else {
+ // Toggle to show settings - prefer ethernet if available, otherwise wireless
+ 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
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ text: qsTr("Devices (%1)").arg(Nmcli.ethernetDevices.length)
+ font.pointSize: Appearance.font.size.normal
+ font.weight: 500
+ }
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("All available ethernet devices")
+ color: Colours.palette.m3outline
+ }
+
+ Repeater {
+ id: ethernetRepeater
+
+ Layout.fillWidth: true
+ model: Nmcli.ethernetDevices
+
+ delegate: StyledRect {
+ required property var modelData
+
+ Layout.fillWidth: true
+
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, root.session.ethernet.active === modelData ? Colours.tPalette.m3surfaceContainer.a : 0)
+ radius: Appearance.rounding.normal
+
+ StateLayer {
+ function onClicked(): void {
+ root.session.network.active = null;
+ root.session.ethernet.active = modelData;
+ }
+ }
+
+ 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.connected ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh
+
+ 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
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+
+ spacing: 0
+
+ StyledText {
+ Layout.fillWidth: true
+ elide: Text.ElideRight
+ maximumLineCount: 1
+
+ text: modelData.interface || qsTr("Unknown")
+ }
+
+ 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 {
+ implicitWidth: implicitHeight
+ implicitHeight: connectIcon.implicitHeight + Appearance.padding.smaller * 2
+
+ radius: Appearance.rounding.full
+ color: Qt.alpha(Colours.palette.m3primaryContainer, modelData.connected ? 1 : 0)
+
+ StateLayer {
+ 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
+ text: modelData.connected ? "link_off" : "link"
+ color: modelData.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
+ }
+ }
+ }
+
+ implicitHeight: rowLayout.implicitHeight + Appearance.padding.normal * 2
+ }
+ }
+ }
+ }
+
+ CollapsibleSection {
+ id: wirelessListSection
+
+ Layout.fillWidth: true
+ title: qsTr("Wireless")
+ expanded: true
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ text: qsTr("Networks (%1)").arg(Nmcli.networks.length)
+ font.pointSize: Appearance.font.size.normal
+ font.weight: 500
+ }
+
+ StyledText {
+ visible: Nmcli.scanning
+ text: qsTr("Scanning...")
+ color: Colours.palette.m3primary
+ font.pointSize: Appearance.font.size.small
+ }
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ text: qsTr("All available WiFi networks")
+ color: Colours.palette.m3outline
+ }
+
+ Repeater {
+ id: wirelessRepeater
+
+ Layout.fillWidth: true
+ model: ScriptModel {
+ values: [...Nmcli.networks].sort((a, b) => {
+ // Put active/connected network first
+ if (a.active !== b.active)
+ return b.active - a.active;
+ // Then sort by signal strength
+ return b.strength - a.strength;
+ })
+ }
+
+ delegate: StyledRect {
+ required property var modelData
+
+ Layout.fillWidth: true
+
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, (modelData && root.session.network.active === modelData) ? Colours.tPalette.m3surfaceContainer.a : 0)
+ radius: Appearance.rounding.normal
+
+ StateLayer {
+ function onClicked(): void {
+ if (!modelData) {
+ return;
+ }
+ root.session.ethernet.active = null;
+ root.session.network.active = modelData;
+ // Check if we need to refresh saved connections when selecting a network
+ if (modelData.ssid) {
+ checkSavedProfileForNetwork(modelData.ssid);
+ }
+ }
+ }
+
+ RowLayout {
+ id: wirelessRowLayout
+
+ 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: wirelessIcon.implicitHeight + Appearance.padding.normal * 2
+
+ radius: Appearance.rounding.normal
+ color: (modelData && modelData.active) ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh
+
+ MaterialIcon {
+ id: wirelessIcon
+
+ anchors.centerIn: parent
+ text: Icons.getNetworkIcon(modelData && modelData.strength !== undefined ? modelData.strength : 0)
+ font.pointSize: Appearance.font.size.large
+ fill: (modelData && modelData.active) ? 1 : 0
+ color: (modelData && modelData.active) ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
+ }
+
+ StyledRect {
+ id: lockBadge
+
+ visible: modelData && modelData.isSecure
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.margins: -Appearance.padding.smaller / 2
+
+ implicitWidth: lockIconSize + Appearance.padding.smaller
+ implicitHeight: lockIconSize + Appearance.padding.smaller
+ radius: Appearance.rounding.full
+ color: Colours.palette.m3secondaryContainer
+
+ readonly property real lockIconSize: lockIcon.implicitWidth
+
+ Elevation {
+ anchors.fill: parent
+ radius: parent.radius
+ z: -1
+ level: 2
+ }
+
+ MaterialIcon {
+ id: lockIcon
+
+ anchors.centerIn: parent
+ text: "lock"
+ font.pointSize: Appearance.font.size.small
+ fill: 1
+ color: Colours.palette.m3onSurface
+ }
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+
+ spacing: 0
+
+ StyledText {
+ Layout.fillWidth: true
+ elide: Text.ElideRight
+ maximumLineCount: 1
+
+ text: (modelData && modelData.ssid) ? modelData.ssid : qsTr("Unknown")
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ Layout.fillWidth: true
+ text: {
+ if (!modelData) return qsTr("Open");
+ 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 && modelData.active) ? Colours.palette.m3primary : Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ font.weight: (modelData && modelData.active) ? 500 : 400
+ elide: Text.ElideRight
+ }
+ }
+ }
+
+ StyledRect {
+ implicitWidth: implicitHeight
+ implicitHeight: wirelessConnectIcon.implicitHeight + Appearance.padding.smaller * 2
+
+ radius: Appearance.rounding.full
+ color: Qt.alpha(Colours.palette.m3primaryContainer, (modelData && modelData.active) ? 1 : 0)
+
+ StateLayer {
+ function onClicked(): void {
+ if (modelData && modelData.active) {
+ Nmcli.disconnectFromNetwork();
+ } else if (modelData) {
+ handleWirelessConnect(modelData);
+ }
+ }
+ }
+
+ MaterialIcon {
+ id: wirelessConnectIcon
+
+ anchors.centerIn: parent
+ text: (modelData && modelData.active) ? "link_off" : "link"
+ color: (modelData && modelData.active) ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
+ }
+ }
+ }
+
+ implicitHeight: wirelessRowLayout.implicitHeight + Appearance.padding.normal * 2
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Item {
+ id: rightNetworkItem
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ ClippingRectangle {
+ id: networkClippingRect
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ anchors.leftMargin: 0
+ anchors.rightMargin: Appearance.padding.normal / 2
+
+ radius: rightBorder.innerRadius
+ color: "transparent"
+
+ // Right pane - networking details/settings
+ Loader {
+ id: loader
+
+ property var ethernetPane: root.session.ethernet.active
+ property var wirelessPane: root.session.network.active
+ property var pane: ethernetPane || wirelessPane
+ property string paneId: ethernetPane ? (ethernetPane.interface || "") : (wirelessPane ? (wirelessPane.ssid || wirelessPane.bssid || "") : "")
+ property Component targetComponent: settings
+ property Component nextComponent: settings
+
+ function getComponentForPane() {
+ return pane ? (ethernetPane ? ethernetDetails : wirelessDetails) : settings;
+ }
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large * 2
+
+ opacity: 1
+ scale: 1
+ transformOrigin: Item.Center
+ clip: false
+
+ asynchronous: true
+ sourceComponent: loader.targetComponent
+
+ Component.onCompleted: {
+ targetComponent = getComponentForPane();
+ nextComponent = targetComponent;
+ }
+
+ Behavior on paneId {
+ SequentialAnimation {
+ ParallelAnimation {
+ Anim {
+ target: loader
+ property: "opacity"
+ to: 0
+ easing.bezierCurve: Appearance.anim.curves.standardAccel
+ }
+ Anim {
+ target: loader
+ property: "scale"
+ to: 0.8
+ easing.bezierCurve: Appearance.anim.curves.standardAccel
+ }
+ }
+ PropertyAction {
+ target: loader
+ property: "targetComponent"
+ value: loader.nextComponent
+ }
+ ParallelAnimation {
+ Anim {
+ target: loader
+ property: "opacity"
+ to: 1
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ Anim {
+ target: loader
+ property: "scale"
+ to: 1
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+ }
+ }
+
+ onPaneChanged: {
+ nextComponent = getComponentForPane();
+ paneId = ethernetPane ? (ethernetPane.interface || "") : (wirelessPane ? (wirelessPane.ssid || wirelessPane.bssid || "") : "");
+ }
+ }
+ }
+
+ InnerBorder {
+ id: rightBorder
+
+ leftThickness: Appearance.padding.normal / 2
+ }
+
+ Component {
+ id: settings
+
+ 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: ethernetDetails
+
+ 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: wirelessDetails
+
+ 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
+ }
+
+ component Anim: NumberAnimation {
+ target: loader
+ duration: Appearance.anim.durations.normal / 2
+ easing.type: Easing.BezierSpline
+ }
+
+ function checkSavedProfileForNetwork(ssid: string): void {
+ if (ssid && ssid.length > 0) {
+ Nmcli.loadSavedConnections(() => {});
+ }
+ }
+
+ function handleWirelessConnect(network): void {
+ if (Nmcli.active && Nmcli.active.ssid !== network.ssid) {
+ Nmcli.disconnectFromNetwork();
+ Qt.callLater(() => {
+ connectToWirelessNetwork(network);
+ });
+ } else {
+ connectToWirelessNetwork(network);
+ }
+ }
+
+ function connectToWirelessNetwork(network): void {
+ if (network.isSecure) {
+ const hasSavedProfile = Nmcli.hasSavedProfile(network.ssid);
+
+ if (hasSavedProfile) {
+ Nmcli.connectToNetwork(network.ssid, "", network.bssid, null);
+ } else {
+ Nmcli.connectToNetworkWithPasswordCheck(
+ network.ssid,
+ network.isSecure,
+ (result) => {
+ if (result.needsPassword) {
+ if (Nmcli.pendingConnection) {
+ Nmcli.connectionCheckTimer.stop();
+ Nmcli.immediateCheckTimer.stop();
+ Nmcli.immediateCheckTimer.checkCount = 0;
+ Nmcli.pendingConnection = null;
+ }
+ root.session.network.showPasswordDialog = true;
+ root.session.network.pendingNetwork = network;
+ }
+ },
+ network.bssid
+ );
+ }
+ } else {
+ Nmcli.connectToNetwork(network.ssid, "", network.bssid, null);
+ }
+ }
+}
+
diff --git a/modules/controlcenter/network/WirelessDetails.qml b/modules/controlcenter/network/WirelessDetails.qml
new file mode 100644
index 0000000..09abff3
--- /dev/null
+++ b/modules/controlcenter/network/WirelessDetails.qml
@@ -0,0 +1,245 @@
+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 QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Session session
+ readonly property var network: session.network.active
+
+ implicitWidth: layout.implicitWidth
+ implicitHeight: layout.implicitHeight
+
+ Component.onCompleted: {
+ updateDeviceDetails();
+ checkSavedProfile();
+ }
+
+ onNetworkChanged: {
+ // Restart timer when network changes
+ 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() {
+ // When details are updated, check if we should stop the timer
+ if (network && network.ssid) {
+ const isActive = network.active || (Nmcli.active && Nmcli.active.ssid === network.ssid);
+ if (isActive && Nmcli.wirelessDeviceDetails && Nmcli.wirelessDeviceDetails !== null) {
+ // We have details for the active network, stop the timer
+ connectionUpdateTimer.stop();
+ }
+ }
+ }
+ }
+
+ Timer {
+ id: connectionUpdateTimer
+ interval: 500
+ repeat: true
+ running: network && network.ssid
+ onTriggered: {
+ // Periodically check if network becomes active and update details
+ if (network) {
+ const isActive = network.active || (Nmcli.active && Nmcli.active.ssid === network.ssid);
+ if (isActive) {
+ // Network is active - check if we have details
+ if (!Nmcli.wirelessDeviceDetails || Nmcli.wirelessDeviceDetails === null) {
+ // Network is active but we don't have details yet, fetch them
+ Nmcli.getWirelessDeviceDetails("", () => {
+ // After fetching, check if we got details - if not, timer will try again
+ });
+ } else {
+ // We have details, can stop the timer
+ connectionUpdateTimer.stop();
+ }
+ } else {
+ // Network is not active, clear details
+ 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;
+ }
+ }
+
+ ColumnLayout {
+ id: layout
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ spacing: Appearance.spacing.normal
+
+ ConnectionHeader {
+ icon: root.network?.isSecure ? "lock" : "wifi"
+ title: root.network?.ssid ?? qsTr("Unknown")
+ }
+
+ 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) {
+ root.handleConnect();
+ } 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);
+ }
+ }
+ }
+ }
+
+ 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")
+ }
+ }
+
+ SectionHeader {
+ title: qsTr("Connection information")
+ description: qsTr("Network connection details")
+ }
+
+ SectionContainer {
+ ConnectionInfoSection {
+ deviceDetails: Nmcli.wirelessDeviceDetails
+ }
+ }
+ }
+
+ function handleConnect(): void {
+ if (Nmcli.active && Nmcli.active.ssid !== root.network.ssid) {
+ Nmcli.disconnectFromNetwork();
+ Qt.callLater(() => {
+ connectToNetwork();
+ });
+ } else {
+ connectToNetwork();
+ }
+ }
+
+ function connectToNetwork(): void {
+ if (root.network.isSecure) {
+ const hasSavedProfile = Nmcli.hasSavedProfile(root.network.ssid);
+
+ if (hasSavedProfile) {
+ Nmcli.connectToNetwork(root.network.ssid, "", root.network.bssid, null);
+ } else {
+ Nmcli.connectToNetworkWithPasswordCheck(root.network.ssid, root.network.isSecure, result => {
+ if (result.needsPassword) {
+ if (Nmcli.pendingConnection) {
+ Nmcli.connectionCheckTimer.stop();
+ Nmcli.immediateCheckTimer.stop();
+ Nmcli.immediateCheckTimer.checkCount = 0;
+ Nmcli.pendingConnection = null;
+ }
+ root.session.network.showPasswordDialog = true;
+ root.session.network.pendingNetwork = root.network;
+ }
+ }, root.network.bssid);
+ }
+ } else {
+ Nmcli.connectToNetwork(root.network.ssid, "", root.network.bssid, null);
+ }
+ }
+}
diff --git a/modules/controlcenter/network/WirelessList.qml b/modules/controlcenter/network/WirelessList.qml
new file mode 100644
index 0000000..00af47a
--- /dev/null
+++ b/modules/controlcenter/network/WirelessList.qml
@@ -0,0 +1,261 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "."
+import qs.components
+import qs.components.controls
+import qs.components.containers
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ required property Session session
+
+ spacing: Appearance.spacing.small
+
+ 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"
+
+ onClicked: {
+ Nmcli.toggleWifi(null);
+ }
+ }
+
+ ToggleButton {
+ toggled: Nmcli.scanning
+ icon: "wifi_find"
+ accent: "Secondary"
+
+ onClicked: {
+ Nmcli.rescanWifi();
+ }
+ }
+
+ ToggleButton {
+ toggled: !root.session.network.active
+ icon: "settings"
+ accent: "Primary"
+
+ onClicked: {
+ if (root.session.network.active)
+ root.session.network.active = null;
+ else {
+ root.session.network.active = view.model.get(0)?.modelData ?? null;
+ }
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ StyledText {
+ text: qsTr("Networks (%1)").arg(Nmcli.networks.length)
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+
+ StyledText {
+ visible: Nmcli.scanning
+ text: qsTr("Scanning...")
+ color: Colours.palette.m3primary
+ font.pointSize: Appearance.font.size.small
+ }
+ }
+
+ StyledText {
+ text: qsTr("All available WiFi networks")
+ color: Colours.palette.m3outline
+ }
+
+ StyledListView {
+ id: view
+
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ model: ScriptModel {
+ values: [...Nmcli.networks].sort((a, b) => {
+ // Put active/connected network first
+ if (a.active !== b.active)
+ return b.active - a.active;
+ // Then sort by signal strength
+ return b.strength - a.strength;
+ })
+ }
+
+ spacing: Appearance.spacing.small / 2
+ clip: true
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: view
+ }
+
+ delegate: StyledRect {
+ required property var modelData
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+
+ color: Qt.alpha(Colours.tPalette.m3surfaceContainer, root.session.network.active === modelData ? Colours.tPalette.m3surfaceContainer.a : 0)
+ radius: Appearance.rounding.normal
+ border.width: root.session.network.active === modelData ? 1 : 0
+ border.color: Colours.palette.m3primary
+
+ StateLayer {
+ function onClicked(): void {
+ root.session.network.active = modelData;
+ // Check if we need to refresh saved connections when selecting a network
+ 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: modelData.isSecure ? "lock" : "wifi"
+ font.pointSize: Appearance.font.size.large
+ fill: modelData.active ? 1 : 0
+ color: modelData.active ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
+ }
+ }
+
+ StyledText {
+ Layout.fillWidth: true
+ elide: Text.ElideRight
+ maximumLineCount: 1
+
+ text: modelData.ssid || qsTr("Unknown")
+ }
+
+ StyledText {
+ text: modelData.active ? qsTr("Connected") : (modelData.isSecure ? qsTr("Secured") : qsTr("Open"))
+ color: modelData.active ? Colours.palette.m3primary : Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ font.weight: modelData.active ? 500 : 400
+ }
+
+ StyledText {
+ text: qsTr("%1%").arg(modelData.strength)
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ 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 {
+ handleConnect(modelData);
+ }
+ }
+ }
+
+ 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
+ }
+ }
+
+ function checkSavedProfileForNetwork(ssid: string): void {
+ if (ssid && ssid.length > 0) {
+ Nmcli.loadSavedConnections(() => {});
+ }
+ }
+
+ function handleConnect(network): void {
+ if (Nmcli.active && Nmcli.active.ssid !== network.ssid) {
+ Nmcli.disconnectFromNetwork();
+ Qt.callLater(() => {
+ connectToNetwork(network);
+ });
+ } else {
+ connectToNetwork(network);
+ }
+ }
+
+ function connectToNetwork(network): void {
+ if (network.isSecure) {
+ const hasSavedProfile = Nmcli.hasSavedProfile(network.ssid);
+
+ if (hasSavedProfile) {
+ Nmcli.connectToNetwork(network.ssid, "", network.bssid, null);
+ } else {
+ Nmcli.connectToNetworkWithPasswordCheck(
+ network.ssid,
+ network.isSecure,
+ (result) => {
+ if (result.needsPassword) {
+ if (Nmcli.pendingConnection) {
+ Nmcli.connectionCheckTimer.stop();
+ Nmcli.immediateCheckTimer.stop();
+ Nmcli.immediateCheckTimer.checkCount = 0;
+ Nmcli.pendingConnection = null;
+ }
+ root.session.network.showPasswordDialog = true;
+ root.session.network.pendingNetwork = network;
+ }
+ },
+ network.bssid
+ );
+ }
+ } else {
+ Nmcli.connectToNetwork(network.ssid, "", network.bssid, null);
+ }
+ }
+} \ No newline at end of file
diff --git a/modules/controlcenter/network/WirelessPane.qml b/modules/controlcenter/network/WirelessPane.qml
new file mode 100644
index 0000000..22364a1
--- /dev/null
+++ b/modules/controlcenter/network/WirelessPane.qml
@@ -0,0 +1,167 @@
+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
+
+RowLayout {
+ 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
+
+ WirelessList {
+ 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
+ }
+ }
+
+ 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"
+
+ Loader {
+ id: loader
+
+ property var pane: root.session.network.active
+ property string paneId: pane ? (pane.ssid || pane.bssid || "") : ""
+ property Component targetComponent: settings
+ property Component nextComponent: settings
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large * 2
+
+ opacity: 1
+ scale: 1
+ transformOrigin: Item.Center
+
+ clip: false
+ asynchronous: true
+ sourceComponent: loader.targetComponent
+
+ Component.onCompleted: {
+ targetComponent = pane ? details : settings;
+ nextComponent = targetComponent;
+ }
+
+ Behavior on paneId {
+ SequentialAnimation {
+ ParallelAnimation {
+ Anim {
+ target: loader
+ property: "opacity"
+ to: 0
+ easing.bezierCurve: Appearance.anim.curves.standardAccel
+ }
+ Anim {
+ target: loader
+ property: "scale"
+ to: 0.8
+ easing.bezierCurve: Appearance.anim.curves.standardAccel
+ }
+ }
+ PropertyAction {
+ target: loader
+ property: "targetComponent"
+ value: loader.nextComponent
+ }
+ ParallelAnimation {
+ Anim {
+ target: loader
+ property: "opacity"
+ to: 1
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ Anim {
+ target: loader
+ property: "scale"
+ to: 1
+ easing.bezierCurve: Appearance.anim.curves.standardDecel
+ }
+ }
+ }
+ }
+
+ onPaneChanged: {
+ nextComponent = pane ? details : settings;
+ paneId = pane ? (pane.ssid || pane.bssid || "") : "";
+ }
+ }
+ }
+
+ InnerBorder {
+ id: rightBorder
+
+ leftThickness: Appearance.padding.normal / 2
+ }
+
+ Component {
+ id: settings
+
+ StyledFlickable {
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: settingsInner.height
+ clip: true
+
+ WirelessSettings {
+ id: settingsInner
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ session: root.session
+ }
+ }
+ }
+
+ Component {
+ id: details
+
+ WirelessDetails {
+ session: root.session
+ }
+ }
+ }
+
+ WirelessPasswordDialog {
+ anchors.fill: parent
+ session: root.session
+ z: 1000
+ }
+
+ component Anim: NumberAnimation {
+ target: loader
+ duration: Appearance.anim.durations.normal / 2
+ easing.type: Easing.BezierSpline
+ }
+} \ No newline at end of file
diff --git a/modules/controlcenter/network/WirelessPasswordDialog.qml b/modules/controlcenter/network/WirelessPasswordDialog.qml
new file mode 100644
index 0000000..4b350be
--- /dev/null
+++ b/modules/controlcenter/network/WirelessPasswordDialog.qml
@@ -0,0 +1,534 @@
+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 Quickshell
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Session session
+
+ readonly property var network: {
+ // Prefer pendingNetwork, then active 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 => {
+ // 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;
+ }
+ }
+
+ property string passwordBuffer: ""
+
+ Connections {
+ target: root.session.network
+ function onShowPasswordDialogChanged(): void {
+ if (root.session.network.showPasswordDialog) {
+ // Use callLater to ensure focus happens after dialog is fully rendered
+ Qt.callLater(() => {
+ passwordContainer.forceActiveFocus();
+ passwordContainer.passwordBuffer = "";
+ connectButton.hasError = false;
+ });
+ }
+ }
+ }
+
+ Connections {
+ target: root
+ function onVisibleChanged(): void {
+ if (root.visible) {
+ // Use callLater to ensure focus happens after dialog is fully rendered
+ 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;
+ }
+
+ // Clear any previous error
+ hasError = false;
+
+ // Set connecting state
+ connecting = true;
+ enabled = false;
+ text = qsTr("Connecting...");
+
+ // Connect to network
+ Nmcli.connectToNetwork(root.network.ssid, password, root.network.bssid || "", 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.visible || !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++;
+ checkConnectionStatus();
+ }
+
+ onRunningChanged: {
+ if (!running) {
+ repeatCount = 0;
+ }
+ }
+ }
+
+ Timer {
+ id: connectionSuccessTimer
+ interval: 500
+ onTriggered: {
+ // Double-check connection is still active
+ 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 = "";
+ // 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();
+ }
+}
diff --git a/modules/controlcenter/network/WirelessSettings.qml b/modules/controlcenter/network/WirelessSettings.qml
new file mode 100644
index 0000000..0eb1578
--- /dev/null
+++ b/modules/controlcenter/network/WirelessSettings.qml
@@ -0,0 +1,81 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+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
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignHCenter
+ text: "wifi"
+ font.pointSize: Appearance.font.size.extraLarge * 3
+ font.bold: true
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("Network settings")
+ font.pointSize: Appearance.font.size.large
+ font.bold: true
+ }
+
+ 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/taskbar/ConnectedButtonGroup.qml b/modules/controlcenter/taskbar/ConnectedButtonGroup.qml
new file mode 100644
index 0000000..e35ccfc
--- /dev/null
+++ b/modules/controlcenter/taskbar/ConnectedButtonGroup.qml
@@ -0,0 +1,170 @@
+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 isChecked: false
+
+ // Initialize from root property
+ Component.onCompleted: {
+ if (root.rootItem && modelData.propertyName) {
+ isChecked = root.rootItem[modelData.propertyName];
+ }
+ }
+
+ checked: isChecked
+ toggle: false
+ type: TextButton.Tonal
+
+ // Listen for property changes on rootItem
+ Connections {
+ target: root.rootItem
+ enabled: root.rootItem !== null && modelData.propertyName !== undefined
+
+ function onShowAudioChanged() {
+ if (modelData.propertyName === "showAudio") {
+ button.isChecked = root.rootItem.showAudio;
+ }
+ }
+
+ function onShowMicrophoneChanged() {
+ if (modelData.propertyName === "showMicrophone") {
+ button.isChecked = root.rootItem.showMicrophone;
+ }
+ }
+
+ function onShowKbLayoutChanged() {
+ if (modelData.propertyName === "showKbLayout") {
+ button.isChecked = root.rootItem.showKbLayout;
+ }
+ }
+
+ function onShowNetworkChanged() {
+ if (modelData.propertyName === "showNetwork") {
+ button.isChecked = root.rootItem.showNetwork;
+ }
+ }
+
+ function onShowBluetoothChanged() {
+ if (modelData.propertyName === "showBluetooth") {
+ button.isChecked = root.rootItem.showBluetooth;
+ }
+ }
+
+ function onShowBatteryChanged() {
+ if (modelData.propertyName === "showBattery") {
+ button.isChecked = root.rootItem.showBattery;
+ }
+ }
+
+ function onShowLockStatusChanged() {
+ if (modelData.propertyName === "showLockStatus") {
+ button.isChecked = root.rootItem.showLockStatus;
+ }
+ }
+
+ function onTrayBackgroundChanged() {
+ if (modelData.propertyName === "trayBackground") {
+ button.isChecked = root.rootItem.trayBackground;
+ }
+ }
+
+ function onTrayCompactChanged() {
+ if (modelData.propertyName === "trayCompact") {
+ button.isChecked = root.rootItem.trayCompact;
+ }
+ }
+
+ function onTrayRecolourChanged() {
+ if (modelData.propertyName === "trayRecolour") {
+ button.isChecked = root.rootItem.trayRecolour;
+ }
+ }
+ }
+
+ // 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) {
+ modelData.onToggled(!checked);
+ }
+ }
+
+ 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..18d5304
--- /dev/null
+++ b/modules/controlcenter/taskbar/TaskbarPane.qml
@@ -0,0 +1,643 @@
+pragma ComponentBehavior: Bound
+
+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
+
+ // Clock
+ property bool clockShowIcon: Config.bar.clock.showIcon ?? true
+
+ // Bar Behavior
+ property bool persistent: Config.bar.persistent ?? true
+ property bool showOnHover: Config.bar.showOnHover ?? true
+ property int dragThreshold: Config.bar.dragThreshold ?? 20
+
+ // Status Icons
+ 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
+
+ // Tray Settings
+ property bool trayBackground: Config.bar.tray.background ?? false
+ property bool trayCompact: Config.bar.tray.compact ?? false
+ property bool trayRecolour: Config.bar.tray.recolour ?? false
+
+ // Workspaces
+ 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
+
+ anchors.fill: parent
+
+ Component.onCompleted: {
+ // Update entries
+ 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) {
+ // Update clock setting
+ Config.bar.clock.showIcon = root.clockShowIcon;
+
+ // Update bar behavior
+ Config.bar.persistent = root.persistent;
+ Config.bar.showOnHover = root.showOnHover;
+ Config.bar.dragThreshold = root.dragThreshold;
+
+ // Update status icons
+ 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;
+
+ // Update tray settings
+ Config.bar.tray.background = root.trayBackground;
+ Config.bar.tray.compact = root.trayCompact;
+ Config.bar.tray.recolour = root.trayRecolour;
+
+ // Update workspaces
+ 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;
+
+ // Update entries from the model (same approach as clock - use provided value if available)
+ const entries = [];
+ for (let i = 0; i < entriesModel.count; i++) {
+ const entry = entriesModel.get(i);
+ // If this is the entry being updated, use the provided value (same as clock toggle reads from switch)
+ // Otherwise use the value from the model
+ let enabled = entry.enabled;
+ if (entryIndex !== undefined && i === entryIndex) {
+ enabled = entryEnabled;
+ }
+ entries.push({
+ id: entry.id,
+ enabled: enabled
+ });
+ }
+ Config.bar.entries = entries;
+
+ // Persist changes to disk
+ Config.save();
+ }
+
+ ListModel {
+ id: entriesModel
+ }
+
+ ClippingRectangle {
+ id: taskbarClippingRect
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ anchors.leftMargin: 0
+ anchors.rightMargin: Appearance.padding.normal / 2
+
+ 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 + Appearance.padding.normal / 2
+
+ asynchronous: true
+ sourceComponent: taskbarContentComponent
+ }
+ }
+
+ InnerBorder {
+ id: taskbarBorder
+ leftThickness: 0
+ rightThickness: Appearance.padding.normal / 2
+ }
+
+ 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 {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignTop
+ spacing: Appearance.spacing.small
+
+ 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();
+ }
+ }
+ }
+ }
+ }
+ }
+
+ ColumnLayout {
+ 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("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();
+ }
+ }
+ ]
+ }
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignTop
+ spacing: Appearance.spacing.small
+
+ 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
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ text: qsTr("Drag threshold")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ StyledRect {
+ Layout.preferredWidth: 70
+ implicitHeight: dragThresholdInput.implicitHeight + Appearance.padding.small * 2
+ color: dragThresholdInputHover.containsMouse || dragThresholdInput.activeFocus
+ ? Colours.layer(Colours.palette.m3surfaceContainer, 3)
+ : Colours.layer(Colours.palette.m3surfaceContainer, 2)
+ radius: Appearance.rounding.small
+ border.width: 1
+ border.color: dragThresholdInput.activeFocus
+ ? Colours.palette.m3primary
+ : Qt.alpha(Colours.palette.m3outline, 0.3)
+
+ Behavior on color { CAnim {} }
+ Behavior on border.color { CAnim {} }
+
+ MouseArea {
+ id: dragThresholdInputHover
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.IBeamCursor
+ acceptedButtons: Qt.NoButton
+ }
+
+ StyledTextField {
+ id: dragThresholdInput
+ anchors.centerIn: parent
+ width: parent.width - Appearance.padding.normal
+ horizontalAlignment: TextInput.AlignHCenter
+ validator: IntValidator { bottom: 0; top: 100 }
+
+ Component.onCompleted: {
+ text = root.dragThreshold.toString();
+ }
+
+ onTextChanged: {
+ if (activeFocus) {
+ const val = parseInt(text);
+ if (!isNaN(val) && val >= 0 && val <= 100) {
+ root.dragThreshold = val;
+ root.saveConfig();
+ }
+ }
+ }
+ onEditingFinished: {
+ const val = parseInt(text);
+ if (isNaN(val) || val < 0 || val > 100) {
+ text = root.dragThreshold.toString();
+ }
+ }
+ }
+ }
+
+ StyledText {
+ text: "px"
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.normal
+ }
+ }
+
+ StyledSlider {
+ id: dragThresholdSlider
+
+ Layout.fillWidth: true
+ implicitHeight: Appearance.padding.normal * 3
+
+ from: 0
+ to: 100
+ value: root.dragThreshold
+ onMoved: {
+ root.dragThreshold = Math.round(dragThresholdSlider.value);
+ if (!dragThresholdInput.activeFocus) {
+ dragThresholdInput.text = Math.round(dragThresholdSlider.value).toString();
+ }
+ 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()
}
+
}
}