diff --git a/default/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index e1cbc28f..78fbd9b8 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -55,7 +55,7 @@ hl.bind("SUPER + CTRL + ALT + W", hl.dsp.exec_cmd("omarchy-notification-weather" hl.bind("SUPER + CTRL + A", hl.dsp.exec_cmd("omarchy-launch-audio"), { description = "Audio controls" }) hl.bind("SUPER + CTRL + B", hl.dsp.exec_cmd("omarchy-launch-bluetooth"), { description = "Bluetooth controls" }) -hl.bind("SUPER + CTRL + W", hl.dsp.exec_cmd("omarchy-launch-wifi"), { description = "Wifi controls" }) +hl.bind("SUPER + CTRL + W", hl.dsp.exec_cmd("omarchy-shell-ipc networkPanel toggle"), { description = "Wifi panel" }) hl.bind("SUPER + CTRL + T", hl.dsp.exec_cmd("omarchy-launch-tui btop"), { description = "Activity" }) hl.bind("SUPER + CTRL + X", hl.dsp.exec_cmd("voxtype record toggle"), { description = "Toggle dictation" }) diff --git a/default/quickshell/omarchy-shell/plugins/bar/common/KeyboardPanel.qml b/default/quickshell/omarchy-shell/plugins/bar/common/KeyboardPanel.qml new file mode 100644 index 00000000..ed018fe6 --- /dev/null +++ b/default/quickshell/omarchy-shell/plugins/bar/common/KeyboardPanel.qml @@ -0,0 +1,212 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Commons + +// Layer-shell popup attached to a bar widget icon, designed for +// click-driven AND keyboard-driven panels (e.g. SUPER+CTRL+W summon). +// +// Built on PanelWindow with WlrKeyboardFocus.Exclusive rather than +// PopupWindow (xdg-popup). Layer-shell surfaces declared Exclusive get +// keyboard focus from Hyprland *at map time*, which is the protocol-level +// equivalent of focus-on-launch for xdg-toplevels. xdg-popups don't get +// that — they only receive keys after a click/hover routes focus through +// their parent surface — so keyboard-summoned popups fell flat without it. +// +// API is a subset of Common.PopupCard: anchorItem, owner, bar, open, +// padding, margin, contentWidth/Height, default contentItem. Missing on +// purpose (for now): centerOnBar, triggerMode ("hover"), containsMouse. +// Hover-mode popups (system-stats, weather-flyout) and centered popups +// (calendar week-view) need extra plumbing before migrating; converting +// them is a follow-up. +// +// Positioning: full-screen layer-shell with the card placed inside at +// `cardOrigin`. We use the bar window's height/width for the perpendicular +// axis (away-from-bar) because mapToItem on the anchor returns +// bar-content-relative coords with internal layout offsets baked in +// (e.g. ~13px from the bar's vertical centering of its widget row). The +// parallel axis (along-the-bar) uses the anchor's content x/y since the +// bar spans full screen on that axis. +// +// Outside-click dismissal: an overlay MouseArea catches clicks, with the +// QsWindow.mask subtracting the bar strip so clicks on the bar still +// reach the bar widgets (activePopout coordinator hands off to another +// popup if the user clicks a different bar icon). +PanelWindow { + id: root + + required property Item anchorItem + required property QtObject bar + property var owner: null + property int margin: 10 + property int padding: 14 + property int contentWidth: 280 + property int contentHeight: 200 + property bool open: false + property int gap: 10 // distance between bar edge and panel + + default property alias contentItem: contentHolder.children + + readonly property var coordinatorKey: owner || root + readonly property var anchorWindow: anchorItem ? anchorItem.QsWindow.window : null + readonly property string barPos: bar ? bar.position : "top" + + function closePopout() { + if (owner && "closePopout" in owner) owner.closePopout() + else root.open = false + } + + // --- screen + lifetime --------------------------------------------------- + + screen: anchorWindow ? anchorWindow.screen : null + visible: open || card.opacity > 0 + color: "transparent" + exclusionMode: ExclusionMode.Ignore + + WlrLayershell.namespace: "omarchy-keyboard-panel" + WlrLayershell.layer: WlrLayer.Overlay + // Keyboard focus follows `open` (NOT `visible`). The window remains + // mapped during the fade-out so the opacity animation has something to + // animate, but keyboard/click ownership must release the moment the + // logical close fires — otherwise the user is locked out for 140ms. + WlrLayershell.keyboardFocus: open ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + + // Full-screen layer-shell. The visible card is positioned inside via + // `cardOrigin`. The `mask` below makes the bar area click-through (so + // the user can click another bar icon while the panel is open and the + // activePopout coordinator swaps to that popup); everywhere else, the + // overlay catches the click and dismisses via the MouseArea below. + anchors { + top: true + bottom: true + left: true + right: true + } + + // Clickable region = whole screen MINUS the bar's strip. Clicks on the + // bar pass through to the bar layer; clicks anywhere else are caught + // by us and either land on the card (no-op) or trigger dismissal. + readonly property real _barStripSize: bar ? bar.barSize : 0 + mask: Region { + width: root.screenW + height: root.screenH + Region { + x: root.barPos === "right" ? root.screenW - root._barStripSize : 0 + y: root.barPos === "bottom" ? root.screenH - root._barStripSize : 0 + width: (root.barPos === "top" || root.barPos === "bottom") ? root.screenW : root._barStripSize + height: (root.barPos === "top" || root.barPos === "bottom") ? root._barStripSize : root.screenH + intersection: Intersection.Subtract + } + } + + // Track every layout change between the bar's contentItem and the + // anchor item. `transform` updates whenever any item in that chain + // moves/resizes, which is what makes the position binding below + // actually reactive — mapToItem on its own is a one-shot. + TransformWatcher { + id: anchorWatcher + a: anchorWindow ? anchorWindow.contentItem : null + b: anchorItem + } + + // Anchor item's position within the bar's content surface. For a + // full-width top bar, the content x maps directly to screen x; the y + // returned here has the bar's internal padding baked in (e.g. ~13px + // from vertical centering of the widget row), which is why `cardOrigin` + // below uses `barH` for the perpendicular axis instead of this y. + readonly property point anchorScreenPos: { + anchorWatcher.transform // reactive dependency + if (!anchorItem || !anchorWindow) return Qt.point(0, 0) + return anchorItem.mapToItem(anchorWindow.contentItem, 0, 0) + } + readonly property real anchorW: anchorItem ? anchorItem.width : 0 + readonly property real anchorH: anchorItem ? anchorItem.height : 0 + readonly property real screenW: screen ? screen.width : 0 + readonly property real screenH: screen ? screen.height : 0 + + // Desired top-left of the card in screen coordinates. For the + // perpendicular axis (away-from-bar) we anchor to the bar window's edge + // directly — not the anchor item's y/x — because mapToItem(barContent) + // returns coordinates in the bar's content space, which can be offset + // from the bar surface's screen-anchored corner by internal layout + // (centering wrappers, padding). The bar's surface IS aligned to its + // anchored screen edge, so using `barW`/`barH` gives the right edge + // regardless of how the bar's internal widgets are positioned. For the + // parallel axis (along the bar) the anchor item's reported position is + // still consistent with the bar content origin, so it's accurate for + // centering the card under the icon. + readonly property real barW: anchorWindow ? anchorWindow.width : screenW + readonly property real barH: anchorWindow ? anchorWindow.height : 0 + readonly property point cardOrigin: { + if (!anchorItem || !bar) return Qt.point(margin, margin) + var x = 0, y = 0 + if (barPos === "bottom") { + x = anchorScreenPos.x + anchorW / 2 - contentWidth / 2 + y = screenH - barH - contentHeight - gap + } else if (barPos === "left") { + x = barW + gap + y = anchorScreenPos.y + anchorH / 2 - contentHeight / 2 + } else if (barPos === "right") { + x = screenW - barW - contentWidth - gap + y = anchorScreenPos.y + anchorH / 2 - contentHeight / 2 + } else { // "top" (default) + x = anchorScreenPos.x + anchorW / 2 - contentWidth / 2 + y = barH + gap + } + x = Math.max(margin, Math.min(x, screenW - contentWidth - margin)) + y = Math.max(margin, Math.min(y, screenH - contentHeight - margin)) + return Qt.point(Math.round(x), Math.round(y)) + } + + + // --- popout coordination (same-bar single-popout model) ----------------- + + // Coordinate on `open`, not `visible`. `visible` lags into the fade-out + // animation, which made ownership transfer to a sibling popup race. + onOpenChanged: { + if (!bar) return + if (open) bar.requestPopout(coordinatorKey) + else if (bar.activePopout === coordinatorKey) bar.releasePopout(coordinatorKey) + } + + // --- outside-click dismissal -------------------------------------------- + + // Catches clicks anywhere in the clickable region (i.e. everywhere on + // screen except the bar strip, which is masked out). The card has its + // own MouseArea below so clicks on it don't bubble up here. Disabled + // during the fade-out so the dying overlay doesn't swallow clicks that + // were meant for the apps behind it. + MouseArea { + anchors.fill: parent + enabled: root.open + onClicked: root.closePopout() + } + + // --- card ---------------------------------------------------------------- + + Rectangle { + id: card + x: root.cardOrigin.x + y: root.cardOrigin.y + width: root.contentWidth + height: root.contentHeight + color: Color.popups.background + border.color: Color.popups.border + border.width: 2 + radius: 0 + opacity: root.open ? 1.0 : 0 + Behavior on opacity { + NumberAnimation { duration: 140; easing.type: Easing.OutCubic } + } + + // Swallow clicks on the card so they don't bubble to the dismissal + // MouseArea behind us. + MouseArea { anchors.fill: parent } + + Item { + id: contentHolder + anchors.fill: parent + anchors.margins: root.padding + } + } +} diff --git a/default/quickshell/omarchy-shell/plugins/bar/widgets/networkPanel.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/networkPanel.qml index 4c56d055..f507b9d9 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/widgets/networkPanel.qml +++ b/default/quickshell/omarchy-shell/plugins/bar/widgets/networkPanel.qml @@ -12,7 +12,11 @@ Item { property var settings: ({}) property bool popupOpen: false - function closePopout() { popupOpen = false } + // Centralized close so callers can't forget to drop the passphrase prompt. + function closePopout() { + popupOpen = false + passwordSsid = "" + } // Live connection details from `ip` / /sys / iw. property var info: ({}) // { iface, type, ip, prefix, gateway, speed, duplex, ssid, signal, freq, bitrate } @@ -22,6 +26,95 @@ Item { property string dnsProvider: "" property string pendingDnsProvider: "" + // Per-row in-flight state. `actionSsid` flips on for the row whose action + // is currently running so it can render "Connecting…" / "Disconnecting…" / + // "Forgetting…". `passwordSsid` is the row currently expanded into + // password-entry mode; we keep it open across refresh cycles so a slow scan + // doesn't collapse the input the user is typing into. Rows must gate + // comparisons on the matching `*Kind`/`*Reason` being non-empty so a + // hidden-SSID row (ssid == "") doesn't collide with the "" defaults. + property string actionSsid: "" + property string actionKind: "" // "connect" | "disconnect" | "forget" + property string failureSsid: "" + property string failureReason: "" + property string passwordSsid: "" + + // True while any wifi action or known-network probe is mid-flight. Rows + // disable themselves on this so clicks on the other rows don't silently + // no-op against runAction's serialized guard. + readonly property bool busy: actionProc.running || knownCheck.running + + // Index into `wifiNetworks` for keyboard navigation. -1 = no selection. + property int selectedIndex: -1 + + // The panel below is its own layer-shell with Exclusive keyboard focus, + // so Hyprland grants focus when the surface is mapped (popupOpen flips + // to true). That's what makes the SUPER+CTRL+W keybind actually work + // — OnDemand only grants focus on click/hover. + onPopupOpenChanged: { + if (popupOpen) { + refresh() + selectedIndex = wifiNetworks.length > 0 ? 0 : -1 + Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) + } + } + + // When the passphrase prompt closes (Esc / Cancel / success) restore + // focus to the keyCatcher so j/k/Enter resume working without a click. + onPasswordSsidChanged: { + if (passwordSsid === "" && popupOpen) { + Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) + } + } + + // Keep selectedIndex valid as scans refresh the network list. + onWifiNetworksChanged: { + if (wifiNetworks.length === 0) selectedIndex = -1 + else if (selectedIndex >= wifiNetworks.length) selectedIndex = wifiNetworks.length - 1 + else if (selectedIndex < 0 && popupOpen) selectedIndex = 0 + } + + function selectByDelta(delta) { + if (wifiNetworks.length === 0) { selectedIndex = -1; return } + if (selectedIndex < 0) selectedIndex = delta > 0 ? 0 : wifiNetworks.length - 1 + else selectedIndex = (selectedIndex + delta + wifiNetworks.length) % wifiNetworks.length + } + + // Enter/Space on the highlighted row. Mirrors row-click semantics: + // connected → disconnect, protected-unknown → password prompt (via + // known-network probe), open/known → connect. + function activateSelected() { + if (busy || selectedIndex < 0 || selectedIndex >= wifiNetworks.length) return + var net = wifiNetworks[selectedIndex] + if (!net) return + if (net.connected) { disconnect(net.ssid); return } + if (isProtected(net.security)) { + var quotedSsid = bar.shellQuote(net.ssid) + knownCheck.targetSsid = net.ssid + knownCheck.command = ["bash", "-c", ` +iwctl known-networks list 2>/dev/null \\ + | sed -e 's/\\x1b\\[[0-9;]*m//g' \\ + | awk -v s=${quotedSsid} 'NR > 3 { + line = $0 + sub(/^[[:space:]]+/, "", line) + sub(/[[:space:]]+$/, "", line) + n = split(line, p, /[[:space:]]{2,}/) + if (n >= 1 && p[1] == s) { print "yes"; exit } + }'`] + knownCheck.running = true + return + } + connectKnown(net.ssid) + } + + // 'x' on the highlighted row. Only meaningful on the currently-connected + // network — forget is a no-op (and the X icon is hidden) otherwise. + function forgetSelected() { + if (busy || selectedIndex < 0 || selectedIndex >= wifiNetworks.length) return + var net = wifiNetworks[selectedIndex] + if (net && net.connected) forget(net.ssid) + } + readonly property string kind: bar ? bar.networkKind : "disconnected" readonly property string label: bar ? bar.networkLabel : "" readonly property int signalStrength: bar ? bar.networkSignal : -1 @@ -134,11 +227,104 @@ Item { root.popupOpen = false } + function isProtected(security) { + var s = String(security || "").toLowerCase() + return s !== "" && s !== "open" + } + + function runAction(kind, ssid, command) { + if (actionProc.running) return + actionSsid = ssid + actionKind = kind + failureSsid = "" + failureReason = "" + actionProc.command = ["bash", "-c", command] + actionProc.running = true + // Safety net: if onExited never fires (process death, signal handler + // throws, etc.), clear the busy state so the row doesn't get stuck on + // "Connecting…" / "Disconnecting…" forever. + actionTimeout.restart() + } + + function connectKnown(ssid) { + var quotedSsid = bar.shellQuote(ssid) + runAction("connect", ssid, ` +station=$(iwctl station list 2>/dev/null | sed -e 's/\\x1b\\[[0-9;]*m//g' | awk '/^[[:space:]]*wl/ { print $1; exit }') +[[ -z $station ]] && { echo "no Wi-Fi station available" >&2; exit 1; } +iwctl --dont-ask station "$station" connect ${quotedSsid} +`) + } + + function connectWithPassphrase(ssid, passphrase) { + var quotedSsid = bar.shellQuote(ssid) + var quotedPass = bar.shellQuote(passphrase) + runAction("connect", ssid, ` +station=$(iwctl station list 2>/dev/null | sed -e 's/\\x1b\\[[0-9;]*m//g' | awk '/^[[:space:]]*wl/ { print $1; exit }') +[[ -z $station ]] && { echo "no Wi-Fi station available" >&2; exit 1; } +iwctl --passphrase ${quotedPass} station "$station" connect ${quotedSsid} +`) + } + + function disconnect(ssid) { + runAction("disconnect", ssid, ` +station=$(iwctl station list 2>/dev/null | sed -e 's/\\x1b\\[[0-9;]*m//g' | awk '/^[[:space:]]*wl/ { print $1; exit }') +[[ -z $station ]] && { echo "no Wi-Fi station available" >&2; exit 1; } +iwctl station "$station" disconnect +`) + } + + // iwd doesn't reliably tear down an active connection when you forget the + // network underneath it, so if the station is currently on this SSID we + // disconnect first and bail on failure rather than reporting a misleading + // success. If the station isn't on this SSID (or there is no station), + // skip straight to forget. + function forget(ssid) { + var quotedSsid = bar.shellQuote(ssid) + runAction("forget", ssid, ` +station=$(iwctl station list 2>/dev/null | sed -e 's/\\x1b\\[[0-9;]*m//g' | awk '/^[[:space:]]*wl/ { print $1; exit }') +if [[ -n $station ]]; then + current=$(iwctl station "$station" show 2>/dev/null \ + | sed -e 's/\\x1b\\[[0-9;]*m//g' \ + | awk '/Connected network/ { + s = $0 + sub(/.*Connected network[[:space:]]+/, "", s) + sub(/[[:space:]]+$/, "", s) + print s + exit + }') + if [[ $current == ${quotedSsid} ]]; then + iwctl station "$station" disconnect || { echo "failed to disconnect before forget" >&2; exit 1; } + fi +fi +iwctl known-networks ${quotedSsid} forget +`) + } + + function launchImpala() { + if (!bar) return + var quotedPath = bar.shellQuote(bar.omarchyPath) + var quotedBin = bar.shellQuote(bar.omarchyPath + "/bin/omarchy-launch-wifi") + var quotedFullPath = bar.shellQuote(bar.omarchyPath + "/bin:" + (Quickshell.env("PATH") || "")) + bar.run("OMARCHY_PATH=" + quotedPath + " PATH=" + quotedFullPath + " " + quotedBin) + } + implicitWidth: button.implicitWidth implicitHeight: button.implicitHeight Component.onCompleted: refresh() + // Lets a Hyprland keybind summon the panel without needing to click the + // bar icon. Paired with the SUPER+CTRL+W binding in utilities.lua. + IpcHandler { + target: "networkPanel" + function toggle(): void { + if (root.popupOpen) root.closePopout() + else root.popupOpen = true + } + function show(): void { if (!root.popupOpen) root.popupOpen = true } + function hide(): void { root.closePopout() } + } + // Pulls everything we want about the active route's interface in one shot. Process { id: detailsProc @@ -219,13 +405,83 @@ iwctl station "$station" get-networks rssi-dbms 2>/dev/null \\ } } + // Action runner for connect/disconnect/forget and DNS provider changes. + // Streams stderr for wifi actions so we can surface "Operation failed" or + // "Invalid passphrase" inline rather than dropping the user back to a + // silent UI. setDns() and runAction() both gate on `actionProc.running`, + // so the two flows can't overlap on this shared process. Process { id: actionProc + stdout: StdioCollector { id: actionStdout; waitForEnd: true } + stderr: StdioCollector { id: actionStderr; waitForEnd: true } onExited: function(exitCode) { + // DNS change finished — pendingDnsProvider is the in-flight marker + // for that flow (setDns doesn't touch actionKind), so handle it and + // return before the wifi-action path. if (root.pendingDnsProvider !== "") { if (exitCode === 0) root.dnsProvider = root.pendingDnsProvider root.pendingDnsProvider = "" + return } + // Timeout already cleared our state and killed us — the exit signal + // is stale, don't clobber whatever the user has done since. + if (root.actionKind === "") return + actionTimeout.stop() + var ssid = root.actionSsid + var kind = root.actionKind + if (exitCode === 0) { + if (kind === "connect") root.passwordSsid = "" + root.failureSsid = "" + root.failureReason = "" + } else { + root.failureSsid = ssid + var reason = (actionStderr.text || actionStdout.text || "").trim() + if (!reason) { + if (kind === "connect") reason = "Failed to connect" + else if (kind === "disconnect") reason = "Failed to disconnect" + else reason = "Failed to forget" + } + // Squash multi-line iwctl errors into a single readable line. + root.failureReason = reason.split("\n").pop() + } + root.actionSsid = "" + root.actionKind = "" + root.refresh() + } + } + + // Poll detailsProc while the panel is open. `iwctl connect` returns + // success the moment iwd accepts credentials — the IP/route isn't + // actually assigned until a beat later, so a single post-action refresh + // races against routing and the header stays blank. Polling fills the + // details in as soon as the route comes up; cheap since the script is + // small and only runs while the panel is visible. + Timer { + id: detailsPoll + interval: 1500 + repeat: true + running: root.popupOpen + onTriggered: if (!detailsProc.running) detailsProc.running = true + } + + Timer { + id: actionTimeout + interval: 15000 + repeat: false + onTriggered: { + if (!root.actionKind) return + var reason + if (root.actionKind === "connect") reason = "Timed out connecting" + else if (root.actionKind === "disconnect") reason = "Timed out disconnecting" + else reason = "Timed out forgetting" + // Clear state *before* killing the process so the eventual onExited + // sees actionKind === "" and bails out as stale. + root.failureSsid = root.actionSsid + root.failureReason = reason + root.actionSsid = "" + root.actionKind = "" + if (actionProc.running) actionProc.running = false // SIGTERM + root.refresh() } } @@ -239,15 +495,20 @@ iwctl station "$station" get-networks rssi-dbms 2>/dev/null \\ tooltipText: bar ? bar.networkTooltip() : "" onPressed: function(b) { - if (b === Qt.RightButton) root.bar.run(root.bar.omarchyPath + "/bin/omarchy-launch-wifi") - else { - root.popupOpen = !root.popupOpen - if (root.popupOpen) root.refresh() - } + if (b === Qt.RightButton) root.launchImpala() + else if (root.popupOpen) root.closePopout() + else { root.popupOpen = true; root.refresh() } } } - Common.PopupCard { + // Keyboard-driven popup anchored to the bar widget icon. The shared + // Common.KeyboardPanel handles the layer-shell PanelWindow scaffolding + // (Exclusive focus on map, screen binding, anchored-to-icon positioning, + // outside-click via an overlay MouseArea + Region mask that lets the bar + // remain clickable, fade animation, popout coordination). What stays + // here is the wifi-specific UI inside. + Common.KeyboardPanel { + id: panel anchorItem: button owner: root bar: root.bar @@ -255,9 +516,42 @@ iwctl station "$station" get-networks rssi-dbms 2>/dev/null \\ contentWidth: 340 contentHeight: column.implicitHeight + 28 + // Catches all unhandled keys for keyboard navigation. AfterItem priority + // lets the passphrase TextField (a child via focus chain) get its keys + // first; only events the focused subtree ignores bubble back here. + Item { + id: keyCatcher + anchors.fill: parent + focus: true + Keys.priority: Keys.AfterItem + Keys.onPressed: function(event) { + if (root.passwordSsid !== "") return + if (event.key === Qt.Key_Escape) { + root.closePopout() + event.accepted = true + } else if (event.key === Qt.Key_Down || event.text === "j") { + root.selectByDelta(1) + event.accepted = true + } else if (event.key === Qt.Key_Up || event.text === "k") { + root.selectByDelta(-1) + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Space) { + root.activateSelected() + event.accepted = true + } else if (event.text === "x" || event.text === "X") { + root.forgetSelected() + event.accepted = true + } else if (event.text === "r" || event.text === "R") { + root.refresh() + event.accepted = true + } + } + Column { id: column - anchors.fill: parent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top spacing: 12 // Header — interface name + type, refresh on the right. @@ -512,81 +806,395 @@ iwctl station "$station" get-networks rssi-dbms 2>/dev/null \\ } // Scrollable network list — cap the height so a busy neighbourhood - // doesn't push the popup off-screen. Bluetooth panel uses the same - // pattern. - Flickable { + // doesn't push the popup off-screen. ListView (vs Repeater+Column) + // gives us positionViewAtIndex for free, which is what keeps the + // keyboard-selected row scrolled into view as j/k walk past the + // visible window. + ListView { + id: networkList visible: root.wifiStationAvailable width: parent.width - height: Math.min(networkList.implicitHeight, 240) - contentWidth: width - contentHeight: networkList.implicitHeight + height: Math.min(contentHeight, 240) + spacing: 4 clip: true boundsBehavior: Flickable.StopAtBounds + interactive: contentHeight > height ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } - Column { - id: networkList - width: parent.width - spacing: 6 + model: root.wifiStationAvailable ? root.wifiNetworks : [] + currentIndex: root.selectedIndex + onCurrentIndexChanged: if (currentIndex >= 0) positionViewAtIndex(currentIndex, ListView.Contain) - Repeater { - model: root.wifiStationAvailable ? root.wifiNetworks : [] - - Common.PillButton { - required property var modelData - - width: networkList.width - leftAlign: true - iconText: root.wifiIconFor(modelData.signal) - text: (modelData.ssid || "Hidden") + (modelData.security ? " · " + modelData.security : "") - foreground: root.bar.foreground - horizontalPadding: 10 - verticalPadding: 6 - active: modelData.connected - - onClicked: { - if (modelData.connected) return - if (actionProc.running) return - // Known networks (and open networks) can connect non-interactively - // via iwctl. Anything else needs Impala for passphrase entry. - var ssid = root.bar.shellQuote(modelData.ssid) - var security = root.bar.shellQuote(modelData.security || "") - var omarchyPath = root.bar.omarchyPath - actionProc.command = ["bash", "-c", ` -ssid=${ssid} -security=${security} -station=$(iwctl station list 2>/dev/null | sed -e 's/\\x1b\\[[0-9;]*m//g' | awk '/^[[:space:]]*wl/ { print $1; exit }') -known=$(iwctl known-networks list 2>/dev/null | sed -e 's/\\x1b\\[[0-9;]*m//g' | awk -v s="$ssid" 'NR > 3 { - line = $0 - sub(/^[[:space:]]+/, "", line) - sub(/[[:space:]]+$/, "", line) - n = split(line, p, /[[:space:]]{2,}/) - if (n >= 1 && p[1] == s) { print "yes"; exit } -}') -if [[ -n $station && ( -n $known || $security == "open" ) ]]; then - iwctl --dont-ask station "$station" connect "$ssid" -else - ${omarchyPath}/bin/omarchy-launch-wifi -fi -`] - actionProc.running = true - root.popupOpen = false - } - } + // Wrapper takes the required props from ListView's delegate context + // (which doesn't bind into nested `component` declarations like + // NetworkRow) and passes them down explicitly. + delegate: Item { + required property var modelData + required property int index + width: ListView.view.width + height: row.implicitHeight + NetworkRow { + id: row + width: parent.width + net: parent.modelData + index: parent.index } } } - Common.PillButton { - visible: root.wifiStationAvailable - width: parent.width - iconText: "󰖩" - text: "Open Wi-Fi manager (Impala)" - foreground: root.bar.foreground - horizontalPadding: 10 - verticalPadding: 6 - onClicked: { root.bar.run(root.bar.omarchyPath + "/bin/omarchy-launch-wifi"); root.popupOpen = false } + } + } + } + + // A single Wi-Fi network entry. Collapses to a one-line pill normally; + // expands inline to a passphrase prompt when the user picks a protected + // network we don't have credentials for. Clicking a connected row + // disconnects; the X button on a connected row forgets the network. + component NetworkRow: Rectangle { + id: row + required property var net + required property int index + + readonly property bool isConnected: net && net.connected + readonly property bool isProtected: root.isProtected(net ? net.security : "") + readonly property bool isSelected: root.selectedIndex === index + // Gate on the matching *Kind/*Reason being non-empty so a hidden-SSID + // row (ssid == "") doesn't match the "" defaults of actionSsid etc. + readonly property bool isBusy: root.actionKind !== "" && root.actionSsid === (net ? net.ssid : "") + readonly property bool isFailed: root.failureReason !== "" && root.failureSsid === (net ? net.ssid : "") + readonly property bool isPasswordOpen: root.passwordSsid !== "" && root.passwordSsid === (net ? net.ssid : "") + + readonly property string statusText: { + if (!net) return "" + if (isBusy && root.actionKind === "connect") return "Connecting…" + if (isBusy && root.actionKind === "disconnect") return "Disconnecting…" + if (isBusy && root.actionKind === "forget") return "Forgetting…" + if (isFailed) return root.failureReason || "Failed" + if (isConnected) return "Connected" + return "" + } + + readonly property color statusColor: { + if (isFailed) return root.bar.urgent + if (isBusy) return root.bar.foreground + if (isConnected) return root.bar.foreground + return Qt.darker(root.bar.foreground, 1.5) + } + + implicitHeight: rowBody.implicitHeight + (isPasswordOpen ? passwordPanel.implicitHeight + 6 : 0) + radius: 4 + color: isSelected + ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.18) + : rowMouse.containsMouse + ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.12) + : (isConnected ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.06) : "transparent") + + Behavior on color { ColorAnimation { duration: 120 } } + + MouseArea { + id: rowMouse + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: rowBody.implicitHeight + hoverEnabled: true + acceptedButtons: Qt.LeftButton + cursorShape: Qt.PointingHandCursor + enabled: !root.busy + + onClicked: { + if (!row.net) return + if (row.isConnected) { + root.disconnect(row.net.ssid) + return + } + if (row.isProtected) { + // Known protected network: iwd has the passphrase, just connect. + // Otherwise expand the inline password prompt for this row. Stash + // the SSID as a string — if we held a reference to the row + // delegate it could be destroyed by a model refresh, and a rapid + // second click would overwrite it and misroute the first result. + var quotedSsid = root.bar.shellQuote(row.net.ssid) + knownCheck.targetSsid = row.net.ssid + knownCheck.command = ["bash", "-c", ` +iwctl known-networks list 2>/dev/null \\ + | sed -e 's/\\x1b\\[[0-9;]*m//g' \\ + | awk -v s=${quotedSsid} 'NR > 3 { + line = $0 + sub(/^[[:space:]]+/, "", line) + sub(/[[:space:]]+$/, "", line) + n = split(line, p, /[[:space:]]{2,}/) + if (n >= 1 && p[1] == s) { print "yes"; exit } + }'`] + knownCheck.running = true + return + } + root.connectKnown(row.net.ssid) + } + } + + Item { + id: rowBody + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 10 + anchors.rightMargin: 10 + implicitHeight: Math.max(networkIcon.implicitHeight, networkInfo.implicitHeight, forgetBtn.implicitHeight) + 12 + + Text { + id: networkIcon + text: row.net ? root.wifiIconFor(row.net.signal) : "" + color: row.statusColor + font.family: root.bar.fontFamily + font.pixelSize: 14 + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + + Rectangle { + id: forgetBtn + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 22 + height: 22 + radius: 4 + visible: row.isConnected + color: forgetMouse.containsMouse + ? Qt.rgba(root.bar.urgent.r, root.bar.urgent.g, root.bar.urgent.b, 0.20) + : "transparent" + + Behavior on color { ColorAnimation { duration: 120 } } + + Text { + anchors.centerIn: parent + text: "󰅙" + color: forgetMouse.containsMouse ? root.bar.urgent : Qt.darker(root.bar.foreground, 1.3) + font.family: root.bar.fontFamily + font.pixelSize: 14 + } + + MouseArea { + id: forgetMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: !root.busy + onClicked: if (row.net) root.forget(row.net.ssid) + } + + ToolTip { + visible: forgetMouse.containsMouse + text: "Forget network" + delay: 400 + padding: 0 + background: Rectangle { + color: root.bar.background + border.color: root.bar.foreground + border.width: 1 + radius: 0 + opacity: 0.97 + } + contentItem: Text { + text: "Forget network" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 11 + leftPadding: 10 + rightPadding: 10 + topPadding: 6 + bottomPadding: 6 + } + } + } + + // Shows a lock glyph on the right for protected networks that + // aren't currently connected. Once connected, the forget X takes + // its place (and 'protected' is implied by the fact we're on it). + // Same 22-wide right-anchored centered geometry as forgetBtn so the + // glyph centers line up across rows. + Text { + id: lockIndicator + visible: row.isProtected && !row.isConnected + width: 22 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + horizontalAlignment: Text.AlignHCenter + text: "󰌾" + color: Qt.darker(root.bar.foreground, 1.4) + font.family: root.bar.fontFamily + font.pixelSize: 13 + } + + Column { + id: networkInfo + spacing: 1 + anchors.left: networkIcon.right + anchors.leftMargin: 10 + anchors.right: forgetBtn.visible ? forgetBtn.left + : lockIndicator.visible ? lockIndicator.left + : parent.right + anchors.rightMargin: (forgetBtn.visible || lockIndicator.visible) ? 8 : 0 + anchors.verticalCenter: parent.verticalCenter + + Text { + text: row.net ? (row.net.ssid || "Hidden") : "" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 12 + elide: Text.ElideRight + width: parent.width + } + Text { + // Signal strength is conveyed by the wifi-bars icon and a lock + // glyph on the right denotes protected networks, so the second + // line only carries action status (Connecting…, Connected, + // Failed, etc.). Collapses to zero height when empty so rows + // without status keep a tight one-line look. + text: row.statusText + visible: row.statusText !== "" + height: visible ? implicitHeight : 0 + color: row.statusColor + font.family: root.bar.fontFamily + font.pixelSize: 10 + elide: Text.ElideRight + width: parent.width + } + } + } + + // Inline passphrase prompt — only shown when we hit a protected network + // we don't have saved credentials for. Submitting (Enter or the check + // button) fires connect; Esc cancels back to the row. + Item { + id: passwordPanel + visible: row.isPasswordOpen + anchors.left: parent.left + anchors.right: parent.right + anchors.top: rowMouse.bottom + anchors.leftMargin: 10 + anchors.rightMargin: 10 + anchors.topMargin: 4 + implicitHeight: pwField.implicitHeight + 8 + + TextField { + id: pwField + anchors.left: parent.left + anchors.right: connectPwBtn.left + anchors.verticalCenter: parent.verticalCenter + anchors.rightMargin: 6 + echoMode: TextInput.Password + placeholderText: "Passphrase" + font.family: root.bar.fontFamily + font.pixelSize: 12 + color: root.bar.foreground + selectionColor: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.35) + selectedTextColor: root.bar.foreground + placeholderTextColor: Qt.darker(root.bar.foreground, 1.6) + leftPadding: 8 + rightPadding: 8 + topPadding: 6 + bottomPadding: 6 + enabled: !row.isBusy + + background: Rectangle { + color: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, pwField.activeFocus ? 0.10 : 0.05) + border.color: pwField.activeFocus + ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.45) + : Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.20) + border.width: 1 + radius: 4 + } + + onAccepted: { + if (!root.busy && row.net && text.length > 0) root.connectWithPassphrase(row.net.ssid, text) + } + Keys.onEscapePressed: { root.passwordSsid = ""; text = "" } + + onVisibleChanged: if (visible) Qt.callLater(forceActiveFocus) + Component.onCompleted: if (visible) Qt.callLater(forceActiveFocus) + } + + // 22×22 right-anchored to line up with forgetBtn and lockIndicator + // above. Esc closes the prompt (handled by pwField.Keys.onEscapePressed) + // so there's no separate cancel button. + Rectangle { + id: connectPwBtn + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 22 + height: 22 + radius: 4 + property bool clickEnabled: !root.busy && row.net && pwField.text.length > 0 + color: connectPwMouse.containsMouse && connectPwBtn.clickEnabled + ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.20) + : "transparent" + + Behavior on color { ColorAnimation { duration: 120 } } + + Text { + anchors.centerIn: parent + text: "󰄬" + color: connectPwBtn.clickEnabled + ? (connectPwMouse.containsMouse ? root.bar.foreground : Qt.darker(root.bar.foreground, 1.3)) + : Qt.darker(root.bar.foreground, 2.0) + font.family: root.bar.fontFamily + font.pixelSize: 14 + } + + MouseArea { + id: connectPwMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: connectPwBtn.clickEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: if (connectPwBtn.clickEnabled) root.connectWithPassphrase(row.net.ssid, pwField.text) + } + + ToolTip { + visible: connectPwMouse.containsMouse + text: "Connect" + delay: 400 + padding: 0 + background: Rectangle { + color: root.bar.background + border.color: root.bar.foreground + border.width: 1 + radius: 0 + opacity: 0.97 + } + contentItem: Text { + text: "Connect" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 11 + leftPadding: 10 + rightPadding: 10 + topPadding: 6 + bottomPadding: 6 + } + } + } + } + } + + // One-shot probe: is the just-clicked SSID in iwd's known-networks? + // If so, skip the passphrase prompt and connect directly. We hold an SSID + // string (not a row delegate) so a model refresh during the probe can't + // leave us pointing at a destroyed object. Rows are globally disabled + // while `knownCheck.running`, so the SSID can't be overwritten mid-flight. + Process { + id: knownCheck + property string targetSsid: "" + stdout: StdioCollector { + id: knownStdout + waitForEnd: true + onStreamFinished: { + var ssid = knownCheck.targetSsid + knownCheck.targetSsid = "" + if (!ssid) return + if (text.indexOf("yes") !== -1) root.connectKnown(ssid) + else root.passwordSsid = ssid } } }