add volume and brightness control
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] : []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user