Build the Panama Hyprland desktop

This commit is contained in:
Gabriel Brown
2026-08-17 10:32:55 -04:00
parent 67033f2a31
commit 5248883e4b
190 changed files with 18554 additions and 34 deletions
@@ -0,0 +1,61 @@
// Output / input device picker. Writing Pipewire.preferredDefaultAudioSink is
// what actually moves audio; the default* properties are read-only reflections
// of what the session manager settled on.
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
import qs.config
Item {
id: root
// false → input devices.
property bool output: true
property alias maxHeight: list.maxHeight
implicitHeight: list.implicitHeight
readonly property var nodes: {
return Pipewire.nodes.values.filter(n => {
if (n.isStream)
return false;
// Sources have to be filtered on the type flags: !isSink also
// matches video nodes (webcams show up here otherwise).
return root.output ? n.isSink : (n.type & PwNodeType.AudioSource) === PwNodeType.AudioSource;
});
}
readonly property var current: root.output ? Pipewire.defaultAudioSink : Pipewire.defaultAudioSource
function select(node): void {
if (root.output)
Pipewire.preferredDefaultAudioSink = node;
else
Pipewire.preferredDefaultAudioSource = node;
}
ScrollColumn {
id: list
anchors.fill: parent
maxHeight: 220
Repeater {
model: root.nodes
RowButton {
id: nodeRow
required property var modelData
width: parent.width
implicitHeight: 38
icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic"
iconFallback: "audio-card-symbolic"
label: nodeRow.modelData.description || nodeRow.modelData.nickname || nodeRow.modelData.name
selected: nodeRow.modelData === root.current
onClicked: root.select(nodeRow.modelData)
}
}
}
}
@@ -0,0 +1,85 @@
// Volume row: mute button, slider, and a chevron that opens the device picker.
//
// PwObjectTracker is mandatory — without it node.audio.volume reads 0 and
// writes are dropped on the floor with no error.
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
import qs.widgets
import qs.config
Item {
id: root
property var node: null
// Output vs input, purely for icon selection.
property bool output: true
property bool expanded: false
signal toggleExpanded
implicitHeight: 32
visible: root.node !== null
readonly property real volume: root.node && root.node.audio ? root.node.audio.volume : 0
readonly property bool muted: root.node && root.node.audio ? root.node.audio.muted : false
PwObjectTracker {
objects: root.node ? [root.node] : []
}
function iconName(): string {
if (root.output) {
if (root.muted || root.volume <= 0.001)
return "audio-volume-muted-symbolic";
if (root.volume < 0.34)
return "audio-volume-low-symbolic";
if (root.volume < 0.67)
return "audio-volume-medium-symbolic";
return "audio-volume-high-symbolic";
}
return root.muted ? "microphone-sensitivity-muted-symbolic" : "audio-input-microphone-symbolic";
}
IconButton {
id: muteButton
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
size: 30
iconSize: 17
icon: root.iconName()
iconFallback: root.output ? "audio-volume-high-symbolic" : "audio-input-microphone-symbolic"
onClicked: {
if (root.node && root.node.audio)
root.node.audio.muted = !root.node.audio.muted;
}
}
ValueSlider {
anchors.left: muteButton.right
anchors.leftMargin: 6
anchors.right: chevron.left
anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter
value: root.muted ? 0 : root.volume
onMoved: v => {
if (!root.node || !root.node.audio)
return;
// Nudging the slider is also how you unmute, same as GNOME.
root.node.audio.muted = false;
root.node.audio.volume = v;
}
}
IconButton {
id: chevron
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
size: 28
iconSize: 14
icon: root.expanded ? "pan-up-symbolic" : "pan-down-symbolic"
iconFallback: "go-next-symbolic"
onClicked: root.toggleExpanded()
}
}
@@ -0,0 +1,139 @@
// Bluetooth device picker. Paired devices first (that's what you actually
// reconnect to), then whatever discovery has turned up.
import QtQuick
import Quickshell
import Quickshell.Widgets
import Quickshell.Bluetooth
import qs.config
Item {
id: root
// True while the section is open. Discovery is expensive, so it only runs
// while the list is on screen — same as the GNOME panel.
property bool active: false
property alias maxHeight: list.maxHeight
readonly property var adapter: Bluetooth.defaultAdapter
property bool discoveryOwned: false
implicitHeight: list.implicitHeight
readonly property var devices: {
const list = Bluetooth.devices.values.slice();
list.sort((a, b) => {
if (a.connected !== b.connected)
return a.connected ? -1 : 1;
if (a.paired !== b.paired)
return a.paired ? -1 : 1;
return (a.name || "").localeCompare(b.name || "");
});
return list;
}
function syncDiscovery(): void {
if (!root.adapter)
return;
const shouldDiscover = root.active && root.adapter.enabled;
if (shouldDiscover && !root.adapter.discovering) {
root.adapter.discovering = true;
root.discoveryOwned = true;
} else if (!shouldDiscover && root.discoveryOwned && root.adapter.discovering) {
root.adapter.discovering = false;
root.discoveryOwned = false;
}
}
onActiveChanged: root.syncDiscovery()
onAdapterChanged: {
if (root.active)
root.syncDiscovery();
}
Component.onDestruction: {
if (root.adapter && root.discoveryOwned && root.adapter.discovering)
root.adapter.discovering = false;
}
function stateText(device): string {
if (device.pairing)
return "Pairing…";
switch (device.state) {
case BluetoothDeviceState.Connecting:
return "Connecting…";
case BluetoothDeviceState.Disconnecting:
return "Disconnecting…";
case BluetoothDeviceState.Connected:
return device.batteryAvailable ? "Connected · " + Math.round(device.battery * 100) + "%" : "Connected";
default:
return device.paired ? "Paired" : "";
}
}
// Click cycles the obvious next step: pair, connect, disconnect.
function activate(device): void {
if (device.connected)
device.disconnect();
else if (device.paired)
device.connect();
else
device.pair();
}
ScrollColumn {
id: list
anchors.fill: parent
maxHeight: 300
Text {
width: parent.width
visible: root.devices.length === 0
topPadding: 12
bottomPadding: 12
horizontalAlignment: Text.AlignHCenter
text: {
if (!root.adapter)
return "No Bluetooth adapter";
if (!root.adapter.enabled)
return "Bluetooth is off";
return "Searching…";
}
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Repeater {
model: root.devices
RowButton {
id: deviceRow
required property var modelData
width: parent.width
// BlueZ reports a plain freedesktop name ("audio-headphones");
// the symbolic variant is the one that can be recoloured.
icon: deviceRow.modelData.icon !== "" ? deviceRow.modelData.icon + "-symbolic" : "bluetooth-symbolic"
iconFallback: "bluetooth-symbolic"
label: deviceRow.modelData.name || deviceRow.modelData.address
sublabel: root.stateText(deviceRow.modelData)
selected: deviceRow.modelData.connected
onClicked: root.activate(deviceRow.modelData)
// Forgetting is destructive enough that it gets its own
// control rather than sharing the row click.
IconButton {
anchors.verticalCenter: parent.verticalCenter
visible: deviceRow.modelData.paired
size: 26
iconSize: 13
icon: "user-trash-symbolic"
iconFallback: "window-close-symbolic"
onClicked: deviceRow.modelData.forget()
}
}
}
}
}
@@ -0,0 +1,69 @@
// Backlight slider, via brightnessctl.
//
// This machine drives an external DisplayPort monitor and has no backlight
// class device at all (brightnessctl only reports keyboard/NIC LEDs), so the
// row removes itself rather than sitting there as a dead control. Probed once
// at startup — backlight devices do not appear and disappear.
import QtQuick
import Quickshell
import Quickshell.Io
import qs.widgets
import qs.config
Item {
id: root
property bool available: false
property real value: 0
visible: root.available
implicitHeight: root.available ? 32 : 0
// `-m` is the machine-readable form: name,class,current,percent,max
Process {
running: true
command: ["brightnessctl", "-m", "-c", "backlight", "-l"]
stdout: StdioCollector {
onStreamFinished: root.parseDevices(this.text)
}
}
function parseDevices(text: string): void {
for (const line of text.trim().split("\n")) {
const fields = line.split(",");
if (fields.length < 5 || fields[1] !== "backlight")
continue;
root.available = true;
root.value = parseInt(fields[3]) / 100;
return;
}
}
function apply(v: real): void {
root.value = v;
// Never go fully dark: a 0% backlight looks like a broken shell.
Quickshell.execDetached(["brightnessctl", "-c", "backlight", "-q", "set", Math.max(1, Math.round(v * 100)) + "%"]);
}
// ValueSlider draws its own leading icon, but symbolic icons need
// recolouring to be visible — see ThemedIcon.
ThemedIcon {
id: glyph
anchors.left: parent.left
anchors.leftMargin: 6
anchors.verticalCenter: parent.verticalCenter
size: 17
icon: "display-brightness-symbolic"
}
ValueSlider {
anchors.left: glyph.right
anchors.leftMargin: 12
anchors.right: parent.right
anchors.rightMargin: 32
anchors.verticalCenter: parent.verticalCenter
value: root.value
onMoved: v => root.apply(v)
}
}
@@ -0,0 +1,53 @@
// A small circular icon button — mute toggles, chevrons, per-row actions.
import QtQuick
import Quickshell
import qs.config
import qs.widgets
Rectangle {
id: root
property string icon: ""
property string iconFallback: "dialog-information-symbolic"
property int size: 28
property int iconSize: 16
property color tint: Theme.fg
signal clicked
implicitWidth: root.size
implicitHeight: root.size
radius: root.size / 2
border.width: 0
color: {
if (mouse.pressed)
return Theme.alpha(Theme.fg, Theme.activeAlpha);
if (mouse.containsMouse)
return Theme.alpha(Theme.fg, Theme.hoverAlpha);
return "transparent";
}
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
ThemedIcon {
anchors.centerIn: parent
size: root.iconSize
icon: root.icon
iconFallback: root.iconFallback
tint: root.tint
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
}
@@ -0,0 +1,101 @@
// The quick settings popover — GNOME's system menu (Super+S).
//
// A layer-shell surface rather than a PopupWindow because it needs keyboard
// focus for the wifi password field, and because the bar it hangs from is
// owned by a different module.
import QtQuick
import Quickshell
import Quickshell.Wayland
import Quickshell.Hyprland
import qs.config
import qs.services
import qs.widgets
PanelWindow {
id: root
visible: ShellState.quickSettingsOpen
color: "transparent"
// Hangs off the top-right corner, clear of the floating bar.
anchors.top: true
anchors.right: true
margins.top: Theme.barHeight + Theme.barGap * 2
margins.right: Theme.barSideMargin
exclusiveZone: 0
implicitWidth: Theme.popoverWidth
implicitHeight: panel.implicitHeight
// The `qs-popover` prefix is matched by a layerrule in hypr/rules.lua.
WlrLayershell.namespace: "qs-popover-quicksettings"
WlrLayershell.layer: WlrLayer.Overlay
// OnDemand rather than Exclusive: the panel must be able to take typing for
// the wifi password field, but must not steal it while merely visible.
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
onVisibleChanged: {
if (root.visible)
openAnim.restart();
else
panel.reset();
}
// Closes the panel when a click lands anywhere else on the desktop.
HyprlandFocusGrab {
windows: [root]
active: root.visible
onCleared: ShellState.close()
}
Rectangle {
id: surface
anchors.fill: parent
radius: Theme.popoverRadius
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.08)
// The prism edge — see widgets/PrismEdge.qml. Sits just inside the 1px
// border so the two don't fight for the same row of pixels.
PrismEdge {
anchors.top: parent.top
anchors.topMargin: 1
anchors.left: parent.left
anchors.right: parent.right
inset: parent.radius
}
transform: Scale {
id: openScale
origin.x: surface.width
origin.y: 0
xScale: 1
yScale: 1
}
// Escape reaches here by propagating up from whatever holds focus
// (usually the password field), so it works in every sub-state.
Item {
anchors.fill: parent
focus: true
Keys.onEscapePressed: ShellState.close()
QuickSettingsPanel {
id: panel
anchors.fill: parent
}
}
}
NumberAnimation {
id: openAnim
target: openScale
property: "yScale"
from: 0.94
to: 1.0
duration: Theme.durNormal
easing.type: Easing.OutCubic
}
}
@@ -0,0 +1,296 @@
// The contents of the quick settings popover — GNOME's system menu, in order:
// toggle grid, detail lists, sliders, footer.
//
// Kept separate from QuickSettings.qml (the PanelWindow) so it can be dropped
// into a FloatingWindow for testing: layer-shell surfaces cannot map outside a
// wlroots compositor.
import QtQuick
import Quickshell
import Quickshell.Widgets
import Quickshell.Networking
import Quickshell.Bluetooth
import Quickshell.Services.Pipewire
import qs.config
import qs.services
import qs.widgets
Item {
id: root
implicitWidth: Theme.popoverWidth
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
// "" | "wifi" | "bluetooth" | "sink" | "source". Only one detail list is
// open at a time, so the panel never grows past the screen.
property string expandedSection: ""
function expand(name: string): void {
root.expandedSection = root.expandedSection === name ? "" : name;
}
// Collapse everything — called when the popover closes so it reopens in a
// known state.
function reset(): void {
root.expandedSection = "";
}
// ── System state ────────────────────────────────────────────────────────
readonly property var wifiDevice: {
for (const device of Networking.devices.values) {
if (device.type === DeviceType.Wifi)
return device;
}
return null;
}
readonly property var wiredDevice: {
for (const device of Networking.devices.values) {
if (device.type === DeviceType.Wired && device.connected)
return device;
}
return null;
}
readonly property string ssid: {
if (!root.wifiDevice || !root.wifiDevice.networks)
return "";
for (const network of root.wifiDevice.networks.values) {
if (network.connected)
return network.name;
}
return "";
}
readonly property var btAdapter: Bluetooth.defaultAdapter
readonly property string btSummary: {
if (!root.btAdapter)
return "Unavailable";
if (!root.btAdapter.enabled)
return "Off";
for (const device of Bluetooth.devices.values) {
if (device.connected)
return device.name || device.address;
}
return "On";
}
readonly property int cellWidth: (content.width - Theme.itemSpacing) / 2
Column {
id: content
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Theme.popoverPadding
spacing: Theme.itemSpacing
// ── Toggle grid ─────────────────────────────────────────────────────
Grid {
columns: 2
spacing: Theme.itemSpacing
Toggle {
width: root.cellWidth
icon: Networking.wifiEnabled ? "network-wireless-symbolic" : "network-wireless-offline-symbolic"
label: "Wi-Fi"
active: Networking.wifiEnabled
enabled: Networking.wifiHardwareEnabled
sublabel: {
if (!Networking.wifiEnabled)
return "Off";
return root.ssid !== "" ? root.ssid : "Not connected";
}
onToggled: Networking.wifiEnabled = !Networking.wifiEnabled
onExpanded: root.expand("wifi")
}
Toggle {
width: root.cellWidth
icon: root.btAdapter && root.btAdapter.enabled ? "bluetooth-active-symbolic" : "bluetooth-disabled-symbolic"
label: "Bluetooth"
active: root.btAdapter !== null && root.btAdapter.enabled
enabled: root.btAdapter !== null
sublabel: root.btSummary
onToggled: {
if (root.btAdapter)
root.btAdapter.enabled = !root.btAdapter.enabled;
}
onExpanded: root.expand("bluetooth")
}
Toggle {
width: root.cellWidth
icon: "preferences-desktop-screensaver-symbolic"
label: "Caffeine"
sublabel: Caffeine.enabled ? "Staying awake" : "Off"
active: Caffeine.enabled
onToggled: Caffeine.toggle()
}
Toggle {
width: root.cellWidth
icon: "night-light-symbolic"
label: "Night Light"
active: NightLight.active
sublabel: {
if (NightLight.automatic)
return NightLight.active ? "Scheduled · on" : "Scheduled";
return NightLight.active ? NightLight.temperature + "K" : "Off";
}
onToggled: NightLight.toggle()
// No detail list to open, so the secondary action flips the
// schedule on and off instead.
onExpanded: NightLight.automatic = !NightLight.automatic
}
}
RowButton {
width: content.width
icon: "preferences-system-time-symbolic"
iconFallback: "appointment-soon-symbolic"
label: FocusSession.active ? "Focus · " + FocusSession.workspaceLabel : "Start focus session"
sublabel: FocusSession.active ? FocusSession.remainingText + (FocusSession.paused ? " · paused" : " remaining") : Settings.focusDurationMinutes + " minutes · current workspace"
selected: FocusSession.active
onClicked: {
ShellState.close();
FocusSession.reveal();
}
}
// ── Detail lists ────────────────────────────────────────────────────
Section {
width: content.width
expanded: root.expandedSection === "wifi"
WifiList {
anchors.left: parent.left
anchors.right: parent.right
device: root.wifiDevice
active: root.expandedSection === "wifi"
}
}
Section {
width: content.width
expanded: root.expandedSection === "bluetooth"
BluetoothList {
anchors.left: parent.left
anchors.right: parent.right
active: root.expandedSection === "bluetooth"
}
}
Rectangle {
width: content.width
height: 1
color: Theme.alpha(Theme.fg, 0.1)
}
// ── Sliders ─────────────────────────────────────────────────────────
AudioSlider {
width: content.width
node: Pipewire.defaultAudioSink
output: true
expanded: root.expandedSection === "sink"
onToggleExpanded: root.expand("sink")
}
Section {
width: content.width
expanded: root.expandedSection === "sink"
AudioDeviceList {
anchors.left: parent.left
anchors.right: parent.right
output: true
}
}
AudioSlider {
width: content.width
node: Pipewire.defaultAudioSource
output: false
expanded: root.expandedSection === "source"
onToggleExpanded: root.expand("source")
}
Section {
width: content.width
expanded: root.expandedSection === "source"
AudioDeviceList {
anchors.left: parent.left
anchors.right: parent.right
output: false
}
}
BrightnessControl {
width: content.width
}
Rectangle {
width: content.width
height: 1
color: Theme.alpha(Theme.fg, 0.1)
}
// ── Footer ──────────────────────────────────────────────────────────
Item {
width: content.width
height: 36
ThemedIcon {
id: avatar
anchors.left: parent.left
anchors.leftMargin: 4
anchors.verticalCenter: parent.verticalCenter
size: 20
icon: "avatar-default-symbolic"
iconFallback: "user-info-symbolic"
}
Text {
anchors.left: avatar.right
anchors.leftMargin: 10
anchors.right: actions.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: Quickshell.env("USER") || "user"
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
}
Row {
id: actions
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 4
IconButton {
size: 32
iconSize: 17
icon: "preferences-system-symbolic"
onClicked: {
ShellState.close();
ShellState.openSettings("home");
}
}
IconButton {
size: 32
iconSize: 17
icon: "system-shutdown-symbolic"
onClicked: ShellState.open("powermenu")
}
}
}
}
}
@@ -0,0 +1,108 @@
// One line in a detail list (a wifi network, a bluetooth device, an output
// device). Icon, two lines of text, and an optional trailing slot.
import QtQuick
import Quickshell
import qs.config
import qs.widgets
Rectangle {
id: root
property string icon: ""
// Used when `icon` is not in the active icon theme — several of the names
// GNOME uses only exist in Adwaita, and iconPath() returns "" otherwise.
property string iconFallback: "dialog-information-symbolic"
property string label: ""
property string sublabel: ""
property bool selected: false
property bool dimmed: false
default property alias trailing: trailingRow.data
signal clicked
implicitHeight: 44
radius: Theme.cardRadius - 2
border.width: 0
color: {
if (root.selected)
return Theme.alpha(Theme.accent, Theme.activeAlpha);
if (mouse.pressed)
return Theme.alpha(Theme.fg, Theme.activeAlpha);
if (mouse.containsMouse)
return Theme.alpha(Theme.fg, Theme.hoverAlpha);
return "transparent";
}
Behavior on color {
ColorAnimation {
duration: Theme.durFast
}
}
opacity: root.dimmed ? 0.5 : 1.0
// Declared first so anything in the trailing slot stacks above it and can
// take its own clicks (a per-row disconnect button, say).
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
ThemedIcon {
id: leading
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
size: 20
icon: root.icon
iconFallback: root.iconFallback
tint: root.selected ? Theme.accent : Theme.fg
visible: root.icon !== ""
}
Column {
anchors.left: leading.visible ? leading.right : parent.left
anchors.leftMargin: leading.visible ? 10 : 12
anchors.right: trailingRow.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
spacing: 1
Text {
width: parent.width
text: root.label
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: root.selected ? Font.DemiBold : Font.Normal
}
Text {
width: parent.width
visible: root.sublabel !== ""
text: root.sublabel
color: Theme.fgDim
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
// Full-height rather than vertically centred so slot contents can anchor
// their own verticalCenter to it without a size loop.
Row {
id: trailingRow
anchors.right: parent.right
anchors.rightMargin: 10
anchors.top: parent.top
anchors.bottom: parent.bottom
spacing: 6
}
}
@@ -0,0 +1,42 @@
// A column that grows to fit its contents up to `maxHeight`, then scrolls.
// Used by every detail list in the panel so they all overflow the same way.
import QtQuick
import qs.config
Item {
id: root
property int maxHeight: 300
property alias spacing: column.spacing
default property alias content: column.data
implicitHeight: Math.min(column.implicitHeight, root.maxHeight)
Flickable {
id: flick
anchors.fill: parent
contentHeight: column.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
Column {
id: column
width: flick.width
spacing: 2
}
}
// Static indicator — no fade timers, nothing repainting while idle. It is
// only there so a clipped list doesn't look like the whole list.
Rectangle {
anchors.right: parent.right
width: 3
radius: 1.5
border.width: 0
color: Theme.alpha(Theme.fg, 0.25)
visible: flick.contentHeight > flick.height + 1
height: Math.max(24, flick.height * (flick.height / Math.max(1, flick.contentHeight)))
y: (flick.height - height) * (flick.contentY / Math.max(1, flick.contentHeight - flick.height))
}
}
@@ -0,0 +1,38 @@
// An expander: collapses to zero height and animates open, GNOME-style.
//
// The child is measured at its natural size and the *section* animates; the
// child itself never re-layouts, so opening a 20-row wifi list costs one
// height animation rather than twenty.
import QtQuick
import qs.config
Item {
id: root
property bool expanded: false
default property alias content: holder.data
clip: true
// Column/ColumnLayout skip invisible children, so this also collapses the
// surrounding spacing when closed.
visible: implicitHeight > 0
implicitHeight: root.expanded ? holder.implicitHeight : 0
Behavior on implicitHeight {
NumberAnimation {
duration: Theme.durNormal
easing.type: Easing.OutCubic
}
}
Item {
id: holder
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
implicitHeight: childrenRect.height
height: implicitHeight
}
}
@@ -0,0 +1,274 @@
// The wifi picker. This is the reason the quick settings panel exists: the
// user must never have to drop to nmcli to join a network.
//
// Everything here goes through Quickshell.Networking (NetworkManager over
// DBus) — no shelling out.
import QtQuick
import Quickshell
import Quickshell.Widgets
import Quickshell.Networking
import qs.config
import qs.widgets
Item {
id: root
// The WifiDevice, or null while NetworkManager is still enumerating.
property var device: null
// True while the section is open. Drives the NM scan request so we are not
// burning radio time scanning for a list nobody is looking at.
property bool active: false
property alias maxHeight: list.maxHeight
// SSID whose inline password field is open, and the last failure.
property string passwordFor: ""
property string failedSsid: ""
property string failedText: ""
implicitHeight: list.implicitHeight
// GNOME's ordering: the current network, then saved ones, then by signal.
readonly property var networks: {
if (!root.device || !root.device.networks)
return [];
const list = root.device.networks.values.slice();
list.sort((a, b) => {
if (a.connected !== b.connected)
return a.connected ? -1 : 1;
if (a.known !== b.known)
return a.known ? -1 : 1;
return b.signalStrength - a.signalStrength;
});
return list;
}
function syncScanner(): void {
if (root.device)
root.device.scannerEnabled = root.active;
}
onActiveChanged: root.syncScanner()
onDeviceChanged: root.syncScanner()
Component.onCompleted: root.syncScanner()
Component.onDestruction: {
if (root.device)
root.device.scannerEnabled = false;
}
function signalIcon(strength: real): string {
if (strength >= 0.8)
return "network-wireless-signal-excellent-symbolic";
if (strength >= 0.55)
return "network-wireless-signal-good-symbolic";
if (strength >= 0.3)
return "network-wireless-signal-ok-symbolic";
if (strength > 0.05)
return "network-wireless-signal-weak-symbolic";
return "network-wireless-signal-none-symbolic";
}
// NM's failure reasons are useful but not English.
function failureText(reason): string {
switch (reason) {
case ConnectionFailReason.NoSecrets:
return "Wrong password";
case ConnectionFailReason.WifiAuthTimeout:
return "Authentication timed out";
case ConnectionFailReason.WifiNetworkLost:
return "Network out of range";
default:
return "Couldn't connect";
}
}
function isSecured(network): bool {
return network.security !== WifiSecurityType.Open && network.security !== WifiSecurityType.Owe && network.security !== WifiSecurityType.Unknown;
}
// Left click on a row. Saved and open networks connect immediately;
// anything else reveals the password field rather than failing silently.
function activate(network): void {
root.failedSsid = "";
if (network.connected)
return;
if (network.known || !root.isSecured(network)) {
root.passwordFor = "";
network.connect();
return;
}
root.passwordFor = root.passwordFor === network.name ? "" : network.name;
}
ScrollColumn {
id: list
anchors.fill: parent
maxHeight: 340
Item {
width: parent.width
height: 30
visible: root.networks.length > 0
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "Available networks"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
}
Text {
width: parent.width
visible: root.networks.length === 0
topPadding: 12
bottomPadding: 12
horizontalAlignment: Text.AlignHCenter
text: root.device ? (Networking.wifiEnabled ? "Scanning…" : "Wi-Fi is off") : "No Wi-Fi adapter"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Repeater {
model: root.networks
Column {
id: entry
required property var modelData
width: parent.width
spacing: 0
readonly property bool secured: root.isSecured(entry.modelData)
readonly property string ssid: entry.modelData.name
// Reported by NM when a connection attempt gives up. Bad
// passwords land here, so re-open the field to retry.
Connections {
target: entry.modelData
function onConnectionFailed(reason) {
root.failedSsid = entry.ssid;
root.failedText = root.failureText(reason);
if (entry.secured && !entry.modelData.known)
root.passwordFor = entry.ssid;
}
}
RowButton {
width: parent.width
icon: root.signalIcon(entry.modelData.signalStrength)
iconFallback: "network-wireless-symbolic"
label: entry.ssid !== "" ? entry.ssid : "Hidden network"
selected: entry.modelData.connected
sublabel: {
if (entry.modelData.stateChanging)
return "Connecting…";
if (entry.modelData.connected)
return "Connected";
if (root.failedSsid === entry.ssid)
return root.failedText;
if (entry.modelData.known)
return "Saved";
return "";
}
onClicked: root.activate(entry.modelData)
ThemedIcon {
anchors.verticalCenter: parent.verticalCenter
size: 14
visible: entry.secured
tint: Theme.fgMuted
icon: "network-wireless-encrypted-symbolic"
iconFallback: "changes-prevent-symbolic"
}
IconButton {
visible: entry.modelData.connected
size: 26
iconSize: 13
icon: "window-close-symbolic"
onClicked: entry.modelData.disconnect()
}
}
Section {
id: pskSection
width: parent.width
expanded: root.passwordFor === entry.ssid
onExpandedChanged: {
if (pskSection.expanded)
pskInput.forceActiveFocus();
else
pskInput.text = "";
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 10
anchors.rightMargin: 10
height: 42
y: 4
radius: Theme.cardRadius - 2
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.4)
color: Theme.alpha(Theme.fg, 0.08)
TextInput {
id: pskInput
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: sendButton.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
echoMode: TextInput.Password
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.5)
selectedTextColor: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
clip: true
onAccepted: {
if (pskInput.text.length > 0) {
root.failedSsid = "";
entry.modelData.connectWithPsk(pskInput.text);
root.passwordFor = "";
}
}
Text {
anchors.fill: parent
verticalAlignment: Text.AlignVCenter
visible: pskInput.text === ""
text: "Password"
color: Theme.fgMuted
font: pskInput.font
}
}
IconButton {
id: sendButton
anchors.right: parent.right
anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter
size: 30
iconSize: 15
icon: "go-next-symbolic"
onClicked: pskInput.accepted()
}
}
}
}
}
}
}