From 7ff0f584e0e038d81c6757aed5dccfc53b33a5d7 Mon Sep 17 00:00:00 2001 From: Asmir A Date: Tue, 18 Aug 2026 12:36:52 +0200 Subject: [PATCH] add volume and brightness control --- README.md | 60 ++++++++++- components/BrightnessGlyph.qml | 51 ++++++++++ components/Slider.qml | 112 ++++++++++++++++++++ components/SliderRow.qml | 102 +++++++++++++++++++ components/VolumeGlyph.qml | 114 +++++++++++++++++++++ config/Config.qml | 21 ++++ config/Theme.qml | 5 + flake.nix | 3 + modules/Bar.qml | 5 + modules/ControlsMenu.qml | 80 +++++++++++++++ modules/ControlsPanel.qml | 181 +++++++++++++++++++++++++++++++++ services/Audio.qml | 59 +++++++++++ services/Brightness.qml | 146 ++++++++++++++++++++++++++ shell.qml | 47 +++++++++ 14 files changed, 984 insertions(+), 2 deletions(-) create mode 100644 components/BrightnessGlyph.qml create mode 100644 components/Slider.qml create mode 100644 components/SliderRow.qml create mode 100644 components/VolumeGlyph.qml create mode 100644 modules/ControlsMenu.qml create mode 100644 modules/ControlsPanel.qml create mode 100644 services/Audio.qml create mode 100644 services/Brightness.qml diff --git a/README.md b/README.md index afdfd19..a00d03e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/components/BrightnessGlyph.qml b/components/BrightnessGlyph.qml new file mode 100644 index 0000000..7f07e55 --- /dev/null +++ b/components/BrightnessGlyph.qml @@ -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 + } + } + } +} diff --git a/components/Slider.qml b/components/Slider.qml new file mode 100644 index 0000000..9a85eae --- /dev/null +++ b/components/Slider.qml @@ -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); + } + } +} diff --git a/components/SliderRow.qml b/components/SliderRow.qml new file mode 100644 index 0000000..1b14961 --- /dev/null +++ b/components/SliderRow.qml @@ -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) + } +} diff --git a/components/VolumeGlyph.qml b/components/VolumeGlyph.qml new file mode 100644 index 0000000..c404334 --- /dev/null +++ b/components/VolumeGlyph.qml @@ -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 + } + } + } +} diff --git a/config/Config.qml b/config/Config.qml index d034e28..d4b690e 100644 --- a/config/Config.qml +++ b/config/Config.qml @@ -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 diff --git a/config/Theme.qml b/config/Theme.qml index 3bfdec4..99bfa6f 100644 --- a/config/Theme.qml +++ b/config/Theme.qml @@ -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" diff --git a/flake.nix b/flake.nix index c352612..eaa18f9 100644 --- a/flake.nix +++ b/flake.nix @@ -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" diff --git a/modules/Bar.qml b/modules/Bar.qml index 7b5839d..5bc1540 100644 --- a/modules/Bar.qml +++ b/modules/Bar.qml @@ -67,6 +67,11 @@ PanelWindow { Layout.fillHeight: true } + ControlsMenu { + bar: bar + Layout.fillHeight: true + } + WifiMenu { bar: bar Layout.fillHeight: true diff --git a/modules/ControlsMenu.qml b/modules/ControlsMenu.qml new file mode 100644 index 0000000..6c5eb47 --- /dev/null +++ b/modules/ControlsMenu.qml @@ -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 + } +} diff --git a/modules/ControlsPanel.qml b/modules/ControlsPanel.qml new file mode 100644 index 0000000..7ff135d --- /dev/null +++ b/modules/ControlsPanel.qml @@ -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 + } + } + } + } +} diff --git a/services/Audio.qml b/services/Audio.qml new file mode 100644 index 0000000..c188da4 --- /dev/null +++ b/services/Audio.qml @@ -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] : [] + } +} diff --git a/services/Brightness.qml b/services/Brightness.qml new file mode 100644 index 0000000..a75552a --- /dev/null +++ b/services/Brightness.qml @@ -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(); + } + } +} diff --git a/shell.qml b/shell.qml index 8a35183..1818db9 100644 --- a/shell.qml +++ b/shell.qml @@ -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"