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
+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
}
}
}
}