486 lines
20 KiB
QML
486 lines
20 KiB
QML
// 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
|
|
}
|
|
}
|
|
}
|