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
196 lines
7.1 KiB
QML
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
|
|
}
|
|
}
|