Wi-Fi had a switch and wired did not, which left no way to take the cable down without reaching for nmcli -- and this machine has two interfaces on one subnet, so turning one off is a genuinely useful thing to be able to do. Both halves are needed to turn it off. Disconnecting alone survives the session but not a carrier event or a reboot, because NetworkManager brings an autoconnecting device straight back; the switch sets the device's autoconnect alongside it. The device property rather than the connection profile, so a toggle here does not quietly rewrite a saved connection somebody expects to come up at boot. The first version built a trap door. It decided whether the switch was usable from hasLink, which reads false while a device is merely disconnected even though NetworkManager still reports the carrier as on -- so turning Ethernet off made the switch disable itself, blame the cable, and offer no way back. A wired device that exists can always be asked to come up; if there is really no cable the attempt fails and says so, which is the honest failure. The word "off" is used where the old text guessed "no cable", because nothing available here can tell those apart. Verified as a round trip against the real device, including that off stays off through eight seconds rather than reconnecting a moment later, and that the mechanism tested is the one the code uses -- the first test drove the connection profile while the code drives the device, which are different things. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
410 lines
15 KiB
QML
410 lines
15 KiB
QML
// 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.controlCenterWidth
|
|
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")
|
|
}
|
|
|
|
// Only present when there is a wired device at all -- most machines
|
|
// this runs on have one, but a laptop without a dock does not, and
|
|
// an Ethernet tile there would be a control for absent hardware.
|
|
Toggle {
|
|
width: root.cellWidth
|
|
visible: Connectivity.wiredDevice !== null
|
|
icon: Connectivity.wiredOn
|
|
? "network-wired-symbolic"
|
|
: "network-wired-disconnected-symbolic"
|
|
label: "Ethernet"
|
|
active: Connectivity.wiredOn
|
|
enabled: Connectivity.wiredAvailable
|
|
sublabel: {
|
|
if (Connectivity.wiredOn)
|
|
return Connectivity.wiredDevice.linkSpeed > 0
|
|
? Connectivity.wiredDevice.linkSpeed + " Mb/s" : "Connected";
|
|
return "Off";
|
|
}
|
|
onToggled: Connectivity.setWired(!Connectivity.wiredOn)
|
|
}
|
|
|
|
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: ColorScheme.dark ? "weather-clear-night-symbolic" : "weather-clear-symbolic"
|
|
label: "Appearance"
|
|
sublabel: ColorScheme.dark ? "Dark" : "Light"
|
|
// "active" reads as the non-default state across this grid, and
|
|
// dark is what Panama ships, so light is the lit one.
|
|
active: !ColorScheme.dark
|
|
// Writes the preference and stops. ColorScheme propagates it to
|
|
// GTK, the portal, the terminals, the launcher, btop, tmux and
|
|
// the lock screen; nothing here needs to know that list.
|
|
onToggled: SystemSettings.commitPreference("colorScheme",
|
|
ColorScheme.dark ? "light" : "dark")
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// ── Power ───────────────────────────────────────────────────────────
|
|
// Only where a power-profiles daemon is actually running. A desktop
|
|
// without one should not show a control that cannot do anything.
|
|
RowButton {
|
|
visible: PowerProfiles.available
|
|
width: content.width
|
|
icon: {
|
|
switch (PowerProfiles.active) {
|
|
case "power-saver": return "power-profile-power-saver-symbolic";
|
|
case "performance": return "power-profile-performance-symbolic";
|
|
default: return "power-profile-balanced-symbolic";
|
|
}
|
|
}
|
|
iconFallback: "preferences-system-power-symbolic"
|
|
label: "Power profile"
|
|
sublabel: PowerProfiles.degraded !== ""
|
|
? PowerProfiles.label(PowerProfiles.active) + " · limited"
|
|
: PowerProfiles.label(PowerProfiles.active)
|
|
selected: root.expandedSection === "power"
|
|
onClicked: root.expand("power")
|
|
}
|
|
|
|
Section {
|
|
width: content.width
|
|
expanded: root.expandedSection === "power"
|
|
|
|
PowerProfileList {
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
}
|
|
}
|
|
|
|
// ── 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
|
|
}
|
|
|
|
// ── Connected life ─────────────────────────────────────────────────
|
|
HomeControls {
|
|
width: content.width
|
|
expanded: root.expandedSection === "home"
|
|
onToggleExpanded: root.expand("home")
|
|
}
|
|
|
|
PhoneControls {
|
|
width: content.width
|
|
expanded: root.expandedSection === "phone"
|
|
onToggleExpanded: root.expand("phone")
|
|
}
|
|
|
|
Rectangle {
|
|
width: content.width
|
|
height: 1
|
|
color: Theme.alpha(Theme.fg, 0.1)
|
|
}
|
|
|
|
// ── Footer ──────────────────────────────────────────────────────────
|
|
Item {
|
|
width: content.width
|
|
height: 36
|
|
|
|
// The account's own picture, the same file the lock screen and the
|
|
// login screen read. A generic glyph sat here while a real avatar
|
|
// was already set, which made the desktop look like it did not know
|
|
// whose it was.
|
|
Item {
|
|
id: avatar
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 4
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 22
|
|
height: 22
|
|
|
|
ClippingRectangle {
|
|
anchors.fill: parent
|
|
radius: width / 2
|
|
color: "transparent"
|
|
visible: UserAccounts.avatarUrl !== ""
|
|
|
|
Image {
|
|
anchors.fill: parent
|
|
source: UserAccounts.avatarUrl
|
|
fillMode: Image.PreserveAspectCrop
|
|
// Replaced in place when the picture changes, so the
|
|
// cache has to be told to let go of the old one.
|
|
cache: false
|
|
asynchronous: true
|
|
sourceSize.width: 44
|
|
sourceSize.height: 44
|
|
}
|
|
}
|
|
|
|
ThemedIcon {
|
|
anchors.centerIn: parent
|
|
visible: UserAccounts.avatarUrl === ""
|
|
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: UserAccounts.me
|
|
? UserAccounts.displayName(UserAccounts.me)
|
|
: (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")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|