diff --git a/Components/ButtonBase.qml b/Components/ButtonBase.qml index 50a56a9..2e17634 100644 --- a/Components/ButtonBase.qml +++ b/Components/ButtonBase.qml @@ -15,7 +15,7 @@ CustomRect { property color activeOnColor property bool checked property real checkedRadius: Tokens.rounding.medium - property real defaultRadius: Tokens.rounding.large + property real defaultRadius: Math.min(width, height) / 2 property color disabledColor: Qt.alpha(Colors.palette.m3onSurface, 0.1) property color disabledOnColor: Qt.alpha(Colors.palette.m3onSurface, 0.38) property bool fillWidth diff --git a/Components/CircularIndicator.qml b/Components/CircularIndicator.qml index 94112e9..0bffb7a 100644 --- a/Components/CircularIndicator.qml +++ b/Components/CircularIndicator.qml @@ -34,6 +34,7 @@ BusyIndicator { anchors.fill: parent bgColor: root.bgColor fgColor: root.fgColor + hasEndIndicator: false padding: root.padding rotation: manager.rotation startAngle: manager.startFraction * 360 @@ -53,6 +54,7 @@ BusyIndicator { Anim { duration: manager.completeEndDuration * Tokens.anim.durations.scale properties: "opacity,internalStrokeWidth" + type: Anim.DefaultEffects } } diff --git a/Components/CustomTextField.qml b/Components/CustomTextField.qml index 19618af..d9b19c2 100644 --- a/Components/CustomTextField.qml +++ b/Components/CustomTextField.qml @@ -23,6 +23,7 @@ TextFieldBase { property string leadingIcon readonly property int leadingOffset: leadingIcon ? leadingIconLoader.width + leadingIconLoader.anchors.leftMargin : 0 property int radius: Tokens.rounding.small + property alias sLayer: stateLayer readonly property real smallFontScale: smallFontSize / font.pointSize property int smallFontSize: Tokens.font.size.small property string supportingText @@ -145,6 +146,7 @@ TextFieldBase { sourceComponent: MaterialIcon { color: Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.larger text: root.leadingIcon } } diff --git a/Drawers/Interactions.qml b/Drawers/Interactions.qml index 85dd0da..6846a81 100644 --- a/Drawers/Interactions.qml +++ b/Drawers/Interactions.qml @@ -236,7 +236,7 @@ Item { } } - if (Config.dock.enable && Config.dock.hoverToReveal && !root.visibilities.dock && !root.visibilities.launcher && root.inBottomPanel(root.panels.dock, x, y)) + if (Config.dock.enabled && Config.dock.hoverToReveal && !root.visibilities.dock && !root.visibilities.launcher && root.inBottomPanel(root.panels.dock, x, y)) root.visibilities.dock = true; if (root.geometry.barContains(x, y)) diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index b87e720..fd7e6a1 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -11,6 +11,7 @@ import ZShell.Blobs import qs.Daemons import qs.Components import qs.Modules.Bar +import qs.Modules.Bar.Popouts.Network as N import ZShell.Config import qs.Helpers import qs.Drawers @@ -144,7 +145,17 @@ CustomWindow { HyprlandFocusGrab { id: focusGrab - active: visibilities.dock || visibilities.resources || visibilities.launcher || visibilities.sidebar || visibilities.dashboard || visibilities.settings || visibilities.clipboard || (panels.popouts.hasCurrent && panels.popouts.currentName.startsWith("traymenu") && (!Config.bar.tray.showOnHover || (panels.popouts.current as StackView)?.depth > 1)) + active: { + const v = visibilities; + const conf = Config; + if (!N.PopupManager.closed && panels.popouts.hasCurrent) + return true; + if ((v.launcher && conf.launcher.enabled) || (v.sidebar && conf.sidebar.enabled) || (v.resources && conf.dashboard.performance.enabled) || (v.dashboard && conf.dashboard.enabled) || v.settings || (v.clipboard && conf.clipboard.enabled) || (v.dock && conf.dock.enabled)) + return true; + if (panels.popouts.hasCurrent && panels.popouts.currentName.startsWith("traymenu") && (!conf.bar.tray.showOnHover || (panels.popouts.current as StackView)?.depth > 1)) + return true; + return false; + } windows: [root] onCleared: { diff --git a/Helpers/Icons.qml b/Helpers/Icons.qml index 03be431..0e1246d 100644 --- a/Helpers/Icons.qml +++ b/Helpers/Icons.qml @@ -77,16 +77,6 @@ Singleton { "99": "thunderstorm" }) - function getAppCategoryIcon(name: string, fallback: string): string { - const categories = DesktopEntries.heuristicLookup(name)?.categories; - - if (categories) - for (const [key, value] of Object.entries(categoryIcons)) - if (categories.includes(key)) - return value; - return fallback; - } - function getAppIcon(name: string, fallback: string): string { const icon = DesktopEntries.heuristicLookup(name)?.icon; if (fallback !== "undefined") diff --git a/Helpers/Network.qml b/Helpers/Network.qml new file mode 100644 index 0000000..a8e56bb --- /dev/null +++ b/Helpers/Network.qml @@ -0,0 +1,114 @@ +pragma Singleton + +import Quickshell +import Quickshell.Networking +import QtQuick + +Singleton { + id: root + + property bool active: false + readonly property list connectedNetworks: networks.filter(n => n.connected) + property Network connectingNetwork + readonly property list devices: Networking.devices.values + property Network failedNetwork + readonly property list knownNetworks: networks.filter(n => n.known && !n.connected) + readonly property list networks: { + const list = []; + for (const d of wifiDevices) { + for (const n of d.networks.values) { + if (!list.includes(n)) + list.push(n); + } + } + return list; + } + property bool scanning: false + readonly property list unknownNetworks: networks.filter(n => !n.known) + readonly property list wifiDevices: devices.filter(d => wifiDevice(d)) + readonly property bool wifiEnabled: Networking.wifiEnabled + + signal networkConnected + + function isSecure(security): bool { + return (security === WifiSecurityType.WpaPsk || security === WifiSecurityType.Wpa2Psk || security === WifiSecurityType.Sae); + } + + function requestConnect(network: Network, secret = undefined): void { + connectingNetwork = network; + if (secret === undefined || secret === "") { + network.connect(); + } else { + (network as WifiNetwork).connectWithPsk(secret); + } + } + + function setScan(value: bool): void { + for (const d of wifiDevices) { + d.scannerEnabled = value; + } + } + + function setWifi(value: bool): void { + Networking.wifiEnabled = value; + } + + function wifiDevice(dev): bool { + return dev.type === DeviceType.Wifi; + } + + onActiveChanged: { + for (const d of wifiDevices) + d.scannerEnabled = active; + } + + Timer { + id: flushFailed + + interval: 5000 + running: root.failedNetwork + + onTriggered: { + root.failedNetwork = null; + } + } + + Timer { + id: checkConnection + + interval: 500 + repeat: true + running: root.connectingNetwork + + onTriggered: { + var completedNetwork; + + switch (root.connectingNetwork.state) { + case ConnectionState.Connecting: + break; + case ConnectionState.Connected: + completedNetwork = root.connectingNetwork; + root.networkConnected(); + break; + case ConnectionState.Disconnected: + root.failedNetwork = root.connectingNetwork; + completedNetwork = root.connectingNetwork; + break; + } + + if (completedNetwork) { + root.connectingNetwork = null; + } + } + } + + Timer { + interval: 10000 + running: checkConnection.running + + onTriggered: { + root.failedNetwork = root.connectingNetwork; + root.connectingNetwork = null; + } + } +} diff --git a/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index fe33739..5673cfb 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -205,15 +205,6 @@ GridLayout { } } - DelegateChoice { - roleValue: "network" - - delegate: EntryWrapper { - NetworkWidget { - } - } - } - DelegateChoice { roleValue: "media" diff --git a/Modules/Bar/Components/StatusIcons.qml b/Modules/Bar/Components/StatusIcons.qml index bb520e6..6cacee2 100644 --- a/Modules/Bar/Components/StatusIcons.qml +++ b/Modules/Bar/Components/StatusIcons.qml @@ -105,6 +105,28 @@ WidgetBase { } } + DelegateChoice { + roleValue: "bluetooth" + + delegate: EntryWrapper { + name: "bluetooth" + + BluetoothWidget { + } + } + } + + DelegateChoice { + roleValue: "network" + + delegate: EntryWrapper { + name: "network" + + NetworkWidget { + } + } + } + DelegateChoice { roleValue: "power" diff --git a/Modules/Bar/Components/StatusIcons/BluetoothWidget.qml b/Modules/Bar/Components/StatusIcons/BluetoothWidget.qml new file mode 100644 index 0000000..3cfaa1e --- /dev/null +++ b/Modules/Bar/Components/StatusIcons/BluetoothWidget.qml @@ -0,0 +1,14 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Services +import qs.Components + +MaterialIcon { + Layout.alignment: Qt.AlignVCenter + animate: true + color: Colors.palette.m3onSurface // Network.connected ? root.textColor : Colors.palette.m3error + fill: 1 + font.pointSize: Tokens.font.size.larger + text: "bluetooth" +} diff --git a/Modules/Bar/Components/StatusIcons/NetworkWidget.qml b/Modules/Bar/Components/StatusIcons/NetworkWidget.qml new file mode 100644 index 0000000..76ba475 --- /dev/null +++ b/Modules/Bar/Components/StatusIcons/NetworkWidget.qml @@ -0,0 +1,14 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Services +import qs.Components + +MaterialIcon { + Layout.alignment: Qt.AlignVCenter + animate: true + color: Colors.palette.m3onSurface // Network.connected ? root.textColor : Colors.palette.m3error + fill: 1 + font.pointSize: Tokens.font.size.larger + text: "android_wifi_4_bar" +} diff --git a/Modules/Bar/Popouts/BluetoothPopout.qml b/Modules/Bar/Popouts/BluetoothPopout.qml new file mode 100644 index 0000000..6680bb4 --- /dev/null +++ b/Modules/Bar/Popouts/BluetoothPopout.qml @@ -0,0 +1,385 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import QtQml.Models +import Quickshell +import Quickshell.Bluetooth +import ZShell.Components +import ZShell.Config +import qs.Services +import qs.Components +import qs.Helpers + +CustomClippingRect { + id: root + + readonly property list btConnected: Bluetooth.devices.values.filter(d => d.connected) + + implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 + implicitWidth: 500 + + ColumnLayout { + id: layout + + anchors.fill: parent + anchors.margins: Tokens.padding.larger + spacing: Tokens.spacing.extraSmall / 2 + + CustomText { + Layout.rightMargin: Tokens.padding.extraSmall + Layout.topMargin: Tokens.padding.normal + font.pointSize: Tokens.font.size.large + text: qsTr("Bluetooth") + } + + Toggle { + checked: Bluetooth.defaultAdapter?.enabled ?? false // qmllint disable unresolved-type + first: true + subtext: qsTr("Toggle bluetooth device state") + text: checked ? qsTr("Enabled") : qsTr("Disabled") + + onToggled: { + const adapter = Bluetooth.defaultAdapter; // qmllint disable unresolved-type + if (adapter) + adapter.enabled = checked; + } + } + + Toggle { + checked: Bluetooth.defaultAdapter?.discovering ?? false // qmllint disable unresolved-type + last: root.btConnected.length === 0 + subtext: qsTr("Enable scanning for new devices") + text: qsTr("Discover") + + onToggled: { + const adapter = Bluetooth.defaultAdapter; // qmllint disable unresolved-type + if (adapter) + adapter.discovering = checked; + } + } + + ListView { + id: rows + + Layout.fillWidth: true + Layout.preferredHeight: contentHeight + interactive: false + section.property: "connected" + spacing: Tokens.spacing.extraSmall / 2 + + add: Transition { + Anim { + from: 0 + property: "opacity" + to: 1 + } + } + delegate: DelegateChooser { + role: "isDivider" + + DelegateChoice { + roleValue: true + + delegate: StatusDivider { + } + } + + DelegateChoice { + delegate: BluetoothItem { + list: rows + width: rows.width + } + } + } + displaced: Transition { + Anim { + easing.type: Easing.OutCubic + property: "y" + } + } + model: ScriptModel { + objectProp: "address" + values: { + const byName = (a, b) => a.name.localeCompare(b.name); + return [...[...Bluetooth.devices.values].filter(d => d.connected).sort(byName).slice(0, 5), + { + isDivider: true + }, + ...[...Bluetooth.devices.values].filter(d => !d.connected).sort(byName).slice(0, 5)]; + } + } + move: Transition { + Anim { + easing.type: Easing.OutCubic + property: "y" + } + } + remove: Transition { + Anim { + property: "opacity" + to: 0 + } + } + section.delegate: Rectangle { + required property string section + + color: "transparent" + height: childrenRect.height + width: ListView.view.width + + CustomText { + text: parent.section + } + } + } + } + + component BluetoothItem: ConnectedRect { + id: btItem + + readonly property int connectedCount: root.btConnected.length + readonly property bool connecting: Network.connectingNetwork === modelData + property int horizontalPadding: Tokens.padding.largeIncreased + readonly property bool inConnectedGroup: index < connectedCount + required property int index + required property ListView list + readonly property bool loading: modelData.state === BluetoothDeviceState.Connecting || modelData.state === BluetoothDeviceState.Disconnecting // qmllint disable unresolved-type + + required property BluetoothDevice modelData + property int verticalPadding: Tokens.padding.smaller + + first: !inConnectedGroup && index === connectedCount + 1 + implicitHeight: rowLayout.implicitHeight + verticalPadding * 2 + last: inConnectedGroup ? index === connectedCount - 1 : index === list.count - 1 + radius: Tokens.rounding.small + + Timer { + interval: 50 + running: btItem.ListView.delayRemove + + onTriggered: btItem.ListView.delayRemove = false + } + + Connections { + function onConnectedChanged(): void { + if (!btItem.modelData.connected) + btItem.ListView.delayRemove = true; + } + + function onPairedChanged(): void { + if (btItem.modelData.paired) + btItem.modelData.connect(); + } + + target: btItem.modelData + } + + RowLayout { + id: rowLayout + + anchors.fill: parent + anchors.leftMargin: btItem.horizontalPadding + anchors.rightMargin: btItem.horizontalPadding + + Column { + id: column + + Layout.fillWidth: true + + CustomText { + Layout.fillWidth: true + Layout.leftMargin: Tokens.spacing.extraSmall + Layout.rightMargin: Tokens.spacing.extraSmall + elide: Text.ElideRight + text: btItem.modelData.name + } + + CustomText { + id: subtext + + anchors.left: parent.left + anchors.right: parent.right + color: Colors.palette.m3outline + elide: Text.ElideRight + font.pointSize: Tokens.font.size.small + text: btItem.modelData.connected ? (btItem.modelData.batteryAvailable ? qsTr("%1 battery left").arg(Math.round(btItem.modelData.battery * 100) + "%") : qsTr("Battery status unknown")) : btItem.modelData.address + } + } + + Item { + Layout.alignment: Qt.AlignRight + implicitHeight: icon.height + implicitWidth: icon.width + + MaterialIcon { + id: icon + + font.pointSize: Tokens.font.size.large + opacity: btItem.connecting ? 0 : 1 + text: btItem.modelData.batteryAvailable ? Icons.getBatteryIcon(btItem.modelData.battery) : "battery_unknown" + visible: btItem.modelData.state === BluetoothDeviceState.Connected // qmllint disable unresolved-type + + Behavior on opacity { + Anim { + } + } + } + + Loader { + active: opacity > 0 + anchors.fill: parent + opacity: btItem.connecting ? 1 : 0 + + Behavior on opacity { + Anim { + } + } + sourceComponent: CircularIndicator { + bgColor: "transparent" + running: true + } + } + } + + ButtonRow { + spacing: Tokens.spacing.small + + IconTextButton { + font.pointSize: Tokens.font.size.small + icon: btItem.modelData.connected ? "link_off" : btItem.modelData.paired ? "link" : "add_link" + text: btItem.modelData.connected ? qsTr("Disconnect") : btItem.modelData.paired ? qsTr("Connect") : qsTr("Pair") + + onClicked: { + if (btItem.modelData.connected) + btItem.modelData.disconnect(); + else if (btItem.modelData.paired) + btItem.modelData.connect(); + else if (!btItem.modelData.paired) + btItem.modelData.pair(); + else if (btItem.modelData.pairing) + btItem.modelData.cancelPair(); + } + } + } + } + } + component ConnectedRect: CustomRect { + id: bg + + property bool first + property bool last + + bottomLeftRadius: last ? Tokens.rounding.large : Tokens.rounding.extraSmall + bottomRightRadius: last ? Tokens.rounding.large : Tokens.rounding.extraSmall + color: Colors.tPalette.m3surfaceContainer + topLeftRadius: first ? Tokens.rounding.large : Tokens.rounding.extraSmall + topRightRadius: first ? Tokens.rounding.large : Tokens.rounding.extraSmall + + Behavior on bottomLeftRadius { + Anim { + } + } + Behavior on bottomRightRadius { + Anim { + } + } + Behavior on topLeftRadius { + Anim { + } + } + Behavior on topRightRadius { + Anim { + } + } + } + component StatusDivider: CustomText { + bottomPadding: Tokens.spacing.extraSmall + color: Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.smaller + text: { + const devices = Bluetooth.devices.values; // qmllint disable unresolved-type + const connectedCount = devices.filter(d => d.connected).length; + let available = qsTr("%1 device%2 available").arg(devices.length - connectedCount).arg(devices.length === 1 ? "" : "s"); + return available; + } + topPadding: Tokens.spacing.small + } + component Toggle: CustomSwitch { + id: toggleItem + + readonly property alias bg: bg + property alias first: bg.first + property alias last: bg.last + property string subtext + + Layout.fillWidth: true + cLayer: 2 + horizontalPadding: Tokens.padding.largeIncreased + implicitHeight: Math.max(implicitContentHeight, implicitIndicatorHeight) + verticalPadding * 2 + implicitWidth: implicitContentWidth + implicitIndicatorWidth + horizontalPadding * 2 + indicator.anchors.right: right + indicator.anchors.rightMargin: toggleItem.horizontalPadding + indicator.anchors.verticalCenter: verticalCenter + verticalPadding: Tokens.padding.normal + + background: ConnectedRect { + id: bg + + StateLayer { + id: stateLayer + + manualPressOverride: toggleItem.pressed + } + } + contentItem: Item { + anchors.left: parent.left + anchors.leftMargin: toggleItem.horizontalPadding + anchors.right: toggleItem.indicator.left + anchors.rightMargin: Tokens.spacing.normal + implicitHeight: column.implicitHeight + implicitWidth: column.implicitWidth + + Column { + id: column + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + CustomText { + id: label + + anchors.left: parent.left + anchors.right: parent.right + elide: Text.ElideRight + font: { + const f = Qt.font(label.font); + f.pointSize = Tokens.font.size.smaller; + return f; + } + text: toggleItem.text + } + + CustomText { + id: subtext + + anchors.left: parent.left + anchors.right: parent.right + color: Colors.palette.m3outline + elide: Text.ElideRight + font: { + const f = Qt.font(subtext.font); + f.pointSize = Tokens.font.size.small; + return f; + } + text: toggleItem.subtext + visible: toggleItem.subtext + } + } + } + + onPressed: stateLayer.press(stateLayer.mouseX, stateLayer.mouseY) + } +} diff --git a/Modules/Bar/Popouts/Content.qml b/Modules/Bar/Popouts/Content.qml index f0707d3..f756d08 100644 --- a/Modules/Bar/Popouts/Content.qml +++ b/Modules/Bar/Popouts/Content.qml @@ -80,6 +80,13 @@ Item { } } + Popout { + name: "bluetooth" + + sourceComponent: BluetoothPopout { + } + } + Popout { name: "updates" diff --git a/Modules/Bar/Popouts/Network/NetworkOverlay.qml b/Modules/Bar/Popouts/Network/NetworkOverlay.qml new file mode 100644 index 0000000..ce70adc --- /dev/null +++ b/Modules/Bar/Popouts/Network/NetworkOverlay.qml @@ -0,0 +1,71 @@ +import QtQuick +import ZShell.Config +import qs.Services +import qs.Helpers +import qs.Components + +CustomRect { + id: root + + readonly property bool closing: PopupManager.closed + readonly property url currentPopup: PopupManager.currentPopup + property bool shouldBeVisible: loader.status === Loader.Ready + + color: Qt.alpha(Colors.palette.m3shadow, 0.3) + opacity: shouldBeVisible && !closing ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + Anim { + } + } + + onVisibleChanged: { + if (!visible) { + PopupManager.requestClose(); + PopupManager.currentPopup = ""; + } + } + + CustomMouseArea { + anchors.fill: parent + hoverEnabled: true + preventStealing: true + propagateComposedEvents: false + + onClicked: { + const insideItemWidth = mouseX < loader.x + loader.item.width && mouseX > loader.x; + const insideItemHeight = mouseY < loader.y + loader.item.height && mouseY > loader.y; + if (insideItemHeight && insideItemWidth) + return; + + PopupManager.requestClose(); + } + } + + Loader { + id: loader + + anchors.centerIn: parent + + Connections { + function onCurrentPopupChanged(): void { + if (PopupManager.currentPopup) { + loader.setSource(PopupManager.currentPopup, PopupManager.currentPopupProps); + } else { + loader.source = null; + } + } + + target: PopupManager + } + } + + Connections { + function onNetworkConnected(): void { + PopupManager.requestClose(); + } + + target: Network + } +} diff --git a/Modules/Bar/Popouts/Network/PSKOverlay.qml b/Modules/Bar/Popouts/Network/PSKOverlay.qml new file mode 100644 index 0000000..267d0ac --- /dev/null +++ b/Modules/Bar/Popouts/Network/PSKOverlay.qml @@ -0,0 +1,103 @@ +import Quickshell +import QtQuick +import QtQuick.Layouts +import ZShell.Components +import ZShell.Config +import qs.Services +import qs.Components +import qs.Helpers + +CustomClippingRect { + id: root + + required property var network + + color: Colors.palette.m3surfaceContainer + implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 + implicitWidth: layout.implicitWidth + layout.anchors.margins * 2 + radius: Tokens.rounding.large + + ColumnLayout { + id: layout + + anchors.fill: parent + anchors.margins: Tokens.padding.extraLarge + spacing: Tokens.spacing.normal + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + font.pointSize: Tokens.font.size.extraLarge * 2 + text: "lock" + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: Colors.palette.m3onSurfaceVariant + text: qsTr("Connect to %1").arg(root.network.name) + } + + CustomTextField { + id: passwordField + + Layout.fillWidth: true + Layout.preferredWidth: 400 + echoMode: CustomTextField.Password + errorText: qsTr("Failed to connect with given password") + isError: Network.failedNetwork + leadingIcon: "lock" + passwordMaskDelay: 5000 + placeholderText: qsTr("Input password") + } + + ButtonRow { + id: buttonRow + + Layout.fillWidth: true + spacing: Tokens.spacing.smaller + + IconTextButton { + fillWidth: true + font.pointSize: Tokens.font.size.normal + icon: "close" + inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) + inactiveOnColor: Colors.palette.m3onSurfaceVariant + isRound: true + isToggle: false + shapeMorph: true + text: "Cancel" + + onClicked: PopupManager.requestClose() + } + + IconTextButton { + fillWidth: true + font.pointSize: Tokens.font.size.normal + icon: Network.connectingNetwork ? "" : "check" + inactiveColor: Network.connectingNetwork ? Colors.palette.m3primaryContainer : Network.failedNetwork ? Colors.palette.m3error : Colors.palette.m3primary + inactiveOnColor: Network.connectingNetwork ? Colors.palette.m3onPrimaryContainer : Network.failedNetwork ? Colors.palette.m3onError : Colors.palette.m3onPrimary + isRound: true + isToggle: false + shapeMorph: true + text: Network.connectingNetwork ? "" : "Apply" + + onClicked: Network.requestConnect(root.network, passwordField.text) + + Loader { + active: opacity > 0 + anchors.centerIn: parent + opacity: Network.connectingNetwork ? 1 : 0 + + Behavior on opacity { + Anim { + } + } + sourceComponent: CircularIndicator { + bgColor: "transparent" + implicitSize: buttonRow.height - Tokens.padding.normal + running: true + } + } + } + } + } +} diff --git a/Modules/Bar/Popouts/Network/PopupManager.qml b/Modules/Bar/Popouts/Network/PopupManager.qml new file mode 100644 index 0000000..e0e5834 --- /dev/null +++ b/Modules/Bar/Popouts/Network/PopupManager.qml @@ -0,0 +1,22 @@ +pragma Singleton + +import Quickshell +import QtQuick + +Singleton { + id: root + + property bool closed: true + property url currentPopup: "" + property var currentPopupProps: ({}) + + function requestClose(): void { + closed = true; + } + + function requestOpen(source: url, properties = {}): void { + currentPopupProps = properties; + currentPopup = source; + closed = false; + } +} diff --git a/Modules/Bar/Popouts/NetworkPopout.qml b/Modules/Bar/Popouts/NetworkPopout.qml index f955d0f..a005570 100644 --- a/Modules/Bar/Popouts/NetworkPopout.qml +++ b/Modules/Bar/Popouts/NetworkPopout.qml @@ -1,36 +1,362 @@ pragma ComponentBehavior: Bound -import Quickshell -import Quickshell.Networking import QtQuick import QtQuick.Layouts -import qs.Components +import Quickshell +import ZShell.Components import ZShell.Config -import qs.Modules -import qs.Helpers as Helpers +import qs.Services +import qs.Modules.Bar.Popouts.Network +import qs.Components +import qs.Helpers -Item { +CustomClippingRect { id: root required property var wrapper + anchors.horizontalCenter: parent.horizontalCenter + implicitHeight: networkPopContent.height + networkPopContent.anchors.margins * 2 + implicitWidth: 500 + 8 * 2 + radius: (20 - Tokens.padding.small) * Tokens.rounding.scale + + Component.onCompleted: Network.active = true + Component.onDestruction: Network.active = false + ColumnLayout { - id: layout + id: networkPopContent - spacing: 8 + anchors.left: parent.left + anchors.margins: Tokens.padding.large + anchors.right: parent.right + anchors.top: parent.top + spacing: Tokens.spacing.extraSmall / 2 - Repeater { - model: Helpers.Network.devices + CustomText { + Layout.preferredHeight: visible ? implicitHeight : 0 + Layout.rightMargin: Tokens.padding.extraSmall + font.pointSize: Tokens.font.size.large + text: qsTr("Wifi") + } - CustomRadioButton { - id: network + Toggle { + Layout.preferredHeight: visible ? implicitHeight : 0 + checked: Network.wifiEnabled + first: true + last: Network.connectedNetworks.length === 0 + subtext: qsTr("Toggle WiFi device state") + text: checked ? qsTr("Enabled") : qsTr("Disabled") - required property NetworkDevice modelData + onToggled: Network.setWifi(checked) + } - checked: Helpers.Network.activeDevice?.name === modelData.name - text: modelData.description - visible: modelData.name !== "lo" + ListView { + id: rows + + Layout.fillWidth: true + Layout.preferredHeight: contentHeight + interactive: false + section.property: "connected" + spacing: Tokens.spacing.extraSmall / 2 + + add: Transition { + Anim { + from: 0 + property: "opacity" + to: 1 + } + } + delegate: DelegateChooser { + role: "isDivider" + + DelegateChoice { + roleValue: true + + delegate: StatusDivider { + } + } + + DelegateChoice { + delegate: NetworkItem { + list: rows + width: rows.width + } + } + } + displaced: Transition { + Anim { + easing.type: Easing.OutCubic + property: "y" + } + } + model: ScriptModel { + values: { + const byStrengthName = (a, b) => b.signalStrength - a.signalStrength || a.name.localeCompare(b.name); + const cNetworks = [...Network.connectedNetworks]; + const kNetworks = [...Network.knownNetworks]; + const uNetworks = [...Network.unknownNetworks]; + + return [...cNetworks.sort(byStrengthName), ...(kNetworks.length > 0 ? [ + { + isDivider: true, + type: "known" + }, + ...kNetworks.sort(byStrengthName)] : []), ...(uNetworks.length > 0 ? [ + { + isDivider: true, + type: "unknown" + }, + ...uNetworks.sort(byStrengthName)] : [])]; + } + } + move: Transition { + Anim { + easing.type: Easing.OutCubic + property: "y" + } + } + remove: Transition { + Anim { + property: "opacity" + to: 0 + } + } + section.delegate: Rectangle { + required property string section + + color: "transparent" + height: childrenRect.height + width: ListView.view.width + + CustomText { + text: parent.section + } } } } + + NetworkOverlay { + anchors.fill: parent + } + + component ConnectedRect: CustomRect { + id: bg + + property bool first + property bool last + + bottomLeftRadius: last ? Tokens.rounding.large : Tokens.rounding.extraSmall + bottomRightRadius: last ? Tokens.rounding.large : Tokens.rounding.extraSmall + color: Colors.tPalette.m3surfaceContainer + topLeftRadius: first ? Tokens.rounding.large : Tokens.rounding.extraSmall + topRightRadius: first ? Tokens.rounding.large : Tokens.rounding.extraSmall + } + component NetworkItem: ConnectedRect { + id: networkItem + + readonly property int connectedCount: Network.connectedNetworks.length + readonly property bool connecting: Network.connectingNetwork === modelData + property int horizontalPadding: Tokens.padding.largeIncreased + readonly property bool inConnectedGroup: index < connectedCount + // sectioning first / last item in list reminder + // if index === network.connectedNetworks.length - 1 + // if index === network.connectedNetworks.length - 1 + network.knownNetworks.length - 2 + // if index === network.connectedNetworks.length - 1 + network.knownNetworks.length + // list.model.values.length - 1 + required property int index + readonly property bool isSecure: Network.isSecure(modelData.security) + required property ListView list + required property var modelData + property int verticalPadding: Tokens.padding.smaller + + Layout.fillWidth: true + first: !inConnectedGroup && index === connectedCount + 1 + implicitHeight: rowLayout.implicitHeight + verticalPadding * 2 + last: inConnectedGroup ? index === connectedCount - 1 : index === list.count - 1 + radius: Tokens.rounding.small + + RowLayout { + id: rowLayout + + Layout.preferredHeight: visible ? rowLayout.implicitHeight + Tokens.padding.smaller * 2 : 0 + anchors.fill: parent + anchors.leftMargin: networkItem.horizontalPadding + anchors.rightMargin: networkItem.horizontalPadding + + Column { + id: column + + Layout.fillWidth: true + + CustomText { + Layout.fillWidth: true + Layout.leftMargin: Tokens.spacing.extraSmall + Layout.rightMargin: Tokens.spacing.extraSmall + color: networkItem.modelData.active ? Colors.palette.m3primary : Colors.palette.m3onSurface + elide: Text.ElideRight + text: networkItem.modelData.name + } + + CustomText { + id: subtext + + anchors.left: parent.left + anchors.right: parent.right + color: Colors.palette.m3outline + elide: Text.ElideRight + font.pointSize: Tokens.font.size.small + text: networkItem.modelData.connected ? qsTr("Connected: %1 strength").arg(Math.round(networkItem.modelData.signalStrength * 100) + "%") : qsTr("%1 strength").arg(Math.round(networkItem.modelData.signalStrength * 100) + "%") + } + } + + Item { + Layout.alignment: Qt.AlignRight + implicitHeight: icon.height + implicitWidth: icon.width + + MaterialIcon { + id: icon + + color: networkItem.modelData.active ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.large + opacity: networkItem.connecting ? 0 : 1 + text: Icons.getNetworkIcon(networkItem.modelData.signalStrength * 100, networkItem.isSecure) + + Behavior on opacity { + Anim { + } + } + } + + Loader { + active: opacity > 0 + anchors.fill: parent + opacity: networkItem.connecting ? 1 : 0 + + Behavior on opacity { + Anim { + } + } + sourceComponent: CircularIndicator { + bgColor: "transparent" + running: true + } + } + } + } + + StateLayer { + color: networkItem.modelData.active ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + + onClicked: { + if (networkItem.isSecure && !networkItem.known) { + PopupManager.requestOpen(Qt.resolvedUrl("Network/PSKOverlay.qml"), { + network: networkItem.modelData + }); + } else { + Network.requestConnect(networkItem.modelData); + } + } + } + } + component StatusDivider: CustomText { + required property var modelData + + bottomPadding: Tokens.spacing.extraSmall + color: Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.smaller + text: { + if (modelData.type === "known") { + const count = Network.knownNetworks.filter(n => !n.connected).length; + + return qsTr("%1 known network%2").arg(count).arg(count === 1 ? "" : "s"); + } + + if (modelData.type === "unknown") { + const count = Network.unknownNetworks.filter(n => !n.connected).length; + + return qsTr("%1 network%2 available").arg(count).arg(count === 1 ? "" : "s"); + } + + return qsTr("object unknown"); + } + topPadding: Tokens.spacing.small + } + component Toggle: CustomSwitch { + id: toggleItem + + readonly property alias bg: bg + property alias first: bg.first + property alias last: bg.last + property string subtext + + Layout.fillWidth: true + cLayer: 2 + horizontalPadding: Tokens.padding.largeIncreased + implicitHeight: Math.max(implicitContentHeight, implicitIndicatorHeight) + verticalPadding * 2 + implicitWidth: implicitContentWidth + implicitIndicatorWidth + horizontalPadding * 2 + indicator.anchors.right: right + indicator.anchors.rightMargin: toggleItem.horizontalPadding + indicator.anchors.verticalCenter: verticalCenter + verticalPadding: Tokens.padding.normal + + background: ConnectedRect { + id: bg + + StateLayer { + id: stateLayer + + manualPressOverride: toggleItem.pressed + } + } + contentItem: Item { + anchors.left: parent.left + anchors.leftMargin: toggleItem.horizontalPadding + anchors.right: toggleItem.indicator.left + anchors.rightMargin: Tokens.spacing.normal + implicitHeight: column.implicitHeight + implicitWidth: column.implicitWidth + + Column { + id: column + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + CustomText { + id: label + + anchors.left: parent.left + anchors.right: parent.right + elide: Text.ElideRight + font: { + const f = Qt.font(label.font); + f.pointSize = Tokens.font.size.smaller; + return f; + } + text: toggleItem.text + } + + CustomText { + id: subtext + + anchors.left: parent.left + anchors.right: parent.right + color: Colors.palette.m3outline + elide: Text.ElideRight + font: { + const f = Qt.font(subtext.font); + f.pointSize = Tokens.font.size.small; + return f; + } + text: toggleItem.subtext + visible: toggleItem.subtext + } + } + } + + onPressed: stateLayer.press(stateLayer.mouseX, stateLayer.mouseY) + } } diff --git a/Modules/Dock/Wrapper.qml b/Modules/Dock/Wrapper.qml index fc65575..49eaea0 100644 --- a/Modules/Dock/Wrapper.qml +++ b/Modules/Dock/Wrapper.qml @@ -12,7 +12,7 @@ Item { property real offsetScale: shouldBeActive ? 0 : 1 required property var panels required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.dock && Config.dock.enable + readonly property bool shouldBeActive: visibilities.dock && Config.dock.enabled required property PersistentProperties visibilities anchors.bottomMargin: (-implicitHeight - 5) * offsetScale diff --git a/Modules/Settings/PageCompRegistry.qml b/Modules/Settings/PageCompRegistry.qml index 22aa679..f1f7263 100644 --- a/Modules/Settings/PageCompRegistry.qml +++ b/Modules/Settings/PageCompRegistry.qml @@ -2,8 +2,8 @@ pragma Singleton import QtQuick import QtQuick.Layouts -import qs.Components import ZShell.Config +import qs.Components import qs.Modules.Settings.Common import qs.Modules.Settings.Pages import qs.Modules.Settings.Pages.Wallpaper @@ -45,7 +45,11 @@ QtObject { // Connectivity Component { - PlaceholderComp { + StackPage { + Component { + NetworkPage { + } + } } }, Component { diff --git a/Modules/Settings/Pages/NetworkPage.qml b/Modules/Settings/Pages/NetworkPage.qml new file mode 100644 index 0000000..57e3ce3 --- /dev/null +++ b/Modules/Settings/Pages/NetworkPage.qml @@ -0,0 +1,240 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import ZShell.Config +import qs.Modules.Settings.Common +import qs.Modules.Bar.Popouts.Network +import qs.Components +import qs.Helpers +import qs.Services + +PageBase { + id: root + + title: qsTr("Network") + + ColumnLayout { + id: networkContent + + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + spacing: Tokens.spacing.extraSmall / 2 + width: root.cappedWidth + + Toggle { + Layout.preferredHeight: visible ? implicitHeight : 0 + checked: Network.wifiEnabled + first: true + last: connected.count === 0 + subtext: qsTr("Toggle WiFi device state") + text: checked ? qsTr("Enabled") : qsTr("Disabled") + + onToggled: Network.setWifi(checked) + } + + Repeater { + id: connected + + visible: Network.connectedNetworks.length > 0 + + model: ScriptModel { + values: [...Network.connectedNetworks].sort((a, b) => { + return b.signalStrength - a.signalStrength; + }) + } + + NetworkItem { + first: false + known: true + repeater: connected + } + } + } + + component NetworkItem: ConnectedRect { + id: networkItem + + readonly property bool connecting: Network.connectingNetwork === modelData + property int horizontalPadding: Tokens.padding.largeIncreased + required property int index + readonly property bool isSecure: Network.isSecure(modelData.security) + required property bool known + required property var modelData + required property Repeater repeater + + Layout.fillWidth: true + Layout.preferredHeight: visible ? rowLayout.implicitHeight + Tokens.padding.smaller * 2 : 0 + first: index === 0 + last: index === (repeater.count - 1) + radius: Tokens.rounding.small + + RowLayout { + id: rowLayout + + anchors.fill: parent + anchors.leftMargin: networkItem.horizontalPadding + anchors.rightMargin: networkItem.horizontalPadding + + Column { + id: column + + Layout.fillWidth: true + + CustomText { + Layout.fillWidth: true + Layout.leftMargin: Tokens.spacing.extraSmall + Layout.rightMargin: Tokens.spacing.extraSmall + color: networkItem.modelData.active ? Colors.palette.m3primary : Colors.palette.m3onSurface + elide: Text.ElideRight + text: networkItem.modelData.name + } + + CustomText { + id: subtext + + anchors.left: parent.left + anchors.right: parent.right + color: Colors.palette.m3outline + elide: Text.ElideRight + font.pointSize: Tokens.font.size.small + text: qsTr("%1 strength").arg(Math.round(networkItem.modelData.signalStrength * 100) + "%") + } + } + + Item { + Layout.alignment: Qt.AlignRight + implicitHeight: icon.height + implicitWidth: icon.width + + MaterialIcon { + id: icon + + color: networkItem.modelData.active ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.large + opacity: networkItem.connecting ? 0 : 1 + text: Icons.getNetworkIcon(networkItem.modelData.signalStrength * 100, networkItem.isSecure) + + Behavior on opacity { + Anim { + } + } + } + + Loader { + active: opacity > 0 + anchors.fill: parent + opacity: networkItem.connecting ? 1 : 0 + + Behavior on opacity { + Anim { + } + } + sourceComponent: CircularIndicator { + bgColor: "transparent" + running: true + } + } + } + } + + StateLayer { + color: networkItem.modelData.active ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + + onClicked: { + if (networkItem.isSecure && !networkItem.known) { + PopupManager.requestOpen(Qt.resolvedUrl("Network/PSKOverlay.qml"), { + network: networkItem.modelData + }); + } else { + Network.requestConnect(networkItem.modelData); + } + } + } + } + component Spacer: Item { + id: spacer + + required property Repeater repeater + + Layout.preferredHeight: repeater.count > 0 ? Tokens.spacing.extraSmall : 0 + } + component Toggle: CustomSwitch { + id: toggleItem + + readonly property alias bg: bg + property alias first: bg.first + property alias last: bg.last + property string subtext + + Layout.fillWidth: true + cLayer: 2 + horizontalPadding: Tokens.padding.largeIncreased + implicitHeight: Math.max(implicitContentHeight, implicitIndicatorHeight) + verticalPadding * 2 + implicitWidth: implicitContentWidth + implicitIndicatorWidth + horizontalPadding * 2 + indicator.anchors.right: right + indicator.anchors.rightMargin: toggleItem.horizontalPadding + indicator.anchors.verticalCenter: verticalCenter + verticalPadding: Tokens.padding.normal + + background: ConnectedRect { + id: bg + + StateLayer { + id: stateLayer + + manualPressOverride: toggleItem.pressed + } + } + contentItem: Item { + anchors.left: parent.left + anchors.leftMargin: toggleItem.horizontalPadding + anchors.right: toggleItem.indicator.left + anchors.rightMargin: Tokens.spacing.normal + implicitHeight: column.implicitHeight + implicitWidth: column.implicitWidth + + Column { + id: column + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + CustomText { + id: label + + anchors.left: parent.left + anchors.right: parent.right + elide: Text.ElideRight + font: { + const f = Qt.font(label.font); + f.pointSize = Tokens.font.size.smaller; + return f; + } + text: toggleItem.text + } + + CustomText { + id: subtext + + anchors.left: parent.left + anchors.right: parent.right + color: Colors.palette.m3outline + elide: Text.ElideRight + font: { + const f = Qt.font(subtext.font); + f.pointSize = Tokens.font.size.small; + return f; + } + text: toggleItem.subtext + visible: toggleItem.subtext + } + } + } + + onPressed: stateLayer.press(stateLayer.mouseX, stateLayer.mouseY) + } +} diff --git a/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml b/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml index ae17efa..2835082 100644 --- a/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml +++ b/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml @@ -8,7 +8,9 @@ PageBase { readonly property var builtinIcons: ({ audio: qsTr("Speakers"), microphone: qsTr("Microphone"), - power: qsTr("Battery") + power: qsTr("Battery"), + network: qsTr("Network"), + bluetooth: qsTr("Bluetooth") }) isSubPage: true @@ -91,5 +93,15 @@ PageBase { onToggled: Config.bar.popouts.statusIcons = checked } + + ToggleRow { + checked: Config.bar.popouts.network + last: true + settingAnchor: "bar-status-network-popout" + subtext: qsTr("Show a details popout when hovering the network icon") + text: qsTr("Network popout on hover") + + onToggled: Config.bar.popouts.network = checked + } } }