diff --git a/config/dot/hypr/monitors.lua b/config/dot/hypr/monitors.lua index 43c86c7..beff4f9 100644 --- a/config/dot/hypr/monitors.lua +++ b/config/dot/hypr/monitors.lua @@ -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 diff --git a/config/dot/quickshell/config/DesktopPreferences.qml b/config/dot/quickshell/config/DesktopPreferences.qml index a42f17d..76eb438 100644 --- a/config/dot/quickshell/config/DesktopPreferences.qml +++ b/config/dot/quickshell/config/DesktopPreferences.qml @@ -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 { diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index 651c9b8..e79dfcc 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -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" }, diff --git a/config/dot/quickshell/modules/dock/Dock.qml b/config/dot/quickshell/modules/dock/Dock.qml index ebad4c5..d41c1e7 100644 --- a/config/dot/quickshell/modules/dock/Dock.qml +++ b/config/dot/quickshell/modules/dock/Dock.qml @@ -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 diff --git a/config/dot/quickshell/modules/dock/DockBody.qml b/config/dot/quickshell/modules/dock/DockBody.qml index 7388e14..9dc6e73 100644 --- a/config/dot/quickshell/modules/dock/DockBody.qml +++ b/config/dot/quickshell/modules/dock/DockBody.qml @@ -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() } } } diff --git a/config/dot/quickshell/modules/dock/DockContextMenu.qml b/config/dot/quickshell/modules/dock/DockContextMenu.qml index f4358d0..d850af1 100644 --- a/config/dot/quickshell/modules/dock/DockContextMenu.qml +++ b/config/dot/quickshell/modules/dock/DockContextMenu.qml @@ -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; } } } diff --git a/config/dot/quickshell/modules/dock/DockItem.qml b/config/dot/quickshell/modules/dock/DockItem.qml index dca3fac..eebe628 100644 --- a/config/dot/quickshell/modules/dock/DockItem.qml +++ b/config/dot/quickshell/modules/dock/DockItem.qml @@ -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; diff --git a/config/dot/quickshell/modules/settings/ColorWell.qml b/config/dot/quickshell/modules/settings/ColorWell.qml index 33ab631..05b406d 100644 --- a/config/dot/quickshell/modules/settings/ColorWell.qml +++ b/config/dot/quickshell/modules/settings/ColorWell.qml @@ -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() diff --git a/config/dot/quickshell/modules/settings/CountdownRing.qml b/config/dot/quickshell/modules/settings/CountdownRing.qml index 5a49f2f..fbdc538 100644 --- a/config/dot/quickshell/modules/settings/CountdownRing.qml +++ b/config/dot/quickshell/modules/settings/CountdownRing.qml @@ -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 { diff --git a/config/dot/quickshell/modules/settings/DisplayArrangement.qml b/config/dot/quickshell/modules/settings/DisplayArrangement.qml index a72cdb4..a11b86d 100644 --- a/config/dot/quickshell/modules/settings/DisplayArrangement.qml +++ b/config/dot/quickshell/modules/settings/DisplayArrangement.qml @@ -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 diff --git a/config/dot/quickshell/modules/settings/DockPinsStrip.qml b/config/dot/quickshell/modules/settings/DockPinsStrip.qml index e9ffd37..5857e5b 100644 --- a/config/dot/quickshell/modules/settings/DockPinsStrip.qml +++ b/config/dot/quickshell/modules/settings/DockPinsStrip.qml @@ -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 { diff --git a/config/dot/quickshell/modules/settings/GradientSliderRow.qml b/config/dot/quickshell/modules/settings/GradientSliderRow.qml index b827b37..15d5d9e 100644 --- a/config/dot/quickshell/modules/settings/GradientSliderRow.qml +++ b/config/dot/quickshell/modules/settings/GradientSliderRow.qml @@ -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 diff --git a/config/dot/quickshell/modules/settings/SoundPage.qml b/config/dot/quickshell/modules/settings/SoundPage.qml index 9d49de6..2999cf4 100644 --- a/config/dot/quickshell/modules/settings/SoundPage.qml +++ b/config/dot/quickshell/modules/settings/SoundPage.qml @@ -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 { diff --git a/config/dot/quickshell/modules/settings/ThemeSaturationRow.qml b/config/dot/quickshell/modules/settings/ThemeSaturationRow.qml index a6fddbe..e745939 100644 --- a/config/dot/quickshell/modules/settings/ThemeSaturationRow.qml +++ b/config/dot/quickshell/modules/settings/ThemeSaturationRow.qml @@ -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); diff --git a/config/dot/quickshell/scripts/panama-lock b/config/dot/quickshell/scripts/panama-lock index 6f3eec5..f7a0c9d 100755 --- a/config/dot/quickshell/scripts/panama-lock +++ b/config/dot/quickshell/scripts/panama-lock @@ -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" diff --git a/config/dot/quickshell/scripts/panama-osd b/config/dot/quickshell/scripts/panama-osd index 9e0e76e..5662563 100755 --- a/config/dot/quickshell/scripts/panama-osd +++ b/config/dot/quickshell/scripts/panama-osd @@ -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 +# " ", 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 } diff --git a/config/dot/quickshell/scripts/panama-settings-docs b/config/dot/quickshell/scripts/panama-settings-docs index 7519228..1ac0a22 100755 --- a/config/dot/quickshell/scripts/panama-settings-docs +++ b/config/dot/quickshell/scripts/panama-settings-docs @@ -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"), diff --git a/config/dot/quickshell/scripts/panama-theme-apps b/config/dot/quickshell/scripts/panama-theme-apps index 8295910..81b5d66 100755 --- a/config/dot/quickshell/scripts/panama-theme-apps +++ b/config/dot/quickshell/scripts/panama-theme-apps @@ -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 diff --git a/config/dot/quickshell/services/ColorScheme.qml b/config/dot/quickshell/services/ColorScheme.qml index 5125fd1..23ef871 100644 --- a/config/dot/quickshell/services/ColorScheme.qml +++ b/config/dot/quickshell/services/ColorScheme.qml @@ -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); } diff --git a/config/dot/quickshell/services/Displays.qml b/config/dot/quickshell/services/Displays.qml index f80f3ab..139969b 100644 --- a/config/dot/quickshell/services/Displays.qml +++ b/config/dot/quickshell/services/Displays.qml @@ -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 diff --git a/config/dot/quickshell/services/KdeConnect.qml b/config/dot/quickshell/services/KdeConnect.qml index a32b58c..3b6991e 100644 --- a/config/dot/quickshell/services/KdeConnect.qml +++ b/config/dot/quickshell/services/KdeConnect.qml @@ -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; diff --git a/config/dot/quickshell/services/Notifs.qml b/config/dot/quickshell/services/Notifs.qml index 04b5cfe..4e220c4 100644 --- a/config/dot/quickshell/services/Notifs.qml +++ b/config/dot/quickshell/services/Notifs.qml @@ -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; } diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index d36b99d..fde5f78 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -42,7 +42,7 @@ Singleton { "wallpaper": "appearance", "lockAppearance": "appearance", "dock": "dock", - "focus": "workspaces", + "focus": "notifications", "display": "displays", "nightLight": "displays", "idle": "power", diff --git a/config/dot/quickshell/services/SoundCards.qml b/config/dot/quickshell/services/SoundCards.qml index b7e7c05..7e838e8 100644 --- a/config/dot/quickshell/services/SoundCards.qml +++ b/config/dot/quickshell/services/SoundCards.qml @@ -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()); } } diff --git a/config/dot/quickshell/services/SoundDefaults.qml b/config/dot/quickshell/services/SoundDefaults.qml index f5155d9..7c80d63 100644 --- a/config/dot/quickshell/services/SoundDefaults.qml +++ b/config/dot/quickshell/services/SoundDefaults.qml @@ -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()); } } diff --git a/config/dot/quickshell/services/SoundFeedback.qml b/config/dot/quickshell/services/SoundFeedback.qml index adeb337..1354d64 100644 --- a/config/dot/quickshell/services/SoundFeedback.qml +++ b/config/dot/quickshell/services/SoundFeedback.qml @@ -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) diff --git a/config/dot/quickshell/services/ThemeProfileModel.js b/config/dot/quickshell/services/ThemeProfileModel.js index e93bf7e..1a741b2 100644 --- a/config/dot/quickshell/services/ThemeProfileModel.js +++ b/config/dot/quickshell/services/ThemeProfileModel.js @@ -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 diff --git a/config/dot/quickshell/services/ThemeProfiles.qml b/config/dot/quickshell/services/ThemeProfiles.qml index 280243a..d08a97c 100644 --- a/config/dot/quickshell/services/ThemeProfiles.qml +++ b/config/dot/quickshell/services/ThemeProfiles.qml @@ -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; diff --git a/config/dot/quickshell/services/VideoWallpaper.qml b/config/dot/quickshell/services/VideoWallpaper.qml index 891050c..e8c16ef 100644 --- a/config/dot/quickshell/services/VideoWallpaper.qml +++ b/config/dot/quickshell/services/VideoWallpaper.qml @@ -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. diff --git a/config/dot/quickshell/services/Wallpaper.qml b/config/dot/quickshell/services/Wallpaper.qml index f8c53da..5cbb4d3 100644 --- a/config/dot/quickshell/services/Wallpaper.qml +++ b/config/dot/quickshell/services/Wallpaper.qml @@ -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; } diff --git a/config/local/share/vicinae/scripts/settings-notifications b/config/local/share/vicinae/scripts/settings-notifications index b9afd50..0314c41 100755 --- a/config/local/share/vicinae/scripts/settings-notifications +++ b/config/local/share/vicinae/scripts/settings-notifications @@ -5,6 +5,6 @@ # @vicinae.mode silent # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.description Open Notifications & Focus in Settings. -# @vicinae.keywords ["settings", "notification duration", "critical notification duration", "notification history", "visible banners"] +# @vicinae.keywords ["settings", "focus modes", "notification duration", "critical notification duration", "notification history", "visible banners"] exec "$HOME/.config/quickshell/scripts/panama-action" settings-page notifications diff --git a/config/local/share/vicinae/scripts/settings-workspaces b/config/local/share/vicinae/scripts/settings-workspaces index 2533c15..71aadd2 100755 --- a/config/local/share/vicinae/scripts/settings-workspaces +++ b/config/local/share/vicinae/scripts/settings-workspaces @@ -5,6 +5,6 @@ # @vicinae.mode silent # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.description Open Workspaces in Settings. -# @vicinae.keywords ["settings", "focus modes", "focus session length", "switch back and forth", "wrap around at the ends", "let applications take focus", "hide the terminal that launched a window", "pointer changes active display"] +# @vicinae.keywords ["settings", "focus session length", "switch back and forth", "wrap around at the ends", "let applications take focus", "hide the terminal that launched a window", "pointer changes active display"] exec "$HOME/.config/quickshell/scripts/panama-action" settings-page workspaces diff --git a/docs/settings.md b/docs/settings.md index 8179883..b8de58e 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -115,7 +115,7 @@ Found on **Shell › Dock**. | **Icon size**
`dockIconSize` | 48 px | How large the Dock's application icons are drawn. Range 32–80. | | **Reveal delay**
`dockRevealDelayMs` | 0 ms | Zero reveals the Dock the instant the pointer reaches the edge. Range 0–1000. | | **Hide delay**
`dockHideDelayMs` | 250 ms | Prevents flicker when crossing between icons. Range 0–2000. | -| **Pinned applications**
`dockPinned` | [ | Applications that stay in the Dock whether or not they are running | +| **Pinned applications**
`dockPinned` | — | Applications that stay in the Dock whether or not they are running | ## edges @@ -149,12 +149,11 @@ Found on **Appearance**. ## focus -Found on **Shell › Workspaces**. +Found on **Notifications & Focus**. | Setting | Default | What it does | |---|---|---| -| **Focus modes**
`focusModes` | [ | What quiets this machine, and what turns it on | -| **Focus session length**
`focusDurationMinutes` | 45 min | How long a focus session runs before it ends itself. Range 5–180. | +| **Focus modes**
`focusModes` | — | What quiets this machine, and what turns it on | ## gaming @@ -409,6 +408,7 @@ Found on **Shell › Workspaces**. | Setting | Default | What it does | |---|---|---| +| **Focus session length**
`focusDurationMinutes` | 45 min | How long a focus session runs before it ends itself. Range 5–180. | | **Switch back and forth**
`workspaceBackAndForth` `binds:workspace_back_and_forth` | false | Selecting the workspace you are already on returns you to the previous one | | **Wrap around at the ends**
`allowWorkspaceCycles` `binds:allow_workspace_cycles` | false | Moving past the last workspace continues from the first | | **Let applications take focus**
`focusOnActivate` `misc:focus_on_activate` | false | An application asking for attention is switched to, rather than only highlighted | diff --git a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md index fc1ae99..285b1d8 100644 --- a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md +++ b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md @@ -345,3 +345,16 @@ re-checked against their files as those landed: constructs `SoundPage`, whose microphone test and channel strip are one IPC-less click away from `pw-play`. The harness exposes no method that triggers either; keep it that way. + +## Stabilization pass — 2026-08-24 — THE RUN HAPPENED + +Three read-only reviewers swept the whole redesign (37 findings), three fix +waves applied them all, and the full suite then ran three times with Gabriel's +go-ahead: 162/169, 168/169, then **169/169 green**. The seven interim failures +were four suite-contention flakes (pass solo; theme-profiles hardened with a +catalog-settle wait) and three contract bugs (display-layout's stale mirror +expectation, osd-helper's non-atomic stub log + a set -u `local` expansion +trap, sound-cards' jq context rebind). displays-contract and switcher-contract +— the locked-session holdovers — both pass unlocked. Later phases append new +deferred contracts below as before; this line is the baseline they diverge +from. diff --git a/setup/packages/hyprland-packages b/setup/packages/hyprland-packages index 0d96097..2d0a3b3 100644 --- a/setup/packages/hyprland-packages +++ b/setup/packages/hyprland-packages @@ -36,6 +36,9 @@ orca pamixer # pw-dump backs the privacy indicators, pw-play the sound-test button. pipewire-utils +# pactl. SoundCards.qml reads and writes card profiles with it and SoundRouting +# moves a running stream between outputs; wireplumber's own wpctl does neither. +pulseaudio-utils playerctl qrencode qt6-qtwayland diff --git a/tests/quickshell/declared-assets-contract b/tests/quickshell/declared-assets-contract index a7b0350..3c041f8 100755 --- a/tests/quickshell/declared-assets-contract +++ b/tests/quickshell/declared-assets-contract @@ -152,6 +152,7 @@ qml_package() { wl-copy|wl-paste) printf 'wl-clipboard' ;; xdg-open) printf 'xdg-utils' ;; pw-dump|pw-play) printf 'pipewire-utils' ;; + pactl) printf 'pulseaudio-utils' ;; *) printf '%s' "$1" ;; esac } diff --git a/tests/quickshell/declared-dependencies-contract b/tests/quickshell/declared-dependencies-contract index 6b6bafe..5c34593 100755 --- a/tests/quickshell/declared-dependencies-contract +++ b/tests/quickshell/declared-dependencies-contract @@ -83,6 +83,8 @@ package_for() { getenforce) printf 'libselinux-utils' ;; nmcli) printf 'NetworkManager' ;; wpctl) printf 'wireplumber' ;; + pw-play) printf 'pipewire-utils' ;; + pactl) printf 'pulseaudio-utils' ;; nvim) printf 'neovim' ;; fwupdmgr) printf 'fwupd' ;; dnf4) printf 'python3-dnf' ;; diff --git a/tests/quickshell/display-layout-contract b/tests/quickshell/display-layout-contract index 229ad68..08ba88e 100755 --- a/tests/quickshell/display-layout-contract +++ b/tests/quickshell/display-layout-contract @@ -61,14 +61,15 @@ jq -e 'all(. == false)' <<<"$invalid" >/dev/null || fail "an invalid layout was # canvas draws it on top of what it mirrors rather than at stale coordinates. mirror="$(qs_for_harness ipc call display-layout-test mirror)" # -# The mirrored record keeps the coordinates it was stored with -- normalize -# shifts everything by the primary's anchor, and applying that to a position -# nothing reads back would be arithmetic for its own sake. It contributes -# nothing to the bounds, so the desktop measures 3000 x 2000: one display. +# The mirrored record is given its target's normalized position -- its stored +# coordinates stopped meaning anything the moment mirroring was turned on, and +# the compositor chooses the real ones. Keeping a stale pair would still draw +# it somewhere false on the canvas. It contributes nothing to the bounds, so +# the desktop measures 3000 x 2000: one display. jq -e '.valid == true and .normalized == [ {"name":"DP-2","x":0,"y":0,"primary":true,"mirrorOf":""}, - {"name":"HDMI-A-1","x":3140,"y":80,"primary":false,"mirrorOf":"DP-2"}] + {"name":"HDMI-A-1","x":0,"y":0,"primary":false,"mirrorOf":"DP-2"}] and .bounds == {"x":0,"y":0,"width":3000,"height":2000} and (.rects | length) == 2 and .rects[1].mirrorOf == "DP-2" and .rects[1].mirrored == true diff --git a/tests/quickshell/osd-helper-contract b/tests/quickshell/osd-helper-contract index 0bb9e77..6cee9fe 100755 --- a/tests/quickshell/osd-helper-contract +++ b/tests/quickshell/osd-helper-contract @@ -12,9 +12,10 @@ log="$scratch/calls" cat >"$scratch/bin/wpctl" <<'SH' #!/bin/bash -printf 'wpctl' >>"$OSD_TEST_LOG" -printf ' <%s>' "$@" >>"$OSD_TEST_LOG" -printf '\n' >>"$OSD_TEST_LOG" +# One atomic append per call: the blip's pw-play runs in the background +# and a multi-write log line would interleave with the next stub's. +line='wpctl'; for a in "$@"; do line+=" <$a>"; done +printf '%s\n' "$line" >>"$OSD_TEST_LOG" if [[ $1 == "get-volume" ]]; then printf '%s\n' "${WPCTL_OUTPUT:-Volume: 0.58}" fi @@ -22,9 +23,10 @@ SH cat >"$scratch/bin/brightnessctl" <<'SH' #!/bin/bash -printf 'brightnessctl' >>"$OSD_TEST_LOG" -printf ' <%s>' "$@" >>"$OSD_TEST_LOG" -printf '\n' >>"$OSD_TEST_LOG" +# One atomic append per call: the blip's pw-play runs in the background +# and a multi-write log line would interleave with the next stub's. +line='brightnessctl'; for a in "$@"; do line+=" <$a>"; done +printf '%s\n' "$line" >>"$OSD_TEST_LOG" if [[ " $* " == *" -m "* && " $* " != *" set "* ]]; then [[ ${BACKLIGHT_AVAILABLE:-true} == true ]] || exit 1 printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,50%,1000}" @@ -33,9 +35,10 @@ SH cat >"$scratch/bin/panama-brightness" <<'SH' #!/bin/bash -printf 'panama-brightness' >>"$OSD_TEST_LOG" -printf ' <%s>' "$@" >>"$OSD_TEST_LOG" -printf '\n' >>"$OSD_TEST_LOG" +# One atomic append per call: the blip's pw-play runs in the background +# and a multi-write log line would interleave with the next stub's. +line='panama-brightness'; for a in "$@"; do line+=" <$a>"; done +printf '%s\n' "$line" >>"$OSD_TEST_LOG" case "${1:-}" in list) @@ -69,24 +72,27 @@ SH cat >"$scratch/bin/hyprctl" <<'SH' #!/bin/bash -printf 'hyprctl' >>"$OSD_TEST_LOG" -printf ' <%s>' "$@" >>"$OSD_TEST_LOG" -printf '\n' >>"$OSD_TEST_LOG" +# One atomic append per call: the blip's pw-play runs in the background +# and a multi-write log line would interleave with the next stub's. +line='hyprctl'; for a in "$@"; do line+=" <$a>"; done +printf '%s\n' "$line" >>"$OSD_TEST_LOG" printf '[{"name":"%s","focused":true}]\n' "${FOCUSED_MONITOR:-DP-2}" SH cat >"$scratch/bin/notify-send" <<'SH' #!/bin/bash -printf 'notify-send' >>"$OSD_TEST_LOG" -printf ' <%s>' "$@" >>"$OSD_TEST_LOG" -printf '\n' >>"$OSD_TEST_LOG" +# One atomic append per call: the blip's pw-play runs in the background +# and a multi-write log line would interleave with the next stub's. +line='notify-send'; for a in "$@"; do line+=" <$a>"; done +printf '%s\n' "$line" >>"$OSD_TEST_LOG" SH cat >"$scratch/bin/playerctl" <<'SH' #!/bin/bash -printf 'playerctl' >>"$OSD_TEST_LOG" -printf ' <%s>' "$@" >>"$OSD_TEST_LOG" -printf '\n' >>"$OSD_TEST_LOG" +# One atomic append per call: the blip's pw-play runs in the background +# and a multi-write log line would interleave with the next stub's. +line='playerctl'; for a in "$@"; do line+=" <$a>"; done +printf '%s\n' "$line" >>"$OSD_TEST_LOG" if [[ $1 == "metadata" ]]; then printf '%s\n' "${PLAYER_OUTPUT:-Horizon — Tycho}" elif [[ $1 == "status" ]]; then @@ -96,16 +102,18 @@ SH cat >"$scratch/bin/pw-play" <<'SH' #!/bin/bash -printf 'pw-play' >>"$OSD_TEST_LOG" -printf ' <%s>' "$@" >>"$OSD_TEST_LOG" -printf '\n' >>"$OSD_TEST_LOG" +# One atomic append per call: the blip's pw-play runs in the background +# and a multi-write log line would interleave with the next stub's. +line='pw-play'; for a in "$@"; do line+=" <$a>"; done +printf '%s\n' "$line" >>"$OSD_TEST_LOG" SH cat >"$scratch/bin/qs" <<'SH' #!/bin/bash -printf 'qs' >>"$OSD_TEST_LOG" -printf ' <%s>' "$@" >>"$OSD_TEST_LOG" -printf '\n' >>"$OSD_TEST_LOG" +# One atomic append per call: the blip's pw-play runs in the background +# and a multi-write log line would interleave with the next stub's. +line='qs'; for a in "$@"; do line+=" <$a>"; done +printf '%s\n' "$line" >>"$OSD_TEST_LOG" [[ ${OSD_TEST_FAIL_QS:-false} != true ]] SH @@ -123,7 +131,12 @@ mkdir -p "$scratch/config-default" # is what a fresh install looks like: the helper must fall back to the schema # defaults rather than treating an absent file as an error. settings_root() { - local name="$1" body="${2:-}" root="$scratch/config-$name" + # Three separate statements on purpose: bash expands every word of one + # `local` command before performing any of its assignments, so $name in + # the third RHS would be unbound (set -u) if these shared a line. + local name="$1" + local body="${2:-}" + local root="$scratch/config-$name" rm -rf "$root" mkdir -p "$root/panama" [[ -n "$body" ]] && printf '%s\n' "$body" >"$root/panama/settings.json" @@ -202,10 +215,10 @@ assert_line 'wpctl <@DEFAULT_AUDIO_SOURCE@> ' assert_line 'qs <72> <100> ' # ── Over-amplification: the ceiling is a setting, not a constant ───────────── -# `wpctl set-volume` clamps to 1.0 unless told otherwise, and it clamps the -# *result* -- so the limit has to be on the down step too. Without it, coming -# down from 130% would snap to 100% instead of stepping to 124%, which reads as -# the slider jumping on its own. +# `-l` caps the *result*, and without it wpctl caps nothing -- 100% is not a +# ceiling it enforces on its own. The limit is on the down step for that reason: +# with over-amplification off, stepping down from a volume already above 100% +# has to land under the ceiling rather than merely one step lower. : >"$log" OSD_CONFIG_HOME="$(settings_root overamp '{"overAmplification": true}')" \ run_helper volume up 6 @@ -245,6 +258,11 @@ assert_line 'qs <58> <100> <58%>' # ── The volume blip ───────────────────────────────────────────────────────── # Default on, per the schema, so a settings.json that has never been written # still clicks. Fire-and-forget: the blip must never gate the OSD. +# +# Each case below awaits its own click before the next one starts, which is also +# what keeps them independent: the helper takes a non-blocking lock in its +# runtime directory so a held-down volume key clicks once rather than stacking +# one pw-play per repeat, and a click still sounding would suppress the next. : >"$log" run_helper volume up 6 await_line "pw-play <$blip_sound>" diff --git a/tests/quickshell/sound-cards-contract b/tests/quickshell/sound-cards-contract index 712c59e..f067ab7 100755 --- a/tests/quickshell/sound-cards-contract +++ b/tests/quickshell/sound-cards-contract @@ -260,8 +260,9 @@ jq -e '.cards[0].name == "alsa_card.pci-0000_00_1f.3" # Profiles become an ordered list. An object has no order, and a dropdown does. jq -e '.cards[0].profiles | type == "array"' >/dev/null <<<"$state" \ || fail "profiles are still keyed by name, so the dropdown has no order: $state" -jq -e '(.cards[0].profiles | map(.name)) | index("output:analog-stereo+input:analog-stereo") != null - and (.cards[0].profiles | map(.name) | index("output:hdmi-stereo")) != null' \ +jq -e '(.cards[0].profiles | map(.name)) as $names + | ($names | index("output:analog-stereo+input:analog-stereo")) != null + and ($names | index("output:hdmi-stereo")) != null' \ >/dev/null <<<"$state" || fail "a profile pactl reported went missing: $state" jq -e '(.cards[0].profiles[] | select(.name == "output:analog-stereo+input:analog-stereo") | .description) == "Analog Stereo Duplex"' >/dev/null <<<"$state" \ diff --git a/tests/quickshell/theme-profiles-contract b/tests/quickshell/theme-profiles-contract index 509674c..81195c4 100755 --- a/tests/quickshell/theme-profiles-contract +++ b/tests/quickshell/theme-profiles-contract @@ -100,14 +100,19 @@ const free = model.createCustomProfile([], { }) assert.equal(free.profile.name, 'Nord') assert.equal(free.profile.id, 'custom-nord') -// A stored custom colliding with a catalog name is dropped; the same record -// against the three-entry fallback is kept, because there "Nord" is free. +// A stored custom colliding with a catalog name is RENAMED, never dropped — +// upgrades that add shipped themes must not delete a user's saved theme. The +// same record against the three-entry fallback keeps its name, because there +// "Nord" is free. Ids stay the identity either way. const impostor = { id: 'custom-nord', name: 'Nord', scheme: 'dark', accent: '#88c0d0', secondary: '#81a1c1', shipped: false } -assert.equal(model.validCustomProfiles([impostor], shippedCatalog).length, 0) -assert.equal(model.validCustomProfiles([impostor]).length, 1) +const renamed = model.validCustomProfiles([impostor], shippedCatalog) +assert.equal(renamed.length, 1) +assert.equal(renamed[0].id, 'custom-nord') +assert.equal(renamed[0].name, 'Nord 2') +assert.equal(model.validCustomProfiles([impostor])[0].name, 'Nord') // ── createCustomProfile carries the optional fields through ───────────────── const rich = model.createCustomProfile([], { @@ -287,7 +292,7 @@ cleanup() { trap cleanup EXIT qs_for_test --daemonize >/dev/null -for _ in $(seq 1 40); do +for _ in $(seq 1 100); do qs_for_test ipc show 2>/dev/null | rg -q '^target theme-profiles-test$' && break sleep 0.1 done @@ -297,8 +302,16 @@ qs_for_test ipc show 2>/dev/null | rg -q '^target theme-profiles-test$' \ status() { qs_for_test ipc call theme-profiles-test status; } # The catalog is live here, so the profile list is the ten shipped themes, not -# the three-entry pre-load fallback. -jq -e '.active.id == "moon" and (.profiles | length) == 10' <<<"$(status)" >/dev/null +# the three-entry pre-load fallback. The catalog arrives through an async +# FileView read, so under a loaded machine (the full suite) it can land a +# beat after the IPC target does -- wait for it rather than asserting the +# race. +for _ in $(seq 1 50); do + jq -e '(.profiles | length) == 10' <<<"$(status)" >/dev/null 2>&1 && break + sleep 0.1 +done +jq -e '.active.id == "moon" and (.profiles | length) == 10' <<<"$(status)" >/dev/null \ + || { printf 'theme profiles contract: catalog never loaded ten themes: %s\n' "$(status)" >&2; exit 1; } # ── A scheme flip lands on the remembered theme for that side ─────────────── # This is the whole point of themeDark/themeLight. Before them, flipping to diff --git a/tests/quickshell/video-wallpaper-contract b/tests/quickshell/video-wallpaper-contract index 67063bb..d9e2c56 100755 --- a/tests/quickshell/video-wallpaper-contract +++ b/tests/quickshell/video-wallpaper-contract @@ -130,8 +130,10 @@ rg -Fq '["systemctl", "--user", "stop", "hyprpaper.service"]' "$service" \ || fail 'starting a video does not stop hyprpaper, so the two race for the layer' rg -Fq '["systemctl", "--user", "start", "hyprpaper.service"]' "$service" \ || fail 'stopping a video does not bring hyprpaper back' +rg -Fq 'Wallpaper.applyCurrentPolicy(' "$service" \ + || fail 'the still policy is not reapplied once hyprpaper returns' rg -Fq 'Wallpaper.refreshActive()' "$service" \ - || fail 'the still wallpaper is not reapplied once hyprpaper returns' + || fail 'the fallback readback of hyprpaper state is gone' # Wallpaper.qml routes a video away from the hyprpaper transaction, which would # only fail validation, and gives hyprpaper a beat to come back on the way out.