// A numeric setting, bound to a schema key by name. // // SliderRow { setting: "windowRounding" } // // Range, step, label, explanation, and unit all come from PreferenceSchema. // // Three behaviors worth knowing: // // * The row is responsive. The Settings window is a normal tiled window, so // its width is whatever the layout gives it -- anywhere from a half-screen // split to the full 4500px display, and `implicitWidth` is only a hint. // Below a usable inline width the slider moves onto its own line under the // label instead of squeezing the explanation into a five-line column. // * The readout follows the drag immediately, but the value is only committed // after a short quiet period. Compositor-backed settings are applied and // verified one batch at a time, and a slider fires dozens of changes per // second -- committing each would spend the whole drag rejecting // overlapping writes. // * Between commits the row shows what you are dragging; once settled it // shows what is actually stored. If the compositor refuses a value the row // falls back to the stored one rather than displaying a value nothing // accepted. // // This does not extend SettingRow: that component fixes the control to a // trailing column of a set width, which is the layout this row needs to be able // to abandon. The label, explanation, and divider match it exactly. import QtQuick import qs.config import qs.services import qs.widgets Item { id: root required property string setting property bool divider: true property string zeroLabel: "" readonly property var spec: PreferenceSchema.spec(root.setting) readonly property string label: root.spec ? root.spec.label : root.setting readonly property string detail: root.spec ? root.spec.detail : "" readonly property real minimum: root.spec && root.spec.min !== undefined ? root.spec.min : 0 readonly property real maximum: root.spec && root.spec.max !== undefined ? root.spec.max : 100 readonly property real step: root.spec && root.spec.step !== undefined ? root.spec.step : 1 readonly property string unit: root.spec && root.spec.unit !== undefined ? root.spec.unit : "" readonly property real stored: { const value = DesktopPreferences.get(root.setting); return typeof value === "number" ? value : root.minimum; } // Shown while dragging; null means "nothing pending, show what is stored". // Not -1: several schema entries (e.g. pointerSensitivity) are legitimately // negative, and -1 would be indistinguishable from a real committed value. property var pending: null readonly property real shown: root.pending !== null ? root.pending : root.stored // Below this the label and a usable slider cannot share a line without one // of them becoming useless. readonly property bool inline: width >= 520 // A fixed trailing width rather than a share of the row. A proportional // control looks reasonable at 900px and absurd at 4500px, where the slider // would be a meter long next to a two-word label -- and this window is // tiled, so it really can be that wide. readonly property int controlSpan: 300 width: parent ? parent.width : 620 implicitHeight: root.inline ? Math.max(56, copy.implicitHeight + 20) : copy.implicitHeight + 32 + 30 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)); // Steps below 1 are fractional (opacity, pointer speed); rounding to two // places keeps 0.8500000000000001 out of the readout and the store. return root.step < 1 ? Math.round(clamped * 100) / 100 : clamped; } // One schema step, from the arrow keys, committed through the same debounce // the pointer drag uses -- holding an arrow reads as a drag rather than as // a burst of writes the compositor would spend the whole time rejecting. function nudge(steps: int): void { const raw = root.shown + steps * root.step; const clamped = Math.max(root.minimum, Math.min(root.maximum, raw)); root.pending = root.step < 1 ? Math.round(clamped * 100) / 100 : clamped; commitTimer.restart(); } // What a screen reader is told the slider is sitting on. Qt 6.11's attached // Accessible type carries no structured value or range, so the reading and // its bounds go in the description, in the row's own display units. readonly property string reading: `${root.display(root.shown)}, ${root.display(root.minimum)} to ${root.display(root.maximum)}` function display(value: real): string { if (value === 0 && root.zeroLabel !== "") return root.zeroLabel; const text = root.step < 1 ? value.toFixed(2) : String(value); return root.unit === "" ? text : `${text} ${root.unit}`; } // Both children are positioned explicitly rather than by anchors. Binding // an anchor to `undefined` to switch layouts does not reliably release it, // which left the slider anchored to both edges and the label squeezed into // whatever was left. 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 ValueSlider { id: slider anchors.left: parent.left anchors.right: readout.left anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter value: root.maximum > root.minimum ? (root.shown - root.minimum) / (root.maximum - root.minimum) : 0 onMoved: ratio => { root.pending = root.quantise(ratio); commitTimer.restart(); } activeFocusOnTab: true Accessible.role: Accessible.Slider Accessible.name: root.label Accessible.description: root.detail === "" ? root.reading : `${root.detail} — ${root.reading}` Accessible.focusable: true Accessible.focused: slider.activeFocus Accessible.onIncreaseAction: root.nudge(1) Accessible.onDecreaseAction: root.nudge(-1) Keys.onLeftPressed: root.nudge(-1) Keys.onDownPressed: root.nudge(-1) Keys.onRightPressed: root.nudge(1) Keys.onUpPressed: root.nudge(1) // Drawn only while the slider holds keyboard focus: transparent // fill, so at rest there is nothing here at all. Rectangle { anchors.fill: parent anchors.margins: -2 radius: 9 color: "transparent" visible: slider.activeFocus border.width: 2 border.color: Theme.accentSecondary } } Text { id: readout anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter width: 58 horizontalAlignment: Text.AlignRight text: root.display(root.shown) color: Theme.fgDim font.family: Theme.fontFamily // The readout changes digit by digit under the pointer; tabular // figures stop it twitching sideways as it does. 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: 140 onTriggered: { if (root.pending === null) return; SystemSettings.commitPreference(root.setting, root.pending); // Hand the display back to the stored value. If the write was // refused, the row snaps back to what is really in effect. releaseTimer.restart(); } } Timer { id: releaseTimer interval: 160 onTriggered: root.pending = null } }