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