253 lines
8.9 KiB
QML
253 lines
8.9 KiB
QML
// A slider whose track carries a meaning, and whose value is committed once the
|
||
// gesture is over rather than on every pixel.
|
||
//
|
||
// Two rows on the Displays page need something SliderRow cannot give them:
|
||
//
|
||
// * The night light temperature, whose track should BE the warmth it sets --
|
||
// a neutral track with a prism fill says nothing about what 2700 K looks
|
||
// like, and this is the one slider whose numbers most people cannot picture.
|
||
// * The SDR trims, which write through the display transaction. A transaction
|
||
// per pixel of drag would arm a fifteen-second countdown dozens of times;
|
||
// the value is applied once, when the button comes up.
|
||
//
|
||
// On release, not on an idle timer. Holding a slider still for a third of a
|
||
// second is not the end of a gesture, and the commit an idle timer made
|
||
// mid-hold armed a display transaction that turned this row's `enabled` false
|
||
// with the button still down -- the control went dead under the hand using it.
|
||
// The wheel has no release, so it keeps the timer, and that is all the timer is
|
||
// for now.
|
||
//
|
||
// Not schema-bound and not a SettingRow: the caller says what the value is and
|
||
// what to do with a new one, and the row stacks its control below the label in
|
||
// a narrow window exactly as SliderRow does.
|
||
|
||
import QtQuick
|
||
import qs.config
|
||
|
||
Item {
|
||
id: root
|
||
|
||
property string label: ""
|
||
property string detail: ""
|
||
property bool divider: true
|
||
property bool enabled: true
|
||
|
||
property real minimum: 0
|
||
property real maximum: 100
|
||
property real step: 1
|
||
|
||
// What is in effect. While a drag is in flight the row shows `pending`
|
||
// instead, and hands the display back once the commit has been made.
|
||
property real value: 0
|
||
property var pending: null
|
||
readonly property real shown: root.pending !== null ? root.pending : root.value
|
||
|
||
// The caller formats the number, because "1.15×", "3500 K" and "72%" have
|
||
// nothing in common but the digits.
|
||
property string readout: ""
|
||
|
||
// Two or three colours, left to right. Painted across the whole track when
|
||
// `fullTrack` is set -- for a value whose range is the point, like colour
|
||
// temperature -- and as the fill alone otherwise.
|
||
property var trackColors: [Theme.accent, Theme.accentSecondary]
|
||
property bool fullTrack: false
|
||
|
||
property int commitDelay: 320
|
||
|
||
signal committed(real value)
|
||
|
||
readonly property bool inline: root.width >= 520
|
||
readonly property int controlSpan: 300
|
||
|
||
function stopColor(index: int): color {
|
||
const colors = root.trackColors;
|
||
if (!colors || colors.length === 0)
|
||
return Theme.accent;
|
||
return colors[Math.min(index, colors.length - 1)];
|
||
}
|
||
|
||
// Hands the pending value to the caller. Called on release, and by the
|
||
// wheel's idle timer, which is the one input that never has one.
|
||
function commit(): void {
|
||
commitTimer.stop();
|
||
if (root.pending === null)
|
||
return;
|
||
root.committed(root.pending);
|
||
releaseTimer.restart();
|
||
}
|
||
|
||
function quantise(ratio: real): real {
|
||
const raw = root.minimum + ratio * (root.maximum - root.minimum);
|
||
const snapped = Math.round(raw / root.step) * root.step;
|
||
const clamped = Math.max(root.minimum, Math.min(root.maximum, snapped));
|
||
return root.step < 1 ? Math.round(clamped * 100) / 100 : clamped;
|
||
}
|
||
|
||
width: parent ? parent.width : 620
|
||
implicitHeight: root.inline
|
||
? Math.max(56, copy.implicitHeight + 20)
|
||
: copy.implicitHeight + 32 + 30
|
||
opacity: root.enabled ? 1 : 0.45
|
||
|
||
Column {
|
||
id: copy
|
||
|
||
x: 0
|
||
y: root.inline ? (root.height - height) / 2 : 10
|
||
width: root.inline ? root.width - root.controlSpan - 20 : root.width
|
||
spacing: 3
|
||
|
||
Text {
|
||
width: parent.width
|
||
text: root.label
|
||
color: Theme.fg
|
||
font.family: Theme.fontFamily
|
||
font.pixelSize: Theme.fontSize
|
||
font.weight: Font.Medium
|
||
elide: Text.ElideRight
|
||
}
|
||
|
||
Text {
|
||
width: parent.width
|
||
visible: root.detail !== ""
|
||
text: root.detail
|
||
color: Theme.fgDim
|
||
font.family: Theme.fontFamily
|
||
font.pixelSize: Theme.fontSizeSmall
|
||
wrapMode: Text.WordWrap
|
||
}
|
||
}
|
||
|
||
Item {
|
||
id: control
|
||
|
||
width: root.inline ? root.controlSpan : root.width
|
||
height: 32
|
||
x: root.inline ? root.width - width : 0
|
||
y: root.inline ? (root.height - height) / 2 : copy.y + copy.height + 10
|
||
|
||
Rectangle {
|
||
id: track
|
||
|
||
anchors.left: parent.left
|
||
anchors.right: valueLabel.left
|
||
anchors.rightMargin: 12
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
height: 10
|
||
radius: 5
|
||
border.width: 0
|
||
color: Theme.alpha(Theme.fg, 0.12)
|
||
|
||
readonly property real ratio: root.maximum > root.minimum
|
||
? Math.max(0, Math.min(1, (root.shown - root.minimum) / (root.maximum - root.minimum)))
|
||
: 0
|
||
|
||
Rectangle {
|
||
anchors.left: parent.left
|
||
anchors.top: parent.top
|
||
anchors.bottom: parent.bottom
|
||
width: root.fullTrack ? parent.width : parent.width * track.ratio
|
||
radius: parent.radius
|
||
border.width: 0
|
||
|
||
gradient: Gradient {
|
||
orientation: Gradient.Horizontal
|
||
GradientStop { position: 0.0; color: root.stopColor(0) }
|
||
GradientStop { position: 0.5; color: root.stopColor(1) }
|
||
GradientStop { position: 1.0; color: root.stopColor(2) }
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
width: 16
|
||
height: 16
|
||
radius: 8
|
||
border.width: 0
|
||
color: Theme.fg
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
x: Math.max(0, Math.min(parent.width - width, parent.width * track.ratio - width / 2))
|
||
}
|
||
|
||
MouseArea {
|
||
id: drag
|
||
|
||
anchors.fill: parent
|
||
anchors.margins: -8
|
||
enabled: root.enabled
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
|
||
// Local x is 8px ahead of the track's own origin because of the
|
||
// negative margin above; subtract it before turning the
|
||
// position into a fraction of the track.
|
||
function move(positionX: real): void {
|
||
root.pending = root.quantise(
|
||
Math.max(0, Math.min(1, (positionX - 8) / track.width)));
|
||
}
|
||
|
||
onPressed: event => drag.move(event.x)
|
||
onPositionChanged: event => {
|
||
if (drag.pressed)
|
||
drag.move(event.x);
|
||
}
|
||
onReleased: root.commit()
|
||
// A grab taken away mid-drag is not a decision. The row goes
|
||
// back to showing whatever is really in effect rather than
|
||
// keeping a number nobody asked it to apply.
|
||
onCanceled: {
|
||
commitTimer.stop();
|
||
root.pending = null;
|
||
}
|
||
onWheel: event => {
|
||
const direction = event.angleDelta.y > 0 ? 1 : -1;
|
||
root.pending = Math.max(root.minimum,
|
||
Math.min(root.maximum, root.shown + direction * root.step));
|
||
commitTimer.restart();
|
||
}
|
||
}
|
||
}
|
||
|
||
Text {
|
||
id: valueLabel
|
||
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
width: 58
|
||
horizontalAlignment: Text.AlignRight
|
||
text: root.readout
|
||
color: Theme.fgDim
|
||
font.family: Theme.fontFamily
|
||
font.features: Theme.tabularFigures
|
||
font.pixelSize: Theme.fontSize
|
||
elide: Text.ElideRight
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
anchors.left: parent.left
|
||
anchors.right: parent.right
|
||
anchors.bottom: parent.bottom
|
||
height: 1
|
||
visible: root.divider
|
||
color: Theme.alpha(Theme.fg, 0.065)
|
||
}
|
||
|
||
// The wheel's release. A notch is a whole gesture on its own, and a hand
|
||
// spinning through a range means one commit at the end of the spin rather
|
||
// than one per notch -- there is no button coming up to say when that is.
|
||
Timer {
|
||
id: commitTimer
|
||
interval: root.commitDelay
|
||
onTriggered: root.commit()
|
||
}
|
||
|
||
// Hands the readout back to whatever is really in effect. If the change was
|
||
// refused -- an unclean value, a busy transaction -- the row snaps back to
|
||
// the value it had rather than showing one nothing accepted.
|
||
Timer {
|
||
id: releaseTimer
|
||
interval: 400
|
||
onTriggered: root.pending = null
|
||
}
|
||
}
|