Files
Panama/config/dot/quickshell/modules/settings/SliderRow.qml
T
Gabriel Brown e6b4d3c1a1 Stop lists and sliders from losing input under the user
WifiList and the notification toasts built their model from a plain
computed array, so any background property tick (a scan result, an
unrelated notification arriving) reassigned the whole array and the
Repeater destroyed and recreated every delegate -- including one with
an open, focused password field or an in-progress reply. Switched
both to a ScriptModel, which diffs by identity instead of resetting.

ValueSlider had its pointer-to-value mapping offset by 16px (the
hit-area margin was applied with the wrong sign), so 0% was
unreachable and every click landed to the right of where it was
placed -- affects every slider in the shell. SliderRow used -1 as a
sentinel for "nothing pending," which collides with legitimate
negative preference values like pointer sensitivity.

Dock intellihide read the globally focused workspace instead of each
monitor's own, so an empty workspace on one screen could hide the
dock on another; ActivityPanel rebuilt every row once a second during
a recording because the elapsed-time read lived in the model
construction instead of each row's own binding.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:22:58 -04:00

196 lines
7.1 KiB
QML

// A numeric setting, bound to a schema key by name.
//
// SliderRow { setting: "windowRounding" }
//
// Range, step, label, explanation, and unit all come from PreferenceSchema.
//
// Three behaviours 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 metre 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;
}
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();
}
}
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
}
}