Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07db1068f1 | ||
|
|
ffce48964e |
@@ -287,18 +287,25 @@ hl.monitor({
|
|||||||
-- empty monitor leaves the previous binding in place. So the config is the only
|
-- empty monitor leaves the previous binding in place. So the config is the only
|
||||||
-- honest source, and applying a change is a reload.
|
-- honest source, and applying a change is a reload.
|
||||||
if prefs.get("workspacesOnPrimaryOnly", false) == true then
|
if prefs.get("workspacesOnPrimaryOnly", false) == true then
|
||||||
local primary = nil
|
-- Only a record display_entry accepts counts. A half-written entry is one
|
||||||
for output, entry in pairs(displays) do
|
-- the monitor rules above already refuse, so pinning ten workspaces to it on
|
||||||
if type(entry) == "table" and entry.primary == true
|
-- the strength of a `primary` flag nothing else trusts would put them on a
|
||||||
and type(output) == "string" and output:match("^[%w_.-]+$") ~= nil then
|
-- screen that never got a rule of its own.
|
||||||
primary = output
|
local primaries = {}
|
||||||
break
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Without a primary there is nothing to pin to, and guessing one would move
|
-- Without a primary there is nothing to pin to, and guessing one would move
|
||||||
-- every workspace onto whichever screen happened to sort first.
|
-- every workspace onto whichever screen happened to sort first. Two records
|
||||||
if primary ~= nil then
|
-- 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
|
for i = 1, 10 do
|
||||||
hl.workspace_rule({ workspace = tostring(i), monitor = primary })
|
hl.workspace_rule({ workspace = tostring(i), monitor = primary })
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -57,7 +57,12 @@ Singleton {
|
|||||||
const coerced = PreferenceSchema.coerce(key, value);
|
const coerced = PreferenceSchema.coerce(key, value);
|
||||||
if (coerced === undefined)
|
if (coerced === undefined)
|
||||||
return false;
|
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;
|
return true;
|
||||||
|
|
||||||
// Reassign rather than mutate: QML does not notify on in-place changes
|
// Reassign rather than mutate: QML does not notify on in-place changes
|
||||||
@@ -70,6 +75,19 @@ Singleton {
|
|||||||
return true;
|
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 --
|
// Restores every schema default in one write. Complete by construction --
|
||||||
// there is no hand-maintained list to fall out of sync with the schema.
|
// there is no hand-maintained list to fall out of sync with the schema.
|
||||||
function resetDesktopDefaults(): void {
|
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,
|
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
|
||||||
unit: "min",
|
unit: "min",
|
||||||
group: "focus",
|
group: "workspaces",
|
||||||
label: "Focus session length",
|
label: "Focus session length",
|
||||||
detail: "How long a focus session runs before it ends itself"
|
detail: "How long a focus session runs before it ends itself"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -129,16 +129,34 @@ PanelWindow {
|
|||||||
onTriggered: root.revealed = false
|
onTriggered: root.revealed = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Other modules (the bar, the capture overlay) read this. It is one
|
// ShellState.dockRevealed is one shared flag and there is one Dock per
|
||||||
// shared flag but there is one Dock per monitor, so only the instance on
|
// monitor, so exactly one instance may write it -- otherwise whichever
|
||||||
// the currently-focused monitor is allowed to write it -- otherwise
|
// instance last changed reveal state stomps the others and a reader gets an
|
||||||
// whichever instance last changed reveal state would stomp the others,
|
// arbitrary monitor's answer. Scoped this way the flag means "is the dock
|
||||||
// and a reader would see an arbitrary monitor's value. This scopes the
|
// revealed on the monitor the user is on", which is the only question a
|
||||||
// flag to mean "is the dock revealed on the monitor the user is on",
|
// reader outside the dock can sensibly ask of it. (A genuinely per-monitor
|
||||||
// which is what a capture overlay or the bar actually care about.
|
// answer would need the property itself keyed by screen.)
|
||||||
// (A true per-monitor flag would need ShellState.dockRevealed itself to
|
//
|
||||||
// become keyed by screen, which is out of scope here -- see the report.)
|
// Two things disqualify an instance. One is not being on the focused
|
||||||
readonly property bool isFocusedMonitorInstance: root.monitor === null || root.monitor === Hyprland.focusedMonitor
|
// 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()
|
onRevealedChanged: root._syncShellState()
|
||||||
onIsFocusedMonitorInstanceChanged: root._syncShellState()
|
onIsFocusedMonitorInstanceChanged: root._syncShellState()
|
||||||
@@ -183,6 +201,33 @@ PanelWindow {
|
|||||||
Item {
|
Item {
|
||||||
id: maskItem
|
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: {
|
x: {
|
||||||
if (!root.revealed)
|
if (!root.revealed)
|
||||||
return root.position === "right" ? surface.width - root.revealStripHeight : 0;
|
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
|
// to its own edge, which is x 0 on the left and the body on
|
||||||
// the right.
|
// the right.
|
||||||
if (!root.vertical)
|
if (!root.vertical)
|
||||||
return body.x;
|
return maskItem.settledX;
|
||||||
return root.position === "right" ? body.x : 0;
|
return root.position === "right" ? maskItem.settledX : 0;
|
||||||
}
|
}
|
||||||
y: {
|
y: {
|
||||||
if (!root.revealed)
|
if (!root.revealed)
|
||||||
return root.vertical ? 0 : surface.height - root.revealStripHeight;
|
return root.vertical ? 0 : surface.height - root.revealStripHeight;
|
||||||
return body.y;
|
return maskItem.settledY;
|
||||||
}
|
}
|
||||||
width: {
|
width: {
|
||||||
if (!root.revealed)
|
if (!root.revealed)
|
||||||
return root.vertical ? root.revealStripHeight : surface.width;
|
return root.vertical ? root.revealStripHeight : surface.width;
|
||||||
return root.vertical
|
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;
|
: body.width;
|
||||||
}
|
}
|
||||||
height: {
|
height: {
|
||||||
if (!root.revealed)
|
if (!root.revealed)
|
||||||
return root.vertical ? surface.height : root.revealStripHeight;
|
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();
|
body.dismissPreview();
|
||||||
dockContextMenu.anchorItem = anchorItem;
|
dockContextMenu.anchorItem = anchorItem;
|
||||||
dockContextMenu.app = app;
|
dockContextMenu.app = app;
|
||||||
dockContextMenu.visible = true;
|
dockContextMenu.requested = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
vertical: root.vertical
|
vertical: root.vertical
|
||||||
@@ -284,6 +331,33 @@ PanelWindow {
|
|||||||
id: dockContextMenu
|
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
|
// 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
|
// 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
|
// 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
|
// are their own surface: leaving the icon to reach them would otherwise
|
||||||
// close the thing being reached for.
|
// close the thing being reached for.
|
||||||
property Item previewAnchor: null
|
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.
|
// Written by the Dock from the preview popup's own hover.
|
||||||
property bool previewHovered: false
|
property bool previewHovered: false
|
||||||
|
|
||||||
onHoveredItemChanged: root.reconsiderPreview()
|
onHoveredItemChanged: root.reconsiderPreview()
|
||||||
onPreviewHoveredChanged: 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 {
|
function reconsiderPreview(): void {
|
||||||
const item = root.hoveredItem;
|
const item = root.hoveredItem;
|
||||||
@@ -277,7 +298,6 @@ Rectangle {
|
|||||||
previewDwell.stop();
|
previewDwell.stop();
|
||||||
previewGrace.stop();
|
previewGrace.stop();
|
||||||
root.previewAnchor = null;
|
root.previewAnchor = null;
|
||||||
root.previewApp = null;
|
|
||||||
root.previewHovered = false;
|
root.previewHovered = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +309,6 @@ Rectangle {
|
|||||||
if (!item || !item.app || !item.app.windows || item.app.windows.length === 0)
|
if (!item || !item.app || !item.app.windows || item.app.windows.length === 0)
|
||||||
return;
|
return;
|
||||||
root.previewAnchor = item;
|
root.previewAnchor = item;
|
||||||
root.previewApp = item.app;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,6 +394,13 @@ Rectangle {
|
|||||||
onDragMoved: travel => root.moveDrag(travel)
|
onDragMoved: travel => root.moveDrag(travel)
|
||||||
onDragEnded: root.endDrag()
|
onDragEnded: root.endDrag()
|
||||||
onDragCancelled: root.cancelDrag()
|
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))
|
implicitWidth: Math.min(360, Math.max(menu.implicitWidth + Theme.popoverPadding * 2, 240))
|
||||||
implicitHeight: menu.implicitHeight + Theme.popoverPadding * 2
|
implicitHeight: menu.implicitHeight + Theme.popoverPadding * 2
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
visible: false
|
|
||||||
grabFocus: true
|
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,
|
// 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.
|
// and a menu as wide as a browser tab's title is not a menu.
|
||||||
function shortTitle(toplevel: var): string {
|
function shortTitle(toplevel: var): string {
|
||||||
@@ -99,6 +114,10 @@ PopupWindow {
|
|||||||
border.width: 1
|
border.width: 1
|
||||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: menuPointer
|
||||||
|
}
|
||||||
|
|
||||||
PrismEdge {
|
PrismEdge {
|
||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
anchors.topMargin: 1
|
anchors.topMargin: 1
|
||||||
@@ -125,7 +144,7 @@ PopupWindow {
|
|||||||
label: root.shortTitle(modelData)
|
label: root.shortTitle(modelData)
|
||||||
onActivated: {
|
onActivated: {
|
||||||
root.focusToplevel(modelData);
|
root.focusToplevel(modelData);
|
||||||
root.visible = false;
|
root.requested = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,7 +170,7 @@ PopupWindow {
|
|||||||
label: modelData.name
|
label: modelData.name
|
||||||
onActivated: {
|
onActivated: {
|
||||||
modelData.execute();
|
modelData.execute();
|
||||||
root.visible = false;
|
root.requested = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,7 +194,7 @@ PopupWindow {
|
|||||||
onActivated: {
|
onActivated: {
|
||||||
if (root.entry)
|
if (root.entry)
|
||||||
root.entry.execute();
|
root.entry.execute();
|
||||||
root.visible = false;
|
root.requested = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +210,7 @@ PopupWindow {
|
|||||||
root.unpin();
|
root.unpin();
|
||||||
else
|
else
|
||||||
root.pin();
|
root.pin();
|
||||||
root.visible = false;
|
root.requested = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +220,7 @@ PopupWindow {
|
|||||||
rowEnabled: root.windows.length > 0
|
rowEnabled: root.windows.length > 0
|
||||||
onActivated: {
|
onActivated: {
|
||||||
root.quit();
|
root.quit();
|
||||||
root.visible = false;
|
root.requested = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +237,7 @@ PopupWindow {
|
|||||||
label: "Dock settings"
|
label: "Dock settings"
|
||||||
onActivated: {
|
onActivated: {
|
||||||
ShellState.openSettings("dock");
|
ShellState.openSettings("dock");
|
||||||
root.visible = false;
|
root.requested = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,6 +187,17 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onPressed: mev => {
|
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.pressX = mev.x;
|
||||||
mouse.pressY = mev.y;
|
mouse.pressY = mev.y;
|
||||||
mouse.dragActive = false;
|
mouse.dragActive = false;
|
||||||
|
|||||||
@@ -167,7 +167,6 @@ Rectangle {
|
|||||||
activeFocusOnTab: true
|
activeFocusOnTab: true
|
||||||
verticalAlignment: TextInput.AlignVCenter
|
verticalAlignment: TextInput.AlignVCenter
|
||||||
maximumLength: 7
|
maximumLength: 7
|
||||||
text: String(root.swatchColor).toLowerCase()
|
|
||||||
color: root.invalid ? Theme.danger : Theme.fg
|
color: root.invalid ? Theme.danger : Theme.fg
|
||||||
selectionColor: Theme.alpha(Theme.accent, 0.4)
|
selectionColor: Theme.alpha(Theme.accent, 0.4)
|
||||||
selectedTextColor: Theme.fg
|
selectedTextColor: Theme.fg
|
||||||
@@ -178,6 +177,14 @@ Rectangle {
|
|||||||
Accessible.role: Accessible.EditableText
|
Accessible.role: Accessible.EditableText
|
||||||
Accessible.name: root.role + " colour, hex"
|
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
|
onTextEdited: root.invalid = false
|
||||||
onAccepted: root.commit()
|
onAccepted: root.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,15 @@ Item {
|
|||||||
implicitWidth: 40
|
implicitWidth: 40
|
||||||
implicitHeight: 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()
|
onSecondsLeftChanged: ring.requestPaint()
|
||||||
onTotalSecondsChanged: ring.requestPaint()
|
onTotalSecondsChanged: ring.requestPaint()
|
||||||
|
onRingColorChanged: ring.requestPaint()
|
||||||
onVisibleChanged: if (root.visible) ring.requestPaint()
|
onVisibleChanged: if (root.visible) ring.requestPaint()
|
||||||
|
|
||||||
Canvas {
|
Canvas {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
// mirroring, rather than drawing it wherever its stale coordinates point.
|
// mirroring, rather than drawing it wherever its stale coordinates point.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.widgets
|
import qs.widgets
|
||||||
import "../../services/DisplayLayout.js" as DisplayLayout
|
import "../../services/DisplayLayout.js" as DisplayLayout
|
||||||
@@ -198,7 +199,20 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Repeater {
|
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 {
|
Rectangle {
|
||||||
id: tile
|
id: tile
|
||||||
|
|||||||
@@ -91,7 +91,17 @@ Column {
|
|||||||
property int draggingIndex: -1
|
property int draggingIndex: -1
|
||||||
property var workingOrder: []
|
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 {
|
function beginDrag(index: int): void {
|
||||||
root.workingOrder = root.pinned.slice();
|
root.workingOrder = root.pinned.slice();
|
||||||
@@ -119,12 +129,23 @@ Column {
|
|||||||
root.commit(next);
|
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 ────────────────────────────────────────────────────────────
|
// ── Keyboard ────────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// The Repeater's model is a plain array, so committing a move rebuilds
|
// Which POSITION has the keyboard, remembered here rather than left to the
|
||||||
// every delegate and the focused one is destroyed mid-keystroke. The
|
// delegate that happens to hold focus. Unpinning really does destroy the
|
||||||
// focused position is remembered here instead of in the delegate, and the
|
// focused cell, and a keyed model still cannot keep focus on a row that is
|
||||||
// cell that lands on it takes focus back as it is created.
|
// 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
|
property int keyboardIndex: -1
|
||||||
|
|
||||||
@@ -159,7 +180,13 @@ Column {
|
|||||||
spacing: root.cellSpacing
|
spacing: root.cellSpacing
|
||||||
|
|
||||||
Repeater {
|
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 {
|
delegate: Item {
|
||||||
id: cell
|
id: cell
|
||||||
@@ -167,9 +194,10 @@ Column {
|
|||||||
required property var modelData
|
required property var modelData
|
||||||
required property int index
|
required property int index
|
||||||
|
|
||||||
|
readonly property string appId: cell.modelData ? cell.modelData.id : ""
|
||||||
readonly property bool dragging: root.draggingIndex === cell.index
|
readonly property bool dragging: root.draggingIndex === cell.index
|
||||||
readonly property string appName: root.nameFor(cell.modelData)
|
readonly property string appName: root.nameFor(cell.appId)
|
||||||
readonly property string iconSource: root.iconFor(cell.modelData)
|
readonly property string iconSource: root.iconFor(cell.appId)
|
||||||
|
|
||||||
width: root.cellSize
|
width: root.cellSize
|
||||||
height: root.cellSize
|
height: root.cellSize
|
||||||
@@ -194,6 +222,12 @@ Column {
|
|||||||
onActiveFocusChanged: if (cell.activeFocus) root.keyboardIndex = cell.index
|
onActiveFocusChanged: if (cell.activeFocus) root.keyboardIndex = cell.index
|
||||||
Component.onCompleted: if (root.keyboardIndex === cell.index) cell.forceActiveFocus()
|
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 {
|
Connections {
|
||||||
target: root
|
target: root
|
||||||
function onKeyboardIndexChanged(): void {
|
function onKeyboardIndexChanged(): void {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// A slider whose track carries a meaning, and whose value is committed once the
|
// 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:
|
// 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.
|
// like, and this is the one slider whose numbers most people cannot picture.
|
||||||
// * The SDR trims, which write through the display transaction. A transaction
|
// * The SDR trims, which write through the display transaction. A transaction
|
||||||
// per pixel of drag would arm a fifteen-second countdown dozens of times;
|
// 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
|
// 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
|
// 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)];
|
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 {
|
function quantise(ratio: real): real {
|
||||||
const raw = root.minimum + ratio * (root.maximum - root.minimum);
|
const raw = root.minimum + ratio * (root.maximum - root.minimum);
|
||||||
const snapped = Math.round(raw / root.step) * root.step;
|
const snapped = Math.round(raw / root.step) * root.step;
|
||||||
@@ -166,7 +183,6 @@ Item {
|
|||||||
function move(positionX: real): void {
|
function move(positionX: real): void {
|
||||||
root.pending = root.quantise(
|
root.pending = root.quantise(
|
||||||
Math.max(0, Math.min(1, (positionX - 8) / track.width)));
|
Math.max(0, Math.min(1, (positionX - 8) / track.width)));
|
||||||
commitTimer.restart();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onPressed: event => drag.move(event.x)
|
onPressed: event => drag.move(event.x)
|
||||||
@@ -174,6 +190,14 @@ Item {
|
|||||||
if (drag.pressed)
|
if (drag.pressed)
|
||||||
drag.move(event.x);
|
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 => {
|
onWheel: event => {
|
||||||
const direction = event.angleDelta.y > 0 ? 1 : -1;
|
const direction = event.angleDelta.y > 0 ? 1 : -1;
|
||||||
root.pending = Math.max(root.minimum,
|
root.pending = Math.max(root.minimum,
|
||||||
@@ -208,15 +232,13 @@ Item {
|
|||||||
color: Theme.alpha(Theme.fg, 0.065)
|
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 {
|
Timer {
|
||||||
id: commitTimer
|
id: commitTimer
|
||||||
interval: root.commitDelay
|
interval: root.commitDelay
|
||||||
onTriggered: {
|
onTriggered: root.commit()
|
||||||
if (root.pending === null)
|
|
||||||
return;
|
|
||||||
root.committed(root.pending);
|
|
||||||
releaseTimer.restart();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hands the readout back to whatever is really in effect. If the change was
|
// Hands the readout back to whatever is really in effect. If the change was
|
||||||
|
|||||||
@@ -60,6 +60,24 @@ SettingsPage {
|
|||||||
setting: "overAmplification"
|
setting: "overAmplification"
|
||||||
divider: false
|
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 {
|
SettingsCard {
|
||||||
|
|||||||
@@ -165,6 +165,12 @@ SettingRow {
|
|||||||
root.moveTo(drag.ratioAt(event.x));
|
root.moveTo(drag.ratioAt(event.x));
|
||||||
}
|
}
|
||||||
onReleased: root.apply()
|
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 => {
|
onWheel: event => {
|
||||||
root.moveTo((root.shown + (event.angleDelta.y > 0 ? 0.05 : -0.05))
|
root.moveTo((root.shown + (event.angleDelta.y > 0 ? 0.05 : -0.05))
|
||||||
/ root.maximum);
|
/ root.maximum);
|
||||||
|
|||||||
@@ -92,7 +92,11 @@ resolve_wallpaper_path() {
|
|||||||
# A video wallpaper cannot play on the lock screen; VideoWallpaper.qml
|
# A video wallpaper cannot play on the lock screen; VideoWallpaper.qml
|
||||||
# grabs a still frame when one is selected, and that frame stands in.
|
# 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.
|
# 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"
|
local frame="${XDG_STATE_HOME:-$HOME/.local/state}/panama/video-wallpaper-frame.png"
|
||||||
if valid_path "$frame"; then
|
if valid_path "$frame"; then
|
||||||
resolved_wallpaper="$frame"
|
resolved_wallpaper="$frame"
|
||||||
|
|||||||
@@ -15,25 +15,41 @@ settings_file() {
|
|||||||
printf '%s/panama/settings.json\n' "${XDG_CONFIG_HOME:-$HOME/.config}"
|
printf '%s/panama/settings.json\n' "${XDG_CONFIG_HOME:-$HOME/.config}"
|
||||||
}
|
}
|
||||||
|
|
||||||
setting_bool() {
|
# Both switches the volume keys care about, read in a single jq. A held-down
|
||||||
local key="$1" fallback="$2" file value
|
# 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)"
|
file="$(settings_file)"
|
||||||
[[ -r $file ]] || { printf '%s\n' "$fallback"; return 0; }
|
if [[ -r $file ]] && command -v jq >/dev/null 2>&1; then
|
||||||
command -v jq >/dev/null 2>&1 || { printf '%s\n' "$fallback"; return 0; }
|
value="$(jq -r '
|
||||||
value="$(jq -r --arg key "$key" '
|
def flag($key; $fallback):
|
||||||
if type == "object" and (.[$key] | type) == "boolean" then .[$key] else empty end
|
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=""
|
' "$file" 2>/dev/null)" || value=""
|
||||||
[[ $value == true || $value == false ]] || value="$fallback"
|
read -r over blip <<<"$value"
|
||||||
printf '%s\n' "$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
|
# `wpctl set-volume -l` caps the *result*, and without it wpctl does not cap at
|
||||||
# has to be passed on the way down as well. Without it, stepping down from 130%
|
# all -- 100% is not a ceiling it enforces on its own. So the limit is passed on
|
||||||
# would snap to 100% instead of 124%, which reads as the slider jumping on its
|
# the way down too, deliberately: with over-amplification off, stepping down
|
||||||
# own. The microphone never gets this: gain past 100% on a capture device buys
|
# from a volume that is somehow already above 100% lands under the ceiling
|
||||||
# noise, not signal, and the Sound page's input slider stays 0-100 to match.
|
# rather than merely one step lower, which is what having the switch off means.
|
||||||
volume_limit() {
|
# The microphone never gets the raised ceiling: gain past 100% on a capture
|
||||||
if [[ $(setting_bool overAmplification false) == true ]]; then
|
# 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'
|
printf '1.5\n'
|
||||||
else
|
else
|
||||||
printf '1\n'
|
printf '1\n'
|
||||||
@@ -42,14 +58,28 @@ volume_limit() {
|
|||||||
|
|
||||||
# The click that says the volume moved. Backgrounded and never waited on: it is
|
# 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
|
# 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
|
# the key press and the OSD.
|
||||||
# has no file to play, which is a silent desktop rather than a broken key.
|
#
|
||||||
|
# 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() {
|
play_blip() {
|
||||||
|
local enabled="$1"
|
||||||
local sound="${PANAMA_OSD_BLIP_SOUND:-$PANAMA_OSD_BLIP_DEFAULT}"
|
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
|
[[ -f $sound ]] || return 0
|
||||||
pw-play "$sound" >/dev/null 2>&1 &
|
mkdir -p "$runtime_dir" 2>/dev/null || return 0
|
||||||
disown 2>/dev/null || true
|
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() {
|
strict_delivery() {
|
||||||
@@ -96,15 +126,17 @@ show_volume() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
adjust_volume() {
|
adjust_volume() {
|
||||||
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SINK@" limit
|
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SINK@"
|
||||||
limit="$(volume_limit)"
|
local over blip limit
|
||||||
|
read -r over blip <<<"$(volume_settings)"
|
||||||
|
limit="$(volume_ceiling "$over")"
|
||||||
case "$action" in
|
case "$action" in
|
||||||
up) wpctl set-volume -l "$limit" "$target" "${step}%+" || return ;;
|
up) wpctl set-volume -l "$limit" "$target" "${step}%+" || return ;;
|
||||||
down) wpctl set-volume -l "$limit" "$target" "${step}%-" || return ;;
|
down) wpctl set-volume -l "$limit" "$target" "${step}%-" || return ;;
|
||||||
toggle) wpctl set-mute "$target" toggle || return ;;
|
toggle) wpctl set-mute "$target" toggle || return ;;
|
||||||
*) printf 'Usage: panama-osd volume up|down|toggle [step]\n' >&2; return 2 ;;
|
*) printf 'Usage: panama-osd volume up|down|toggle [step]\n' >&2; return 2 ;;
|
||||||
esac
|
esac
|
||||||
play_blip
|
play_blip "$blip"
|
||||||
show_volume "$target" volume
|
show_volume "$target" volume
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,10 +139,23 @@ def read_entries():
|
|||||||
return (found.group(2) if found.group(2) is not None
|
return (found.group(2) if found.group(2) is not None
|
||||||
else found.group(1).strip())
|
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 = {
|
entry = {
|
||||||
"key": name,
|
"key": name,
|
||||||
"type": scalar("type"),
|
"type": scalar("type"),
|
||||||
"default": scalar("def"),
|
"default": default_value(),
|
||||||
"group": scalar("group"),
|
"group": scalar("group"),
|
||||||
"label": scalar("label"),
|
"label": scalar("label"),
|
||||||
"detail": scalar("detail"),
|
"detail": scalar("detail"),
|
||||||
|
|||||||
@@ -103,8 +103,14 @@ for token in black red green yellow blue magenta cyan white \
|
|||||||
[[ -n "${!name:-}" ]] || ansi_ok=false
|
[[ -n "${!name:-}" ]] || ansi_ok=false
|
||||||
done
|
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
|
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
|
for side in dark light; do
|
||||||
name="${side}_pal_$token"
|
name="${side}_pal_$token"
|
||||||
[[ -n "${!name:-}" ]] || both_schemes_ok=false
|
[[ -n "${!name:-}" ]] || both_schemes_ok=false
|
||||||
|
|||||||
@@ -71,16 +71,27 @@ Singleton {
|
|||||||
return Object.keys(palette).sort().map(token => palette[token]).join(",");
|
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
|
// 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
|
// not depend on whichever did not move. Their starting values do not
|
||||||
// matter: the first apply() always runs with force set, which ignores all
|
// matter: the first apply() always runs with force set, which ignores all
|
||||||
// of them.
|
// 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 bool appliedDark: false
|
||||||
property string appliedAccentName: ""
|
property string appliedAccentName: ""
|
||||||
property string appliedThemeId: ""
|
property string appliedThemeId: ""
|
||||||
property string appliedPalettePrint: ""
|
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
|
// Applied one command at a time: Process runs a single command, and several
|
||||||
// of these are separate programs.
|
// of these are separate programs.
|
||||||
property var pending: []
|
property var pending: []
|
||||||
@@ -88,15 +99,39 @@ Singleton {
|
|||||||
Process {
|
Process {
|
||||||
id: runner
|
id: runner
|
||||||
onExited: (exitCode, exitStatus) => {
|
onExited: (exitCode, exitStatus) => {
|
||||||
if (exitCode !== 0)
|
if (exitCode !== 0) {
|
||||||
root.lastError = "The color scheme could not be applied everywhere.";
|
root.lastError = "The color scheme could not be applied everywhere.";
|
||||||
|
root.queueFailed = true;
|
||||||
|
}
|
||||||
root.drain();
|
root.drain();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drain(): void {
|
// Only a queue that emptied without a single failure counts as applied. A
|
||||||
if (runner.running || root.pending.length === 0)
|
// 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;
|
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];
|
const next = root.pending[0];
|
||||||
root.pending = root.pending.slice(1);
|
root.pending = root.pending.slice(1);
|
||||||
runner.exec(next);
|
runner.exec(next);
|
||||||
@@ -236,10 +271,14 @@ Singleton {
|
|||||||
commands.push([root.appThemePath, root.dark ? "dark" : "light", accentName]);
|
commands.push([root.appThemePath, root.dark ? "dark" : "light", accentName]);
|
||||||
}
|
}
|
||||||
|
|
||||||
root.appliedDark = root.dark;
|
// The target, not the record: settleQueue() promotes it to applied*
|
||||||
root.appliedAccentName = accentName;
|
// once every command in the queue has come back clean.
|
||||||
root.appliedThemeId = themeId;
|
root.pendingApplied = {
|
||||||
root.appliedPalettePrint = palettePrint;
|
dark: root.dark,
|
||||||
|
accentName: accentName,
|
||||||
|
themeId: themeId,
|
||||||
|
palettePrint: palettePrint
|
||||||
|
};
|
||||||
|
|
||||||
root.enqueue(commands);
|
root.enqueue(commands);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,7 +156,12 @@ Singleton {
|
|||||||
onExited: (exitCode, exitStatus) => {
|
onExited: (exitCode, exitStatus) => {
|
||||||
// Exit status is advisory only. Hyprland's Lua bridge can report
|
// Exit status is advisory only. Hyprland's Lua bridge can report
|
||||||
// success without applying a value, so exact readback decides.
|
// 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.ticks = 0;
|
||||||
revertVerifyTimer.restart();
|
revertVerifyTimer.restart();
|
||||||
}
|
}
|
||||||
@@ -177,9 +182,16 @@ Singleton {
|
|||||||
}
|
}
|
||||||
// Nothing in flight, so a display genuinely arrived or left. Give it
|
// Nothing in flight, so a display genuinely arrived or left. Give it
|
||||||
// back the arrangement it was last confirmed with -- see restoreStored.
|
// back the arrangement it was last confirmed with -- see restoreStored.
|
||||||
|
root.restoreDeferrals = 0;
|
||||||
restoreDebounce.restart();
|
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.
|
// Docking and undocking should not cost you your arrangement.
|
||||||
//
|
//
|
||||||
// hypr/monitors.lua applies the stored per-output entries, but only when
|
// 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.
|
// says so, which is recoverable. Silence would not be.
|
||||||
// The decision, with no side effects, so it can be tested without driving
|
// The decision, with no side effects, so it can be tested without driving
|
||||||
// a real compositor. Returns one of:
|
// a real compositor. Returns one of:
|
||||||
// { action: "none" } nothing stored, or already correct
|
// { action: "none", reason: "settled" } nothing stored, or already correct
|
||||||
|
// { action: "none", reason: "unavailable" } cannot decide yet, ask again
|
||||||
// { action: "apply", layout } restore this
|
// { action: "apply", layout } restore this
|
||||||
// { action: "refuse" } stored arrangement does not fit
|
// { 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 {
|
function plannedRestore(): var {
|
||||||
if (root.busy || root.awaitingConfirmation || root.monitors.length === 0)
|
if (root.busy || root.awaitingConfirmation || root.monitors.length === 0)
|
||||||
return { action: "none" };
|
return { action: "none", reason: "unavailable" };
|
||||||
|
|
||||||
const stored = DesktopPreferences.get("displays");
|
const stored = DesktopPreferences.get("displays");
|
||||||
const persisted = stored && typeof stored === "object" ? stored : {};
|
const persisted = stored && typeof stored === "object" ? stored : {};
|
||||||
@@ -248,7 +266,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!changed)
|
if (!changed)
|
||||||
return { action: "none" };
|
return { action: "none", reason: "settled" };
|
||||||
|
|
||||||
// Exactly one primary, on a display that is actually here. Undocking
|
// Exactly one primary, on a display that is actually here. Undocking
|
||||||
// takes the primary away, and a layout with none is one
|
// takes the primary away, and a layout with none is one
|
||||||
@@ -271,8 +289,21 @@ Singleton {
|
|||||||
|
|
||||||
function restoreStored(): void {
|
function restoreStored(): void {
|
||||||
const plan = root.plannedRestore();
|
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;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.restoreDeferrals = 0;
|
||||||
|
|
||||||
if (plan.action === "refuse") {
|
if (plan.action === "refuse") {
|
||||||
StatusEvents.publish({
|
StatusEvents.publish({
|
||||||
@@ -428,13 +459,20 @@ Singleton {
|
|||||||
|
|
||||||
// The framebuffer format is the only honest report of the bit depth in
|
// 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
|
// 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
|
// panel that cannot carry the link rate quietly stays at 8.
|
||||||
// this map are read as "unknown", never as a mismatch.
|
//
|
||||||
|
// 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 {
|
function formatBitdepth(format: string): int {
|
||||||
if (format === "XRGB8888")
|
const text = String(format);
|
||||||
return 8;
|
if (/2101010$/.test(text))
|
||||||
if (format === "XRGB2101010")
|
|
||||||
return 10;
|
return 10;
|
||||||
|
if (/8888$/.test(text))
|
||||||
|
return 8;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,7 +582,7 @@ Singleton {
|
|||||||
const saved = root.savedEntry(monitor.name);
|
const saved = root.savedEntry(monitor.name);
|
||||||
const live = DisplayLayout.validColorProfile(monitor.colorPreset)
|
const live = DisplayLayout.validColorProfile(monitor.colorPreset)
|
||||||
? monitor.colorPreset : "auto";
|
? monitor.colorPreset : "auto";
|
||||||
return {
|
const record = {
|
||||||
name: monitor.name,
|
name: monitor.name,
|
||||||
width: monitor.width,
|
width: monitor.width,
|
||||||
height: monitor.height,
|
height: monitor.height,
|
||||||
@@ -560,15 +598,25 @@ Singleton {
|
|||||||
// Keeping the stored policy stops one apply from pinning the
|
// Keeping the stored policy stops one apply from pinning the
|
||||||
// display to whatever automatic happened to pick today.
|
// display to whatever automatic happened to pick today.
|
||||||
colorProfile: saved && saved.colorProfile === "auto" ? "auto" : live,
|
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)
|
sdrBrightness: DisplayLayout.validSdrBrightness(monitor.sdrBrightness)
|
||||||
? monitor.sdrBrightness : 1.0,
|
? monitor.sdrBrightness : 1.0,
|
||||||
sdrSaturation: DisplayLayout.validSdrSaturation(monitor.sdrSaturation)
|
sdrSaturation: DisplayLayout.validSdrSaturation(monitor.sdrSaturation)
|
||||||
? monitor.sdrSaturation : 1.0,
|
? monitor.sdrSaturation : 1.0,
|
||||||
mirrorOf: typeof monitor.mirrorOf === "string" ? monitor.mirrorOf : ""
|
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 stored = DesktopPreferences.get("displays");
|
||||||
const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {});
|
const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {});
|
||||||
for (const record of root.pendingRequestedLayout) {
|
for (const record of root.pendingRequestedLayout) {
|
||||||
next[record.name] = {
|
const entry = {
|
||||||
mode: record.mode,
|
mode: record.mode,
|
||||||
scale: record.scale,
|
scale: record.scale,
|
||||||
transform: record.transform,
|
transform: record.transform,
|
||||||
@@ -856,11 +904,16 @@ Singleton {
|
|||||||
primary: record.primary,
|
primary: record.primary,
|
||||||
vrrMode: record.vrrMode,
|
vrrMode: record.vrrMode,
|
||||||
colorProfile: record.colorProfile,
|
colorProfile: record.colorProfile,
|
||||||
bitdepth: record.bitdepth,
|
|
||||||
sdrBrightness: record.sdrBrightness,
|
sdrBrightness: record.sdrBrightness,
|
||||||
sdrSaturation: record.sdrSaturation,
|
sdrSaturation: record.sdrSaturation,
|
||||||
mirrorOf: record.mirrorOf
|
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)) {
|
if (!DesktopPreferences.set("displays", next)) {
|
||||||
root.lastError = "That display setting could not be saved. Revert it and try again.";
|
root.lastError = "That display setting could not be saved. Revert it and try again.";
|
||||||
@@ -926,11 +979,21 @@ Singleton {
|
|||||||
root.revertExpectedLayout = previous.length > 0 ? previous : null;
|
root.revertExpectedLayout = previous.length > 0 ? previous : null;
|
||||||
root.revertVerificationActive = false;
|
root.revertVerificationActive = false;
|
||||||
root.clearPending();
|
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);
|
root.pushLayout(previous, revertRun);
|
||||||
else
|
} else {
|
||||||
root.lastError = root.revertReason;
|
root.lastError = root.revertReason;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Clears any stored override for an output so it returns to the value
|
// Clears any stored override for an output so it returns to the value
|
||||||
// shipped in hypr/monitors.lua on the next start.
|
// shipped in hypr/monitors.lua on the next start.
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ Singleton {
|
|||||||
property string actionKind: ""
|
property string actionKind: ""
|
||||||
property string actionPath: ""
|
property string actionPath: ""
|
||||||
|
|
||||||
// actionProc's `exited` and its stdout `streamFinished` are not guaranteed
|
// actionProc's `exited` and its stdout `streamFinished` carry half the
|
||||||
// to fire in a particular order (same hazard HomeAssistantConfig.qml's
|
// result each -- the exit and the JSON -- and Quickshell documents no order
|
||||||
// settle-both pattern guards against). These track which of the two have
|
// between them. So neither one finalizes on its own: these track which have
|
||||||
// been observed for the action currently in flight so finishAction() is
|
// been observed for the action currently in flight, and settleAction() runs
|
||||||
// only ever called once both have arrived, with the real stdout JSON as
|
// finishAction() once, on whichever arrives last, with the collected stdout
|
||||||
// the authoritative result.
|
// as the authoritative result.
|
||||||
property bool actionExited: false
|
property bool actionExited: false
|
||||||
property bool actionStdoutDone: false
|
property bool actionStdoutDone: false
|
||||||
property string actionStdoutText: ""
|
property string actionStdoutText: ""
|
||||||
@@ -151,9 +151,8 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Called from both actionProc.onExited and its stdout streamFinished.
|
// Called from both actionProc.onExited and its stdout streamFinished.
|
||||||
// Only finalizes once both signals have arrived for the in-flight action,
|
// Only finalizes once both signals have arrived for the in-flight action --
|
||||||
// since their firing order is not guaranteed -- see the actionExited /
|
// see the actionExited / actionStdoutDone comment above.
|
||||||
// actionStdoutDone comment above.
|
|
||||||
function settleAction(): void {
|
function settleAction(): void {
|
||||||
if (root.actionKind === "")
|
if (root.actionKind === "")
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -265,11 +265,36 @@ Singleton {
|
|||||||
if (notification.urgency === NotificationUrgency.Low)
|
if (notification.urgency === NotificationUrgency.Low)
|
||||||
return;
|
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();
|
const now = Date.now();
|
||||||
if (bell.running || now - root.lastBellAt < 1000)
|
if (bell.running || now - root.lastBellAt < 1000)
|
||||||
return;
|
return;
|
||||||
root.lastBellAt = now;
|
root.lastBellAt = now;
|
||||||
bell.command = SoundFeedback.bellCommand;
|
bell.command = SoundFeedback.playCommand(candidates);
|
||||||
bell.running = true;
|
bell.running = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ Singleton {
|
|||||||
"wallpaper": "appearance",
|
"wallpaper": "appearance",
|
||||||
"lockAppearance": "appearance",
|
"lockAppearance": "appearance",
|
||||||
"dock": "dock",
|
"dock": "dock",
|
||||||
"focus": "workspaces",
|
"focus": "notifications",
|
||||||
"display": "displays",
|
"display": "displays",
|
||||||
"nightLight": "displays",
|
"nightLight": "displays",
|
||||||
"idle": "power",
|
"idle": "power",
|
||||||
|
|||||||
@@ -38,9 +38,19 @@ Singleton {
|
|||||||
// that instead of the live daemon.
|
// that instead of the live daemon.
|
||||||
readonly property string fixturePath: Quickshell.env("PANAMA_SOUND_CARDS_FIXTURE") || ""
|
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 {
|
function refresh(): void {
|
||||||
if (lister.running)
|
if (lister.running) {
|
||||||
|
root.refreshPending = true;
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
root.refreshPending = false;
|
||||||
lister.command = root.fixturePath !== ""
|
lister.command = root.fixturePath !== ""
|
||||||
? ["cat", root.fixturePath]
|
? ["cat", root.fixturePath]
|
||||||
: ["pactl", "-f", "json", "list", "cards"];
|
: ["pactl", "-f", "json", "list", "cards"];
|
||||||
@@ -149,6 +159,10 @@ Singleton {
|
|||||||
root.lastError = "Device profiles could not be read.";
|
root.lastError = "Device profiles could not be read.";
|
||||||
root.loaded = true;
|
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;
|
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 {
|
function refresh(): void {
|
||||||
if (reader.running)
|
if (reader.running) {
|
||||||
|
root.refreshPending = true;
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
root.refreshPending = false;
|
||||||
reader.command = root.fixturePath !== ""
|
reader.command = root.fixturePath !== ""
|
||||||
? ["cat", root.fixturePath]
|
? ["cat", root.fixturePath]
|
||||||
: ["pw-metadata", "-n", "default", "0"];
|
: ["pw-metadata", "-n", "default", "0"];
|
||||||
@@ -173,6 +183,10 @@ Singleton {
|
|||||||
: "PipeWire's remembered default devices could not be read.";
|
: "PipeWire's remembered default devices could not be read.";
|
||||||
if (code !== 0)
|
if (code !== 0)
|
||||||
root.loaded = true;
|
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.
|
// free of file probing on a hot path.
|
||||||
readonly property string homeDir: Quickshell.env("HOME") || ""
|
readonly property string homeDir: Quickshell.env("HOME") || ""
|
||||||
|
|
||||||
readonly property var bellCandidates: [
|
// 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 !== ""
|
||||||
? `${root.homeDir}/.local/share/sounds/${root.soundTheme}/stereo/bell.oga` : "",
|
? `${root.homeDir}/.local/share/sounds/${root.soundTheme}/stereo/${sound}.oga` : "",
|
||||||
`/usr/share/sounds/${root.soundTheme}/stereo/bell.oga`,
|
`/usr/share/sounds/${root.soundTheme}/stereo/${sound}.oga`,
|
||||||
"/usr/share/sounds/freedesktop/stereo/bell.oga"
|
`/usr/share/sounds/freedesktop/stereo/${sound}.oga`
|
||||||
].filter((path, index, all) => path !== "" && all.indexOf(path) === index)
|
].filter((path, index, all) => path !== "" && all.indexOf(path) === index);
|
||||||
|
}
|
||||||
|
|
||||||
// The argv that plays the current theme's bell once, or nothing at all if
|
readonly property var bellCandidates: root.soundCandidates("bell")
|
||||||
// no candidate exists. Shared with Notifs.qml, which plays the same bell
|
|
||||||
// for Panama's own notification popups.
|
// The argv that plays the first candidate that exists, or nothing at all if
|
||||||
readonly property var bellCommand: ["sh", "-c",
|
// none does.
|
||||||
|
function playCommand(candidates: var): var {
|
||||||
|
return ["sh", "-c",
|
||||||
'for candidate in "$@"; do [ -f "$candidate" ] && exec pw-play "$candidate"; done; exit 0',
|
'for candidate in "$@"; do [ -f "$candidate" ] && exec pw-play "$candidate"; done; exit 0',
|
||||||
"qs-sound-feedback"].concat(root.bellCandidates)
|
"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 {
|
function previewAlert(): void {
|
||||||
if (preview.running)
|
if (preview.running)
|
||||||
|
|||||||
@@ -238,27 +238,29 @@ function normalizeStoredProfile(value) {
|
|||||||
return result;
|
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) {
|
function validCustomProfiles(values, shipped) {
|
||||||
if (!Array.isArray(values))
|
if (!Array.isArray(values))
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
|
var catalog = shippedList(shipped);
|
||||||
var ids = {};
|
var ids = {};
|
||||||
var names = {};
|
catalog.forEach(function(profile) { ids[profile.id] = true; });
|
||||||
shippedList(shipped).forEach(function(profile) {
|
|
||||||
ids[profile.id] = true;
|
|
||||||
names[profile.name.toLowerCase()] = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
var result = [];
|
var result = [];
|
||||||
values.forEach(function(value) {
|
values.forEach(function(value) {
|
||||||
var profile = normalizeStoredProfile(value);
|
var profile = normalizeStoredProfile(value);
|
||||||
if (!profile)
|
if (!profile || ids[profile.id])
|
||||||
return;
|
|
||||||
var foldedName = profile.name.toLowerCase();
|
|
||||||
if (ids[profile.id] || names[foldedName])
|
|
||||||
return;
|
return;
|
||||||
ids[profile.id] = true;
|
ids[profile.id] = true;
|
||||||
names[foldedName] = true;
|
profile.name = uniqueName(profile.name, catalog.concat(result));
|
||||||
result.push(profile);
|
result.push(profile);
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
@@ -537,7 +539,11 @@ function resaturatePalette(palette, factor) {
|
|||||||
var next = clamp(hsv.s * (1 + (scale - 1) * strength), 0, 100);
|
var next = clamp(hsv.s * (1 + (scale - 1) * strength), 0, 100);
|
||||||
result[key] = hsvToHex(hsv.h, next, hsv.v);
|
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
|
// 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.
|
// The remembered theme for a scheme, falling back to the catalog default.
|
||||||
function themeForScheme(target: string): string {
|
function themeForScheme(target: string): string {
|
||||||
const key = target === "light" ? "themeLight" : "themeDark";
|
const scheme = target === "light" ? "light" : "dark";
|
||||||
const fallback = target === "light" ? ThemeCatalog.defaultLight : ThemeCatalog.defaultDark;
|
const key = scheme === "light" ? "themeLight" : "themeDark";
|
||||||
|
const fallback = scheme === "light" ? ThemeCatalog.defaultLight : ThemeCatalog.defaultDark;
|
||||||
const stored = String(DesktopPreferences.get(key) || "");
|
const stored = String(DesktopPreferences.get(key) || "");
|
||||||
return ThemeProfileModel.findProfile(root.customProfiles, stored, root.shippedThemes)
|
const profile = ThemeProfileModel.findProfile(
|
||||||
? stored : fallback;
|
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 {
|
function commitActive(profile: var): bool {
|
||||||
@@ -64,6 +70,10 @@ Singleton {
|
|||||||
profile.scheme === "light" ? "themeLight" : "themeDark", profile.id);
|
profile.scheme === "light" ? "themeLight" : "themeDark", profile.id);
|
||||||
SystemSettings.commitPreference("accentName",
|
SystemSettings.commitPreference("accentName",
|
||||||
ThemeProfileModel.nearestCuratedName(profile.scheme, profile.accent));
|
ThemeProfileModel.nearestCuratedName(profile.scheme, profile.accent));
|
||||||
|
// 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);
|
root.applyProfileEffects(profile);
|
||||||
return schemeAccepted && profileAccepted;
|
return schemeAccepted && profileAccepted;
|
||||||
}
|
}
|
||||||
@@ -126,6 +136,11 @@ Singleton {
|
|||||||
|
|
||||||
// The editor's saturation slider. factor 1.0 is neutral.
|
// The editor's saturation slider. factor 1.0 is neutral.
|
||||||
function setSaturation(factor: real): bool {
|
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);
|
const palette = ThemeProfileModel.resaturatePalette(root.activePalette, factor);
|
||||||
if (!palette)
|
if (!palette)
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ pragma Singleton
|
|||||||
//
|
//
|
||||||
// hyprpaper and mpvpaper both claim the background layer and stacking within a
|
// 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
|
// 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
|
// service is stopped, and stopping the video starts it again; this service then
|
||||||
// reapplies the still policy once it returns.
|
// reapplies the still policy through Wallpaper.qml once it returns.
|
||||||
//
|
//
|
||||||
// mpvpaper 1.9 (Terra) is the floor: it carries the libmpv fence-leak
|
// mpvpaper 1.9 (Terra) is the floor: it carries the libmpv fence-leak
|
||||||
// workaround. Known upstream sharp edges — a hotplug segfault and a
|
// workaround. Known upstream sharp edges — a hotplug segfault and a
|
||||||
@@ -125,6 +125,9 @@ Singleton {
|
|||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
root.path = video;
|
root.path = video;
|
||||||
root.manuallyPaused = false;
|
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.command = ["systemctl", "--user", "stop", "hyprpaper.service"];
|
||||||
hyprpaperControl.running = true;
|
hyprpaperControl.running = true;
|
||||||
frameProc.command = ["sh", "-c",
|
frameProc.command = ["sh", "-c",
|
||||||
@@ -141,6 +144,19 @@ Singleton {
|
|||||||
root.restoreConsumed = true;
|
root.restoreConsumed = true;
|
||||||
root.path = "";
|
root.path = "";
|
||||||
root.manuallyPaused = false;
|
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();
|
playerRespawn.stop();
|
||||||
spawnDelay.stop();
|
spawnDelay.stop();
|
||||||
for (const player of root.players) {
|
for (const player of root.players) {
|
||||||
@@ -151,7 +167,22 @@ Singleton {
|
|||||||
reaper.running = true;
|
reaper.running = true;
|
||||||
hyprpaperControl.command = ["systemctl", "--user", "start", "hyprpaper.service"];
|
hyprpaperControl.command = ["systemctl", "--user", "start", "hyprpaper.service"];
|
||||||
hyprpaperControl.running = true;
|
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
|
// Players are created per current output set each (re)spawn, so hotplug
|
||||||
@@ -181,15 +212,32 @@ Singleton {
|
|||||||
reaper.running = true;
|
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 {
|
Process {
|
||||||
id: reaper
|
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()
|
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 {
|
Timer {
|
||||||
id: spawnDelay
|
id: spawnDelay
|
||||||
interval: 300
|
interval: 50
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
if (!root.active)
|
if (!root.active)
|
||||||
return;
|
return;
|
||||||
@@ -201,10 +249,37 @@ Singleton {
|
|||||||
spawned.push(player);
|
spawned.push(player);
|
||||||
}
|
}
|
||||||
root.players = spawned;
|
root.players = spawned;
|
||||||
|
root.playersStartedAt = Date.now();
|
||||||
|
root.crashCounted = false;
|
||||||
pauseSync.restart();
|
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 {
|
Component {
|
||||||
id: playerComponent
|
id: playerComponent
|
||||||
|
|
||||||
@@ -213,12 +288,31 @@ Singleton {
|
|||||||
property string output: ""
|
property string output: ""
|
||||||
property bool retiring: false
|
property bool retiring: false
|
||||||
onExited: {
|
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
|
// A dead player while a video is meant to be active is a
|
||||||
// crash (mpvpaper has a known hotplug segfault): respawn the
|
// crash (mpvpaper has a known hotplug segfault): respawn the
|
||||||
// whole set after a beat rather than reasoning per-output.
|
// whole set after a beat rather than reasoning per-output.
|
||||||
// A retiring player died because the supervisor killed it —
|
if (!root.crashCounted) {
|
||||||
// reacting to that is how the reap loop once ate its young.
|
root.crashCounted = true;
|
||||||
if (!player.retiring && root.active && !playerRespawn.running)
|
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();
|
playerRespawn.restart();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,7 +383,11 @@ Singleton {
|
|||||||
// The service restores its own video, reactively: at cold start the
|
// The service restores its own video, reactively: at cold start the
|
||||||
// preferences file and the mpvpaper probe both land asynchronously, so a
|
// preferences file and the mpvpaper probe both land asynchronously, so a
|
||||||
// one-shot timer (the still pipeline's approach) raced them and lost.
|
// 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
|
property bool restoreConsumed: false
|
||||||
// Harness seam, mirroring Wallpaper.startupRestoreEnabled: a test instance
|
// Harness seam, mirroring Wallpaper.startupRestoreEnabled: a test instance
|
||||||
// must never start playing the user's real wallpaper.
|
// must never start playing the user's real wallpaper.
|
||||||
|
|||||||
@@ -97,8 +97,18 @@ Singleton {
|
|||||||
if (root.transaction === null)
|
if (root.transaction === null)
|
||||||
return;
|
return;
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
root.lastError = "Hyprpaper did not apply that background.";
|
|
||||||
root.transaction = null;
|
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();
|
root.schedulePendingHotplug();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -116,6 +126,34 @@ Singleton {
|
|||||||
onExited: (exitCode, exitStatus) => root.finishVerification(exitCode)
|
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: {
|
Component.onCompleted: {
|
||||||
root.rescan();
|
root.rescan();
|
||||||
root.refreshActive();
|
root.refreshActive();
|
||||||
@@ -253,11 +291,17 @@ Singleton {
|
|||||||
const normalized = root.normalizePolicy(policy);
|
const normalized = root.normalizePolicy(policy);
|
||||||
if (normalized === null) {
|
if (normalized === null) {
|
||||||
root.lastError = "That wallpaper policy is not valid.";
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
const outputs = root.outputNames();
|
const outputs = root.outputNames();
|
||||||
if (outputs.length === 0) {
|
if (outputs.length === 0) {
|
||||||
root.lastError = "No display to set a wallpaper on.";
|
root.lastError = "No display to set a wallpaper on.";
|
||||||
|
root.pendingHotplugReapply = false;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const expected = WallpaperPolicy.effectiveMap(
|
const expected = WallpaperPolicy.effectiveMap(
|
||||||
@@ -266,6 +310,7 @@ Singleton {
|
|||||||
if (Object.keys(expected).length !== outputs.length
|
if (Object.keys(expected).length !== outputs.length
|
||||||
|| Object.values(expected).some(path => path === "")) {
|
|| Object.values(expected).some(path => path === "")) {
|
||||||
root.lastError = "That wallpaper policy is not valid.";
|
root.lastError = "That wallpaper policy is not valid.";
|
||||||
|
root.pendingHotplugReapply = false;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
@@ -318,6 +363,7 @@ Singleton {
|
|||||||
if (!matches) {
|
if (!matches) {
|
||||||
root.lastError = "Hyprpaper did not confirm that background.";
|
root.lastError = "Hyprpaper did not confirm that background.";
|
||||||
root.transaction = null;
|
root.transaction = null;
|
||||||
|
root.abandonStillHandoff();
|
||||||
root.schedulePendingHotplug();
|
root.schedulePendingHotplug();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -325,6 +371,7 @@ Singleton {
|
|||||||
const completed = root.transaction;
|
const completed = root.transaction;
|
||||||
root.activeByOutput = observed;
|
root.activeByOutput = observed;
|
||||||
root.transaction = null;
|
root.transaction = null;
|
||||||
|
root.abandonStillHandoff();
|
||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
if (completed.automatic) {
|
if (completed.automatic) {
|
||||||
root.slideshowPath = completed.policy.slideshowPath;
|
root.slideshowPath = completed.policy.slideshowPath;
|
||||||
@@ -413,28 +460,53 @@ Singleton {
|
|||||||
policy.mode = "single";
|
policy.mode = "single";
|
||||||
policy.globalPath = effectivePath;
|
policy.globalPath = effectivePath;
|
||||||
policy.slideshowPath = effectivePath;
|
policy.slideshowPath = effectivePath;
|
||||||
// Returning from a video: stop mpvpaper first, then give hyprpaper's
|
// Returning from a video: stop mpvpaper first, then offer the policy to
|
||||||
// service a beat to come back before the transaction talks to it.
|
// 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) {
|
if (VideoWallpaper.active) {
|
||||||
VideoWallpaper.stop();
|
VideoWallpaper.stop();
|
||||||
root.pendingStillPolicy = policy;
|
root.pendingStillPolicy = policy;
|
||||||
|
root.stillHandoffAttempts = 0;
|
||||||
stillAfterVideo.restart();
|
stillAfterVideo.restart();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return root.applyPolicy(policy, true, false);
|
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 var pendingStillPolicy: null
|
||||||
|
property int stillHandoffAttempts: 0
|
||||||
|
readonly property int stillHandoffLimit: 3
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
id: stillAfterVideo
|
id: stillAfterVideo
|
||||||
interval: 900
|
interval: 300
|
||||||
onTriggered: {
|
onTriggered: root.attemptStillHandoff()
|
||||||
if (root.pendingStillPolicy) {
|
}
|
||||||
root.applyPolicy(root.pendingStillPolicy, true, false);
|
|
||||||
|
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.pendingStillPolicy = null;
|
||||||
}
|
root.stillHandoffAttempts = 0;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMode(mode: string): bool {
|
function setMode(mode: string): bool {
|
||||||
@@ -458,6 +530,7 @@ Singleton {
|
|||||||
DesktopPreferences.set("videoWallpaperPath", "");
|
DesktopPreferences.set("videoWallpaperPath", "");
|
||||||
VideoWallpaper.stop();
|
VideoWallpaper.stop();
|
||||||
root.pendingStillPolicy = policy;
|
root.pendingStillPolicy = policy;
|
||||||
|
root.stillHandoffAttempts = 0;
|
||||||
stillAfterVideo.restart();
|
stillAfterVideo.restart();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,6 @@
|
|||||||
# @vicinae.mode silent
|
# @vicinae.mode silent
|
||||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||||
# @vicinae.description Open Notifications & Focus in Settings.
|
# @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
|
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page notifications
|
||||||
|
|||||||
@@ -5,6 +5,6 @@
|
|||||||
# @vicinae.mode silent
|
# @vicinae.mode silent
|
||||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||||
# @vicinae.description Open Workspaces in Settings.
|
# @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
|
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. |
|
| **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. |
|
| **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. |
|
| **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
|
## edges
|
||||||
|
|
||||||
@@ -149,12 +149,11 @@ Found on **Appearance**.
|
|||||||
|
|
||||||
## focus
|
## focus
|
||||||
|
|
||||||
Found on **Shell › Workspaces**.
|
Found on **Notifications & Focus**.
|
||||||
|
|
||||||
| Setting | Default | What it does |
|
| Setting | Default | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Focus modes**<br>`focusModes` | [ | What quiets this machine, and what turns it on |
|
| **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. |
|
|
||||||
|
|
||||||
## gaming
|
## gaming
|
||||||
|
|
||||||
@@ -409,6 +408,7 @@ Found on **Shell › Workspaces**.
|
|||||||
|
|
||||||
| Setting | Default | What it does |
|
| 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 |
|
| **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 |
|
| **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 |
|
| **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
|
constructs `SoundPage`, whose microphone test and channel strip are one
|
||||||
IPC-less click away from `pw-play`. The harness exposes no method that
|
IPC-less click away from `pw-play`. The harness exposes no method that
|
||||||
triggers either; keep it that way.
|
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
|
pamixer
|
||||||
# pw-dump backs the privacy indicators, pw-play the sound-test button.
|
# pw-dump backs the privacy indicators, pw-play the sound-test button.
|
||||||
pipewire-utils
|
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
|
playerctl
|
||||||
qrencode
|
qrencode
|
||||||
qt6-qtwayland
|
qt6-qtwayland
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ qml_package() {
|
|||||||
wl-copy|wl-paste) printf 'wl-clipboard' ;;
|
wl-copy|wl-paste) printf 'wl-clipboard' ;;
|
||||||
xdg-open) printf 'xdg-utils' ;;
|
xdg-open) printf 'xdg-utils' ;;
|
||||||
pw-dump|pw-play) printf 'pipewire-utils' ;;
|
pw-dump|pw-play) printf 'pipewire-utils' ;;
|
||||||
|
pactl) printf 'pulseaudio-utils' ;;
|
||||||
*) printf '%s' "$1" ;;
|
*) printf '%s' "$1" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ package_for() {
|
|||||||
getenforce) printf 'libselinux-utils' ;;
|
getenforce) printf 'libselinux-utils' ;;
|
||||||
nmcli) printf 'NetworkManager' ;;
|
nmcli) printf 'NetworkManager' ;;
|
||||||
wpctl) printf 'wireplumber' ;;
|
wpctl) printf 'wireplumber' ;;
|
||||||
|
pw-play) printf 'pipewire-utils' ;;
|
||||||
|
pactl) printf 'pulseaudio-utils' ;;
|
||||||
nvim) printf 'neovim' ;;
|
nvim) printf 'neovim' ;;
|
||||||
fwupdmgr) printf 'fwupd' ;;
|
fwupdmgr) printf 'fwupd' ;;
|
||||||
dnf4) printf 'python3-dnf' ;;
|
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.
|
# canvas draws it on top of what it mirrors rather than at stale coordinates.
|
||||||
mirror="$(qs_for_harness ipc call display-layout-test mirror)"
|
mirror="$(qs_for_harness ipc call display-layout-test mirror)"
|
||||||
#
|
#
|
||||||
# The mirrored record keeps the coordinates it was stored with -- normalize
|
# The mirrored record is given its target's normalized position -- its stored
|
||||||
# shifts everything by the primary's anchor, and applying that to a position
|
# coordinates stopped meaning anything the moment mirroring was turned on, and
|
||||||
# nothing reads back would be arithmetic for its own sake. It contributes
|
# the compositor chooses the real ones. Keeping a stale pair would still draw
|
||||||
# nothing to the bounds, so the desktop measures 3000 x 2000: one display.
|
# 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
|
jq -e '.valid == true
|
||||||
and .normalized == [
|
and .normalized == [
|
||||||
{"name":"DP-2","x":0,"y":0,"primary":true,"mirrorOf":""},
|
{"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 .bounds == {"x":0,"y":0,"width":3000,"height":2000}
|
||||||
and (.rects | length) == 2
|
and (.rects | length) == 2
|
||||||
and .rects[1].mirrorOf == "DP-2" and .rects[1].mirrored == true
|
and .rects[1].mirrorOf == "DP-2" and .rects[1].mirrored == true
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ log="$scratch/calls"
|
|||||||
|
|
||||||
cat >"$scratch/bin/wpctl" <<'SH'
|
cat >"$scratch/bin/wpctl" <<'SH'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
printf 'wpctl' >>"$OSD_TEST_LOG"
|
# One atomic append per call: the blip's pw-play runs in the background
|
||||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
# and a multi-write log line would interleave with the next stub's.
|
||||||
printf '\n' >>"$OSD_TEST_LOG"
|
line='wpctl'; for a in "$@"; do line+=" <$a>"; done
|
||||||
|
printf '%s\n' "$line" >>"$OSD_TEST_LOG"
|
||||||
if [[ $1 == "get-volume" ]]; then
|
if [[ $1 == "get-volume" ]]; then
|
||||||
printf '%s\n' "${WPCTL_OUTPUT:-Volume: 0.58}"
|
printf '%s\n' "${WPCTL_OUTPUT:-Volume: 0.58}"
|
||||||
fi
|
fi
|
||||||
@@ -22,9 +23,10 @@ SH
|
|||||||
|
|
||||||
cat >"$scratch/bin/brightnessctl" <<'SH'
|
cat >"$scratch/bin/brightnessctl" <<'SH'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
printf 'brightnessctl' >>"$OSD_TEST_LOG"
|
# One atomic append per call: the blip's pw-play runs in the background
|
||||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
# and a multi-write log line would interleave with the next stub's.
|
||||||
printf '\n' >>"$OSD_TEST_LOG"
|
line='brightnessctl'; for a in "$@"; do line+=" <$a>"; done
|
||||||
|
printf '%s\n' "$line" >>"$OSD_TEST_LOG"
|
||||||
if [[ " $* " == *" -m "* && " $* " != *" set "* ]]; then
|
if [[ " $* " == *" -m "* && " $* " != *" set "* ]]; then
|
||||||
[[ ${BACKLIGHT_AVAILABLE:-true} == true ]] || exit 1
|
[[ ${BACKLIGHT_AVAILABLE:-true} == true ]] || exit 1
|
||||||
printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,50%,1000}"
|
printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,50%,1000}"
|
||||||
@@ -33,9 +35,10 @@ SH
|
|||||||
|
|
||||||
cat >"$scratch/bin/panama-brightness" <<'SH'
|
cat >"$scratch/bin/panama-brightness" <<'SH'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
printf 'panama-brightness' >>"$OSD_TEST_LOG"
|
# One atomic append per call: the blip's pw-play runs in the background
|
||||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
# and a multi-write log line would interleave with the next stub's.
|
||||||
printf '\n' >>"$OSD_TEST_LOG"
|
line='panama-brightness'; for a in "$@"; do line+=" <$a>"; done
|
||||||
|
printf '%s\n' "$line" >>"$OSD_TEST_LOG"
|
||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
list)
|
list)
|
||||||
@@ -69,24 +72,27 @@ SH
|
|||||||
|
|
||||||
cat >"$scratch/bin/hyprctl" <<'SH'
|
cat >"$scratch/bin/hyprctl" <<'SH'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
printf 'hyprctl' >>"$OSD_TEST_LOG"
|
# One atomic append per call: the blip's pw-play runs in the background
|
||||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
# and a multi-write log line would interleave with the next stub's.
|
||||||
printf '\n' >>"$OSD_TEST_LOG"
|
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}"
|
printf '[{"name":"%s","focused":true}]\n' "${FOCUSED_MONITOR:-DP-2}"
|
||||||
SH
|
SH
|
||||||
|
|
||||||
cat >"$scratch/bin/notify-send" <<'SH'
|
cat >"$scratch/bin/notify-send" <<'SH'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
printf 'notify-send' >>"$OSD_TEST_LOG"
|
# One atomic append per call: the blip's pw-play runs in the background
|
||||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
# and a multi-write log line would interleave with the next stub's.
|
||||||
printf '\n' >>"$OSD_TEST_LOG"
|
line='notify-send'; for a in "$@"; do line+=" <$a>"; done
|
||||||
|
printf '%s\n' "$line" >>"$OSD_TEST_LOG"
|
||||||
SH
|
SH
|
||||||
|
|
||||||
cat >"$scratch/bin/playerctl" <<'SH'
|
cat >"$scratch/bin/playerctl" <<'SH'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
printf 'playerctl' >>"$OSD_TEST_LOG"
|
# One atomic append per call: the blip's pw-play runs in the background
|
||||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
# and a multi-write log line would interleave with the next stub's.
|
||||||
printf '\n' >>"$OSD_TEST_LOG"
|
line='playerctl'; for a in "$@"; do line+=" <$a>"; done
|
||||||
|
printf '%s\n' "$line" >>"$OSD_TEST_LOG"
|
||||||
if [[ $1 == "metadata" ]]; then
|
if [[ $1 == "metadata" ]]; then
|
||||||
printf '%s\n' "${PLAYER_OUTPUT:-Horizon — Tycho}"
|
printf '%s\n' "${PLAYER_OUTPUT:-Horizon — Tycho}"
|
||||||
elif [[ $1 == "status" ]]; then
|
elif [[ $1 == "status" ]]; then
|
||||||
@@ -96,16 +102,18 @@ SH
|
|||||||
|
|
||||||
cat >"$scratch/bin/pw-play" <<'SH'
|
cat >"$scratch/bin/pw-play" <<'SH'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
printf 'pw-play' >>"$OSD_TEST_LOG"
|
# One atomic append per call: the blip's pw-play runs in the background
|
||||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
# and a multi-write log line would interleave with the next stub's.
|
||||||
printf '\n' >>"$OSD_TEST_LOG"
|
line='pw-play'; for a in "$@"; do line+=" <$a>"; done
|
||||||
|
printf '%s\n' "$line" >>"$OSD_TEST_LOG"
|
||||||
SH
|
SH
|
||||||
|
|
||||||
cat >"$scratch/bin/qs" <<'SH'
|
cat >"$scratch/bin/qs" <<'SH'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
printf 'qs' >>"$OSD_TEST_LOG"
|
# One atomic append per call: the blip's pw-play runs in the background
|
||||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
# and a multi-write log line would interleave with the next stub's.
|
||||||
printf '\n' >>"$OSD_TEST_LOG"
|
line='qs'; for a in "$@"; do line+=" <$a>"; done
|
||||||
|
printf '%s\n' "$line" >>"$OSD_TEST_LOG"
|
||||||
[[ ${OSD_TEST_FAIL_QS:-false} != true ]]
|
[[ ${OSD_TEST_FAIL_QS:-false} != true ]]
|
||||||
SH
|
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
|
# 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.
|
# defaults rather than treating an absent file as an error.
|
||||||
settings_root() {
|
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"
|
rm -rf "$root"
|
||||||
mkdir -p "$root/panama"
|
mkdir -p "$root/panama"
|
||||||
[[ -n "$body" ]] && printf '%s\n' "$body" >"$root/panama/settings.json"
|
[[ -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>'
|
assert_line 'qs <ipc> <call> <osd> <progress> <microphone-muted> <72> <100> <Muted>'
|
||||||
|
|
||||||
# ── Over-amplification: the ceiling is a setting, not a constant ─────────────
|
# ── Over-amplification: the ceiling is a setting, not a constant ─────────────
|
||||||
# `wpctl set-volume` clamps to 1.0 unless told otherwise, and it clamps the
|
# `-l` caps the *result*, and without it wpctl caps nothing -- 100% is not a
|
||||||
# *result* -- so the limit has to be on the down step too. Without it, coming
|
# ceiling it enforces on its own. The limit is on the down step for that reason:
|
||||||
# down from 130% would snap to 100% instead of stepping to 124%, which reads as
|
# with over-amplification off, stepping down from a volume already above 100%
|
||||||
# the slider jumping on its own.
|
# has to land under the ceiling rather than merely one step lower.
|
||||||
: >"$log"
|
: >"$log"
|
||||||
OSD_CONFIG_HOME="$(settings_root overamp '{"overAmplification": true}')" \
|
OSD_CONFIG_HOME="$(settings_root overamp '{"overAmplification": true}')" \
|
||||||
run_helper volume up 6
|
run_helper volume up 6
|
||||||
@@ -245,6 +258,11 @@ assert_line 'qs <ipc> <call> <osd> <progress> <volume> <58> <100> <58%>'
|
|||||||
# ── The volume blip ─────────────────────────────────────────────────────────
|
# ── The volume blip ─────────────────────────────────────────────────────────
|
||||||
# Default on, per the schema, so a settings.json that has never been written
|
# 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.
|
# 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"
|
: >"$log"
|
||||||
run_helper volume up 6
|
run_helper volume up 6
|
||||||
await_line "pw-play <$blip_sound>"
|
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.
|
# Profiles become an ordered list. An object has no order, and a dropdown does.
|
||||||
jq -e '.cards[0].profiles | type == "array"' >/dev/null <<<"$state" \
|
jq -e '.cards[0].profiles | type == "array"' >/dev/null <<<"$state" \
|
||||||
|| fail "profiles are still keyed by name, so the dropdown has no order: $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
|
jq -e '(.cards[0].profiles | map(.name)) as $names
|
||||||
and (.cards[0].profiles | map(.name) | index("output:hdmi-stereo")) != null' \
|
| ($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"
|
>/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)
|
jq -e '(.cards[0].profiles[] | select(.name == "output:analog-stereo+input:analog-stereo") | .description)
|
||||||
== "Analog Stereo Duplex"' >/dev/null <<<"$state" \
|
== "Analog Stereo Duplex"' >/dev/null <<<"$state" \
|
||||||
|
|||||||
@@ -100,14 +100,19 @@ const free = model.createCustomProfile([], {
|
|||||||
})
|
})
|
||||||
assert.equal(free.profile.name, 'Nord')
|
assert.equal(free.profile.name, 'Nord')
|
||||||
assert.equal(free.profile.id, 'custom-nord')
|
assert.equal(free.profile.id, 'custom-nord')
|
||||||
// A stored custom colliding with a catalog name is dropped; the same record
|
// A stored custom colliding with a catalog name is RENAMED, never dropped —
|
||||||
// against the three-entry fallback is kept, because there "Nord" is free.
|
// 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 = {
|
const impostor = {
|
||||||
id: 'custom-nord', name: 'Nord', scheme: 'dark',
|
id: 'custom-nord', name: 'Nord', scheme: 'dark',
|
||||||
accent: '#88c0d0', secondary: '#81a1c1', shipped: false
|
accent: '#88c0d0', secondary: '#81a1c1', shipped: false
|
||||||
}
|
}
|
||||||
assert.equal(model.validCustomProfiles([impostor], shippedCatalog).length, 0)
|
const renamed = model.validCustomProfiles([impostor], shippedCatalog)
|
||||||
assert.equal(model.validCustomProfiles([impostor]).length, 1)
|
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 ─────────────────
|
// ── createCustomProfile carries the optional fields through ─────────────────
|
||||||
const rich = model.createCustomProfile([], {
|
const rich = model.createCustomProfile([], {
|
||||||
@@ -287,7 +292,7 @@ cleanup() {
|
|||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
qs_for_test --daemonize >/dev/null
|
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
|
qs_for_test ipc show 2>/dev/null | rg -q '^target theme-profiles-test$' && break
|
||||||
sleep 0.1
|
sleep 0.1
|
||||||
done
|
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; }
|
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 catalog is live here, so the profile list is the ten shipped themes, not
|
||||||
# the three-entry pre-load fallback.
|
# the three-entry pre-load fallback. The catalog arrives through an async
|
||||||
jq -e '.active.id == "moon" and (.profiles | length) == 10' <<<"$(status)" >/dev/null
|
# 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 ───────────────
|
# ── A scheme flip lands on the remembered theme for that side ───────────────
|
||||||
# This is the whole point of themeDark/themeLight. Before them, flipping to
|
# 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'
|
|| fail 'starting a video does not stop hyprpaper, so the two race for the layer'
|
||||||
rg -Fq '["systemctl", "--user", "start", "hyprpaper.service"]' "$service" \
|
rg -Fq '["systemctl", "--user", "start", "hyprpaper.service"]' "$service" \
|
||||||
|| fail 'stopping a video does not bring hyprpaper back'
|
|| 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" \
|
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
|
# 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.
|
# only fail validation, and gives hyprpaper a beat to come back on the way out.
|
||||||
|
|||||||
@@ -61,7 +61,20 @@ Same conventions as the `/ticket` skill:
|
|||||||
and offer to draft a questionnaire with `/to-questionnaire` so it can go to that
|
and offer to draft a questionnaire with `/to-questionnaire` so it can go to that
|
||||||
person in one pass. If Gib would rather ship the story without waiting, the question
|
person in one pass. If Gib would rather ship the story without waiting, the question
|
||||||
becomes a numbered Note naming its owner.
|
becomes a numbered Note naming its owner.
|
||||||
|
Where the scope of the story itself is unsettled, meaning the epic docs, the
|
||||||
|
deliverables, and the code do not already say what this ticket covers, one question
|
||||||
|
is not enough and drafting anyway is how invented scope gets in. Call the Skill tool
|
||||||
|
with "grilling" and work its frontier with Gib until the scope is agreed, then draft.
|
||||||
|
This is the exception rather than the default. Most stories have their scope already
|
||||||
|
worked out in the epic docs, and those go straight to step 4.
|
||||||
4. **Draft the content** in the matching format below, in markdown first.
|
4. **Draft the content** in the matching format below, in markdown first.
|
||||||
|
Then call the Skill tool with "unslop" and apply its pattern detection to the
|
||||||
|
draft: puffery, superficial -ing phrases, "not just X but Y", vague attributions,
|
||||||
|
rule of three padding, alongside the punctuation rules above. Skip its "Adding soul"
|
||||||
|
section entirely. The house formats are rigid templates and an AC table does not
|
||||||
|
want voice, it wants to read as though a person wrote it plainly. Conrad reads these,
|
||||||
|
and prose that reads as machine generated costs the ticket its credibility before
|
||||||
|
anyone weighs what it actually says.
|
||||||
5. **Overwrite guardrail.** If the ticket already has a non-trivial description
|
5. **Overwrite guardrail.** If the ticket already has a non-trivial description
|
||||||
(anything beyond a placeholder), show what is there and confirm before
|
(anything beyond a placeholder), show what is there and confirm before
|
||||||
replacing it. Empty or placeholder tickets are filled without asking.
|
replacing it. Empty or placeholder tickets are filled without asking.
|
||||||
|
|||||||
@@ -56,10 +56,11 @@ step 5.
|
|||||||
7. Choose the Story Risk Level by uncertainty, not size: LOW is pattern following work any dev can do, MEDIUM has some novel modeling or ambiguity, HIGH is a complex subsystem for a lead, CRITICAL is rare and means top devs collaborating. State the level and one sentence of why in the draft.
|
7. Choose the Story Risk Level by uncertainty, not size: LOW is pattern following work any dev can do, MEDIUM has some novel modeling or ambiguity, HIGH is a complex subsystem for a lead, CRITICAL is rare and means top devs collaborating. State the level and one sentence of why in the draft.
|
||||||
8. List dependencies for the apply phase, derived from the Relationships section: the story's own Requires notes cross checked against the epic's story map, expressed as intended Blocks links (blocker first). Note which links already exist in Jira.
|
8. List dependencies for the apply phase, derived from the Relationships section: the story's own Requires notes cross checked against the epic's story map, expressed as intended Blocks links (blocker first). Note which links already exist in Jira.
|
||||||
9. Draft the testing rows: Risk Mitigation rows (Risk Summary, Risk Description, Priority, Likelihood, Mitigation Strategy, empty Mitigation Proof) and Test Cases rows (Summary, Steps, Expected Results, empty Working Feature Proof, Notes). Where a mitigation strategy is an automated test, mark it as a proposed subtask with its type, coverage, and estimate. Keep rows to the ones that matter, three to six of each, not padding.
|
9. Draft the testing rows: Risk Mitigation rows (Risk Summary, Risk Description, Priority, Likelihood, Mitigation Strategy, empty Mitigation Proof) and Test Cases rows (Summary, Steps, Expected Results, empty Working Feature Proof, Notes). Where a mitigation strategy is an automated test, mark it as a proposed subtask with its type, coverage, and estimate. Keep rows to the ones that matter, three to six of each, not padding.
|
||||||
|
A proposed subtask is a promise about what gets tested, so name the behavior the test must demonstrate rather than the file it will live in. The `ticket` skill treats these subtasks as its test first cases in Phase 2, so a vague subtask becomes a vague test.
|
||||||
10. Assess the LaunchDarkly flag situation for the epic and note it in the draft's handoff section: which flag the epic needs or has, and that creation and the Releases field connection are manual steps.
|
10. Assess the LaunchDarkly flag situation for the epic and note it in the draft's handoff section: which flag the epic needs or has, and that creation and the Releases field connection are manual steps.
|
||||||
11. Run the checks:
|
11. Run the checks:
|
||||||
- Path check: extract every repo path mentioned in `review.md` and verify each exists on disk, or is marked (new). Fix or mark every miss.
|
- Path check: extract every repo path mentioned in `review.md` and verify each exists on disk, or is marked (new). Fix or mark every miss.
|
||||||
- Punctuation sweep.
|
- Prose sweep: call the Skill tool with "unslop" and apply its pattern detection to `review.md`, meaning puffery, superficial -ing phrases, "not just X but Y", vague attributions, and rule of three padding, alongside the punctuation rules above. Skip its "Adding soul" section, the voice here is the Lead writing to the implementing developer, not a blog. Henry reads every one of these, and a review that reads as machine generated loses its credibility before its content is weighed.
|
||||||
- Anatomy check against the section list below.
|
- Anatomy check against the section list below.
|
||||||
- Confirm no Jira write has happened.
|
- Confirm no Jira write has happened.
|
||||||
12. Stop. Hand the user the draft with the estimate, risk level, dependency list, and any split recommendation surfaced in the summary, and wait for their review. Do not apply in the same run unless the user has already told you to.
|
12. Stop. Hand the user the draft with the estimate, risk level, dependency list, and any split recommendation surfaced in the summary, and wait for their review. Do not apply in the same run unless the user has already told you to.
|
||||||
|
|||||||
@@ -481,6 +481,19 @@ place from the start.
|
|||||||
reality (codebase, Jira, staging, and prod/CI env with permission) rather than
|
reality (codebase, Jira, staging, and prod/CI env with permission) rather than
|
||||||
taking the ticket's description at face value — this is what makes the plan
|
taking the ticket's description at face value — this is what makes the plan
|
||||||
trustworthy instead of just plausible-sounding.
|
trustworthy instead of just plausible-sounding.
|
||||||
|
|
||||||
|
Then classify the ticket before drafting anything, and say the classification out
|
||||||
|
loud so Gib can override it. Borrowing the `grilling` skill's paths: **bounded** is
|
||||||
|
a well-scoped change to a flow that already exists in this repo, and
|
||||||
|
**architectural** is a new subsystem, a schema change other stories depend on, or
|
||||||
|
anything that alters an interface neighbouring work relies on. Bounded is the common
|
||||||
|
case and goes straight to step 11. For architectural, call the Skill tool with
|
||||||
|
"grilling" and resolve its frontier with Gib BEFORE writing `plan.md` — those
|
||||||
|
questions get answered in conversation, not deferred into the plan's Open questions
|
||||||
|
section, because an architectural plan built on a wrong assumption is the expensive
|
||||||
|
one to discover in Phase 2. Bounded measures the repo, not your familiarity: if the
|
||||||
|
flow being changed isn't already there to read, it isn't bounded. In doubt, take the
|
||||||
|
heavier path.
|
||||||
11. Write `plan.md` in `<target-dir>/plan.md` (a sibling of `resources/`, not inside
|
11. Write `plan.md` in `<target-dir>/plan.md` (a sibling of `resources/`, not inside
|
||||||
it). Structure:
|
it). Structure:
|
||||||
```
|
```
|
||||||
@@ -524,6 +537,21 @@ place from the start.
|
|||||||
column of a ticket test case as part of the case, not as commentary: a note like
|
column of a ticket test case as part of the case, not as commentary: a note like
|
||||||
"confirm the certificate matches the emailed version" is its own thing to prove.
|
"confirm the certificate matches the emailed version" is its own thing to prove.
|
||||||
|
|
||||||
|
**Where the shape is in question, borrow the vocabulary.** When the plan has to
|
||||||
|
decide how deep a module should be, where a seam belongs, or what an interface
|
||||||
|
exposes — not merely which file to edit — call the Skill tool with "codebase-design"
|
||||||
|
and use its terms in the Approach section. `review-ticket` already does this when it
|
||||||
|
writes the dev review; this is the same judgment made again, with code about to be
|
||||||
|
written for real.
|
||||||
|
|
||||||
|
**The Test plan is a design decision, not a formality.** Beyond the cases
|
||||||
|
`ticket.md` already captured, call the Skill tool with "tdd" to decide which
|
||||||
|
additional tests are worth writing, where the seam under test goes, and whether a
|
||||||
|
mock is warranted. That judgment is what keeps this section focused instead of
|
||||||
|
padded: a test plan listing one case per changed file is slop, and three tests that
|
||||||
|
can actually fail beat twelve that can't. If the ticket carries automated-test
|
||||||
|
subtasks from its dev review, name them here as the tests Phase 2 writes first.
|
||||||
|
|
||||||
**Wide refactors are the exception to one-commit steps.** A wide refactor is a single
|
**Wide refactors are the exception to one-commit steps.** A wide refactor is a single
|
||||||
mechanical change whose blast radius fans across the codebase (renaming a column,
|
mechanical change whose blast radius fans across the codebase (renaming a column,
|
||||||
retyping a shared symbol), so one edit breaks thousands of call sites at once and no
|
retyping a shared symbol), so one edit breaks thousands of call sites at once and no
|
||||||
@@ -555,6 +583,16 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
|||||||
naturally splits into independent units). Commit messages are short and imperative,
|
naturally splits into independent units). Commit messages are short and imperative,
|
||||||
describing what changed, following House style above.
|
describing what changed, following House style above.
|
||||||
|
|
||||||
|
**Write the test first for steps that carry real logic**, meaning a router
|
||||||
|
procedure, a derivation, a permission or authorization check, a migration, or a
|
||||||
|
state machine. For those, call the Skill tool with "tdd" and get a failing test
|
||||||
|
before the implementation. A step covered by an automated-test subtask from the
|
||||||
|
ticket's dev review is always one of these, and that subtask names the behavior the
|
||||||
|
test has to demonstrate. Do NOT do this for UI wiring, a grid column, a copy change,
|
||||||
|
or a styling fix — a test-first cycle there produces exactly the smoke-test padding
|
||||||
|
House style rejects. When unsure whether a step qualifies, ask whether the test could
|
||||||
|
ever fail for a reason worth knowing about. If it couldn't, skip it.
|
||||||
|
|
||||||
If a step fails in a way `plan.md` didn't predict, and the cause isn't obvious within
|
If a step fails in a way `plan.md` didn't predict, and the cause isn't obvious within
|
||||||
a couple of minutes, call the Skill tool with "diagnosing-bugs" rather than trying
|
a couple of minutes, call the Skill tool with "diagnosing-bugs" rather than trying
|
||||||
fixes to see what sticks. Note anything it turns up that changes the plan.
|
fixes to see what sticks. Note anything it turns up that changes the plan.
|
||||||
@@ -703,6 +741,14 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
|||||||
tight bullet. If it will not compress to a bullet, it is Jira content, not MR
|
tight bullet. If it will not compress to a bullet, it is Jira content, not MR
|
||||||
content. A reviewer should be able to read the whole file in about a minute; if it
|
content. A reviewer should be able to read the whole file in about a minute; if it
|
||||||
has grown past roughly 120 lines including the handoff, it has drifted.
|
has grown past roughly 120 lines including the handoff, it has drifted.
|
||||||
|
|
||||||
|
Before saving, call the Skill tool with "unslop" and apply its pattern detection to
|
||||||
|
the Summary and Additional Notes ONLY: puffery, superficial -ing phrases, "not just
|
||||||
|
X but Y", vague attributions, rule-of-three padding. The pasted handoff is generated
|
||||||
|
output and is never edited, per the rule above. Skip unslop's "Adding soul" section
|
||||||
|
too — an MR description wants plain and factual, not voice. Henry reads every one of
|
||||||
|
these, and prose that reads as machine-generated costs the MR its credibility before
|
||||||
|
anyone looks at the diff.
|
||||||
9. Fill the Jira ticket fields directly, by issue type. Rich text fields are ADF:
|
9. Fill the Jira ticket fields directly, by issue type. Rich text fields are ADF:
|
||||||
render markdown with `python3 ~/.agents/skills/review-ticket/scripts/review2adf.py
|
render markdown with `python3 ~/.agents/skills/review-ticket/scripts/review2adf.py
|
||||||
render <file.md>` and PUT via `{"fields": {...}}` to `/rest/api/3/issue/<KEY>`.
|
render <file.md>` and PUT via `{"fields": {...}}` to `/rest/api/3/issue/<KEY>`.
|
||||||
@@ -711,6 +757,12 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved
|
|||||||
field-to-type map below is verified against the project's edit screens, don't PUT
|
field-to-type map below is verified against the project's edit screens, don't PUT
|
||||||
a field to a type that doesn't carry it.
|
a field to a type that doesn't carry it.
|
||||||
|
|
||||||
|
**Same prose bar as `mr.md`.** Every field authored here is read by the Lead and the
|
||||||
|
PM. Run the `unslop` skill's pattern detection over the markdown before rendering it
|
||||||
|
to ADF, skipping its "Adding soul" section — Jira fields want plain, factual, and
|
||||||
|
specific. This does not apply to proof cells, which are references to artifacts and
|
||||||
|
test names rather than prose.
|
||||||
|
|
||||||
**Proof first.** Before filling any proof column, capture working feature proof
|
**Proof first.** Before filling any proof column, capture working feature proof
|
||||||
yourself wherever possible: run the app (`run` skill) and screenshot the real
|
yourself wherever possible: run the app (`run` skill) and screenshot the real
|
||||||
feature with browser automation, or capture test output for behavior with no UI.
|
feature with browser automation, or capture test output for behavior with no UI.
|
||||||
|
|||||||
Reference in New Issue
Block a user