Rebuild Displays around the canvas, and let the transaction keep color
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
// A slider whose track carries a meaning, and whose value is committed once the
|
||||
// drag settles 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 pointer stops moving.
|
||||
//
|
||||
// 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)];
|
||||
}
|
||||
|
||||
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)));
|
||||
commitTimer.restart();
|
||||
}
|
||||
|
||||
onPressed: event => drag.move(event.x)
|
||||
onPositionChanged: event => {
|
||||
if (drag.pressed)
|
||||
drag.move(event.x);
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: commitTimer
|
||||
interval: root.commitDelay
|
||||
onTriggered: {
|
||||
if (root.pending === null)
|
||||
return;
|
||||
root.committed(root.pending);
|
||||
releaseTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user