diff --git a/home/isabel/quickshell/components/QuickSettings.qml b/home/isabel/quickshell/components/QuickSettings.qml index 771c7567..c30b5cbc 100644 --- a/home/isabel/quickshell/components/QuickSettings.qml +++ b/home/isabel/quickshell/components/QuickSettings.qml @@ -2,7 +2,6 @@ import QtQuick import QtQuick.Layouts import QtQuick.Controls.Basic import Quickshell -import Quickshell.Io import Quickshell.Widgets import "root:/data" import "root:/services" @@ -13,14 +12,10 @@ ColumnLayout { Layout.fillWidth: true spacing: 12 - Process { - id: openConfigProc - command: ["ghostty", "-e", "nvim", "~/.config/flake"] - } - - Process { - id: powerOffProc - command: ["systemctl", "poweroff"] + Binding { + target: Networking + property: "scanning" + value: networkPanel.visible } Text { @@ -71,7 +66,11 @@ ColumnLayout { IconButton { icon: "view-refresh-symbolic" size: 14 - onClicked: Networking.reload() + // Toggling the scanner off/on prompts a fresh scan. + onClicked: { + Networking.scanning = false; + Networking.scanning = true; + } } } @@ -89,13 +88,16 @@ ColumnLayout { delegate: PanelListItem { required property var modelData width: ListView.view.width - icon: modelData.icon - text: modelData.ssid || "Unknown" - badge: modelData.strength + "%" - active: modelData.active + icon: Networking.networkIcon(modelData) + text: modelData.name || "Unknown" + badge: modelData.known ? "Saved" : Math.round(modelData.signalStrength * 100) + "%" + active: modelData.connected onClicked: { - if (!modelData.active) { - Networking.connectToNetwork(modelData.ssid); + if (modelData.connected) return; + if (Networking.needsPassword(modelData)) { + Runtime.openWifiPassword(modelData); + } else { + modelData.connect(); } } } @@ -103,11 +105,11 @@ ColumnLayout { } Text { - text: Networking.networks?.count === 0 ? "No networks found" : "" + text: Networking.networks.length === 0 ? "No networks found" : "" color: Settings.colors.foreground opacity: 0.5 font.pixelSize: 12 - visible: Networking.networks?.count === 0 + visible: Networking.networks.length === 0 Layout.alignment: Qt.AlignHCenter } @@ -208,13 +210,13 @@ ColumnLayout { Text { text: { if (!Bluetooth.adapter) return "No Bluetooth adapter"; - if (Bluetooth.devices?.count === 0) return "No devices found"; + if (Bluetooth.devices.length === 0) return "No devices found"; return ""; } color: Settings.colors.foreground opacity: 0.5 font.pixelSize: 12 - visible: !Bluetooth.adapter || Bluetooth.devices?.count === 0 + visible: !Bluetooth.adapter || Bluetooth.devices.length === 0 Layout.alignment: Qt.AlignHCenter } @@ -233,14 +235,12 @@ ColumnLayout { label: "Networking" active: Networking.connected onClicked: (mouse) => { - // Do nothing if ethernet is connected - if (Networking.ethernetConnected) return; - if (mouse.button === Qt.RightButton) { networkPanel.visible = !networkPanel.visible + return; } - // otherwise lets actually do wifi stuff + if (Networking.ethernetConnected) return; Networking.toggleWifi() } } @@ -352,18 +352,11 @@ ColumnLayout { } - // Settings button - IconButton { - icon: "applications-system-symbolic" - size: 18 - onClicked: openConfigProc.startDetached() - } - // Power off button IconButton { icon: "system-shutdown-symbolic" size: 18 - onClicked: powerOffProc.startDetached() + onClicked: Quickshell.execDetached(["systemctl", "poweroff"]) } } } diff --git a/home/isabel/quickshell/data/Runtime.qml b/home/isabel/quickshell/data/Runtime.qml index c2b6572c..0d277546 100644 --- a/home/isabel/quickshell/data/Runtime.qml +++ b/home/isabel/quickshell/data/Runtime.qml @@ -10,4 +10,17 @@ Singleton { quickSettingsOpen = !quickSettingsOpen; } property bool quickSettingsOpen: false + + property var wifiPasswordTarget: null + property string wifiPasswordError: "" + + function openWifiPassword(net) { + wifiPasswordError = ""; + wifiPasswordTarget = net; + } + + function closeWifiPassword() { + wifiPasswordTarget = null; + wifiPasswordError = ""; + } } diff --git a/home/isabel/quickshell/modules/Bar.qml b/home/isabel/quickshell/modules/Bar.qml index 5c19b71b..a5a1b9b4 100644 --- a/home/isabel/quickshell/modules/Bar.qml +++ b/home/isabel/quickshell/modules/Bar.qml @@ -26,8 +26,8 @@ Scope { margins { left: 10 - top: 10 - bottom: 10 + top: 6 + bottom: 6 } Rectangle { diff --git a/home/isabel/quickshell/modules/NetworkPasswordDialog.qml b/home/isabel/quickshell/modules/NetworkPasswordDialog.qml new file mode 100644 index 00000000..df5021c8 --- /dev/null +++ b/home/isabel/quickshell/modules/NetworkPasswordDialog.qml @@ -0,0 +1,163 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Quickshell +import Quickshell.Wayland +import Quickshell.Networking as Net +import "root:/data" +import "root:/components" + +LazyLoader { + id: loader + active: Runtime.wifiPasswordTarget !== null + + PanelWindow { + id: win + color: "transparent" + + anchors { + top: true + bottom: true + left: true + right: true + } + + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "network-password" + // Exclusive so the password field reliably receives keystrokes. + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + function submit(): void { + const t = Runtime.wifiPasswordTarget; + if (!t || pwField.text.length === 0) return; + Runtime.wifiPasswordError = ""; + t.connectWithPsk(pwField.text); + } + + Rectangle { + anchors.fill: parent + color: "#80000000" + MouseArea { + anchors.fill: parent + onClicked: Runtime.closeWifiPassword() + } + } + + Rectangle { + anchors.centerIn: parent + width: 360 + implicitHeight: col.implicitHeight + 40 + radius: Settings.rounding + color: Settings.colors.background + border.color: Settings.colors.border + border.width: 1 + + // Swallow clicks so they don't fall through to the backdrop. + MouseArea { anchors.fill: parent } + + ColumnLayout { + id: col + anchors { + fill: parent + margins: 20 + } + spacing: 12 + + Text { + text: "Connect to " + (Runtime.wifiPasswordTarget?.name ?? "") + color: Settings.colors.foreground + font { + pixelSize: 15 + weight: Font.Bold + } + elide: Text.ElideRight + Layout.fillWidth: true + } + + TextField { + id: pwField + Layout.fillWidth: true + echoMode: TextInput.Password + placeholderText: "Password" + placeholderTextColor: Qt.rgba(Settings.colors.foreground.r, Settings.colors.foreground.g, Settings.colors.foreground.b, 0.4) + color: Settings.colors.foreground + focus: true + onAccepted: win.submit() + background: Rectangle { + radius: 8 + color: Settings.colors.backgroundLighter + border.color: pwField.activeFocus ? Settings.colors.accent : Settings.colors.border + border.width: 1 + } + } + + Text { + text: Runtime.wifiPasswordError + color: Settings.colors.error + font.pixelSize: 11 + visible: text !== "" + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Item { Layout.fillWidth: true } + + DialogButton { + label: "Cancel" + onClicked: Runtime.closeWifiPassword() + } + + DialogButton { + label: "Connect" + accent: true + onClicked: win.submit() + } + } + } + } + + Connections { + target: Runtime.wifiPasswordTarget + ignoreUnknownSignals: true + function onConnectedChanged() { + if (Runtime.wifiPasswordTarget?.connected) Runtime.closeWifiPassword(); + } + function onConnectionFailed(reason) { + Runtime.wifiPasswordError = "Connection failed: " + Net.ConnectionFailReason.toString(reason); + } + } + + component DialogButton: Rectangle { + id: btn + property string label: "" + property bool accent: false + signal clicked() + + implicitWidth: btnText.implicitWidth + 28 + implicitHeight: 34 + radius: 8 + color: accent ? Settings.colors.accent : Settings.colors.backgroundLighter + border.color: Settings.colors.border + border.width: 1 + + Text { + id: btnText + anchors.centerIn: parent + text: btn.label + color: btn.accent ? Settings.colors.background : Settings.colors.foreground + font.pixelSize: 12 + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: btn.clicked() + } + } + } +} diff --git a/home/isabel/quickshell/modules/Osd.qml b/home/isabel/quickshell/modules/Osd.qml index a124a4bf..333b9630 100644 --- a/home/isabel/quickshell/modules/Osd.qml +++ b/home/isabel/quickshell/modules/Osd.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import QtQuick.Controls import Quickshell import Quickshell.Widgets import Quickshell.Wayland @@ -44,6 +45,12 @@ Scope { notificationHideTimer.restart(); } + function sendReply(text: string): void { + if (!root.currentNotification || text.length === 0) return; + root.currentNotification.sendInlineReply(text); + root.notificationVisible = false; + } + Timer { id: osdHideTimer interval: 1500 @@ -67,8 +74,6 @@ Scope { PanelWindow { id: osdWindow - property var modelData - screen: modelData visible: root.osdVisible color: "transparent" @@ -147,14 +152,14 @@ Scope { // Notification Popup PanelWindow { id: notificationWindow - property var modelData - screen: modelData visible: root.notificationVisible && root.currentNotification !== null color: "transparent" WlrLayershell.namespace: "notification" WlrLayershell.layer: WlrLayer.Overlay + // Grab keyboard only when there's a reply field to type into. + WlrLayershell.keyboardFocus: root.currentNotification?.hasInlineReply ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None exclusionMode: ExclusionMode.Ignore anchors { @@ -162,7 +167,7 @@ Scope { } implicitWidth: 420 - implicitHeight: 100 + implicitHeight: root.currentNotification?.hasInlineReply ? 150 : 100 margins { top: 16 @@ -228,6 +233,37 @@ Scope { maximumLineCount: 2 Layout.fillWidth: true } + + RowLayout { + visible: root.currentNotification?.hasInlineReply ?? false + Layout.fillWidth: true + Layout.topMargin: 2 + spacing: 6 + + TextField { + id: replyField + Layout.fillWidth: true + placeholderText: root.currentNotification?.inlineReplyPlaceholder || "Reply..." + placeholderTextColor: Qt.rgba(Settings.colors.foreground.r, Settings.colors.foreground.g, Settings.colors.foreground.b, 0.4) + color: Settings.colors.foreground + font.pixelSize: 11 + // Don't let the toast time out while the user is typing. + onActiveFocusChanged: activeFocus ? notificationHideTimer.stop() : notificationHideTimer.restart() + onAccepted: { root.sendReply(text); text = ""; } + background: Rectangle { + radius: 6 + color: Settings.colors.backgroundLighter + border.color: replyField.activeFocus ? Settings.colors.accent : Settings.colors.border + border.width: 1 + } + } + + IconButton { + icon: "mail-send-symbolic" + size: 14 + onClicked: { root.sendReply(replyField.text); replyField.text = ""; } + } + } } IconButton { diff --git a/home/isabel/quickshell/modules/QuickSettingsPopup.qml b/home/isabel/quickshell/modules/QuickSettingsPopup.qml index 7b760d2d..bfda2f9d 100644 --- a/home/isabel/quickshell/modules/QuickSettingsPopup.qml +++ b/home/isabel/quickshell/modules/QuickSettingsPopup.qml @@ -25,6 +25,7 @@ LazyLoader { implicitHeight: screen.height * 0.98 exclusionMode: ExclusionMode.Ignore WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand Rectangle { anchors.fill: parent @@ -82,66 +83,101 @@ LazyLoader { } delegate: Rectangle { + id: notiDelegate required property QsNotifications.Notification modelData width: ListView.view.width - height: 72 + height: modelData.hasInlineReply ? 116 : 72 radius: 8 color: Settings.colors.backgroundLighter border.color: Settings.colors.border border.width: 1 - RowLayout { + ColumnLayout { anchors { fill: parent margins: 10 } - spacing: 10 + spacing: 8 - IconImage { - source: Quickshell.iconPath(modelData?.appIcon ? Utils.getIcon(modelData.appIcon) : "application-x-executable") - - implicitSize: 40 - Layout.alignment: Qt.AlignVCenter - } - - ColumnLayout { + RowLayout { Layout.fillWidth: true Layout.fillHeight: true - spacing: 2 + spacing: 10 - Text { - text: modelData.appName - color: Settings.colors.foreground - font { - pixelSize: 13 - weight: Font.Medium - } - elide: Text.ElideRight - Layout.fillWidth: true + IconImage { + source: Quickshell.iconPath(notiDelegate.modelData?.appIcon ? Utils.getIcon(notiDelegate.modelData.appIcon) : "application-x-executable") + + implicitSize: 40 + Layout.alignment: Qt.AlignVCenter } - Text { - text: modelData.body - color: Settings.colors.foreground - opacity: 0.8 - font.pixelSize: 12 - elide: Text.ElideRight - maximumLineCount: 2 - wrapMode: Text.WordWrap + ColumnLayout { Layout.fillWidth: true + Layout.fillHeight: true + spacing: 2 + + Text { + text: notiDelegate.modelData.appName + color: Settings.colors.foreground + font { + pixelSize: 13 + weight: Font.Medium + } + elide: Text.ElideRight + Layout.fillWidth: true + } + + Text { + text: notiDelegate.modelData.body + color: Settings.colors.foreground + opacity: 0.8 + font.pixelSize: 12 + elide: Text.ElideRight + maximumLineCount: 2 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + + IconButton { + icon: "window-close-symbolic" + size: 14 + Layout.alignment: Qt.AlignTop + onClicked: { + notiDelegate.modelData.dismiss(); + if (Notifications.list.length <= 0) { + popup.visible = false; + } + } } } - IconButton { - icon: "window-close-symbolic" - size: 14 - Layout.alignment: Qt.AlignTop - onClicked: { - modelData.dismiss(); - if (Notifications.list.length <= 0) { - popup.visible = false; + RowLayout { + visible: notiDelegate.modelData.hasInlineReply + Layout.fillWidth: true + spacing: 6 + + TextField { + id: listReplyField + Layout.fillWidth: true + placeholderText: notiDelegate.modelData.inlineReplyPlaceholder || "Reply..." + placeholderTextColor: Qt.rgba(Settings.colors.foreground.r, Settings.colors.foreground.g, Settings.colors.foreground.b, 0.4) + color: Settings.colors.foreground + font.pixelSize: 12 + onAccepted: { notiDelegate.modelData.sendInlineReply(text); text = ""; } + background: Rectangle { + radius: 6 + color: Settings.colors.background + border.color: listReplyField.activeFocus ? Settings.colors.accent : Settings.colors.border + border.width: 1 } } + + IconButton { + icon: "mail-send-symbolic" + size: 14 + onClicked: { notiDelegate.modelData.sendInlineReply(listReplyField.text); listReplyField.text = ""; } + } } } } diff --git a/home/isabel/quickshell/modules/ScreenshotOverlay.qml b/home/isabel/quickshell/modules/ScreenshotOverlay.qml index 3a37c57b..a6123c25 100644 --- a/home/isabel/quickshell/modules/ScreenshotOverlay.qml +++ b/home/isabel/quickshell/modules/ScreenshotOverlay.qml @@ -15,7 +15,7 @@ Variants { readonly property real dpr: modelData ? modelData.devicePixelRatio : 1 readonly property bool isPrimary: modelData && Quickshell.screens.length > 0 && modelData.name === Quickshell.screens[0].name - readonly property string freezePath: "/tmp/qs-screenshot-freeze-" + (modelData ? modelData.name : "x") + ".png" + readonly property string freezePath: "/tmp/qs-screenshot-freeze-" + (modelData ? modelData.name : "x") + ".ppm" readonly property bool frozenReady: Screenshot.active && frozen.status === Image.Ready diff --git a/home/isabel/quickshell/services/Bluetooth.qml b/home/isabel/quickshell/services/Bluetooth.qml index 8377a8b2..89c45f3b 100644 --- a/home/isabel/quickshell/services/Bluetooth.qml +++ b/home/isabel/quickshell/services/Bluetooth.qml @@ -12,16 +12,9 @@ Singleton { readonly property bool powered: adapter?.enabled ?? false readonly property int state: adapter?.state ?? BluetoothAdapterState.Disabled - // Device state - readonly property var devices: adapter?.devices ?? [] - readonly property BluetoothDevice connectedDevice: { - if (!adapter) return null; - for (let i = 0; i < devices.count; i++) { - const device = devices.values[i]; - if (device.connected) return device; - } - return null; - } + // adapter.devices is an ObjectModel: it has .values (a list) but no .count. + readonly property var devices: adapter?.devices?.values ?? [] + readonly property BluetoothDevice connectedDevice: devices.find(d => d.connected) ?? null readonly property bool connected: connectedDevice !== null // Icon based on state diff --git a/home/isabel/quickshell/services/Media.qml b/home/isabel/quickshell/services/Media.qml index bb6af237..31333173 100644 --- a/home/isabel/quickshell/services/Media.qml +++ b/home/isabel/quickshell/services/Media.qml @@ -6,6 +6,7 @@ import Quickshell.Services.Mpris Singleton { id: root - property MprisPlayer selectedPlayer: players[0] - property list players: Mpris.players.values.filter(player => player.length != 0 && player?.trackTitle != "") + // MprisPlayer has no `length`; the old `player.length != 0` filter was a no-op. + property list players: Mpris.players.values.filter(player => player?.trackTitle != "") + property MprisPlayer selectedPlayer: players.length > 0 ? players[0] : null } diff --git a/home/isabel/quickshell/services/Networking.qml b/home/isabel/quickshell/services/Networking.qml index 0292564e..b5f6da58 100644 --- a/home/isabel/quickshell/services/Networking.qml +++ b/home/isabel/quickshell/services/Networking.qml @@ -1,189 +1,70 @@ -// Network service with WiFi and Ethernet support +// Wraps the native Quickshell.Networking singleton. Imported `as Net` because +// this singleton is itself named Networking and the names would collide. pragma Singleton import Quickshell -import Quickshell.Io +import Quickshell.Networking as Net import QtQuick Singleton { id: root + readonly property var wifiDevice: Net.Networking.devices.values.find(d => d.type === Net.DeviceType.Wifi) ?? null + readonly property var wiredDevice: Net.Networking.devices.values.find(d => d.type === Net.DeviceType.Wired) ?? null + // WiFi - readonly property list networks: [] - readonly property AccessPoint activeWifi: networks.find(n => n.active) ?? null - readonly property bool wifiEnabled: internal.wifiEnabled + readonly property bool wifiEnabled: Net.Networking.wifiEnabled + readonly property var networks: wifiDevice?.networks?.values ?? [] + readonly property var activeWifi: networks.find(n => n.connected) ?? null // Ethernet - readonly property bool ethernetConnected: internal.ethernetConnected - readonly property string ethernetDevice: internal.ethernetDevice + readonly property bool ethernetConnected: wiredDevice?.connected ?? false + readonly property string ethernetDevice: wiredDevice?.name ?? "" // Combined state readonly property bool connected: ethernetConnected || activeWifi !== null readonly property string connectionType: ethernetConnected ? "ethernet" : (activeWifi ? "wifi" : "none") + // Scanning is expensive; the UI sets this true only while showing the list. + property bool scanning: false + onScanningChanged: if (wifiDevice) wifiDevice.scannerEnabled = scanning + onWifiDeviceChanged: if (wifiDevice) wifiDevice.scannerEnabled = scanning + + // signalStrength is a fraction 0.0-1.0, not a percentage. + function networkIcon(network): string { + const s = network?.signalStrength ?? 0; + if (s >= 0.75) return "network-wireless-signal-excellent-symbolic"; + if (s >= 0.5) return "network-wireless-signal-good-symbolic"; + if (s >= 0.25) return "network-wireless-signal-ok-symbolic"; + if (s > 0) return "network-wireless-signal-weak-symbolic"; + return "network-wireless-signal-none-symbolic"; + } + readonly property string icon: { if (ethernetConnected) return "network-wired-symbolic"; - if (activeWifi) return activeWifi.icon; + if (activeWifi) return networkIcon(activeWifi); if (!wifiEnabled) return "network-wireless-disabled-symbolic"; return "network-wireless-offline-symbolic"; } readonly property string statusText: { if (ethernetConnected) return ethernetDevice; - if (activeWifi) return activeWifi.ssid; + if (activeWifi) return activeWifi.name; + if (!wifiEnabled) return "WiFi Off"; return "Disconnected"; } - reloadableId: "network" - - function toggleWifi() { - toggleWifiProc.running = true; - } - - function reload() { - checkStatus.running = true; - } - - function connectToNetwork(ssid) { - connectProc.command = ["nmcli", "d", "wifi", "connect", ssid]; - connectProc.running = true; - } - - QtObject { - id: internal - property bool wifiEnabled: true - property bool ethernetConnected: false - property string ethernetDevice: "" - } - - // Monitor for network changes - Process { - running: true - command: ["nmcli", "m"] - stdout: SplitParser { - onRead: checkStatus.running = true - } - } - - // Check overall network status - Process { - id: checkStatus - running: true - command: ["bash", "-c", ` - # Check WiFi status - wifi_status=$(nmcli radio wifi 2>/dev/null) - echo "WIFI:$wifi_status" - - # Check Ethernet - eth_info=$(nmcli -t -f TYPE,STATE,DEVICE,CONNECTION device 2>/dev/null | grep '^ethernet:connected' | head -1) - if [ -n "$eth_info" ]; then - eth_name=$(echo "$eth_info" | cut -d: -f4) - echo "ETH:connected:$eth_name" - else - echo "ETH:disconnected:" - fi - `] - environment: ({ LANG: "C", LC_ALL: "C" }) - stdout: SplitParser { - onRead: data => { - const line = data.trim(); - if (line.startsWith("WIFI:")) { - internal.wifiEnabled = line.includes("enabled"); - } else if (line.startsWith("ETH:")) { - const parts = line.split(":"); - internal.ethernetConnected = parts[1] === "connected"; - internal.ethernetDevice = parts[2] || ""; - } - } - } - onExited: { - if (internal.wifiEnabled) { - getNetworks.running = true; - } - } - } - - Process { - id: toggleWifiProc - command: ["nmcli", "radio", "wifi", internal.wifiEnabled ? "off" : "on"] - onExited: checkStatus.running = true - } - - Process { - id: connectProc - onExited: checkStatus.running = true - } - - Process { - id: getNetworks - command: ["nmcli", "-g", "ACTIVE,SIGNAL,FREQ,SSID,BSSID", "d", "w"] - environment: ({ LANG: "C", LC_ALL: "C" }) - stdout: StdioCollector { - onStreamFinished: { - const PLACEHOLDER = "STRINGWHICHHOPEFULLYWONTBEUSED"; - const rep = new RegExp("\\\\:", "g"); - const rep2 = new RegExp(PLACEHOLDER, "g"); - - const networks = text.trim().split("\n").filter(l => l.length > 0).map(n => { - const net = n.replace(rep, PLACEHOLDER).split(":"); - return { - active: net[0] === "yes", - strength: parseInt(net[1]) || 0, - frequency: parseInt(net[2]) || 0, - ssid: net[3] || "", - bssid: net[4]?.replace(rep2, ":") ?? "" - }; - }).filter(n => n.ssid !== ""); - - const rNetworks = root.networks; - - // Remove networks that no longer exist - const destroyed = rNetworks.filter(rn => !networks.find(n => - n.frequency === rn.frequency && n.ssid === rn.ssid && n.bssid === rn.bssid - )); - for (const network of destroyed) { - const idx = rNetworks.indexOf(network); - if (idx >= 0) { - rNetworks.splice(idx, 1); - network.destroy(); - } - } - - // Update or add networks - for (const network of networks) { - const match = rNetworks.find(n => - n.frequency === network.frequency && n.ssid === network.ssid && n.bssid === network.bssid - ); - if (match) { - match.lastIpcObject = network; - } else { - rNetworks.push(apComp.createObject(root, { lastIpcObject: network })); - } - } - } - } + function toggleWifi(): void { + Net.Networking.wifiEnabled = !Net.Networking.wifiEnabled; } - component AccessPoint: QtObject { - required property var lastIpcObject - readonly property string ssid: lastIpcObject.ssid - readonly property string bssid: lastIpcObject.bssid - readonly property int strength: lastIpcObject.strength - readonly property int frequency: lastIpcObject.frequency - readonly property bool active: lastIpcObject.active - readonly property string icon: { - if (strength >= 75) return "network-wireless-signal-excellent-symbolic"; - if (strength >= 50) return "network-wireless-signal-good-symbolic"; - if (strength >= 25) return "network-wireless-signal-ok-symbolic"; - if (strength > 0) return "network-wireless-signal-weak-symbolic"; - return "network-wireless-signal-none-symbolic"; - } + // Open and OWE (enhanced open) networks join without a password. + function needsPassword(network): bool { + if (!network || network.known) return false; + const s = network.security; + return s !== Net.WifiSecurityType.Open && s !== Net.WifiSecurityType.Owe; } - Component { - id: apComp - AccessPoint {} - } + reloadableId: "network" } - diff --git a/home/isabel/quickshell/services/Notifications.qml b/home/isabel/quickshell/services/Notifications.qml index a31d5a6b..a475a331 100644 --- a/home/isabel/quickshell/services/Notifications.qml +++ b/home/isabel/quickshell/services/Notifications.qml @@ -26,6 +26,7 @@ Singleton { } actionsSupported: true + inlineReplySupported: true } IpcHandler { diff --git a/home/isabel/quickshell/services/Screenshot.qml b/home/isabel/quickshell/services/Screenshot.qml index b3a526ee..f1f1bf0d 100644 --- a/home/isabel/quickshell/services/Screenshot.qml +++ b/home/isabel/quickshell/services/Screenshot.qml @@ -13,9 +13,12 @@ Singleton { property string pendingPath: "" property string pendingKind: "" property var autoGrab: null + // Set true only when the picker (menu) initiated the capture; gates swappy. + property bool fromMenu: false - readonly property string freezeAll: "/tmp/qs-screenshot-freeze-all.png" - function freezeOut(name: string): string { return "/tmp/qs-screenshot-freeze-" + name + ".png"; } + // PPM, not PNG: these are internal temps reloaded immediately by Qt, and + // grim's PNG zlib encoding was the bulk of the freeze latency (~240 -> ~55ms). + function freezeOut(name: string): string { return "/tmp/qs-screenshot-freeze-" + name + ".ppm"; } // XDG_SCREENSHOTS_DIR is not in quickshell's launch environment (only // interactive shells that source ~/.config/user-dirs.dirs export it), so @@ -42,8 +45,7 @@ Singleton { // Freeze before the overlay is shown so the overlay can never be in a capture. let parts = []; for (const s of Quickshell.screens) - parts.push("grim -o " + root.shq(s.name) + " " + root.shq(root.freezeOut(s.name))); - parts.push("grim " + root.shq(root.freezeAll)); + parts.push("grim -t ppm -o " + root.shq(s.name) + " " + root.shq(root.freezeOut(s.name))); freezeProc.command = ["bash", "-c", parts.join(" & ") + " & wait"]; freezeProc.running = true; } @@ -69,6 +71,7 @@ Singleton { root.pendingPath = ""; root.pendingKind = ""; root.autoGrab = null; + root.fromMenu = false; } function window(): void { root.startInstant("window"); } @@ -89,9 +92,13 @@ Singleton { // overlay can't appear in the shot (no teardown race with a live grim). function captureFromFreeze(kind: string): void { if (kind === "all") { + // There's no clean full-layout freeze any more (the full-screen grim was + // the slow part). Tear the overlay down and grab live after a short + // settle, so the layer surface is gone and can't land in the shot. root.active = false; root.mode = ""; - root.copyThenFinalize(root.freezeAll); + root.pendingKind = "all"; + allSettleTimer.restart(); } else if (kind === "output") { outputResolveProc.running = true; } else if (kind === "window") { @@ -99,17 +106,10 @@ Singleton { } } - function copyThenFinalize(src: string): void { - cpProc.command = ["cp", src, root.pendingPath]; - cpProc.running = true; - } - - Process { - id: cpProc - onExited: (code, status) => { - if (code === 0) root.finalize(root.pendingPath); - else root.fail("Could not copy capture"); - } + Timer { + id: allSettleTimer + interval: 120 + onTriggered: root.runLive() } Process { @@ -120,9 +120,11 @@ Singleton { try { const mons = JSON.parse(text); const f = mons.find(m => m.focused) ?? mons[0]; - root.active = false; - root.mode = ""; - root.copyThenFinalize(root.freezeOut(f.name)); + const target = Quickshell.screens.find(s => s.name === f.name) ?? Quickshell.screens[0]; + // Crop the focused output's frozen image to its full rect via the same + // grab path as window mode — turns the PPM freeze into a clean PNG + // without a live re-grab (overlay can't appear in the shot). + root.autoGrab = { output: target.name, x: 0, y: 0, w: target.width, h: target.height }; } catch (e) { root.fail("Could not determine monitor"); } @@ -214,10 +216,16 @@ Singleton { } function finalize(path: string): void { - // swappy's -o - writes its annotated result to stdout (-> wl-copy); its own - // Save button additionally writes to swappy's configured dir. - swappyProc.command = ["bash", "-c", "swappy -f " + root.shq(path) + " -o - | wl-copy"]; - swappyProc.running = true; + if (root.fromMenu) { + // Menu flow only: swappy's -o - writes its annotated result to stdout + // (-> wl-copy); its own Save button additionally writes to swappy's dir. + deliverProc.command = ["bash", "-c", "swappy -f " + root.shq(path) + " -o - | wl-copy"]; + } else { + // Direct keybind: copy the saved PNG to the clipboard, no editor. + deliverProc.command = ["bash", "-c", "wl-copy --type image/png < " + root.shq(path)]; + } + deliverProc.running = true; + root.fromMenu = false; root.pendingKind = ""; root.pendingPath = ""; root.autoGrab = null; @@ -229,8 +237,8 @@ Singleton { root.pendingPath = ""; root.pendingKind = ""; root.autoGrab = null; - notifyProc.command = ["notify-send", "Screenshot failed", msg, "-u", "critical"]; - notifyProc.running = true; + root.fromMenu = false; + Quickshell.execDetached(["notify-send", "Screenshot failed", msg, "-u", "critical"]); } Component.onCompleted: dirProc.running = true @@ -243,23 +251,23 @@ Singleton { const d = text.trim(); if (d.length > 0) root.dir = d; - mkdirProc.command = ["mkdir", "-p", root.dir]; - mkdirProc.running = true; + Quickshell.execDetached(["mkdir", "-p", root.dir]); } } } - Process { id: mkdirProc } - Process { id: notifyProc } - Process { id: swappyProc } + Process { id: deliverProc } IpcHandler { target: "screenshot" - function menu(): void { root.menu(); } - function region(): void { root.region(); } - function window(): void { root.window(); } - function output(): void { root.output(); } - function all(): void { root.all(); } + // fromMenu gates swappy in finalize(): only the picker opens the editor. + // The picker's buttons call root.window()/output()/all() directly (not these + // IPC handlers), so the flag menu() sets survives the in-overlay dispatch. + function menu(): void { root.fromMenu = true; root.menu(); } + function region(): void { root.fromMenu = false; root.region(); } + function window(): void { root.fromMenu = false; root.window(); } + function output(): void { root.fromMenu = false; root.output(); } + function all(): void { root.fromMenu = false; root.all(); } } reloadableId: "screenshot" diff --git a/home/isabel/quickshell/shell.qml b/home/isabel/quickshell/shell.qml index 791b6bdd..513dc273 100644 --- a/home/isabel/quickshell/shell.qml +++ b/home/isabel/quickshell/shell.qml @@ -10,4 +10,5 @@ Scope { Osd {} QuickSettingsPopup {} ScreenshotOverlay {} + NetworkPasswordDialog {} }