add volume and brightness control

This commit is contained in:
2026-08-18 12:36:52 +02:00
parent 1d59b7808b
commit 7ff0f584e0
14 changed files with 984 additions and 2 deletions
+58 -2
View File
@@ -7,7 +7,7 @@ A bottom desktop-environment panel for wlroots-based compositors, built on
| ------- | ----------------------------------------------------------------- | | ------- | ----------------------------------------------------------------- |
| Left | Application menu built from `.desktop` entries, with search, and pinned quick-launch icons | | Left | Application menu built from `.desktop` entries, with search, and pinned quick-launch icons |
| Middle | Open windows (task list), click to focus, middle-click to close | | Middle | Open windows (task list), click to focus, middle-click to close |
| Right | On-screen keyboard, wifi, bluetooth, and a session menu: log out, suspend, restart, shut down | | Right | On-screen keyboard, volume and brightness sliders, wifi, bluetooth, and a session menu: log out, suspend, restart, shut down |
No clock, no tray, no status readouts — it is designed to sit alongside a No clock, no tray, no status readouts — it is designed to sit alongside a
separate status bar. separate status bar.
@@ -27,6 +27,13 @@ separate status bar.
against — since the connection still goes through iwd underneath against — since the connection still goes through iwd underneath
- squeekboard, as a systemd user unit, for the keyboard button — also optional; - squeekboard, as a systemd user unit, for the keyboard button — also optional;
where the unit is not installed the button is absent where the unit is not installed the button is absent
- pipewire for the volume slider. Quickshell speaks to it directly, so there is
no `wpctl` or `pactl` in the loop; without a default sink the slider is absent
- `brightnessctl` for the brightness slider — bundled with the package, and
optional in the sense that a machine with no controllable backlight (a
desktop) simply does not get that row. It writes through logind where the
session cannot write `/sys/class/backlight` itself, so no udev rule or setuid
binary is needed
The window list uses the foreign-toplevel protocol rather than any single The window list uses the foreign-toplevel protocol rather than any single
compositor's IPC, so it works the same everywhere — except under river 0.4, compositor's IPC, so it works the same everywhere — except under river 0.4,
@@ -81,7 +88,7 @@ exec attwm
Either order works: whichever starts first waits for the other. Either order works: whichever starts first waits for the other.
The `launcher`, `wifi`, `bluetooth` and `power` IPC targets each accept The `launcher`, `controls`, `wifi`, `bluetooth` and `power` IPC targets each accept
`toggle`, `open` and `close`. When opened by keybind the menu appears on the screen holding the `toggle`, `open` and `close`. When opened by keybind the menu appears on the screen holding the
focused window. Only one menu is open at a time, so `close` closes whichever it focused window. Only one menu is open at a time, so `close` closes whichever it
is. is.
@@ -93,6 +100,17 @@ which is what to bind to a hardware key or a tablet-mode switch:
att_menu ipc call osk toggle att_menu ipc call osk toggle
``` ```
The `volume` and `brightness` targets move the level without putting anything on
screen, which is what the media keys want — `volume` takes `up`, `down` and
`mute`, `brightness` takes `up` and `down`, each one step of the size configured
in `volumeStep` / `brightnessStep`:
```sh
att_menu ipc call volume up
att_menu ipc call volume mute
att_menu ipc call brightness down
```
## Behaviour ## Behaviour
**Placement.** The panel is a layer-shell surface anchored to the left, right and **Placement.** The panel is a layer-shell surface anchored to the left, right and
@@ -139,6 +157,30 @@ The keyboard also raises and lowers itself when a text field takes or loses
focus, so the button follows squeekboard's own `Visible` property rather than focus, so the button follows squeekboard's own `Visible` property rather than
remembering what it last asked for, and stays right either way. remembering what it last asked for, and stays right either way.
**Volume and brightness.** One button carries the speaker and the volume of the
default sink; clicking it opens a small panel holding both sliders. Drag or
press anywhere along a slider to set it, scroll it to nudge it, and scroll the
panel button itself to change the volume without opening anything. Clicking the
speaker — on the button with the middle mouse button, or in the panel — mutes.
`Up`/`Down` pick a slider, `Left`/`Right` move it a step, `Enter` mutes,
`Escape` closes.
Volume goes through pipewire directly and follows the *default* sink, so
plugging in a headset moves the slider with the sound. Moving the slider
unmutes: a slider that changes a number and produces no sound reads as broken.
Brightness goes through `brightnessctl`, which falls back to logind when the
session cannot write `/sys/class/backlight` itself. It never goes fully dark
(`minBrightness`), since on most laptops the only way back from a black screen
is a hardware key. sysfs raises no change events, so the current value is
re-read on a timer — but only while the panel is open, and the panel is what
sees the hardware keys move it. Rapid changes coalesce onto the newest, so a
drag never queues up a hundred processes.
The volume row is absent where pipewire has no default sink, and the brightness
row where there is no controllable backlight; with neither, the whole button
goes.
**Wifi.** The button shows signal strength in the arcs and the SSID in use next **Wifi.** The button shows signal strength in the arcs and the SSID in use next
to them, struck through when the radio is off. The menu lists the networks in to them, struck through when the radio is off. The menu lists the networks in
range — connected first, then saved, then by strength — with the switch in the range — connected first, then saved, then by strength — with the switch in the
@@ -207,6 +249,12 @@ running with `qs --path .`).
desktop file names without the extension (`ls /usr/share/applications`). desktop file names without the extension (`ls /usr/share/applications`).
- `showOsk` — set `false` to drop the on-screen keyboard button entirely. - `showOsk` — set `false` to drop the on-screen keyboard button entirely.
- `oskUnit` — the systemd user unit squeekboard runs as. - `oskUnit` — the systemd user unit squeekboard runs as.
- `showVolume` / `showBrightness` — set `false` to drop that slider. With both
off the panel button goes too.
- `volumeStep` / `brightnessStep` — one wheel notch, one arrow key, one
`ipc call volume up`. `0.05` is five percent.
- `minBrightness` — floor on the backlight, as a fraction of its maximum.
- `controlsMenuWidth` — width of the slider panel, clamped to the screen.
- `showWifi` — set `false` to drop the wifi button entirely. - `showWifi` — set `false` to drop the wifi button entirely.
- `wifiLabelMaxWidth` — cap on the SSID shown on the panel. - `wifiLabelMaxWidth` — cap on the SSID shown on the panel.
- `wifiMenuWidth` / `wifiMenuHeight` — also clamped to the screen. - `wifiMenuWidth` / `wifiMenuHeight` — also clamped to the screen.
@@ -228,6 +276,8 @@ services/Session.qml Which menu is open, and on which screen
services/AppLauncher.qml Desktop-entry launching services/AppLauncher.qml Desktop-entry launching
services/Attwm.qml attwm's IPC socket, when there is one services/Attwm.qml attwm's IPC socket, when there is one
services/Osk.qml squeekboard: its unit, and its DBus state services/Osk.qml squeekboard: its unit, and its DBus state
services/Audio.qml The default pipewire sink: volume and mute
services/Brightness.qml The backlight: sysfs to read, brightnessctl to write
modules/Bar.qml The panel window and its three sections modules/Bar.qml The panel window and its three sections
modules/AppMenu.qml Left: launcher button modules/AppMenu.qml Left: launcher button
modules/Launcher.qml The application menu overlay modules/Launcher.qml The application menu overlay
@@ -236,6 +286,8 @@ modules/QuickLaunch.qml Left: pinned app icons
modules/WindowList.qml Middle: task list modules/WindowList.qml Middle: task list
modules/WindowButton.qml One task button modules/WindowButton.qml One task button
modules/OskMenu.qml Right: on-screen keyboard button modules/OskMenu.qml Right: on-screen keyboard button
modules/ControlsMenu.qml Right: volume button
modules/ControlsPanel.qml The volume and brightness sliders overlay
modules/WifiMenu.qml Right: wifi button modules/WifiMenu.qml Right: wifi button
modules/WifiPanel.qml The network list overlay modules/WifiPanel.qml The network list overlay
modules/WifiNetworkEntry.qml One network row modules/WifiNetworkEntry.qml One network row
@@ -250,7 +302,11 @@ components/PanelButton.qml Shared hover/press surface
components/BluetoothGlyph.qml The bluetooth rune, drawn components/BluetoothGlyph.qml The bluetooth rune, drawn
components/WifiGlyph.qml The wifi fan, drawn components/WifiGlyph.qml The wifi fan, drawn
components/KeyboardGlyph.qml The keyboard, drawn components/KeyboardGlyph.qml The keyboard, drawn
components/VolumeGlyph.qml The speaker, drawn
components/BrightnessGlyph.qml The sun, drawn
components/ToggleSwitch.qml On/off switch components/ToggleSwitch.qml On/off switch
components/Slider.qml 0-1 slider, touch sized
components/SliderRow.qml A slider with a glyph, label and reading
``` ```
## Notes ## Notes
+51
View File
@@ -0,0 +1,51 @@
import QtQuick
import qs.config
// The sun, drawn: a disc and eight rays. Same reason as the other glyphs —
// symbolic icons come out black on the dark panel, and a drawn one recolours.
Item {
id: root
property color color: Theme.text
property int size: Theme.iconSize
implicitWidth: root.size
implicitHeight: root.size
readonly property real stroke: Math.max(1.5, Math.round(root.size * 0.09))
Rectangle {
anchors.centerIn: parent
width: Math.round(root.size * 0.42)
height: width
radius: width / 2
color: root.color
}
// One ray, drawn at twelve o'clock and rotated about the centre of the
// glyph into each of the eight positions.
Repeater {
model: 8
Rectangle {
id: ray
required property int index
readonly property real inset: root.size * 0.04
x: (root.size - width) / 2
y: ray.inset
width: root.stroke
height: Math.round(root.size * 0.16)
radius: width / 2
color: root.color
transform: Rotation {
origin.x: ray.width / 2
origin.y: root.size / 2 - ray.inset
angle: ray.index * 45
}
}
}
}
+112
View File
@@ -0,0 +1,112 @@
import QtQuick
import qs.config
// A themed 0-1 slider. Reports intent through `moved` rather than driving
// `value` itself, the same way ToggleSwitch does, so the caller can keep it
// bound to the real state — the volume pipewire ended up at, the brightness the
// backlight accepted — and let the slider follow.
//
// While a drag is in flight the knob tracks the pointer instead, so a backend
// that answers a moment late cannot make it stutter under the finger.
Item {
id: root
property real value: 0
// One wheel notch, one arrow key.
property real stepSize: 0.05
property color fillColor: Theme.accent
signal moved(real value)
// Where the drag has got to, used in place of `value` until it ends.
property real dragValue: 0
readonly property real position: mouse.pressed ? root.dragValue : Math.max(0, Math.min(1, root.value))
// The hit area is a good deal taller than the groove: this panel gets used
// with a finger as often as a pointer.
implicitWidth: 120
implicitHeight: Theme.sliderKnob + Theme.padding * 2
readonly property real travel: root.width - Theme.sliderKnob
function setFromX(x: real): void {
const position = root.travel > 0 ? (x - Theme.sliderKnob / 2) / root.travel : 0;
const value = Math.max(0, Math.min(1, position));
root.dragValue = value;
root.moved(value);
}
function step(delta: real): void {
root.moved(Math.max(0, Math.min(1, root.position + delta)));
}
Rectangle {
id: groove
anchors {
left: parent.left
right: parent.right
verticalCenter: parent.verticalCenter
}
height: Theme.sliderTrack
radius: height / 2
color: Theme.background
border.width: Theme.borderWidth
border.color: Theme.border
Rectangle {
// Stops under the middle of the knob, so the fill and the knob move
// as one piece rather than the fill running out ahead of it.
width: Math.min(groove.width, Theme.sliderKnob / 2 + root.travel * root.position)
height: groove.height
radius: groove.radius
color: root.enabled ? root.fillColor : Theme.textDim
}
}
Rectangle {
id: knob
x: root.travel * root.position
anchors.verticalCenter: parent.verticalCenter
width: Theme.sliderKnob
height: width
radius: width / 2
color: root.enabled ? Theme.text : Theme.textDim
border.width: Theme.borderWidth
border.color: Theme.background
scale: mouse.pressed ? 1.15 : 1
Behavior on scale {
NumberAnimation {
duration: Theme.animDuration
easing.type: Theme.animEasing
}
}
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
preventStealing: true
// A press anywhere on the groove jumps there: on a touchscreen there is
// no way to grab the knob first.
onPressed: event => root.setFromX(event.x)
onPositionChanged: event => {
if (mouse.pressed)
root.setFromX(event.x);
}
onWheel: event => {
const notches = event.angleDelta.y / 120;
if (notches !== 0)
root.step(notches * root.stepSize);
}
}
}
+102
View File
@@ -0,0 +1,102 @@
import QtQuick
import qs.config
// One labelled slider: glyph, name and reading on top, the slider under them.
// Both rows of the controls panel are this.
//
// The glyph goes in `content`, so each row can bring its own — and, where the
// glyph does something when clicked (mute), say so with `iconClickable`.
Item {
id: root
property string label: ""
property string valueText: ""
property real value: 0
property real stepSize: 0.05
// Selected by the keyboard, which is what arrow keys move.
property bool selected: false
property bool iconClickable: false
default property alias content: glyph.data
signal moved(real value)
signal iconClicked
implicitHeight: header.height + slider.height
function step(delta: real): void {
slider.step(delta);
}
Item {
id: header
anchors {
left: parent.left
right: parent.right
top: parent.top
}
height: Theme.iconSize
Item {
id: glyph
anchors.verticalCenter: parent.verticalCenter
width: childrenRect.width
height: childrenRect.height
}
// Sibling of the glyph rather than a child of it: inside, it would be
// part of the childrenRect the glyph sizes itself from.
MouseArea {
anchors.fill: glyph
enabled: root.iconClickable
cursorShape: Qt.PointingHandCursor
onClicked: root.iconClicked()
}
Text {
anchors {
left: glyph.right
right: reading.left
leftMargin: Theme.padding
rightMargin: Theme.padding
verticalCenter: parent.verticalCenter
}
elide: Text.ElideRight
text: root.label
color: root.selected ? Theme.accent : Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.bold: true
}
Text {
id: reading
anchors {
right: parent.right
verticalCenter: parent.verticalCenter
}
text: root.valueText
color: Theme.textDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.bold: true
}
}
Slider {
id: slider
anchors {
left: parent.left
right: parent.right
top: header.bottom
}
value: root.value
stepSize: root.stepSize
onMoved: value => root.moved(value)
}
}
+114
View File
@@ -0,0 +1,114 @@
import QtQuick
import QtQuick.Shapes
import qs.config
// The speaker, drawn for the same reason as the wifi fan: themes ship it
// symbolic-only, and drawing it lets the waves light up with the volume.
Item {
id: root
property color color: Theme.text
// Unlit waves stay drawn, faintly, so the glyph keeps one silhouette at
// every volume instead of changing shape as it moves.
property color dimColor: Theme.textDim
property int size: Theme.iconSize
// Lit waves, 0-2.
property int level: 2
// Struck through with a diagonal, for "muted".
property bool struck: false
implicitWidth: root.size
implicitHeight: root.size
readonly property real stroke: Math.max(1.5, root.size * 0.085)
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
// Body and cone in one outline, starting at the top left of the body.
ShapePath {
fillColor: root.color
strokeWidth: -1
startX: root.size * 0.10
startY: root.size * 0.38
PathLine {
x: root.size * 0.28
y: root.size * 0.38
}
PathLine {
x: root.size * 0.50
y: root.size * 0.16
}
PathLine {
x: root.size * 0.50
y: root.size * 0.84
}
PathLine {
x: root.size * 0.28
y: root.size * 0.62
}
PathLine {
x: root.size * 0.10
y: root.size * 0.62
}
PathLine {
x: root.size * 0.10
y: root.size * 0.38
}
}
}
Repeater {
model: 2
Shape {
id: wave
required property int index
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
strokeColor: root.level > wave.index ? root.color : root.dimColor
strokeWidth: root.stroke
fillColor: "transparent"
capStyle: ShapePath.RoundCap
// Centred on the cone's mouth, opening right: -45° to 45°.
PathAngleArc {
centerX: root.size * 0.50
centerY: root.size * 0.50
radiusX: root.size * (0.18 + wave.index * 0.14)
radiusY: root.size * (0.18 + wave.index * 0.14)
startAngle: -45
sweepAngle: 90
}
}
}
}
Shape {
anchors.fill: parent
visible: root.struck
preferredRendererType: Shape.CurveRenderer
ShapePath {
strokeColor: root.color
strokeWidth: root.stroke
fillColor: "transparent"
capStyle: ShapePath.RoundCap
startX: root.size * 0.12
startY: root.size * 0.88
PathLine {
x: root.size * 0.88
y: root.size * 0.12
}
}
}
}
+21
View File
@@ -65,6 +65,27 @@ Singleton {
// ".desktop" is accepted. // ".desktop" is accepted.
readonly property var quickLaunch: ["firefox", "org.kde.dolphin"] readonly property var quickLaunch: ["firefox", "org.kde.dolphin"]
// ---- Volume and brightness -------------------------------------------
// Set false to drop the volume slider. The button also hides itself where
// pipewire has no default sink.
readonly property bool showVolume: true
// Set false to drop the brightness slider. It also hides itself on machines
// with no controllable backlight, or without brightnessctl to drive it, so
// leaving this on costs a desktop nothing.
readonly property bool showBrightness: true
// One wheel notch, one arrow key, one `ipc call volume up`.
readonly property real volumeStep: 0.05
readonly property real brightnessStep: 0.05
// Floor on the backlight, as a fraction of its maximum. A screen at zero
// looks broken, and on most laptops the only way back is a hardware key.
readonly property real minBrightness: 0.01
readonly property int controlsMenuWidth: 280
// ---- Wifi ------------------------------------------------------------ // ---- Wifi ------------------------------------------------------------
// Set false to drop the wifi button. It also hides itself where there is no // Set false to drop the wifi button. It also hides itself where there is no
+5
View File
@@ -12,6 +12,11 @@ Singleton {
readonly property int iconSize: 22 readonly property int iconSize: 22
readonly property int borderWidth: 1 readonly property int borderWidth: 1
// Slider groove and knob. The knob is deliberately larger than the groove
// is thick: the sliders get dragged with a finger as well as a pointer.
readonly property int sliderTrack: 6
readonly property int sliderKnob: 16
// Palette // Palette
readonly property color background: "#14161b" readonly property color background: "#14161b"
readonly property color surface: "#1c1f26" readonly property color surface: "#1c1f26"
+3
View File
@@ -46,6 +46,9 @@
# session's own PATH is searched first, so a compositor's # session's own PATH is searched first, so a compositor's
# swaymsg/hyprctl/niri is still picked up for logging out. # swaymsg/hyprctl/niri is still picked up for logging out.
pkgs.systemd pkgs.systemd
# The backlight slider. Volume needs nothing: quickshell
# speaks to pipewire itself.
pkgs.brightnessctl
] ]
} \ } \
--suffix XDG_DATA_DIRS : "${pkgs.adwaita-icon-theme}/share:${pkgs.hicolor-icon-theme}/share" --suffix XDG_DATA_DIRS : "${pkgs.adwaita-icon-theme}/share:${pkgs.hicolor-icon-theme}/share"
+5
View File
@@ -67,6 +67,11 @@ PanelWindow {
Layout.fillHeight: true Layout.fillHeight: true
} }
ControlsMenu {
bar: bar
Layout.fillHeight: true
}
WifiMenu { WifiMenu {
bar: bar bar: bar
Layout.fillHeight: true Layout.fillHeight: true
+80
View File
@@ -0,0 +1,80 @@
import QtQuick
import qs.config
import qs.components
import qs.services
// Right section: the volume button, which also opens the brightness slider.
// The sliders live in ControlsPanel.qml as a separate layer surface.
Item {
id: root
required property var bar
readonly property int percent: Math.round(Audio.volume * 100)
// Nothing to control — no pipewire sink and no backlight — means no button.
visible: Audio.available || Brightness.available
implicitWidth: button.implicitWidth
PanelButton {
id: button
anchors.verticalCenter: parent.verticalCenter
height: root.height - Theme.spacing
minContentWidth: Theme.iconSize
active: panel.visible
activeColor: Theme.accent
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
onClicked: event => {
// Middle click mutes, the way it does on most panels.
if (event.button === Qt.MiddleButton)
Audio.toggleMute();
else
Session.toggle("controls", panel.screenName);
}
Row {
spacing: Theme.spacing
VolumeGlyph {
size: Theme.iconSize
struck: Audio.muted || !Audio.available
level: !Audio.available || Audio.muted ? 0 : Audio.volume > 0.5 ? 2 : Audio.volume > 0 ? 1 : 0
color: button.active ? Theme.background : Audio.muted || !Audio.available ? Theme.textDim : Theme.text
dimColor: button.active ? Qt.rgba(Theme.background.r, Theme.background.g, Theme.background.b, 0.4) : Theme.border
}
Text {
height: Theme.iconSize
verticalAlignment: Text.AlignVCenter
visible: Audio.available
text: root.percent + "%"
color: button.active ? Theme.background : Audio.muted ? Theme.textDim : Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.bold: true
}
}
}
// Scrolling the button changes the volume without opening anything, which
// is the fastest way to nudge it with a pointer.
WheelHandler {
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
enabled: Audio.available
onWheel: event => {
const notches = event.angleDelta.y / 120;
if (notches !== 0)
Audio.step(notches * Config.volumeStep);
}
}
ControlsPanel {
id: panel
bar: root.bar
}
}
+181
View File
@@ -0,0 +1,181 @@
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.components
import qs.services
// The volume and brightness sliders. Same full-screen layer surface as the
// other menus, so click-away and Escape work on any wlroots compositor.
//
// A row is only here when there is something behind it: a machine with no
// backlight gets the volume slider alone, and the panel shrinks to fit.
PanelWindow {
id: root
required property var bar
readonly property string screenName: root.bar.screen ? root.bar.screen.name : ""
visible: Session.isOpen("controls", root.screenName)
screen: root.bar.screen
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: Config.namespace + "-controls"
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
anchors {
left: true
right: true
top: true
bottom: true
}
// Reserve nothing, respect what others reserved — see Launcher.qml. Keeps
// the menu clear of the bar and of an on-screen keyboard.
exclusionMode: ExclusionMode.Normal
exclusiveZone: 0
color: "transparent"
readonly property int barInset: Config.reserveSpace ? 0 : Theme.barHeight
// ---- Data ------------------------------------------------------------
// The rows on screen, in order, as ids the key handler can act on.
readonly property var rows: [Audio.available ? "volume" : "", Brightness.available ? "brightness" : ""].filter(row => row !== "")
property int currentIndex: 0
readonly property string currentRow: root.rows[root.currentIndex] ?? ""
// One arrow key, in the step size the row it lands on uses.
function adjust(direction: int): void {
if (root.currentRow === "volume")
Audio.step(direction * Config.volumeStep);
else if (root.currentRow === "brightness")
Brightness.step(direction * Config.brightnessStep);
}
onVisibleChanged: {
if (root.visible) {
root.currentIndex = 0;
Brightness.refresh();
keyHandler.forceActiveFocus();
}
}
// sysfs raises no change events, so the backlight is polled — but only
// while it is being looked at. The hardware keys move it behind our back;
// nothing else does.
Timer {
interval: 1000
repeat: true
running: root.visible && Brightness.available
onTriggered: Brightness.refresh()
}
// ---- Chrome ----------------------------------------------------------
// Click-away scrim.
MouseArea {
anchors.fill: parent
onClicked: Session.close()
}
Item {
id: keyHandler
anchors.fill: parent
focus: true
Keys.onEscapePressed: Session.close()
Keys.onDownPressed: root.currentIndex = Math.min(root.currentIndex + 1, root.rows.length - 1)
Keys.onUpPressed: root.currentIndex = Math.max(root.currentIndex - 1, 0)
Keys.onLeftPressed: root.adjust(-1)
Keys.onRightPressed: root.adjust(1)
Keys.onReturnPressed: if (root.currentRow === "volume")
Audio.toggleMute()
Keys.onEnterPressed: if (root.currentRow === "volume")
Audio.toggleMute()
}
Rectangle {
id: menu
// Anchored to the panel's right edge, above the button that opens it.
anchors {
right: parent.right
bottom: parent.bottom
rightMargin: Theme.padding
bottomMargin: root.barInset + Theme.padding
}
width: Math.min(Config.controlsMenuWidth, root.width - Theme.padding * 2)
height: Theme.padding * 4 + column.height
color: Theme.surface
radius: Theme.radius * 2
border.width: Theme.borderWidth
border.color: Theme.border
// Swallow clicks so they do not reach the scrim behind.
MouseArea {
anchors.fill: parent
}
Column {
id: column
anchors {
left: parent.left
right: parent.right
verticalCenter: parent.verticalCenter
margins: Theme.padding * 2
}
spacing: Theme.padding
SliderRow {
width: column.width
visible: Audio.available
label: "Volume"
valueText: Audio.muted ? "Muted" : Math.round(Audio.volume * 100) + "%"
value: Audio.volume
stepSize: Config.volumeStep
selected: root.currentRow === "volume"
iconClickable: true
onMoved: value => {
// Moving the slider is a request for that much sound, so a
// muted sink comes back rather than moving silently.
Audio.setMuted(false);
Audio.setVolume(value);
}
onIconClicked: Audio.toggleMute()
VolumeGlyph {
size: 18
struck: Audio.muted
level: Audio.muted ? 0 : Audio.volume > 0.5 ? 2 : Audio.volume > 0 ? 1 : 0
color: Audio.muted ? Theme.textDim : Theme.text
}
}
SliderRow {
width: column.width
visible: Brightness.available
label: "Brightness"
valueText: Math.round(Brightness.value * 100) + "%"
value: Brightness.value
stepSize: Config.brightnessStep
selected: root.currentRow === "brightness"
onMoved: value => Brightness.set(value)
BrightnessGlyph {
size: 18
color: Theme.text
}
}
}
}
}
+59
View File
@@ -0,0 +1,59 @@
pragma Singleton
import Quickshell
import Quickshell.Services.Pipewire
import qs.config
// The default audio sink. Quickshell talks to pipewire directly, so there is no
// wpctl or pactl to shell out to and no polling: the properties below change as
// the graph does, whoever moved them.
//
// Everything is expressed against the default sink rather than a remembered
// one, so switching outputs — plugging in a headset, say — moves the panel with
// the sound.
Singleton {
id: root
readonly property PwNode sink: Pipewire.defaultAudioSink
// Pipewire objects only carry live properties while something is tracking
// them, so `audio` is null until the tracker below binds the node.
readonly property bool available: Config.showVolume && root.sink !== null && root.sink.ready && root.sink.audio !== null
// 0-1 for the usable range, though pipewire allows more and another client
// may well have set more. Reported as-is; the slider is what clamps.
readonly property real volume: root.available ? root.sink.audio.volume : 0
readonly property bool muted: root.available ? root.sink.audio.muted : false
function setVolume(value: real): void {
if (!root.available)
return;
root.sink.audio.volume = Math.max(0, Math.min(1, value));
}
// Raising the volume from silence unmutes: a volume key that appears to do
// nothing is worse than one that does slightly more than it said.
function step(delta: real): void {
if (!root.available)
return;
if (delta > 0 && root.muted)
root.setMuted(false);
root.setVolume(root.volume + delta);
}
function setMuted(muted: bool): void {
if (root.available)
root.sink.audio.muted = muted;
}
function toggleMute(): void {
root.setMuted(!root.muted);
}
PwObjectTracker {
objects: root.sink ? [root.sink] : []
}
}
+146
View File
@@ -0,0 +1,146 @@
pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
// The display backlight, driven through brightnessctl.
//
// Writing to /sys/class/backlight needs privileges a session usually does not
// have, so the write goes through brightnessctl, which asks logind when it
// cannot open the file itself. Reads do not: actual_brightness is world
// readable, so the current value comes straight off sysfs rather than from a
// process spawned every couple of seconds.
//
// sysfs attributes do not raise inotify events, so `refresh()` is polled by
// whoever is displaying the value — only the controls panel, and only while it
// is on screen. Nothing here runs with the panel closed.
Singleton {
id: root
// Device name as brightnessctl knows it, e.g. "intel_backlight", or "" on a
// machine with no backlight — a desktop, or a laptop whose panel is not
// controllable. Without one there is no brightness row.
property string device: ""
property int max: 0
property int current: 0
readonly property bool available: Config.showBrightness && root.device !== "" && root.max > 0
readonly property real value: root.max > 0 ? root.current / root.max : 0
// Never all the way off: a backlight at zero is indistinguishable from a
// broken panel, and on most laptops the only way back is the hardware key.
readonly property int minimum: Math.max(1, Math.round(root.max * Config.minBrightness))
// What was last asked for, and whether a request landed while the previous
// one was still in flight. Dragging the slider produces far more requests
// than brightnessctl can service, so they coalesce onto the newest.
property int pending: 0
property bool queued: false
function set(fraction: real): void {
if (!root.available)
return;
const raw = Math.max(root.minimum, Math.min(root.max, Math.round(fraction * root.max)));
if (raw === root.current && !write.running)
return;
// Optimistic, like the keyboard button: the slider has to track the
// finger, not the process. The next refresh overwrites this with what
// the hardware actually did.
root.current = raw;
root.pending = raw;
if (write.running)
root.queued = true;
else
root.dispatch();
}
function step(delta: real): void {
root.set(root.value + delta);
}
function dispatch(): void {
write.command = ["brightnessctl", "-m", "-q", "-c", "backlight", "-d", root.device, "set", String(root.pending)];
write.running = true;
}
function refresh(): void {
// A write in flight is the newer truth: sysfs still holds the value the
// drag has already moved past.
if (root.available && !write.running && !root.queued)
actual.reload();
}
// `brightnessctl -m` prints one CSV line per device:
// device,class,current,percent,max
function parseInfo(text: string): void {
const fields = text.trim().split("\n")[0].split(",");
if (fields.length < 5)
return;
const max = parseInt(fields[4]);
if (!(max > 0))
return;
root.device = fields[0];
root.max = max;
root.current = parseInt(fields[2]) || 0;
}
Process {
id: write
}
// Guarded by `command -v` so a machine without brightnessctl gets a quiet
// "no backlight" rather than a failed spawn on every start.
Process {
running: Config.showBrightness
command: ["sh", "-c", "command -v brightnessctl >/dev/null 2>&1 && brightnessctl -m -c backlight || true"]
stdout: StdioCollector {
onStreamFinished: root.parseInfo(this.text)
}
}
// actual_brightness, not brightness: what the hardware settled on, which is
// also what the hardware keys move.
FileView {
id: actual
path: root.available ? `/sys/class/backlight/${root.device}/actual_brightness` : ""
printErrors: false
onLoaded: {
if (write.running || root.queued)
return;
const value = parseInt(this.text());
if (value >= 0)
root.current = value;
}
}
Connections {
target: write
function onExited(exitCode: int, exitStatus: int): void {
if (root.queued) {
root.queued = false;
root.dispatch();
return;
}
// What the hardware settled on, which is the value the next step
// has to count from — and, when the write was refused (no logind,
// or a device that will not take the value), the number that goes
// back under the slider.
actual.reload();
}
}
}
+47
View File
@@ -1,5 +1,6 @@
import Quickshell import Quickshell
import Quickshell.Io import Quickshell.Io
import qs.config
import qs.modules import qs.modules
import qs.services import qs.services
@@ -84,6 +85,52 @@ ShellRoot {
} }
} }
IpcHandler {
target: "controls"
function toggle(): void {
Session.toggle("controls", "");
}
function open(): void {
Session.open("controls", "");
}
function close(): void {
Session.close();
}
}
// Not a menu: these are for the volume and brightness keys, which want to
// move the level without putting anything on screen.
IpcHandler {
target: "volume"
function up(): void {
Audio.step(Config.volumeStep);
}
function down(): void {
Audio.step(-Config.volumeStep);
}
function mute(): void {
Audio.toggleMute();
}
}
IpcHandler {
target: "brightness"
function up(): void {
Brightness.step(Config.brightnessStep);
}
function down(): void {
Brightness.step(-Config.brightnessStep);
}
}
IpcHandler { IpcHandler {
target: "bluetooth" target: "bluetooth"