Files
Panama/config/dot/quickshell/modules/settings/IdleTimeline.qml
T

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
}
}
}