Draw idle as one timeline, and let the power button answer to its owner
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,22 +5,50 @@
|
||||
// ones and falls apart at four with sentences under them -- and on this page
|
||||
// two cards sit side by side, so a row has half the width it used to.
|
||||
//
|
||||
// Not schema-bound. Some of these choices are Panama settings and some are
|
||||
// display state that has to go through a keep-or-revert transaction, so the
|
||||
// caller says what to do with the value rather than this writing it.
|
||||
// Schema-bound when you name a key, hand-fed when you do not:
|
||||
//
|
||||
// OptionPickerRow { setting: "powerButtonAction" }
|
||||
//
|
||||
// gets its label, explanation, option list and current value from
|
||||
// PreferenceSchema and commits the pick for you, exactly as ToggleRow and
|
||||
// ChoiceRow do. Leaving `setting` empty keeps the original behavior, which is
|
||||
// what the display rows need: some of these choices are not preferences at all
|
||||
// but compositor state going through a keep-or-revert transaction, so the
|
||||
// caller says what a pick means rather than this writing it.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
PickerRow {
|
||||
id: root
|
||||
|
||||
// Empty means "not a stored preference"; see above.
|
||||
property string setting: ""
|
||||
|
||||
readonly property var spec:
|
||||
root.setting === "" ? null : PreferenceSchema.spec(root.setting)
|
||||
|
||||
// [{ value, label, detail }]
|
||||
property var options: []
|
||||
property var current: null
|
||||
property var options: root.spec && root.spec.options ? root.spec.options : []
|
||||
property var current: root.setting === "" ? null : DesktopPreferences.get(root.setting)
|
||||
|
||||
label: root.spec ? root.spec.label : ""
|
||||
detail: root.spec ? root.spec.detail : ""
|
||||
|
||||
signal picked(var value)
|
||||
|
||||
// Connected rather than handled inline: a caller that declares its own
|
||||
// `onPicked` replaces a handler written in this file, and a schema-bound
|
||||
// row that silently stopped writing would be a bad way to find that out.
|
||||
Connections {
|
||||
target: root
|
||||
function onPicked(value: var): void {
|
||||
if (root.setting !== "")
|
||||
SystemSettings.commitPreference(root.setting, value);
|
||||
}
|
||||
}
|
||||
|
||||
readonly property var currentOption:
|
||||
root.options.find(option => option.value === root.current) ?? null
|
||||
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
// Power & Lock.
|
||||
//
|
||||
// The three idle timings used to be three sliders in a column, and the
|
||||
// relationship between them -- blank, then lock, then sleep -- was left for you
|
||||
// to assemble in your head. IdleTimeline draws that sequence; the sliders stay
|
||||
// underneath it as the precise way to set a number. Same values, same store,
|
||||
// two ways in.
|
||||
//
|
||||
// One idle card, not two. The wall-power and battery timings are the same three
|
||||
// concepts, and hypridle holds one set at a time, so the card follows the power
|
||||
// source and swaps its sliders rather than showing both sets at once and
|
||||
// leaving you to work out which one is actually in effect.
|
||||
//
|
||||
// hypridle has no IPC for reconfiguration, so these values reach it by
|
||||
// regenerating its config and restarting the daemon (services/IdleLock.qml).
|
||||
// That only happens when Panama manages the daemon, and the card below says
|
||||
// plainly which state you are in rather than presenting sliders that silently
|
||||
// do nothing.
|
||||
// That only happens while Panama manages the daemon, and the Management card
|
||||
// says plainly which state you are in rather than presenting controls that
|
||||
// silently do nothing.
|
||||
//
|
||||
// What this page does NOT do is suspend, hibernate or power the machine off.
|
||||
// Those live in the power menu, where destructive entries arm on the first
|
||||
// press and fire on the second; a settings page that could do them on a single
|
||||
// click would be a worse power menu with none of the safeguards.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
@@ -14,171 +30,431 @@ import qs.services
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
// Probing the power daemon is a D-Bus round trip, so it happens when the
|
||||
// page opens rather than at shell startup.
|
||||
Component.onCompleted: if (!PowerProfiles.scanned) PowerProfiles.refresh()
|
||||
|
||||
title: "Power & Lock"
|
||||
lede: "When the screen turns off, when the session locks, and whether it ever sleeps."
|
||||
lede: "When this machine rests, locks, and how it spends its power."
|
||||
|
||||
// Reading the power daemon is a D-Bus round trip; the wake locks and the
|
||||
// battery's wear figures each cost a subprocess. None of them belongs on a
|
||||
// shell-startup path or on a polling timer, so they are asked for when the
|
||||
// page opens. The page is built by a Loader and destroyed on navigation, so
|
||||
// "when it opens" is exactly what Component.onCompleted means here.
|
||||
Component.onCompleted: {
|
||||
if (!PowerProfiles.scanned)
|
||||
PowerProfiles.refresh();
|
||||
IdleLock.refreshInhibitors();
|
||||
Battery.refreshHealth();
|
||||
}
|
||||
|
||||
// Which set of timings is in force is a live fact about the machine rather
|
||||
// than a preference, so one card follows it instead of two cards each
|
||||
// claiming half the truth. A desktop is never on battery -- see Battery.
|
||||
readonly property bool onBattery: Battery.available && !Battery.acOnline
|
||||
|
||||
readonly property string powerState: {
|
||||
if (Battery.charging)
|
||||
return "charging";
|
||||
if (Battery.status === "Full")
|
||||
return "full";
|
||||
if (Battery.acOnline)
|
||||
return "plugged in, not charging";
|
||||
return "discharging";
|
||||
}
|
||||
|
||||
// ── Wake locks ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// logind's inhibitor list, split by the distinction that decides what it
|
||||
// means. A `block` is a thing actually keeping the machine awake. A `delay`
|
||||
// holds sleep for a few seconds on the way down and nothing more --
|
||||
// NetworkManager, UPower and hypridle itself hold delays permanently, so a
|
||||
// row that counted them would report every machine as pinned awake forever
|
||||
// and the row would be worth nothing.
|
||||
readonly property var wakeLocks: {
|
||||
const held = IdleLock.inhibitors;
|
||||
return Array.isArray(held) ? held : [];
|
||||
}
|
||||
|
||||
readonly property var wakeBlocks:
|
||||
root.wakeLocks.filter(entry => entry.mode === "block")
|
||||
|
||||
readonly property int wakeDelays: root.wakeLocks.length - root.wakeBlocks.length
|
||||
|
||||
// A wake lock is taken and dropped by applications while you watch -- a
|
||||
// video starts, an update finishes -- so a row claiming to say what is
|
||||
// happening "right now" has to keep asking. Only while the page exists.
|
||||
Timer {
|
||||
interval: 8000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: IdleLock.refreshInhibitors()
|
||||
}
|
||||
|
||||
// Absolute paths are correct and unreadable. The home prefix is the part
|
||||
// nobody needs to be told.
|
||||
function shorten(path: string): string {
|
||||
const home = Quickshell.env("HOME") ?? "";
|
||||
return home !== "" && path.startsWith(home) ? "~" + path.slice(home.length) : path;
|
||||
}
|
||||
|
||||
// A number and what it means, for the facts a battery reports about itself.
|
||||
// Deliberately not rows: three short numbers side by side is a glance, and
|
||||
// three rows of "Health ......... 89%" is a table to read.
|
||||
component StatTile: Rectangle {
|
||||
id: tile
|
||||
|
||||
property string value: ""
|
||||
property string caption: ""
|
||||
|
||||
implicitHeight: tileBody.implicitHeight + 20
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.alpha(Theme.bgDark, 0.55)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Column {
|
||||
id: tileBody
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: 11
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: tile.value
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeLarge + 3
|
||||
font.weight: Font.DemiBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: tile.caption
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Power profile ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Not a stored preference: the daemon owns it, it survives Panama restarts,
|
||||
// and anything else on the system can change it, so a copy here would drift.
|
||||
SettingsCard {
|
||||
visible: PowerProfiles.available || PowerProfiles.lastError !== ""
|
||||
title: "Power profile"
|
||||
subtitle: {
|
||||
if (!PowerProfiles.available)
|
||||
return PowerProfiles.lastError;
|
||||
if (PowerProfiles.degraded !== "")
|
||||
return "Held back right now: " + PowerProfiles.degraded;
|
||||
// Fedora serves this interface from tuned-ppd rather than
|
||||
// power-profiles-daemon. Naming the daemon that is actually
|
||||
// answering beats implying one that is not installed.
|
||||
return "Applied by the system's profile daemon — tuned here — so it outlives Panama, and anything else on the machine can change it.";
|
||||
}
|
||||
|
||||
PowerProfileTiles {
|
||||
profiles: PowerProfiles.profiles
|
||||
current: PowerProfiles.active
|
||||
busy: PowerProfiles.busy
|
||||
onPicked: profile => PowerProfiles.set(profile)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Idle ─────────────────────────────────────────────────────────────────
|
||||
SettingsCard {
|
||||
title: Battery.available
|
||||
? (root.onBattery ? "Idle — on battery" : "Idle — on wall power")
|
||||
: "Idle"
|
||||
subtitle: {
|
||||
if (!IdleLock.managed)
|
||||
return "hypridle is running its shipped configuration. These are a stored intention until you turn management on below.";
|
||||
if (Battery.available)
|
||||
return "What happens as the machine sits untouched. Drag the stops or use the sliders — the same numbers. hypridle holds one set of timings at a time, so these swap over when the charger does.";
|
||||
return "What happens as the machine sits untouched. Drag the stops or use the sliders — the same numbers.";
|
||||
}
|
||||
|
||||
IdleTimeline {
|
||||
// The picture draws whichever set is in force, because hypridle can
|
||||
// only be running one of them.
|
||||
blankKey: root.onBattery ? "screenBlankMinutesBattery" : "screenBlankMinutes"
|
||||
lockKey: root.onBattery ? "lockMinutesBattery" : "lockMinutes"
|
||||
suspendKey: root.onBattery ? "suspendMinutesBattery" : "suspendMinutes"
|
||||
|
||||
// Stepped back while hypridle runs its own config: the numbers are
|
||||
// still stored and still editable, they are just not what the
|
||||
// machine is doing.
|
||||
opacity: IdleLock.managed ? 1 : 0.6
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 14
|
||||
}
|
||||
|
||||
// Both sets are declared and one is shown. Six sliders in a column
|
||||
// would be the two cards this page just stopped having, stacked.
|
||||
SliderRow {
|
||||
visible: !root.onBattery
|
||||
setting: "screenBlankMinutes"
|
||||
zeroLabel: "Never"
|
||||
}
|
||||
SliderRow {
|
||||
visible: !root.onBattery
|
||||
setting: "lockMinutes"
|
||||
zeroLabel: "Never"
|
||||
}
|
||||
SliderRow {
|
||||
visible: !root.onBattery
|
||||
setting: "suspendMinutes"
|
||||
zeroLabel: "Never"
|
||||
}
|
||||
|
||||
SliderRow {
|
||||
visible: root.onBattery
|
||||
setting: "screenBlankMinutesBattery"
|
||||
zeroLabel: "Never"
|
||||
}
|
||||
SliderRow {
|
||||
visible: root.onBattery
|
||||
setting: "lockMinutesBattery"
|
||||
zeroLabel: "Never"
|
||||
}
|
||||
SliderRow {
|
||||
visible: root.onBattery
|
||||
setting: "suspendMinutesBattery"
|
||||
zeroLabel: "Never"
|
||||
}
|
||||
|
||||
ToggleRow { setting: "lockOnSleep" }
|
||||
|
||||
// hypridle has no conditional listener -- it cannot be told "unless
|
||||
// something is playing". What it does have is logind's inhibitors,
|
||||
// which every well-behaved player already takes. Naming them is the
|
||||
// honest substitute for a rule that cannot be written.
|
||||
//
|
||||
// "Nothing" is said only when logind has been asked and answered with
|
||||
// no blocks. Not being able to ask is a different claim, and the row
|
||||
// makes it rather than quietly reporting a clear machine.
|
||||
SettingRow {
|
||||
label: "Keeping the machine awake right now"
|
||||
detail: {
|
||||
if (!IdleLock.inhibitorsKnown)
|
||||
return "Could not ask logind what is holding the machine awake";
|
||||
const delays = root.wakeDelays > 0
|
||||
? " · " + root.wakeDelays
|
||||
+ (root.wakeDelays === 1 ? " service" : " services")
|
||||
+ " briefly delay sleep on the way down, which is normal"
|
||||
: "";
|
||||
if (root.wakeBlocks.length === 0)
|
||||
return "Nothing — no application holds a wake lock" + delays;
|
||||
return "These hold sleep off entirely, so the timings above wait for them" + delays;
|
||||
}
|
||||
controlWidth: 0
|
||||
divider: root.wakeBlocks.length > 0
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.wakeBlocks
|
||||
|
||||
TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: parent.width
|
||||
label: String(modelData.who ?? "Something")
|
||||
detail: String(modelData.why ?? "")
|
||||
value: String(modelData.what ?? "")
|
||||
controlWidth: 120
|
||||
divider: index < root.wakeBlocks.length - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Battery ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Absent on a desktop, in full. `available` is false until a battery has
|
||||
// actually been read, so this is not an empty card claiming 0%.
|
||||
SettingsCard {
|
||||
visible: Battery.available
|
||||
title: "Battery"
|
||||
subtitle: Battery.acOnline
|
||||
? "On wall power."
|
||||
: "On battery. The timings below switch to their battery values automatically."
|
||||
subtitle: Math.round(Battery.percent) + "% · " + root.powerState
|
||||
|
||||
TextRow {
|
||||
label: "Charge"
|
||||
value: Math.round(Battery.percent) + "%"
|
||||
}
|
||||
// Each tile is drawn only where the firmware reported the number
|
||||
// behind it. Plenty of packs report neither health nor cycles, and a
|
||||
// tile reading "100% of design capacity" on a battery that never said
|
||||
// is a confident wrong answer about whether the hardware is dying.
|
||||
Item {
|
||||
id: healthTiles
|
||||
|
||||
TextRow {
|
||||
label: "State"
|
||||
value: {
|
||||
if (Battery.charging)
|
||||
return "Charging";
|
||||
if (Battery.status === "Full")
|
||||
return "Full";
|
||||
if (Battery.acOnline)
|
||||
return "Plugged in, not charging";
|
||||
return "On battery";
|
||||
readonly property int count: (Battery.healthPercent > 0 ? 1 : 0)
|
||||
+ (Battery.cycleCount > 0 ? 1 : 0)
|
||||
+ (Battery.chargeLimitSupported ? 1 : 0)
|
||||
readonly property real tileWidth:
|
||||
(healthTiles.width - 10 * (healthTiles.count - 1))
|
||||
/ Math.max(1, healthTiles.count)
|
||||
|
||||
width: parent.width
|
||||
visible: healthTiles.count > 0
|
||||
implicitHeight: tiles.implicitHeight + 12
|
||||
|
||||
Row {
|
||||
id: tiles
|
||||
|
||||
width: parent.width
|
||||
spacing: 10
|
||||
|
||||
StatTile {
|
||||
width: healthTiles.tileWidth
|
||||
visible: Battery.healthPercent > 0
|
||||
value: Math.round(Battery.healthPercent) + "%"
|
||||
caption: "Health — of its design capacity"
|
||||
}
|
||||
|
||||
StatTile {
|
||||
width: healthTiles.tileWidth
|
||||
visible: Battery.cycleCount > 0
|
||||
value: String(Battery.cycleCount)
|
||||
caption: "Charge cycles"
|
||||
}
|
||||
|
||||
// Read back from the firmware rather than from the setting: the
|
||||
// slider below asks, and some firmware clamps or ignores the
|
||||
// value. This tile is what the hardware actually did.
|
||||
StatTile {
|
||||
width: healthTiles.tileWidth
|
||||
visible: Battery.chargeLimitSupported
|
||||
value: Battery.chargeLimit + "%"
|
||||
caption: "Charging stops at"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The two points at which the desktop starts telling you. These were
|
||||
// in the schema and reachable from settings search long before any
|
||||
// page rendered them -- search delivered people to this card and the
|
||||
// controls were not here.
|
||||
SliderRow { setting: "batteryLowPercent" }
|
||||
SliderRow { setting: "batteryCriticalPercent" }
|
||||
ChoiceRow {
|
||||
setting: "batteryCriticalAction"
|
||||
divider: Battery.chargeLimitSupported
|
||||
}
|
||||
|
||||
// Only where the firmware actually has a ceiling. A machine whose
|
||||
// kernel exposes nothing gets no control at all, rather than one that
|
||||
// would accept a value and change nothing.
|
||||
SliderRow {
|
||||
visible: Battery.chargeLimitSupported
|
||||
setting: "batteryChargeLimit"
|
||||
}
|
||||
|
||||
// The two points at which the desktop starts telling you.
|
||||
SliderRow { setting: "batteryLowPercent" }
|
||||
SliderRow { setting: "batteryCriticalPercent" }
|
||||
ChoiceRow {
|
||||
setting: "batteryCriticalAction"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// The same profiles GNOME's Power panel offers. Not a stored preference --
|
||||
// the daemon owns it, it survives Panama restarts, and anything else on the
|
||||
// system can change it, so a copy here would drift.
|
||||
// ── The lid ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Read-only by design. The decision follows what is connected (see
|
||||
// services/LidPolicy.qml), and the deliberate absence of an override is
|
||||
// part of that design: a lid switch set to "never suspend" is a laptop that
|
||||
// cooks in a bag. Saying so here beats leaving the behavior to be
|
||||
// discovered by closing it.
|
||||
SettingsCard {
|
||||
visible: PowerProfiles.available || PowerProfiles.lastError !== ""
|
||||
title: "Power profile"
|
||||
subtitle: PowerProfiles.degraded !== ""
|
||||
? "Performance is limited right now: " + PowerProfiles.degraded
|
||||
: (PowerProfiles.available
|
||||
? "Applies to the whole system and persists across sessions."
|
||||
: PowerProfiles.lastError)
|
||||
visible: Battery.available
|
||||
title: "When the lid closes"
|
||||
|
||||
Repeater {
|
||||
model: PowerProfiles.profiles
|
||||
SettingRow {
|
||||
label: LidPolicy.inhibited
|
||||
? "Stays awake — an external display is connected"
|
||||
: "Suspends — unless an external display is connected"
|
||||
detail: "Docked, the lid is just a lid. Panama holds a systemd inhibitor while a second screen is attached and logind does the rest, so this is a fact about the session rather than a setting something else could quietly disagree with."
|
||||
controlWidth: 160
|
||||
divider: false
|
||||
|
||||
SettingRow {
|
||||
id: profileRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: PowerProfiles.label(profileRow.modelData)
|
||||
detail: PowerProfiles.detail(profileRow.modelData)
|
||||
value: profileRow.modelData === PowerProfiles.active ? "Active" : ""
|
||||
divider: profileRow.index < PowerProfiles.profiles.length - 1
|
||||
activatable: profileRow.modelData !== PowerProfiles.active && !PowerProfiles.busy
|
||||
onActivated: PowerProfiles.set(profileRow.modelData)
|
||||
// SoundBadge is the pill Panama already has for "how this thing is
|
||||
// attached, or why it is not here"; the name is where it was first
|
||||
// needed rather than what it is.
|
||||
SoundBadge {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: LidPolicy.inhibited ? "Docked · staying awake" : "Will suspend"
|
||||
tone: LidPolicy.inhibited ? Theme.ok : Theme.fgMuted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── The power button ─────────────────────────────────────────────────────
|
||||
SettingsCard {
|
||||
// Named for the power source only on a machine that has two of them.
|
||||
// On a desktop this is simply "Idle behavior", as it always was.
|
||||
title: Battery.available ? "Idle behavior on wall power" : "Idle behavior"
|
||||
subtitle: IdleLock.managed
|
||||
? (Battery.available && !Battery.acOnline
|
||||
? "Managed here. These apply when the charger is connected; the battery timings below are what is in effect right now."
|
||||
: "Idle timings are managed here. Changes take effect immediately.")
|
||||
: "hypridle is running its shipped configuration. Turn on management below to make these adjustable."
|
||||
title: "The power button"
|
||||
subtitle: "logind is told to ignore the key, so what a press does is Panama's decision rather than the system's — and changing it needs no root. Holding it down still cuts the power through the firmware, as ever."
|
||||
|
||||
SliderRow { setting: "screenBlankMinutes"; zeroLabel: "Never" }
|
||||
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
|
||||
SliderRow { setting: "suspendMinutes"; zeroLabel: "Never" }
|
||||
ToggleRow { setting: "lockOnSleep"; divider: false }
|
||||
}
|
||||
|
||||
// Absent on a desktop. hypridle holds one set of timeouts at a time, so
|
||||
// these do not layer on top of the card above -- they replace it whenever
|
||||
// the charger is unplugged, and panama-idle rebuilds the config at that
|
||||
// moment.
|
||||
SettingsCard {
|
||||
visible: Battery.available
|
||||
title: "Idle behavior on battery"
|
||||
subtitle: Battery.acOnline
|
||||
? "What will apply once the charger is unplugged."
|
||||
: "In effect right now."
|
||||
|
||||
SliderRow { setting: "screenBlankMinutesBattery"; zeroLabel: "Never" }
|
||||
SliderRow { setting: "lockMinutesBattery"; zeroLabel: "Never" }
|
||||
SliderRow { setting: "suspendMinutesBattery"; zeroLabel: "Never"; divider: false }
|
||||
}
|
||||
|
||||
// What the lid does. Informational by design: the decision follows what
|
||||
// is connected (see services/LidPolicy.qml), and the deliberate absence
|
||||
// of an override is part of the design -- a lid switch set to "never
|
||||
// suspend" is a laptop that cooks in a bag. Saying so here beats leaving
|
||||
// the behavior undiscoverable.
|
||||
SettingsCard {
|
||||
visible: Battery.available
|
||||
title: "When the lid closes"
|
||||
subtitle: "Decided by what is connected rather than by a setting: with an external display attached the machine is docked and keeps running; on its own it suspends, locking on the way down."
|
||||
|
||||
TextRow {
|
||||
label: "Right now"
|
||||
value: LidPolicy.inhibited
|
||||
? "Stays awake — an external display is connected"
|
||||
: "Suspends"
|
||||
OptionPickerRow {
|
||||
setting: "powerButtonAction"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// Informational like the lid card above it: the behavior is a logind
|
||||
// drop-in plus a compositor bind (see keybinds.lua), not a preference,
|
||||
// and saying what a physical button does beats leaving it to be
|
||||
// discovered by pressing it.
|
||||
// ── Session ──────────────────────────────────────────────────────────────
|
||||
SettingsCard {
|
||||
title: "The power button"
|
||||
subtitle: "A press opens the power menu instead of powering off immediately. Holding it still forces power off through the firmware, as ever."
|
||||
}
|
||||
title: "Session"
|
||||
|
||||
// Only shown when the numbers are actually contradictory, rather than as a
|
||||
// permanent warning nobody reads.
|
||||
SettingsCard {
|
||||
visible: IdleLock.lockBeforeBlank
|
||||
title: "Lock happens before the screen turns off"
|
||||
subtitle: "The session will lock at " + IdleLock.lockMinutes
|
||||
+ " minutes and the display will not blank until " + IdleLock.blankMinutes
|
||||
+ ". That works, but the screen stays lit on the lock screen for the difference."
|
||||
ActionRow {
|
||||
label: "Lock the screen now"
|
||||
detail: "Same as the Super+L shortcut"
|
||||
action: "Lock"
|
||||
// Straight to logind, as the power menu and the keybind both do.
|
||||
// Locking is the one session action with nothing to lose, which is
|
||||
// why it is the only one this page offers.
|
||||
onTriggered: Quickshell.execDetached(["loginctl", "lock-session"])
|
||||
}
|
||||
|
||||
// The probe lives on IdleLock now, so the row states this machine's
|
||||
// own answer. Before logind has answered, it claims nothing.
|
||||
SettingRow {
|
||||
label: "Hibernate"
|
||||
detail: {
|
||||
if (!IdleLock.canHibernateKnown)
|
||||
return "Asking logind whether this machine can resume from disk…";
|
||||
if (IdleLock.canHibernate)
|
||||
return "Available — the power menu offers it, and logind says this machine can resume from disk.";
|
||||
return "Unavailable on this machine — swap lives in compressed RAM (zram), which vanishes with the power, so suspend is the deepest rest it has.";
|
||||
}
|
||||
controlWidth: 0
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Power menu"
|
||||
detail: {
|
||||
const action = DesktopPreferences.get("powerButtonAction");
|
||||
if (action === "poweroff")
|
||||
return "Ctrl+Alt+Delete, or the power button — which opens it with Power Off already armed, so a second press finishes the job";
|
||||
if (action === "menu")
|
||||
return "Ctrl+Alt+Delete, or the power button";
|
||||
return "Ctrl+Alt+Delete";
|
||||
}
|
||||
controlWidth: 0
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Management ───────────────────────────────────────────────────────────
|
||||
SettingsCard {
|
||||
title: "Management"
|
||||
subtitle: "hypridle's configuration is generated into your state directory and the service is pointed at it with a systemd drop-in. ~/.config/hypr is a symlink into the configuration repository, so the shipped file cannot be rewritten in place."
|
||||
|
||||
SettingRow {
|
||||
label: "Manage idle timings here"
|
||||
detail: IdleLock.serviceState === "active"
|
||||
? "hypridle is running"
|
||||
: "hypridle is " + IdleLock.serviceState
|
||||
label: "Let Panama manage idle behavior"
|
||||
detail: {
|
||||
const state = IdleLock.serviceState === "active"
|
||||
? "hypridle running"
|
||||
: "hypridle " + IdleLock.serviceState;
|
||||
if (IdleLock.generatedPath === "")
|
||||
return state;
|
||||
return "Generated at " + root.shorten(IdleLock.generatedPath) + " · " + state;
|
||||
}
|
||||
controlWidth: 48
|
||||
divider: false
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
@@ -188,14 +464,6 @@ SettingsPage {
|
||||
onToggled: value => IdleLock.setManaged(value)
|
||||
}
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Lock the screen now"
|
||||
detail: "Same as the Super+L shortcut"
|
||||
action: "Lock"
|
||||
divider: false
|
||||
onTriggered: Quickshell.execDetached(["loginctl", "lock-session"])
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// The system power profiles as tiles rather than a stack of rows.
|
||||
//
|
||||
// Three rows with the word "Active" in the trailing column is a list you have
|
||||
// to read to find out what is set. Three tiles with one lit answers that
|
||||
// without reading anything -- and the profile is the one control on the Power
|
||||
// page a person changes on purpose rather than tunes once and forgets.
|
||||
//
|
||||
// Presentation only, the same shape ColorProfileTiles has: the page passes in
|
||||
// what the daemon says and decides what a pick means, so this cannot offer a
|
||||
// profile the daemon would refuse. Names and one-line descriptions still come
|
||||
// from PowerProfiles, which is where they live so the Control Center and
|
||||
// Settings cannot disagree about what "Balanced" means.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Flow {
|
||||
id: root
|
||||
|
||||
// The daemon's own list, its current answer, and whether a change is still
|
||||
// in flight.
|
||||
property var profiles: []
|
||||
property string current: ""
|
||||
property bool busy: false
|
||||
|
||||
signal picked(string profile)
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 10
|
||||
bottomPadding: 12
|
||||
|
||||
// The same freedesktop symbolic names the quick-settings panel uses, so a
|
||||
// themed icon set redresses both at once.
|
||||
function iconFor(profile: string): string {
|
||||
switch (profile) {
|
||||
case "power-saver": return "power-profile-power-saver-symbolic";
|
||||
case "performance": return "power-profile-performance-symbolic";
|
||||
default: return "power-profile-balanced-symbolic";
|
||||
}
|
||||
}
|
||||
|
||||
// Three across when they fit, one across when they do not. There is no
|
||||
// useful two-across arrangement of three tiles.
|
||||
readonly property int columns: root.width >= 460 ? 3 : 1
|
||||
readonly property real tileWidth:
|
||||
(root.width - root.spacing * (root.columns - 1)) / root.columns
|
||||
|
||||
Repeater {
|
||||
model: root.profiles
|
||||
|
||||
Rectangle {
|
||||
id: tile
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property string profile: String(tile.modelData)
|
||||
readonly property bool selected: tile.profile === root.current
|
||||
|
||||
width: root.tileWidth
|
||||
implicitHeight: body.implicitHeight + 24
|
||||
radius: Theme.cardRadius
|
||||
opacity: root.busy && !tile.selected ? 0.55 : 1
|
||||
color: tile.selected
|
||||
? Theme.alpha(Theme.accent, 0.09)
|
||||
: Theme.alpha(Theme.fg, tileHover.hovered ? 0.08 : 0.04)
|
||||
border.width: tile.selected || tile.activeFocus ? 2 : 1
|
||||
border.color: tile.activeFocus
|
||||
? Theme.accentSecondary
|
||||
: (tile.selected ? Theme.alpha(Theme.accent, 0.6) : Theme.alpha(Theme.fg, 0.08))
|
||||
activeFocusOnTab: true
|
||||
|
||||
Accessible.role: Accessible.RadioButton
|
||||
Accessible.name: PowerProfiles.label(tile.profile)
|
||||
Accessible.checked: tile.selected
|
||||
|
||||
function choose(): void {
|
||||
if (!tile.selected && !root.busy)
|
||||
root.picked(tile.profile);
|
||||
}
|
||||
|
||||
Keys.onReturnPressed: tile.choose()
|
||||
Keys.onSpacePressed: tile.choose()
|
||||
|
||||
Column {
|
||||
id: body
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: 12
|
||||
spacing: 6
|
||||
|
||||
ThemedIcon {
|
||||
icon: root.iconFor(tile.profile)
|
||||
iconFallback: "preferences-system-power-symbolic"
|
||||
size: 20
|
||||
tint: tile.selected ? Theme.accent : Theme.fgDim
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: PowerProfiles.label(tile.profile)
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: PowerProfiles.detail(tile.profile)
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: tileHover
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: {
|
||||
tile.choose();
|
||||
tile.forceActiveFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,3 +132,5 @@ PasswordStrengthRow 1.0 PasswordStrengthRow.qml
|
||||
StockAvatarPicker 1.0 StockAvatarPicker.qml
|
||||
FingerprintEnrollPanel 1.0 FingerprintEnrollPanel.qml
|
||||
OnlineAccountRow 1.0 OnlineAccountRow.qml
|
||||
IdleTimeline 1.0 IdleTimeline.qml
|
||||
PowerProfileTiles 1.0 PowerProfileTiles.qml
|
||||
|
||||
Reference in New Issue
Block a user