Review everything shipped this weekend, and fix what the reviewers caught

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 13:29:42 -04:00
parent ffce48964e
commit 07db1068f1
42 changed files with 959 additions and 219 deletions
+15 -8
View File
@@ -287,18 +287,25 @@ hl.monitor({
-- empty monitor leaves the previous binding in place. So the config is the only
-- honest source, and applying a change is a reload.
if prefs.get("workspacesOnPrimaryOnly", false) == true then
local primary = nil
for output, entry in pairs(displays) do
if type(entry) == "table" and entry.primary == true
and type(output) == "string" and output:match("^[%w_.-]+$") ~= nil then
primary = output
break
-- Only a record display_entry accepts counts. A half-written entry is one
-- the monitor rules above already refuse, so pinning ten workspaces to it on
-- the strength of a `primary` flag nothing else trusts would put them on a
-- screen that never got a rule of its own.
local primaries = {}
for output, _ in pairs(displays) do
local entry = display_entry(output)
if entry ~= nil and entry.primary == true then
primaries[#primaries + 1] = output
end
end
-- Without a primary there is nothing to pin to, and guessing one would move
-- every workspace onto whichever screen happened to sort first.
if primary ~= nil then
-- every workspace onto whichever screen happened to sort first. Two records
-- both claiming primary is the same problem wearing a different hat: pairs()
-- has no order, so picking one of them would pin the workspaces to a
-- different screen from one reload to the next. Neither case guesses.
if #primaries == 1 then
local primary = primaries[1]
for i = 1, 10 do
hl.workspace_rule({ workspace = tostring(i), monitor = primary })
end
@@ -57,7 +57,12 @@ Singleton {
const coerced = PreferenceSchema.coerce(key, value);
if (coerced === undefined)
return false;
if (root.values[key] === coerced)
// A json value comes out of coerce() with a fresh identity every time,
// so `===` never held for one and an identical write still bumped the
// revision. The revision is what drives the video restore, the scheme
// reconciliation and the theme coalesce timer, so re-storing the same
// display map ran all three again for a change nobody made.
if (root.sameStoredValue(key, coerced))
return true;
// Reassign rather than mutate: QML does not notify on in-place changes
@@ -70,6 +75,19 @@ Singleton {
return true;
}
// Whether the store already holds `coerced` for `key`. Scalars compare by
// value; json compares by serialization, which is the only comparison an
// object or array has here. Two equal objects written in a different key
// order serialize differently and are treated as a change -- that is the
// old behaviour, so the comparison can only ever remove churn, never
// swallow a real write.
function sameStoredValue(key: string, coerced: var): bool {
const stored = root.values[key];
if (PreferenceSchema.spec(key)?.type === "json")
return stored !== undefined && JSON.stringify(stored) === JSON.stringify(coerced);
return stored === coerced;
}
// Restores every schema default in one write. Complete by construction --
// there is no hand-maintained list to fall out of sync with the schema.
function resetDesktopDefaults(): void {
@@ -294,10 +294,14 @@ Singleton {
}
]
},
// Grouped with the workspaces rather than with focus, because a group is
// where a setting is found rather than what it is about: the slider that
// sets this is a schema-bound row on WorkspacesPage. The focus group
// routes to Notifications, which is where focusModes renders.
{
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
unit: "min",
group: "focus",
group: "workspaces",
label: "Focus session length",
detail: "How long a focus session runs before it ends itself"
},
+90 -16
View File
@@ -129,16 +129,34 @@ PanelWindow {
onTriggered: root.revealed = false
}
// Other modules (the bar, the capture overlay) read this. It is one
// shared flag but there is one Dock per monitor, so only the instance on
// the currently-focused monitor is allowed to write it -- otherwise
// whichever instance last changed reveal state would stomp the others,
// and a reader would see an arbitrary monitor's value. This scopes the
// flag to mean "is the dock revealed on the monitor the user is on",
// which is what a capture overlay or the bar actually care about.
// (A true per-monitor flag would need ShellState.dockRevealed itself to
// become keyed by screen, which is out of scope here -- see the report.)
readonly property bool isFocusedMonitorInstance: root.monitor === null || root.monitor === Hyprland.focusedMonitor
// ShellState.dockRevealed is one shared flag and there is one Dock per
// monitor, so exactly one instance may write it -- otherwise whichever
// instance last changed reveal state stomps the others and a reader gets an
// arbitrary monitor's answer. Scoped this way the flag means "is the dock
// revealed on the monitor the user is on", which is the only question a
// reader outside the dock can sensibly ask of it. (A genuinely per-monitor
// answer would need the property itself keyed by screen.)
//
// Two things disqualify an instance. One is not being on the focused
// monitor, which is the whole point. The other is not being on screen at
// all: a Dock whose screen is not in `dockScreens` is invisible, and an
// invisible dock reporting itself as revealed is a lie a reader acts on. An
// instance whose monitor could not be resolved -- created standalone, or
// asked before Hyprland has reported the screen -- knows nothing about
// which monitor the user is on, so it writes only when there is no focused
// monitor to be wrong about rather than stomping the instance that knows.
//
// Nothing reads the flag today: the bar and the capture overlay it was
// written for both stopped. It is written correctly rather than left
// half-wrong, and when ShellState is next opened the property, this
// arbitration and _syncShellState should go together.
readonly property bool isFocusedMonitorInstance: {
if (!root.onThisScreen)
return false;
if (root.monitor !== null)
return root.monitor === Hyprland.focusedMonitor;
return Hyprland.focusedMonitor === null;
}
onRevealedChanged: root._syncShellState()
onIsFocusedMonitorInstanceChanged: root._syncShellState()
@@ -183,6 +201,33 @@ PanelWindow {
Item {
id: maskItem
// Where the body comes to rest once the slide finishes, mirroring
// DockBody's own x/y bindings in their revealed case.
//
// The revealed region is built from these rather than from the
// body's live position. The body slides in over a couple of hundred
// milliseconds, and a region that follows it in is a region that is
// a few pixels tall on the frame the dock is summoned -- the exact
// frame the pointer that summoned it needs to be inside, and the
// exact frame it drops the hover and sends the dock back. Following
// the animation also means an input-region commit to the compositor
// on every one of those frames, for a rectangle that is only right
// on the last of them.
//
// Clamped rather than assigned outright so the mask still tracks
// the body if anything else ever moves it: the reveal only ever
// approaches these values from outside the screen edge.
readonly property real settledX: {
if (!root.vertical)
return body.x;
return root.position === "left"
? Math.max(body.x, root.tooltipSpace)
: Math.min(body.x, surface.width - body.width - root.tooltipSpace);
}
readonly property real settledY: root.vertical
? body.y
: Math.min(body.y, root.tooltipSpace)
x: {
if (!root.revealed)
return root.position === "right" ? surface.width - root.revealStripHeight : 0;
@@ -194,25 +239,27 @@ PanelWindow {
// to its own edge, which is x 0 on the left and the body on
// the right.
if (!root.vertical)
return body.x;
return root.position === "right" ? body.x : 0;
return maskItem.settledX;
return root.position === "right" ? maskItem.settledX : 0;
}
y: {
if (!root.revealed)
return root.vertical ? 0 : surface.height - root.revealStripHeight;
return body.y;
return maskItem.settledY;
}
width: {
if (!root.revealed)
return root.vertical ? root.revealStripHeight : surface.width;
return root.vertical
? (root.position === "right" ? surface.width - body.x : body.x + body.width)
? (root.position === "right"
? surface.width - maskItem.settledX
: maskItem.settledX + body.width)
: body.width;
}
height: {
if (!root.revealed)
return root.vertical ? surface.height : root.revealStripHeight;
return root.vertical ? body.height : surface.height - body.y;
return root.vertical ? body.height : surface.height - maskItem.settledY;
}
}
@@ -226,7 +273,7 @@ PanelWindow {
body.dismissPreview();
dockContextMenu.anchorItem = anchorItem;
dockContextMenu.app = app;
dockContextMenu.visible = true;
dockContextMenu.requested = true;
}
vertical: root.vertical
@@ -284,6 +331,33 @@ PanelWindow {
id: dockContextMenu
}
// The safety net for a latched interaction flag.
//
// Every term of `interactionHeld` is cleared by an event that can go
// missing: a drag by its release, which a stolen grab eats; a preview by
// the pointer leaving, which a destroyed anchor never reports; a menu by a
// row being chosen, which a menu whose anchor died is never offered. Any
// one of them left set holds the dock revealed with the pointer nowhere
// near it, and there is no gesture that gets it back -- the flag is stuck,
// so the dock is out until the shell restarts.
//
// Eight seconds of held-but-untouched is not a gesture. The pointer being
// on the previews or on the menu counts as touched, because both are their
// own surfaces and leaving the dock is how you reach them.
Timer {
id: interactionWatchdog
interval: 8000
running: root.interactionHeld && !pointer.hovered
&& !dockPreviews.hovered && !dockContextMenu.hovered
onTriggered: {
body.cancelDrag();
body.dismissPreview();
dockContextMenu.requested = false;
}
}
// Its own surface rather than something drawn inside the dock: the dock's
// input mask is a thin strip when hidden and the bar's own rectangle when
// shown, and widening it to cover a preview would hand the dock every
@@ -248,13 +248,34 @@ Rectangle {
// are their own surface: leaving the icon to reach them would otherwise
// close the thing being reached for.
property Item previewAnchor: null
property var previewApp: null
// Looked up in the live model rather than snapshotted when the dwell fires.
// `items` is rebuilt into fresh objects whenever any window opens or
// closes, so a snapshot keeps the window list the app had when the pointer
// stopped moving: a window closed while its preview is up stays in the
// strip, and its ScreencopyView goes on holding a handle to a surface that
// no longer exists. Reading it back out of `items` means the strip empties
// itself, and an app whose last window closed drops the preview entirely.
readonly property var previewApp: {
const anchor = root.previewAnchor;
if (!anchor || !anchor.app)
return null;
const id = anchor.app.appId;
for (let i = 0; i < root.items.length; i++) {
if (root.items[i].appId === id)
return root.items[i];
}
return null;
}
// Written by the Dock from the preview popup's own hover.
property bool previewHovered: false
onHoveredItemChanged: root.reconsiderPreview()
onPreviewHoveredChanged: root.reconsiderPreview()
// A window opening or closing changes what the strip should be showing --
// including, when it was the last one, whether there should be a strip.
onItemsChanged: root.reconsiderPreview()
function reconsiderPreview(): void {
const item = root.hoveredItem;
@@ -277,7 +298,6 @@ Rectangle {
previewDwell.stop();
previewGrace.stop();
root.previewAnchor = null;
root.previewApp = null;
root.previewHovered = false;
}
@@ -289,7 +309,6 @@ Rectangle {
if (!item || !item.app || !item.app.windows || item.app.windows.length === 0)
return;
root.previewAnchor = item;
root.previewApp = item.app;
}
}
@@ -375,6 +394,13 @@ Rectangle {
onDragMoved: travel => root.moveDrag(travel)
onDragEnded: root.endDrag()
onDragCancelled: root.cancelDrag()
// Keying the model keeps a delegate alive through a rebuild,
// but not through the app's last window closing while it is
// being dragged: that row is gone, and the release it owed is
// gone with it. Without this the dock keeps dragIndex forever,
// which reads as a dock that will not hide again.
Component.onDestruction: if (root.dragIndex === dockItem.index) root.cancelDrag()
}
}
}
@@ -32,9 +32,24 @@ PopupWindow {
implicitWidth: Math.min(360, Math.max(menu.implicitWidth + Theme.popoverPadding * 2, 240))
implicitHeight: menu.implicitHeight + Theme.popoverPadding * 2
color: "transparent"
visible: false
grabFocus: true
// Whether the dock has asked for the menu. Visibility is that AND a live
// anchor, rather than the request alone: the dock icon a menu is anchored
// to is a delegate, and a delegate dies when its app's last window closes.
// An anchored PopupWindow whose anchor has gone stays mapped with a null
// anchor -- a menu hanging over the desktop, attached to nothing, holding
// the dock revealed behind it.
property bool requested: false
visible: root.requested && root.anchorItem !== null
onAnchorItemChanged: if (root.anchorItem === null) root.requested = false
// Read by the Dock's interaction watchdog. A menu with the pointer on it is
// a menu being read, not a stuck flag -- and the pointer being here means
// it is not on the dock, which is the only other thing the dock can see.
readonly property bool hovered: menuPointer.hovered
// Long window titles are the one thing here that can be arbitrarily wide,
// and a menu as wide as a browser tab's title is not a menu.
function shortTitle(toplevel: var): string {
@@ -99,6 +114,10 @@ PopupWindow {
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.08)
HoverHandler {
id: menuPointer
}
PrismEdge {
anchors.top: parent.top
anchors.topMargin: 1
@@ -125,7 +144,7 @@ PopupWindow {
label: root.shortTitle(modelData)
onActivated: {
root.focusToplevel(modelData);
root.visible = false;
root.requested = false;
}
}
}
@@ -151,7 +170,7 @@ PopupWindow {
label: modelData.name
onActivated: {
modelData.execute();
root.visible = false;
root.requested = false;
}
}
}
@@ -175,7 +194,7 @@ PopupWindow {
onActivated: {
if (root.entry)
root.entry.execute();
root.visible = false;
root.requested = false;
}
}
@@ -191,7 +210,7 @@ PopupWindow {
root.unpin();
else
root.pin();
root.visible = false;
root.requested = false;
}
}
@@ -201,7 +220,7 @@ PopupWindow {
rowEnabled: root.windows.length > 0
onActivated: {
root.quit();
root.visible = false;
root.requested = false;
}
}
@@ -218,7 +237,7 @@ PopupWindow {
label: "Dock settings"
onActivated: {
ShellState.openSettings("dock");
root.visible = false;
root.requested = false;
}
}
}
@@ -187,6 +187,17 @@ Item {
}
onPressed: mev => {
// A press arriving while a drag is still marked active means the
// last one never got its release -- a grab stolen by a surface that
// opened over the dock, most often. Clearing the flag alone would
// leave DockBody still holding a dragIndex, and with it the
// interaction hold that keeps the dock revealed forever; the drag
// has to be cancelled through the same path a stolen grab uses.
if (mouse.dragActive) {
mouse.dragActive = false;
mouse.dragConsumed = false;
root.dragCancelled();
}
mouse.pressX = mev.x;
mouse.pressY = mev.y;
mouse.dragActive = false;
@@ -167,7 +167,6 @@ Rectangle {
activeFocusOnTab: true
verticalAlignment: TextInput.AlignVCenter
maximumLength: 7
text: String(root.swatchColor).toLowerCase()
color: root.invalid ? Theme.danger : Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.4)
selectedTextColor: Theme.fg
@@ -178,6 +177,14 @@ Rectangle {
Accessible.role: Accessible.EditableText
Accessible.name: root.role + " colour, hex"
// Seeded once, then written only by the guarded handlers
// below and by onSwatchColorChanged. A binding here would
// be a second writer for the same text, silently dropped
// the first time one of them assigned to it -- so which of
// the two was in charge depended on what had happened
// earlier in the session.
Component.onCompleted: hexInput.text = String(root.swatchColor).toLowerCase()
onTextEdited: root.invalid = false
onAccepted: root.commit()
@@ -17,8 +17,15 @@ Item {
implicitWidth: 40
implicitHeight: 40
// A Canvas reads Theme inside onPaint, where nothing records a dependency
// on it, so a palette change would leave the ring drawn in the old warn
// tone until the next tick -- or for good, on a countdown sitting still.
// Naming the colour as a property gives the repaint something to watch.
readonly property color ringColor: Theme.warn
onSecondsLeftChanged: ring.requestPaint()
onTotalSecondsChanged: ring.requestPaint()
onRingColorChanged: ring.requestPaint()
onVisibleChanged: if (root.visible) ring.requestPaint()
Canvas {
@@ -12,6 +12,7 @@
// mirroring, rather than drawing it wherever its stale coordinates point.
import QtQuick
import Quickshell
import qs.config
import qs.widgets
import "../../services/DisplayLayout.js" as DisplayLayout
@@ -198,7 +199,20 @@ Item {
}
Repeater {
model: root.tiles
// Keyed on the output name rather than fed the array directly.
// Every drag step and every arrow key rewrites draftLayout,
// which rebuilds `tiles` into a fresh array of fresh objects --
// a new model, which resets the Repeater and destroys the tile
// being dragged on its first millimetre of travel. The drag
// ended there, silently, and nothing was ever applied; the
// arrow keys lost focus after a single step for the same
// reason. Keyed, the same rebuild is "this row moved", and the
// tile under the pointer keeps its handler and its focus.
model: ScriptModel {
values: root.tiles
objectProp: "name"
comparisonMode: ObjectComparison.Structure
}
Rectangle {
id: tile
@@ -91,7 +91,17 @@ Column {
property int draggingIndex: -1
property var workingOrder: []
readonly property var displayed: root.draggingIndex >= 0 ? root.workingOrder : root.pinned
// The order the strip is drawing, as rows the Repeater can identify. A
// plain array of ids is a NEW model every time the order changes, and
// reordering is the one thing this strip does: handing that to a Repeater
// resets it and destroys the delegate the pointer is holding, mid-press, on
// the first cell crossed. The gesture dies there and draggingIndex stays
// set, which latches the strip. Keyed on the pin id -- the only thing a pin
// is -- a reorder becomes a row move instead, and the delegate under the
// hand survives it. One-property objects because ScriptModel identifies a
// row by a property of it, which a bare string has none of.
readonly property var displayed: (root.draggingIndex >= 0 ? root.workingOrder : root.pinned)
.map(id => ({ id: id }))
function beginDrag(index: int): void {
root.workingOrder = root.pinned.slice();
@@ -119,12 +129,23 @@ Column {
root.commit(next);
}
// The drag let go of without a decision. Used when the cell being dragged
// is destroyed under the pointer -- an unpin from somewhere else, a
// settings file rewritten underneath -- where the working order is a
// rearrangement of a list that no longer exists, and writing it back would
// undo whatever really happened.
function cancelDrag(): void {
root.draggingIndex = -1;
root.workingOrder = [];
}
// ── Keyboard ────────────────────────────────────────────────────────────
//
// The Repeater's model is a plain array, so committing a move rebuilds
// every delegate and the focused one is destroyed mid-keystroke. The
// focused position is remembered here instead of in the delegate, and the
// cell that lands on it takes focus back as it is created.
// Which POSITION has the keyboard, remembered here rather than left to the
// delegate that happens to hold focus. Unpinning really does destroy the
// focused cell, and a keyed model still cannot keep focus on a row that is
// gone; remembering the index means the cell that lands on it takes focus
// back as it arrives, so Delete twice in a row deletes twice.
property int keyboardIndex: -1
@@ -159,7 +180,13 @@ Column {
spacing: root.cellSpacing
Repeater {
model: root.displayed
// Keyed on the pin id: see `displayed`. This is the same reason
// DockBody keys the dock's own icons.
model: ScriptModel {
values: root.displayed
objectProp: "id"
comparisonMode: ObjectComparison.Structure
}
delegate: Item {
id: cell
@@ -167,9 +194,10 @@ Column {
required property var modelData
required property int index
readonly property string appId: cell.modelData ? cell.modelData.id : ""
readonly property bool dragging: root.draggingIndex === cell.index
readonly property string appName: root.nameFor(cell.modelData)
readonly property string iconSource: root.iconFor(cell.modelData)
readonly property string appName: root.nameFor(cell.appId)
readonly property string iconSource: root.iconFor(cell.appId)
width: root.cellSize
height: root.cellSize
@@ -194,6 +222,12 @@ Column {
onActiveFocusChanged: if (cell.activeFocus) root.keyboardIndex = cell.index
Component.onCompleted: if (root.keyboardIndex === cell.index) cell.forceActiveFocus()
// A cell that goes away while it is the one being dragged takes
// the release with it -- there is no MouseArea left to report
// one -- and the strip would sit there with draggingIndex still
// set, refusing every later press.
Component.onDestruction: if (cell.dragging) root.cancelDrag()
Connections {
target: root
function onKeyboardIndexChanged(): void {
@@ -1,5 +1,5 @@
// A slider whose track carries a meaning, and whose value is committed once the
// drag settles rather than on every pixel.
// gesture is over rather than on every pixel.
//
// Two rows on the Displays page need something SliderRow cannot give them:
//
@@ -8,7 +8,14 @@
// like, and this is the one slider whose numbers most people cannot picture.
// * The SDR trims, which write through the display transaction. A transaction
// per pixel of drag would arm a fifteen-second countdown dozens of times;
// the value is applied once, when the pointer stops moving.
// the value is applied once, when the button comes up.
//
// On release, not on an idle timer. Holding a slider still for a third of a
// second is not the end of a gesture, and the commit an idle timer made
// mid-hold armed a display transaction that turned this row's `enabled` false
// with the button still down -- the control went dead under the hand using it.
// The wheel has no release, so it keeps the timer, and that is all the timer is
// for now.
//
// Not schema-bound and not a SettingRow: the caller says what the value is and
// what to do with a new one, and the row stacks its control below the label in
@@ -59,6 +66,16 @@ Item {
return colors[Math.min(index, colors.length - 1)];
}
// Hands the pending value to the caller. Called on release, and by the
// wheel's idle timer, which is the one input that never has one.
function commit(): void {
commitTimer.stop();
if (root.pending === null)
return;
root.committed(root.pending);
releaseTimer.restart();
}
function quantise(ratio: real): real {
const raw = root.minimum + ratio * (root.maximum - root.minimum);
const snapped = Math.round(raw / root.step) * root.step;
@@ -166,7 +183,6 @@ Item {
function move(positionX: real): void {
root.pending = root.quantise(
Math.max(0, Math.min(1, (positionX - 8) / track.width)));
commitTimer.restart();
}
onPressed: event => drag.move(event.x)
@@ -174,6 +190,14 @@ Item {
if (drag.pressed)
drag.move(event.x);
}
onReleased: root.commit()
// A grab taken away mid-drag is not a decision. The row goes
// back to showing whatever is really in effect rather than
// keeping a number nobody asked it to apply.
onCanceled: {
commitTimer.stop();
root.pending = null;
}
onWheel: event => {
const direction = event.angleDelta.y > 0 ? 1 : -1;
root.pending = Math.max(root.minimum,
@@ -208,15 +232,13 @@ Item {
color: Theme.alpha(Theme.fg, 0.065)
}
// The wheel's release. A notch is a whole gesture on its own, and a hand
// spinning through a range means one commit at the end of the spin rather
// than one per notch -- there is no button coming up to say when that is.
Timer {
id: commitTimer
interval: root.commitDelay
onTriggered: {
if (root.pending === null)
return;
root.committed(root.pending);
releaseTimer.restart();
}
onTriggered: root.commit()
}
// Hands the readout back to whatever is really in effect. If the change was
@@ -60,6 +60,24 @@ SettingsPage {
setting: "overAmplification"
divider: false
}
// Turning over-amplification off has to bring the level down with it.
// The preference only moves the top of the range; PipeWire keeps
// whatever was set, so a device left at 140% stays there with a slider
// that now ends at 100 and shows itself pinned at full. Nothing the
// page can do afterwards expresses the difference, and the reading is
// wrong until something else happens to move the volume.
Connections {
target: Settings
function onOverAmplificationChanged(): void {
if (Settings.overAmplification)
return;
const audio = root.currentOutput?.audio ?? null;
if (audio && audio.volume > 1)
audio.volume = 1;
}
}
}
SettingsCard {
@@ -165,6 +165,12 @@ SettingRow {
root.moveTo(drag.ratioAt(event.x));
}
onReleased: root.apply()
// A grab taken away mid-drag never releases, so nothing
// would ever apply the reading left on screen -- and the
// next commit would measure its ratio against a number the
// palette was never re-saturated to. Back to the last
// reading that was really applied.
onCanceled: root.shown = root.committed
onWheel: event => {
root.moveTo((root.shown + (event.angleDelta.y > 0 ? 0.05 : -0.05))
/ root.maximum);
+5 -1
View File
@@ -92,7 +92,11 @@ resolve_wallpaper_path() {
# A video wallpaper cannot play on the lock screen; VideoWallpaper.qml
# grabs a still frame when one is selected, and that frame stands in.
# A video with no cached frame falls through to the shipped image.
if [[ "$candidate" =~ \.(mp4|mkv|webm)$ ]]; then
#
# Lowercased first, because panama-video-wallpaper scans with -iname: a
# HOLIDAY.MP4 it happily offers would otherwise be handed to the lock screen
# as an image and render as nothing at all.
if [[ "${candidate,,}" =~ \.(mp4|mkv|webm)$ ]]; then
local frame="${XDG_STATE_HOME:-$HOME/.local/state}/panama/video-wallpaper-frame.png"
if valid_path "$frame"; then
resolved_wallpaper="$frame"
+56 -24
View File
@@ -15,25 +15,41 @@ settings_file() {
printf '%s/panama/settings.json\n' "${XDG_CONFIG_HOME:-$HOME/.config}"
}
setting_bool() {
local key="$1" fallback="$2" file value
# Both switches the volume keys care about, read in a single jq. A held-down
# volume key repeats about twenty times a second, and each repeat used to spawn
# jq twice -- once for the ceiling, once for the click. Prints them as
# "<overAmplification> <volumeChangeBlip>", falling back to the schema defaults
# whenever the file cannot be read or does not carry the key.
volume_settings() {
local file value over=false blip=true
file="$(settings_file)"
[[ -r $file ]] || { printf '%s\n' "$fallback"; return 0; }
command -v jq >/dev/null 2>&1 || { printf '%s\n' "$fallback"; return 0; }
value="$(jq -r --arg key "$key" '
if type == "object" and (.[$key] | type) == "boolean" then .[$key] else empty end
' "$file" 2>/dev/null)" || value=""
[[ $value == true || $value == false ]] || value="$fallback"
printf '%s\n' "$value"
if [[ -r $file ]] && command -v jq >/dev/null 2>&1; then
value="$(jq -r '
def flag($key; $fallback):
if type == "object" and (.[$key] | type) == "boolean"
then (.[$key] | tostring)
else $fallback
end;
[flag("overAmplification"; "false"), flag("volumeChangeBlip"; "true")]
| join(" ")
' "$file" 2>/dev/null)" || value=""
read -r over blip <<<"$value"
[[ $over == true || $over == false ]] || over=false
[[ $blip == true || $blip == false ]] || blip=true
fi
printf '%s %s\n' "$over" "$blip"
}
# wpctl clamps to 1.0 by default, and it clamps the *result* -- so the ceiling
# has to be passed on the way down as well. Without it, stepping down from 130%
# would snap to 100% instead of 124%, which reads as the slider jumping on its
# own. The microphone never gets this: gain past 100% on a capture device buys
# noise, not signal, and the Sound page's input slider stays 0-100 to match.
volume_limit() {
if [[ $(setting_bool overAmplification false) == true ]]; then
# `wpctl set-volume -l` caps the *result*, and without it wpctl does not cap at
# all -- 100% is not a ceiling it enforces on its own. So the limit is passed on
# the way down too, deliberately: with over-amplification off, stepping down
# from a volume that is somehow already above 100% lands under the ceiling
# rather than merely one step lower, which is what having the switch off means.
# The microphone never gets the raised ceiling: gain past 100% on a capture
# device buys noise, not signal, and the Sound page's input slider stays 0-100
# to match.
volume_ceiling() {
if [[ $1 == true ]]; then
printf '1.5\n'
else
printf '1\n'
@@ -42,14 +58,28 @@ volume_limit() {
# The click that says the volume moved. Backgrounded and never waited on: it is
# feedback about something that has already happened, so it must not sit between
# the key press and the OSD. A distribution without the freedesktop sound theme
# has no file to play, which is a silent desktop rather than a broken key.
# the key press and the OSD.
#
# Under a non-blocking lock, in the same runtime directory the DDC lock lives in,
# because key repeat would otherwise start a fresh pw-play over the top of the
# last one twenty times a second. The first press clicks; the repeats that
# arrive while it is still sounding are dropped rather than stacked.
#
# A distribution without the freedesktop sound theme has no file to play, which
# is a silent desktop rather than a broken key.
play_blip() {
local enabled="$1"
local sound="${PANAMA_OSD_BLIP_SOUND:-$PANAMA_OSD_BLIP_DEFAULT}"
[[ $(setting_bool volumeChangeBlip true) == true ]] || return 0
local runtime_dir="${PANAMA_OSD_RUNTIME_DIR:-${XDG_RUNTIME_DIR:-/tmp}/panama-osd-${UID}}"
[[ $enabled == true ]] || return 0
[[ -f $sound ]] || return 0
pw-play "$sound" >/dev/null 2>&1 &
disown 2>/dev/null || true
mkdir -p "$runtime_dir" 2>/dev/null || return 0
chmod 700 "$runtime_dir" 2>/dev/null || true
(
exec {blip_fd}>"$runtime_dir/blip.lock" || exit 0
flock -n "$blip_fd" || exit 0
pw-play "$sound" >/dev/null 2>&1
) &
}
strict_delivery() {
@@ -96,15 +126,17 @@ show_volume() {
}
adjust_volume() {
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SINK@" limit
limit="$(volume_limit)"
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SINK@"
local over blip limit
read -r over blip <<<"$(volume_settings)"
limit="$(volume_ceiling "$over")"
case "$action" in
up) wpctl set-volume -l "$limit" "$target" "${step}%+" || return ;;
down) wpctl set-volume -l "$limit" "$target" "${step}%-" || return ;;
toggle) wpctl set-mute "$target" toggle || return ;;
*) printf 'Usage: panama-osd volume up|down|toggle [step]\n' >&2; return 2 ;;
esac
play_blip
play_blip "$blip"
show_volume "$target" volume
}
@@ -139,10 +139,23 @@ def read_entries():
return (found.group(2) if found.group(2) is not None
else found.group(1).strip())
def default_value():
"""The default, or None when the literal is a block rather than a value.
A "json" setting's `def:` opens an array or an object that runs for
twenty lines, and the scalar reader above captures only the bracket
that opened it. A Default column reading "[" documents nothing and
looks like a parsing bug, which is what it was mistaken for. None
renders as an em dash instead, and the schema stays the place to read
the shape.
"""
value = scalar("def")
return None if value in ("[", "{") else value
entry = {
"key": name,
"type": scalar("type"),
"default": scalar("def"),
"default": default_value(),
"group": scalar("group"),
"label": scalar("label"),
"detail": scalar("detail"),
@@ -103,8 +103,14 @@ for token in black red green yellow blue magenta cyan white \
[[ -n "${!name:-}" ]] || ansi_ok=false
done
# Every token render_vicinae reads, in both schemes. The renderer runs under
# `set -u` in a subshell, so a token missing from one side is not a slightly
# duller launcher theme -- it aborts that render and the file is left as it was.
# The gate has to cover what the renderer actually reads, or it passes and the
# render fails anyway.
both_schemes_ok=true
for token in bg bgDark bgHighlight bgPanel bgPopover fg fgDim fgMuted gutter red; do
for token in bg bgDark bgHighlight bgPanel bgPopover fg fgDim fgMuted gutter red \
accentAlt green magenta orange pink yellow cyan; do
for side in dark light; do
name="${side}_pal_$token"
[[ -n "${!name:-}" ]] || both_schemes_ok=false
+47 -8
View File
@@ -71,16 +71,27 @@ Singleton {
return Object.keys(palette).sort().map(token => palette[token]).join(",");
}
// What the last push actually sent, so apply() can tell an accent-only
// What the last push actually LANDED, so apply() can tell an accent-only
// change apart from a scheme or theme change and skip the steps that do
// not depend on whichever did not move. Their starting values do not
// matter: the first apply() always runs with force set, which ignores all
// of them.
//
// Written by settleQueue() once the whole queue has drained without a
// failure, never by apply(). Recording them at enqueue time meant a
// gsettings write that failed, or a hyprctl issued before the compositor
// was listening, left the shell believing the desktop already agreed with
// it -- and nothing pushed again until some unrelated preference moved.
property bool appliedDark: false
property string appliedAccentName: ""
property string appliedThemeId: ""
property string appliedPalettePrint: ""
// What the in-flight queue is trying to make true, and whether any step of
// it has already failed.
property var pendingApplied: null
property bool queueFailed: false
// Applied one command at a time: Process runs a single command, and several
// of these are separate programs.
property var pending: []
@@ -88,15 +99,39 @@ Singleton {
Process {
id: runner
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
if (exitCode !== 0) {
root.lastError = "The color scheme could not be applied everywhere.";
root.queueFailed = true;
}
root.drain();
}
}
function drain(): void {
if (runner.running || root.pending.length === 0)
// Only a queue that emptied without a single failure counts as applied. A
// failed one leaves the fingerprint alone, so the next apply() sees the same
// difference it saw before and pushes the whole thing again.
function settleQueue(): void {
if (root.pendingApplied === null)
return;
const target = root.pendingApplied;
root.pendingApplied = null;
if (root.queueFailed) {
root.queueFailed = false;
return;
}
root.appliedDark = target.dark;
root.appliedAccentName = target.accentName;
root.appliedThemeId = target.themeId;
root.appliedPalettePrint = target.palettePrint;
}
function drain(): void {
if (runner.running)
return;
if (root.pending.length === 0) {
root.settleQueue();
return;
}
const next = root.pending[0];
root.pending = root.pending.slice(1);
runner.exec(next);
@@ -236,10 +271,14 @@ Singleton {
commands.push([root.appThemePath, root.dark ? "dark" : "light", accentName]);
}
root.appliedDark = root.dark;
root.appliedAccentName = accentName;
root.appliedThemeId = themeId;
root.appliedPalettePrint = palettePrint;
// The target, not the record: settleQueue() promotes it to applied*
// once every command in the queue has come back clean.
root.pendingApplied = {
dark: root.dark,
accentName: accentName,
themeId: themeId,
palettePrint: palettePrint
};
root.enqueue(commands);
}
+83 -20
View File
@@ -156,7 +156,12 @@ Singleton {
onExited: (exitCode, exitStatus) => {
// Exit status is advisory only. Hyprland's Lua bridge can report
// success without applying a value, so exact readback decides.
root.revertVerificationActive = true;
//
// performRevert already armed the timer; restarting it here gives
// the readback its full window from the moment the command actually
// returned, rather than from the moment it was issued.
if (!root.revertVerificationActive)
return;
revertVerifyTimer.ticks = 0;
revertVerifyTimer.restart();
}
@@ -177,9 +182,16 @@ Singleton {
}
// Nothing in flight, so a display genuinely arrived or left. Give it
// back the arrangement it was last confirmed with -- see restoreStored.
root.restoreDeferrals = 0;
restoreDebounce.restart();
}
// How many times the pending restore has been put off because something was
// in flight. Reset by every fresh hotplug and by every decision actually
// acted on; see restoreStored.
property int restoreDeferrals: 0
readonly property int restoreDeferralLimit: 20
// Docking and undocking should not cost you your arrangement.
//
// hypr/monitors.lua applies the stored per-output entries, but only when
@@ -207,12 +219,18 @@ Singleton {
// says so, which is recoverable. Silence would not be.
// The decision, with no side effects, so it can be tested without driving
// a real compositor. Returns one of:
// { action: "none" } nothing stored, or already correct
// { action: "apply", layout } restore this
// { action: "refuse" } stored arrangement does not fit
// { action: "none", reason: "settled" } nothing stored, or already correct
// { action: "none", reason: "unavailable" } cannot decide yet, ask again
// { action: "apply", layout } restore this
// { action: "refuse" } stored arrangement does not fit
//
// The two "none" answers are not the same answer. "Nothing to do" is final;
// "not now" is a question that has to be asked again, and a dock changes the
// topology and the readback at once, so "not now" is the common case exactly
// when the restore matters most.
function plannedRestore(): var {
if (root.busy || root.awaitingConfirmation || root.monitors.length === 0)
return { action: "none" };
return { action: "none", reason: "unavailable" };
const stored = DesktopPreferences.get("displays");
const persisted = stored && typeof stored === "object" ? stored : {};
@@ -248,7 +266,7 @@ Singleton {
}
if (!changed)
return { action: "none" };
return { action: "none", reason: "settled" };
// Exactly one primary, on a display that is actually here. Undocking
// takes the primary away, and a layout with none is one
@@ -271,8 +289,21 @@ Singleton {
function restoreStored(): void {
const plan = root.plannedRestore();
if (plan.action === "none")
if (plan.action === "none") {
// Deferred, not dropped: a query or an apply in flight is a "ask me
// again", and dropping it meant the arrangement stayed lost until
// somebody opened Settings and applied it by hand. Bounded so a
// stuck operation cannot leave this rescheduling itself all session
// -- twenty tries outlasts a full confirmation countdown.
if (plan.reason === "unavailable"
&& root.restoreDeferrals < root.restoreDeferralLimit) {
root.restoreDeferrals += 1;
restoreDebounce.restart();
}
return;
}
root.restoreDeferrals = 0;
if (plan.action === "refuse") {
StatusEvents.publish({
@@ -428,13 +459,20 @@ Singleton {
// The framebuffer format is the only honest report of the bit depth in
// effect: asking for 10-bit and getting it are different things, and a
// panel that cannot carry the link rate quietly stays at 8. Formats outside
// this map are read as "unknown", never as a mismatch.
// panel that cannot carry the link rate quietly stays at 8.
//
// Matched on the channel widths rather than on the two names Panama happens
// to have seen. The channel order is the compositor's business -- XRGB,
// XBGR and ARGB all carry ten bits per channel in 2101010 and eight in 8888
// -- and pinning the whole string reported an output as "unknown" for the
// one part of it that says nothing about depth. Anything else is still read
// as unknown, which is never treated as a mismatch.
function formatBitdepth(format: string): int {
if (format === "XRGB8888")
return 8;
if (format === "XRGB2101010")
const text = String(format);
if (/2101010$/.test(text))
return 10;
if (/8888$/.test(text))
return 8;
return 0;
}
@@ -544,7 +582,7 @@ Singleton {
const saved = root.savedEntry(monitor.name);
const live = DisplayLayout.validColorProfile(monitor.colorPreset)
? monitor.colorPreset : "auto";
return {
const record = {
name: monitor.name,
width: monitor.width,
height: monitor.height,
@@ -560,15 +598,25 @@ Singleton {
// Keeping the stored policy stops one apply from pinning the
// display to whatever automatic happened to pick today.
colorProfile: saved && saved.colorProfile === "auto" ? "auto" : live,
bitdepth: monitor.bitdepth !== 0
? monitor.bitdepth
: (saved && DisplayLayout.validBitdepth(saved.bitdepth) ? saved.bitdepth : 8),
sdrBrightness: DisplayLayout.validSdrBrightness(monitor.sdrBrightness)
? monitor.sdrBrightness : 1.0,
sdrSaturation: DisplayLayout.validSdrSaturation(monitor.sdrSaturation)
? monitor.sdrSaturation : 1.0,
mirrorOf: typeof monitor.mirrorOf === "string" ? monitor.mirrorOf : ""
};
// The bit depth is the one field with no honest default. A
// framebuffer format Panama cannot read means the depth is unknown,
// and filling in 8 turns that ignorance into a request: monitorRule
// would write `bitdepth = 8` at a panel that may well be running at
// 10, and confirm() would store the guess as though it were read.
// Left off instead, which every consumer already understands as "no
// opinion" -- the rule omits the key, the compositor keeps what it
// has, and isPersistedLayoutEntry accepts an entry without one.
if (monitor.bitdepth !== 0)
record.bitdepth = monitor.bitdepth;
else if (saved && DisplayLayout.validBitdepth(saved.bitdepth))
record.bitdepth = saved.bitdepth;
return record;
});
}
@@ -847,7 +895,7 @@ Singleton {
const stored = DesktopPreferences.get("displays");
const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {});
for (const record of root.pendingRequestedLayout) {
next[record.name] = {
const entry = {
mode: record.mode,
scale: record.scale,
transform: record.transform,
@@ -856,11 +904,16 @@ Singleton {
primary: record.primary,
vrrMode: record.vrrMode,
colorProfile: record.colorProfile,
bitdepth: record.bitdepth,
sdrBrightness: record.sdrBrightness,
sdrSaturation: record.sdrSaturation,
mirrorOf: record.mirrorOf
};
// Absent rather than guessed, as currentLayout leaves it. A stored
// 8 that nobody read is one the next start would push at the display
// as a request.
if (DisplayLayout.validBitdepth(record.bitdepth))
entry.bitdepth = record.bitdepth;
next[record.name] = entry;
}
if (!DesktopPreferences.set("displays", next)) {
root.lastError = "That display setting could not be saved. Revert it and try again.";
@@ -926,10 +979,20 @@ Singleton {
root.revertExpectedLayout = previous.length > 0 ? previous : null;
root.revertVerificationActive = false;
root.clearPending();
if (previous.length > 0)
if (previous.length > 0) {
// Armed here, before the push, and not only from revertRun.onExited.
// A hyprctl that fails to start never emits `exited`, and the
// verification timer is the only thing that ever clears
// revertExpectedLayout -- which `busy` counts. So the one failure
// that most needs a way out used to latch the whole Displays page
// busy for the rest of the session, with no error to say why.
root.revertVerificationActive = true;
revertVerifyTimer.ticks = 0;
revertVerifyTimer.restart();
root.pushLayout(previous, revertRun);
else
} else {
root.lastError = root.revertReason;
}
}
// Clears any stored override for an output so it returns to the value
@@ -25,12 +25,12 @@ Singleton {
property string actionKind: ""
property string actionPath: ""
// actionProc's `exited` and its stdout `streamFinished` are not guaranteed
// to fire in a particular order (same hazard HomeAssistantConfig.qml's
// settle-both pattern guards against). These track which of the two have
// been observed for the action currently in flight so finishAction() is
// only ever called once both have arrived, with the real stdout JSON as
// the authoritative result.
// actionProc's `exited` and its stdout `streamFinished` carry half the
// result each -- the exit and the JSON -- and Quickshell documents no order
// between them. So neither one finalizes on its own: these track which have
// been observed for the action currently in flight, and settleAction() runs
// finishAction() once, on whichever arrives last, with the collected stdout
// as the authoritative result.
property bool actionExited: false
property bool actionStdoutDone: false
property string actionStdoutText: ""
@@ -151,9 +151,8 @@ Singleton {
}
// Called from both actionProc.onExited and its stdout streamFinished.
// Only finalizes once both signals have arrived for the in-flight action,
// since their firing order is not guaranteed -- see the actionExited /
// actionStdoutDone comment above.
// Only finalizes once both signals have arrived for the in-flight action --
// see the actionExited / actionStdoutDone comment above.
function settleAction(): void {
if (root.actionKind === "")
return;
+26 -1
View File
@@ -265,11 +265,36 @@ Singleton {
if (notification.urgency === NotificationUrgency.Low)
return;
// The freedesktop sound hints. This is the fix for the double chime:
// an application that plays its own sound sets suppress-sound so the
// notification server stays quiet, and Panama ignoring it meant one
// notification made two noises a beat apart.
//
// The other two say what to play instead of the theme bell --
// sound-file is an absolute path the application supplies, sound-name
// is a theme sound resolved through the same chain the bell uses.
const hints = notification.hints ?? {};
if (hints["suppress-sound"] === true)
return;
const soundFile = String(hints["sound-file"] ?? "");
const soundName = String(hints["sound-name"] ?? "");
const candidates = soundFile.startsWith("/")
? [soundFile]
: (soundName !== ""
? SoundFeedback.soundCandidates(soundName)
: SoundFeedback.bellCandidates);
// A hint naming something unresolvable is a request for that sound, not
// a request for the bell -- substituting would be a lie about which
// notification arrived.
if (candidates.length === 0)
return;
const now = Date.now();
if (bell.running || now - root.lastBellAt < 1000)
return;
root.lastBellAt = now;
bell.command = SoundFeedback.bellCommand;
bell.command = SoundFeedback.playCommand(candidates);
bell.running = true;
}
@@ -42,7 +42,7 @@ Singleton {
"wallpaper": "appearance",
"lockAppearance": "appearance",
"dock": "dock",
"focus": "workspaces",
"focus": "notifications",
"display": "displays",
"nightLight": "displays",
"idle": "power",
+15 -1
View File
@@ -38,9 +38,19 @@ Singleton {
// that instead of the live daemon.
readonly property string fixturePath: Quickshell.env("PANAMA_SOUND_CARDS_FIXTURE") || ""
// Assigning `running = true` to a Process that is already running is a
// no-op, not a queue, so a refresh that arrived mid-read was simply lost --
// and a profile switch's own re-read is exactly the refresh most likely to
// land on top of one, leaving the list showing the profile the card no
// longer has. Remembered here and re-run from lister.onExited instead.
property bool refreshPending: false
function refresh(): void {
if (lister.running)
if (lister.running) {
root.refreshPending = true;
return;
}
root.refreshPending = false;
lister.command = root.fixturePath !== ""
? ["cat", root.fixturePath]
: ["pactl", "-f", "json", "list", "cards"];
@@ -149,6 +159,10 @@ Singleton {
root.lastError = "Device profiles could not be read.";
root.loaded = true;
}
// Deferred a turn so this listing's stdout is parsed before the
// next one starts filling the same collector.
if (root.refreshPending)
Qt.callLater(() => root.refresh());
}
}
@@ -87,9 +87,19 @@ Singleton {
return output ? root.absentSink : root.absentSource;
}
// Assigning `running = true` to a Process that is already running is a
// no-op, not a queue. The default sink and source change together, so the
// two Pipewire signals below arrive back to back and the second read was
// always the one dropped -- which is how a ghost row survived the device
// coming back. Remembered here and re-run from reader.onExited instead.
property bool refreshPending: false
function refresh(): void {
if (reader.running)
if (reader.running) {
root.refreshPending = true;
return;
}
root.refreshPending = false;
reader.command = root.fixturePath !== ""
? ["cat", root.fixturePath]
: ["pw-metadata", "-n", "default", "0"];
@@ -173,6 +183,10 @@ Singleton {
: "PipeWire's remembered default devices could not be read.";
if (code !== 0)
root.loaded = true;
// Deferred a turn so this read's stdout is parsed before the next
// one starts filling the same collector.
if (root.refreshPending)
Qt.callLater(() => root.refresh());
}
}
@@ -43,19 +43,37 @@ Singleton {
// free of file probing on a hot path.
readonly property string homeDir: Quickshell.env("HOME") || ""
readonly property var bellCandidates: [
root.homeDir !== ""
? `${root.homeDir}/.local/share/sounds/${root.soundTheme}/stereo/bell.oga` : "",
`/usr/share/sounds/${root.soundTheme}/stereo/bell.oga`,
"/usr/share/sounds/freedesktop/stereo/bell.oga"
].filter((path, index, all) => path !== "" && all.indexOf(path) === index)
// The candidate paths for one XDG sound name, in preference order. Shared
// with Notifs.qml, which resolves a notification's own `sound-name` hint
// through exactly this chain rather than a second copy of it.
function soundCandidates(name: string): var {
const sound = String(name ?? "").trim();
// A sound name is one entry in a theme directory, never a path. Anything
// that could climb out of it is not a sound name, and these strings
// arrive from other applications.
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(sound))
return [];
return [
root.homeDir !== ""
? `${root.homeDir}/.local/share/sounds/${root.soundTheme}/stereo/${sound}.oga` : "",
`/usr/share/sounds/${root.soundTheme}/stereo/${sound}.oga`,
`/usr/share/sounds/freedesktop/stereo/${sound}.oga`
].filter((path, index, all) => path !== "" && all.indexOf(path) === index);
}
// The argv that plays the current theme's bell once, or nothing at all if
// no candidate exists. Shared with Notifs.qml, which plays the same bell
// for Panama's own notification popups.
readonly property var bellCommand: ["sh", "-c",
'for candidate in "$@"; do [ -f "$candidate" ] && exec pw-play "$candidate"; done; exit 0',
"qs-sound-feedback"].concat(root.bellCandidates)
readonly property var bellCandidates: root.soundCandidates("bell")
// The argv that plays the first candidate that exists, or nothing at all if
// none does.
function playCommand(candidates: var): var {
return ["sh", "-c",
'for candidate in "$@"; do [ -f "$candidate" ] && exec pw-play "$candidate"; done; exit 0',
"qs-sound-feedback"].concat(Array.isArray(candidates) ? candidates : []);
}
// The current theme's bell. Shared with Notifs.qml, which plays it for
// Panama's own notification popups.
readonly property var bellCommand: root.playCommand(root.bellCandidates)
function previewAlert(): void {
if (preview.running)
@@ -238,27 +238,29 @@ function normalizeStoredProfile(value) {
return result;
}
// The id is the identity here, and every custom id is custom-* namespaced, so
// it can never collide with a shipped one by accident; a repeat inside the
// stored list is a genuine duplicate and is dropped.
//
// A NAME is only a label, and a colliding one is renamed rather than dropped.
// Dropping was data loss on a delay: this list is also what gets written back
// to the preference, so a custom theme whose name a later Panama release
// happened to ship was silently deleted at the user's next theme edit.
function validCustomProfiles(values, shipped) {
if (!Array.isArray(values))
return [];
var catalog = shippedList(shipped);
var ids = {};
var names = {};
shippedList(shipped).forEach(function(profile) {
ids[profile.id] = true;
names[profile.name.toLowerCase()] = true;
});
catalog.forEach(function(profile) { ids[profile.id] = true; });
var result = [];
values.forEach(function(value) {
var profile = normalizeStoredProfile(value);
if (!profile)
return;
var foldedName = profile.name.toLowerCase();
if (ids[profile.id] || names[foldedName])
if (!profile || ids[profile.id])
return;
ids[profile.id] = true;
names[foldedName] = true;
profile.name = uniqueName(profile.name, catalog.concat(result));
result.push(profile);
});
return result;
@@ -537,7 +539,11 @@ function resaturatePalette(palette, factor) {
var next = clamp(hsv.s * (1 + (scale - 1) * strength), 0, 100);
result[key] = hsvToHex(hsv.h, next, hsv.v);
});
return result;
// Validated on the way out like every other palette this module builds: a
// factor that is not a number survives clamp() as NaN and turns every token
// into a string no palette reader can use. Returning null makes that a
// rejection the caller can see instead of a corrupt theme it stores.
return normalizePalette(result);
}
// Terminal colors for a custom theme that has none of its own: semantic
@@ -45,11 +45,17 @@ Singleton {
// The remembered theme for a scheme, falling back to the catalog default.
function themeForScheme(target: string): string {
const key = target === "light" ? "themeLight" : "themeDark";
const fallback = target === "light" ? ThemeCatalog.defaultLight : ThemeCatalog.defaultDark;
const scheme = target === "light" ? "light" : "dark";
const key = scheme === "light" ? "themeLight" : "themeDark";
const fallback = scheme === "light" ? ThemeCatalog.defaultLight : ThemeCatalog.defaultDark;
const stored = String(DesktopPreferences.get(key) || "");
return ThemeProfileModel.findProfile(root.customProfiles, stored, root.shippedThemes)
? stored : fallback;
const profile = ThemeProfileModel.findProfile(
root.customProfiles, stored, root.shippedThemes);
// Existing is not enough: the slot has to hold a theme OF that scheme.
// A custom theme edited from dark to light stayed in the dark slot, so
// asking for dark selected a light theme, which flipped the scheme back
// to light -- the light/dark toggle simply stopped working.
return profile && profile.scheme === scheme ? stored : fallback;
}
function commitActive(profile: var): bool {
@@ -64,7 +70,11 @@ Singleton {
profile.scheme === "light" ? "themeLight" : "themeDark", profile.id);
SystemSettings.commitPreference("accentName",
ThemeProfileModel.nearestCuratedName(profile.scheme, profile.accent));
root.applyProfileEffects(profile);
// Effects are the theme's own snapshot of blur, shadow and motion.
// Restoring them for a theme that did not actually become active left
// the desktop wearing half of a theme nobody selected.
if (schemeAccepted && profileAccepted)
root.applyProfileEffects(profile);
return schemeAccepted && profileAccepted;
}
@@ -126,6 +136,11 @@ Singleton {
// The editor's saturation slider. factor 1.0 is neutral.
function setSaturation(factor: real): bool {
// NaN survives the model's clamp and comes back out as a palette of
// unreadable tokens. Reject it here, where there is still a caller to
// tell about it.
if (!isFinite(factor))
return false;
const palette = ThemeProfileModel.resaturatePalette(root.activePalette, factor);
if (!palette)
return false;
@@ -11,8 +11,8 @@ pragma Singleton
//
// hyprpaper and mpvpaper both claim the background layer and stacking within a
// layer is creation order — a race. So while a video is active hyprpaper's
// service is stopped, and stopping the video starts it again; Wallpaper.qml
// reapplies the still policy once it returns.
// service is stopped, and stopping the video starts it again; this service then
// reapplies the still policy through Wallpaper.qml once it returns.
//
// mpvpaper 1.9 (Terra) is the floor: it carries the libmpv fence-leak
// workaround. Known upstream sharp edges — a hotplug segfault and a
@@ -125,6 +125,9 @@ Singleton {
root.lastError = "";
root.path = video;
root.manuallyPaused = false;
// A new video starts its own patience; the previous file's crashes are
// not evidence about this one.
root.crashStreak = 0;
hyprpaperControl.command = ["systemctl", "--user", "stop", "hyprpaper.service"];
hyprpaperControl.running = true;
frameProc.command = ["sh", "-c",
@@ -141,6 +144,19 @@ Singleton {
root.restoreConsumed = true;
root.path = "";
root.manuallyPaused = false;
root.crashStreak = 0;
root.teardownPlayers();
// The stored path is the restore path, the picker's "current" ring and
// what Wallpaper.setSingle reads back. Leaving it set after a deliberate
// stop meant the next login started playing the video again.
DesktopPreferences.set("videoWallpaperPath", "");
stillHandback.restart();
}
// Everything both stop() and the crash bail-out have to do: no player left
// running, no timer armed to start another, and hyprpaper handed back the
// layer it owns.
function teardownPlayers(): void {
playerRespawn.stop();
spawnDelay.stop();
for (const player of root.players) {
@@ -151,7 +167,22 @@ Singleton {
reaper.running = true;
hyprpaperControl.command = ["systemctl", "--user", "start", "hyprpaper.service"];
hyprpaperControl.running = true;
Qt.callLater(() => Wallpaper.refreshActive());
}
// hyprpaper's service needs a beat to be back on the bus before it can be
// asked to draw anything — the same settle Wallpaper.setSingle uses for its
// own video→still handoff. When that handoff is the reason we are stopping,
// it owns the policy (it holds the new one, which is not yet persisted) and
// this stays out of the way.
Timer {
id: stillHandback
interval: 300
onTriggered: {
if (root.active || Wallpaper.pendingStillPolicy !== null)
return;
if (!Wallpaper.applyCurrentPolicy(false))
Wallpaper.refreshActive();
}
}
// Players are created per current output set each (re)spawn, so hotplug
@@ -181,15 +212,32 @@ Singleton {
reaper.running = true;
}
// pkill returns when the signal is delivered, not when the process is gone,
// and mpvpaper takes a moment to tear its GL context down. So the reaper
// waits for the corpses rather than the shell guessing at how long that
// takes: TERM, then poll pgrep in 50ms steps, escalating to KILL halfway
// through and giving up after two seconds so a wedged process cannot hold
// the wallpaper hostage.
Process {
id: reaper
command: ["pkill", "-x", "mpvpaper"]
command: ["sh", "-c", `
pkill -x mpvpaper 2>/dev/null || true
step=0
while [ "$step" -lt 40 ]; do
pgrep -x mpvpaper >/dev/null 2>&1 || exit 0
[ "$step" -eq 20 ] && pkill -9 -x mpvpaper 2>/dev/null
sleep 0.05
step=$((step + 1))
done
`]
onExited: spawnDelay.restart()
}
// The reaper already waited for the old players to die, so this is only the
// hand-back to the event loop, not a guess at how long a kill takes.
Timer {
id: spawnDelay
interval: 300
interval: 50
onTriggered: {
if (!root.active)
return;
@@ -201,10 +249,37 @@ Singleton {
spawned.push(player);
}
root.players = spawned;
root.playersStartedAt = Date.now();
root.crashCounted = false;
pauseSync.restart();
}
}
// A crash loop has to end somewhere. mpvpaper dying within three seconds of
// being spawned is not the hotplug segfault the supervisor exists for — it
// is a file it cannot decode or a VAAPI stack that is not there — and
// respawning forever flashes the desktop black once a second for as long as
// the session lasts, with no error anywhere to explain it.
property real playersStartedAt: 0
property int crashStreak: 0
// One count per spawned set: on two monitors a single crash fires two
// onExited, and counting each would trip the limit half a cycle early.
property bool crashCounted: false
readonly property int crashStreakLimit: 3
readonly property int crashWindowMs: 3000
function giveUp(): void {
root.path = "";
root.manuallyPaused = false;
root.crashStreak = 0;
// Nothing may start it again this session, not even a preference write
// landing in tryRestore's lap.
root.restoreConsumed = true;
root.teardownPlayers();
root.lastError = "Video wallpaper kept crashing — check the file and VAAPI decode";
stillHandback.restart();
}
Component {
id: playerComponent
@@ -213,12 +288,31 @@ Singleton {
property string output: ""
property bool retiring: false
onExited: {
// The object is spent either way: a Process cannot be restarted
// and every respawn builds a fresh one per output, so holding
// this one leaked a Process per crash cycle.
root.players = root.players.filter(candidate => candidate !== player);
Qt.callLater(() => player.destroy());
// A retiring player died because the supervisor killed it —
// reacting to that is how the reap loop once ate its young.
if (player.retiring || !root.active)
return;
// A dead player while a video is meant to be active is a
// crash (mpvpaper has a known hotplug segfault): respawn the
// whole set after a beat rather than reasoning per-output.
// A retiring player died because the supervisor killed it —
// reacting to that is how the reap loop once ate its young.
if (!player.retiring && root.active && !playerRespawn.running)
if (!root.crashCounted) {
root.crashCounted = true;
root.crashStreak = Date.now() - root.playersStartedAt < root.crashWindowMs
? root.crashStreak + 1
: 0;
}
if (root.crashStreak >= root.crashStreakLimit) {
root.giveUp();
return;
}
if (!playerRespawn.running)
playerRespawn.restart();
}
}
@@ -289,7 +383,11 @@ Singleton {
// The service restores its own video, reactively: at cold start the
// preferences file and the mpvpaper probe both land asynchronously, so a
// one-shot timer (the still pipeline's approach) raced them and lost.
// Once per session — a user's stop() is not to be overridden.
// Once per session, and consumed by stop() and giveUp() as well as by the
// restore itself: a preference write is all it takes to re-enter tryRestore,
// so neither a video the user turned off nor one that crashed out may come
// back under them. stop() also clears the stored path, so there is nothing
// left to restore at the next login either.
property bool restoreConsumed: false
// Harness seam, mirroring Wallpaper.startupRestoreEnabled: a test instance
// must never start playing the user's real wallpaper.
+82 -9
View File
@@ -97,8 +97,18 @@ Singleton {
if (root.transaction === null)
return;
if (exitCode !== 0) {
root.lastError = "Hyprpaper did not apply that background.";
root.transaction = null;
// Coming back from a video, hyprpaper's service is being
// started again underneath us and may not have its socket yet.
// That is a "not ready", not a "no", so the handoff is retried
// a few times before it counts as a failure.
if (root.pendingStillPolicy !== null
&& root.stillHandoffAttempts < root.stillHandoffLimit) {
stillAfterVideo.restart();
return;
}
root.lastError = "Hyprpaper did not apply that background.";
root.abandonStillHandoff();
root.schedulePendingHotplug();
return;
}
@@ -116,6 +126,34 @@ Singleton {
onExited: (exitCode, exitStatus) => root.finishVerification(exitCode)
}
// `busy` is the gate on every apply path, on refreshActive and on the
// slideshow timer, and a transaction is the only thing that clears it. So a
// hyprctl that never returns does not fail a wallpaper change — it takes the
// whole wallpaper surface out of service for the rest of the session, with
// no error to say why. Nothing here is a ten-second operation.
Timer {
id: transactionWatchdog
interval: 10000
onTriggered: {
if (root.transaction === null)
return;
root.transaction = null;
root.abandonStillHandoff();
// Killing the stuck child is part of the release: `busy` counts the
// processes too, so leaving one running would keep the latch shut.
applyProcess.running = false;
verifyProcess.running = false;
root.lastError = "Hyprpaper stopped responding while setting that background.";
}
}
onTransactionChanged: {
if (root.transaction === null)
transactionWatchdog.stop();
else
transactionWatchdog.restart();
}
Component.onCompleted: {
root.rescan();
root.refreshActive();
@@ -253,11 +291,17 @@ Singleton {
const normalized = root.normalizePolicy(policy);
if (normalized === null) {
root.lastError = "That wallpaper policy is not valid.";
// Nothing will reach schedulePendingHotplug from here, and a flag
// left standing turns the next unrelated apply into a surprise
// hotplug reapply. The signature change that armed it will arm it
// again if a display really did move.
root.pendingHotplugReapply = false;
return false;
}
const outputs = root.outputNames();
if (outputs.length === 0) {
root.lastError = "No display to set a wallpaper on.";
root.pendingHotplugReapply = false;
return false;
}
const expected = WallpaperPolicy.effectiveMap(
@@ -266,6 +310,7 @@ Singleton {
if (Object.keys(expected).length !== outputs.length
|| Object.values(expected).some(path => path === "")) {
root.lastError = "That wallpaper policy is not valid.";
root.pendingHotplugReapply = false;
return false;
}
root.lastError = "";
@@ -318,6 +363,7 @@ Singleton {
if (!matches) {
root.lastError = "Hyprpaper did not confirm that background.";
root.transaction = null;
root.abandonStillHandoff();
root.schedulePendingHotplug();
return;
}
@@ -325,6 +371,7 @@ Singleton {
const completed = root.transaction;
root.activeByOutput = observed;
root.transaction = null;
root.abandonStillHandoff();
root.lastError = "";
if (completed.automatic) {
root.slideshowPath = completed.policy.slideshowPath;
@@ -413,28 +460,53 @@ Singleton {
policy.mode = "single";
policy.globalPath = effectivePath;
policy.slideshowPath = effectivePath;
// Returning from a video: stop mpvpaper first, then give hyprpaper's
// service a beat to come back before the transaction talks to it.
// Returning from a video: stop mpvpaper first, then offer the policy to
// hyprpaper once its service is back, retrying while it is still coming
// up rather than betting on one long wait (see attemptStillHandoff).
if (VideoWallpaper.active) {
VideoWallpaper.stop();
root.pendingStillPolicy = policy;
root.stillHandoffAttempts = 0;
stillAfterVideo.restart();
return true;
}
return root.applyPolicy(policy, true, false);
}
// The still policy waiting for hyprpaper to come back, and how many times it
// has been offered to it. A single blind wait used to stand in for this: it
// was long enough to be felt on every switch and still short enough to lose
// whenever systemd was busy, and losing meant the desktop kept the video's
// last frame with an error in Settings and no wallpaper behind it.
property var pendingStillPolicy: null
property int stillHandoffAttempts: 0
readonly property int stillHandoffLimit: 3
Timer {
id: stillAfterVideo
interval: 900
onTriggered: {
if (root.pendingStillPolicy) {
root.applyPolicy(root.pendingStillPolicy, true, false);
root.pendingStillPolicy = null;
}
interval: 300
onTriggered: root.attemptStillHandoff()
}
function attemptStillHandoff(): void {
if (root.pendingStillPolicy === null)
return;
// The previous attempt's verification may still be winding down.
if (root.busy) {
stillAfterVideo.restart();
return;
}
root.stillHandoffAttempts += 1;
if (!root.applyPolicy(root.pendingStillPolicy, true, false)) {
// Rejected before hyprpaper was ever asked — retrying an invalid
// policy would only fail identically three more times.
root.abandonStillHandoff();
}
}
function abandonStillHandoff(): void {
root.pendingStillPolicy = null;
root.stillHandoffAttempts = 0;
}
function setMode(mode: string): bool {
@@ -458,6 +530,7 @@ Singleton {
DesktopPreferences.set("videoWallpaperPath", "");
VideoWallpaper.stop();
root.pendingStillPolicy = policy;
root.stillHandoffAttempts = 0;
stillAfterVideo.restart();
return true;
}