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 |
| 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
separate status bar.
@@ -27,6 +27,13 @@ separate status bar.
against — since the connection still goes through iwd underneath
- squeekboard, as a systemd user unit, for the keyboard button — also optional;
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
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.
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
focused window. Only one menu is open at a time, so `close` closes whichever it
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
```
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
**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
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
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
@@ -207,6 +249,12 @@ running with `qs --path .`).
desktop file names without the extension (`ls /usr/share/applications`).
- `showOsk` — set `false` to drop the on-screen keyboard button entirely.
- `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.
- `wifiLabelMaxWidth` — cap on the SSID shown on the panel.
- `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/Attwm.qml attwm's IPC socket, when there is one
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/AppMenu.qml Left: launcher button
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/WindowButton.qml One task 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/WifiPanel.qml The network list overlay
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/WifiGlyph.qml The wifi fan, 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/Slider.qml 0-1 slider, touch sized
components/SliderRow.qml A slider with a glyph, label and reading
```
## 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.
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 ------------------------------------------------------------
// 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 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
readonly property color background: "#14161b"
readonly property color surface: "#1c1f26"
+3
View File
@@ -46,6 +46,9 @@
# session's own PATH is searched first, so a compositor's
# swaymsg/hyprctl/niri is still picked up for logging out.
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"
+5
View File
@@ -67,6 +67,11 @@ PanelWindow {
Layout.fillHeight: true
}
ControlsMenu {
bar: bar
Layout.fillHeight: true
}
WifiMenu {
bar: bar
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.Io
import qs.config
import qs.modules
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 {
target: "bluetooth"