Keyboard-driven wifi panel

The wifi panel was a bar-widget xdg-popup which Hyprland doesn't grant
keyboard focus to until the user clicks or hovers it — keys typed after
a SUPER+CTRL+W summon went to whatever previously had focus.

New shared Common.KeyboardPanel scaffolding: layer-shell PanelWindow
with WlrKeyboardFocus.Exclusive (granted at map time, the protocol-level
equivalent of focus-on-launch for xdg-toplevels), full-screen anchored
with a Region mask that subtracts the bar strip so bar widgets stay
clickable, MouseArea for outside-click dismissal, TransformWatcher for
reactive anchor-position tracking. API is a subset of Common.PopupCard
(no centerOnBar / triggerMode / containsMouse yet — adding when the
other bar popups migrate).

networkPanel.qml uses it. Adds full keyboard navigation:
  j/k or Up/Down  — move selection
  Return/Space    — connect (or disconnect if already connected)
  x               — forget the highlighted network
  r               — refresh scan
  Esc             — close

ListView (with positionViewAtIndex on currentIndex) replaces the
Repeater+Column inside Flickable so the selected row stays visible as
j/k walks past the cap. A 1.5s Timer re-polls detailsProc while open
so the connection details (IP, gateway, signal) populate when routing
actually comes up after a connect rather than waiting for re-summon.

Rows: lock glyph on the right for protected-and-not-connected, X
(forget) for connected — aligned to the same 22-wide column. Connect
glyph in the passphrase prompt switched to a check, no separate cancel
(Esc handles it).

SUPER+CTRL+W in utilities.lua now toggles this panel via
omarchy-shell-ipc instead of launching Impala. Right-click on the bar
icon still launches Impala as a fallback for hidden SSIDs / enterprise
auth / iwd edge cases.
This commit is contained in:
Ryan Hughes
2026-05-17 18:18:53 -04:00
parent fa4d2bc566
commit e3fff41c0e
3 changed files with 892 additions and 72 deletions
+1 -1
View File
@@ -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" })
@@ -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
}
}
}
@@ -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
}
}
}