// One picture of what the machine does while it sits untouched. // // The Power page used to show three sliders stacked in a column and leave you // to hold the relationship between them in your head: blank at 5, lock at 10, // suspend never. The numbers were never the problem -- what was missing is // that they are one sequence, and that some orderings of that sequence are // nonsense. The sliders stay, because they are still the precise way to set a // number; this draws the same three values as the sequence they actually are, // and puts the ordering warnings on the picture instead of in a card further // down that nobody reads. // // ── The scale ──────────────────────────────────────────────────────────────── // // The axis is a FIXED piecewise-linear time scale, not one derived from the // current values. That distinction matters more than it sounds. A scale // computed from the stops means dragging a stop moves the scale, which moves // the stop, under a pointer that has not moved -- the drag chases itself and // snapping becomes unpredictable. Fixing the breakpoints costs a little // proportionality at the long end and buys a drag that behaves. // // Within each band the mapping is exactly proportional. The first quarter of // an hour gets nearly half the track because that is where every timing anyone // actually sets lives; the eight-hour tail gets what is left. // // ── "Never" ────────────────────────────────────────────────────────────────── // // Never is not zero minutes on that scale -- it is the absence of the event, so // putting it at the origin would draw "the screen never blanks" as "the screen // blanks immediately". Never stops park on a reserved shelf past the end of the // scale, drawn muted, which is also what makes dragging a stop off the right // end mean "stop doing this" and dragging it back mean "start again". // // ── Delegate lifetime ──────────────────────────────────────────────────────── // // There is deliberately no Repeater here. The Displays arrangement canvas // learned the hard way that a model rebuilt by the drag itself destroys the // delegate under the pointer on its first millimetre of travel. There are // exactly three stops and they are three declared instances, so the item being // dragged cannot be replaced mid-gesture by anything. import QtQuick import qs.config import qs.services Item { id: root // Which schema keys this timeline reads and writes. The page swaps them for // the battery set when the charger comes out, so the picture always draws // the timings that are actually in force. property string blankKey: "screenBlankMinutes" property string lockKey: "lockMinutes" property string suspendKey: "suspendMinutes" // Minutes to fraction of the track. Monotone by construction, and inverted // below so a pointer position can be turned back into minutes. readonly property var bands: [ { minutes: 15, frac: 0.46 }, { minutes: 60, frac: 0.74 }, { minutes: 480, frac: 0.88 } ] // Everything to the right of this is the Never shelf. readonly property real neverStart: 0.88 // Room for a knob to sit on either end without being clipped. The track's // geometry lives on the root rather than being read off the Rectangle's id, // because the stop below is an inline component and reaching sideways into // a sibling id from one is not something to rely on. readonly property real trackLeft: 10 readonly property real trackSpan: Math.max(1, root.width - 2 * root.trackLeft) readonly property real trackTop: 20 readonly property real trackThickness: 8 // Roughly how much room a stop's label wants. Used only to decide whether // two labels would collide and one should drop to the next line. readonly property real labelSpan: 80 // ── Drag state ─────────────────────────────────────────────────────────── // // One stop at a time. `dragMinutes` is the snapped value the drag is // proposing; `dragFrac` is the raw pointer position, used only to keep a // knob that has landed on the Never shelf under the finger instead of // jumping to the shelf slot it will occupy once the drag ends. property string dragKey: "" property real dragMinutes: 0 property real dragFrac: 0 readonly property bool dragging: root.dragKey !== "" width: parent ? parent.width : 620 implicitHeight: 36 + root.labelRows * 32 + (warnings.visible ? warnings.implicitHeight + 6 : 0) // ── Scale ──────────────────────────────────────────────────────────────── function fracFor(minutes: real): real { const value = Math.max(0, Math.min(480, minutes)); let prevMinutes = 0; let prevFrac = 0; for (const band of root.bands) { if (value <= band.minutes) { const span = band.minutes - prevMinutes; const ratio = span > 0 ? (value - prevMinutes) / span : 0; return prevFrac + ratio * (band.frac - prevFrac); } prevMinutes = band.minutes; prevFrac = band.frac; } return root.neverStart; } function minutesFor(frac: real): real { const value = Math.max(0, Math.min(root.neverStart, frac)); let prevMinutes = 0; let prevFrac = 0; for (const band of root.bands) { if (value <= band.frac) { const span = band.frac - prevFrac; const ratio = span > 0 ? (value - prevFrac) / span : 0; return prevMinutes + ratio * (band.minutes - prevMinutes); } prevMinutes = band.minutes; prevFrac = band.frac; } return 480; } // The schema owns the range and the step, so a drag can never propose a // value the store would refuse. function snap(key: string, minutes: real): real { const spec = PreferenceSchema.spec(key); const step = spec && spec.step ? spec.step : 1; const lower = spec && spec.min !== undefined ? spec.min : 0; const upper = spec && spec.max !== undefined ? spec.max : 480; return Math.max(lower, Math.min(upper, Math.round(minutes / step) * step)); } function stepOf(key: string): real { const spec = PreferenceSchema.spec(key); return spec && spec.step ? spec.step : 1; } // What the timeline should draw for a key: the drag's proposal while one is // in flight, the stored value otherwise. function minutesOf(key: string): real { if (key === root.dragKey) return root.dragMinutes; const stored = DesktopPreferences.get(key); return typeof stored === "number" ? stored : 0; } function caption(minutes: real): string { return minutes <= 0 ? "Never" : minutes + " min"; } // ── Layout ─────────────────────────────────────────────────────────────── // // Three entries in event order, each carrying where its stop sits and which // label line it belongs on. Recomputed whenever a value or the width // changes; nothing here owns any state. readonly property var stops: { const span = root.trackSpan; const entries = [ { key: root.blankKey, title: "Screen off" }, { key: root.lockKey, title: "Lock" }, { key: root.suspendKey, title: "Suspend" } ].map(entry => { const minutes = root.minutesOf(entry.key); return { key: entry.key, title: entry.title, minutes: minutes, never: minutes <= 0 }; }); // Never stops share the shelf, spread across it in event order so two // of them do not land on the same pixel. const shelf = entries.filter(entry => entry.never).length; let taken = 0; for (const entry of entries) { if (entry.never) { entry.frac = root.neverStart + (taken + 0.5) / Math.max(1, shelf) * (1 - root.neverStart); taken += 1; } else { entry.frac = root.fracFor(entry.minutes); } } // A label drops to the next line only when it would otherwise overlap // the last one placed on this one, so the ordinary case stays a single // row and the degenerate one -- three stops crowded onto the shelf -- // stays readable instead of printing over itself. const ordered = entries.slice().sort((a, b) => a.frac - b.frac); let lastByRow = [-root.labelSpan, -root.labelSpan, -root.labelSpan]; for (const entry of ordered) { const x = entry.frac * span; let row = 0; while (row < 2 && (x - lastByRow[row]) < root.labelSpan) row += 1; entry.row = row; lastByRow[row] = x; } return entries; } readonly property int labelRows: 1 + root.stops.reduce((deepest, entry) => Math.max(deepest, entry.row), 0) // ── Orderings that do not mean what they look like ─────────────────────── readonly property real blankMinutes: root.minutesOf(root.blankKey) readonly property real lockMinutes: root.minutesOf(root.lockKey) readonly property real suspendMinutes: root.minutesOf(root.suspendKey) readonly property bool lockBeforeBlank: root.lockMinutes > 0 && root.blankMinutes > 0 && root.lockMinutes < root.blankMinutes readonly property bool suspendBeforeLock: root.suspendMinutes > 0 && root.lockMinutes > 0 && root.suspendMinutes < root.lockMinutes // ── Writing ────────────────────────────────────────────────────────────── // // Same shape as SliderRow: the picture follows the pointer immediately, the // store is written after a short quiet period, and the drag value is handed // back a moment after that so a refused write snaps the stop to what is // really set rather than leaving it where the pointer left it. function propose(key: string, minutes: real, frac: real): void { root.dragKey = key; root.dragMinutes = root.snap(key, minutes); root.dragFrac = Math.max(0, Math.min(1, frac)); release.stop(); commit.restart(); } function settle(): void { if (root.dragKey === "") return; commit.stop(); SystemSettings.commitPreference(root.dragKey, root.dragMinutes); release.restart(); } Timer { id: commit interval: 140 onTriggered: { if (root.dragKey !== "") SystemSettings.commitPreference(root.dragKey, root.dragMinutes); } } Timer { id: release interval: 160 onTriggered: root.dragKey = "" } // ── The picture ────────────────────────────────────────────────────────── Text { x: root.trackLeft y: 0 text: "Active" color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1) font.weight: Font.DemiBold font.capitalization: Font.AllUppercase font.letterSpacing: 0.7 } Rectangle { id: track x: root.trackLeft y: root.trackTop width: root.trackSpan height: root.trackThickness radius: root.trackThickness / 2 border.width: 0 // Blue leads into orchid and fades out toward the far end, so the // track reads as "awake, then less so" rather than as a progress bar. gradient: Gradient { orientation: Gradient.Horizontal GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.42) } GradientStop { position: 0.6; color: Theme.alpha(Theme.accentSecondary, 0.34) } GradientStop { position: 1.0; color: Theme.alpha(Theme.fgMuted, 0.26) } } // Where the scale stops and the Never shelf begins. Rectangle { x: root.neverStart * track.width - width / 2 anchors.verticalCenter: parent.verticalCenter width: 2 height: 16 radius: 1 border.width: 0 color: Theme.alpha(Theme.fg, 0.18) } } // A stop and its label. Declared three times rather than repeated, so a // value change cannot destroy the one under the pointer. component Stop: Item { id: stop required property int slot readonly property var entry: root.stops[stop.slot] readonly property bool never: stop.entry ? stop.entry.never : true readonly property bool held: root.dragging && !!stop.entry && root.dragKey === stop.entry.key // While a knob is being dragged onto the shelf its position comes from // the pointer, because the shelf slot it will end up in is decided by // how many other stops are already there. readonly property real frac: { if (!stop.entry) return 0; if (stop.held && stop.never) return Math.max(root.neverStart, Math.min(1, root.dragFrac)); return stop.entry.frac; } readonly property real centre: root.trackLeft + stop.frac * root.trackSpan anchors.fill: parent function nudge(direction: int): void { if (!stop.entry) return; const step = root.stepOf(stop.entry.key); const next = stop.entry.minutes + direction * step; root.propose(stop.entry.key, Math.max(0, next), root.fracFor(Math.max(0, next))); root.settle(); } Rectangle { id: knob x: stop.centre - width / 2 y: root.trackTop + root.trackThickness / 2 - height / 2 width: 18 height: 18 radius: 9 color: stop.never ? Theme.fgMuted : Theme.fg border.width: 3 border.color: Theme.bg scale: stop.held || knobHover.hovered ? 1.12 : 1 activeFocusOnTab: true Accessible.role: Accessible.Slider Accessible.name: (stop.entry ? stop.entry.title : "") + ", " + root.caption(stop.entry ? stop.entry.minutes : 0) Behavior on scale { NumberAnimation { duration: Theme.durFast; easing.type: Easing.OutQuad } } Rectangle { anchors.centerIn: parent width: 26 height: 26 radius: 13 border.width: 2 border.color: Theme.accentSecondary color: "transparent" visible: knob.activeFocus } Keys.onLeftPressed: stop.nudge(-1) Keys.onRightPressed: stop.nudge(1) HoverHandler { id: knobHover cursorShape: Qt.PointingHandCursor } TapHandler { onTapped: knob.forceActiveFocus() } // target: null and explicit translation maths, the same shape the // display arrangement uses: letting the handler move the item would // fight the binding that puts the knob where the value says. DragHandler { id: drag target: null yAxis.enabled: false property real startFrac: 0 onActiveChanged: { if (active) { drag.startFrac = stop.frac; knob.forceActiveFocus(); } else { root.settle(); } } onTranslationChanged: { if (!drag.active || !stop.entry || root.trackSpan <= 0) return; const frac = Math.max(0, Math.min(1, drag.startFrac + drag.translation.x / root.trackSpan)); // Past the end of the scale is not "eight hours and a bit" // -- it is the event being switched off. const minutes = frac >= root.neverStart ? 0 : root.minutesFor(frac); root.propose(stop.entry.key, minutes, frac); } } } Column { id: label readonly property int row: stop.entry ? stop.entry.row : 0 x: Math.max(0, Math.min(root.width - width, stop.centre - width / 2)) y: 36 + label.row * 32 width: root.labelSpan spacing: 1 Text { width: parent.width horizontalAlignment: Text.AlignHCenter text: stop.entry ? stop.entry.title : "" color: stop.never ? Theme.fgMuted : Theme.fg font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall font.weight: Font.DemiBold elide: Text.ElideRight } Text { width: parent.width horizontalAlignment: Text.AlignHCenter text: root.caption(stop.entry ? stop.entry.minutes : 0) color: Theme.fgDim font.family: Theme.fontFamily font.features: Theme.tabularFigures font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1) elide: Text.ElideRight } } } Stop { slot: 0 } Stop { slot: 1 } Stop { slot: 2 } // ── Warnings, on the picture rather than beside it ─────────────────────── Column { id: warnings x: root.trackLeft y: 36 + root.labelRows * 32 width: Math.max(1, root.width - 2 * root.trackLeft) spacing: 3 visible: root.lockBeforeBlank || root.suspendBeforeLock Text { width: parent.width visible: root.lockBeforeBlank text: "Locks at " + root.caption(root.lockMinutes) + ", before the screen turns off at " + root.caption(root.blankMinutes) + " — it works, but the lock screen stays lit for the difference." color: Theme.warn font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall wrapMode: Text.WordWrap } Text { width: parent.width visible: root.suspendBeforeLock text: "Suspends at " + root.caption(root.suspendMinutes) + ", before the lock at " + root.caption(root.lockMinutes) + " — the idle lock never fires, so locking depends on “Lock before sleeping” below." color: Theme.warn font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall wrapMode: Text.WordWrap } } }