Build the Panama Hyprland desktop
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user