Rebuild Displays around the canvas, and let the transaction keep color

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 10:44:11 -04:00
parent 1f694b00b6
commit 9bc68ba358
29 changed files with 3065 additions and 388 deletions
@@ -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
}
}