Build the settings vocabulary and generate the keymap
Stage 3 and 4 of docs/superpowers/plans/2026-08-17-panama-cohesion.md. Add SettingsPage plus ToggleRow, SliderRow, ChoiceRow, ActionRow, and TextRow. A row names a schema key and needs nothing else: label, detail, range, and unit come from PreferenceSchema, and writes go through SystemSettings.commitPreference, which routes compositor-backed keys through apply-and-verify and local keys straight to the store. The page scaffold that was copy-pasted eleven times is now one component. Rebuild Appearance around a live preview of the real desktop, scaled by the ratio between the preview and the actual monitor so a 10px gap on a 4500px display looks as small as it is. Rebuild Desktop & Dock and Input & Shortcuts on the shared rows, replacing the read-only text that stood in for controls that were merely expensive to add. Generate the shortcut list from hyprctl binds. The page held a hand-typed nineteen entries against a real keymap of a hundred and thirteen; it could not show the rest and went stale whenever a bind changed. Every bind now carries its own description -- backfilled for the twenty-nine that lacked one -- and keybinds-contract.sh fails if any bind lacks one, since undescribed binds are dropped from the page. Make Restore defaults span every store Panama owns. Resetting only the schema store left the Home accessory arrangement customised while claiming to restore defaults, which is worse than no reset because it is silent. Done through HomePreferences' existing public aliases rather than a new API. Four defects found while building: cursor:inactive_timeout is answered by getoption as float, not int. A wrong readAs does not fail loudly; it makes every write to that key look rejected, and the user saw an error for a change that worked. schema-hypr-shape-contract.sh now checks all 23 mapped options against the running compositor. The Settings window is tiled, so implicitWidth is only a hint and rows must survive roughly 400px. SliderRow stacks its control under the label below 520px. Binding an anchor to undefined to switch layouts does not reliably release it. Both row layouts are positioned explicitly. Concurrent compositor writes are queued and merged rather than refused. The startup replay of every compositor-backed preference routinely overlaps a UI change, and refusing left the store and the compositor disagreeing. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
// 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; -1 means "nothing pending, show what is stored".
|
||||
property real pending: -1
|
||||
readonly property real shown: root.pending >= 0 ? 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 < 0)
|
||||
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 = -1
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user