10 Commits
24 changed files with 1384 additions and 141 deletions
@@ -467,7 +467,7 @@ Singleton {
hypr: { path: ["input", "repeat_rate"], option: "input:repeat_rate", readAs: "int" }
},
{
key: "followMouse", type: "enum", def: 1, group: "input",
key: "followMouse", type: "enum", def: 1, group: "pointer",
label: "Pointer focus",
detail: "What moving the pointer does to which window is focused",
// These labels were wrong, and wrong in the worst way: value 1 was
@@ -495,7 +495,7 @@ Singleton {
},
{
key: "pointerSensitivity", type: "real", def: 0.0, min: -1.0, max: 1.0, step: 0.05,
group: "input",
group: "pointer",
label: "Pointer speed",
detail: "Zero is flat, unaccelerated response",
hypr: { path: ["input", "sensitivity"], option: "input:sensitivity", readAs: "float" }
@@ -0,0 +1,81 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.modules.settings
import qs.services
ShellRoot {
DisplayIdentify {}
QtObject {
id: fixtureService
property var monitors: [
{
name: "DP-2", description: "Primary display", width: 4500, height: 3000,
refreshRate: 60, mode: "[email protected]", scale: 1.5,
transform: 0, x: 0, y: 0, primary: true
},
{
name: "HDMI-A-1", description: "Second display", width: 2560, height: 1440,
refreshRate: 60, mode: "[email protected]", scale: 1,
transform: 0, x: 3000, y: 0, primary: false
}
]
property var applied: []
function currentLayout(): var {
return monitors.map(record => Object.assign({}, record));
}
function applyLayout(layout: var): bool {
applied = layout.map(record => Object.assign({}, record));
return true;
}
}
DisplayArrangement {
id: arrangement
width: 800
displayService: fixtureService
selectedOutput: "HDMI-A-1"
}
IpcHandler {
target: "display-arrangement-test"
function status(width: int): string {
arrangement.width = width;
arrangement.resetDraft();
return JSON.stringify(arrangement.canvasSnapshot());
}
function dragFixture(): string {
arrangement.resetDraft();
arrangement.setDraftPosition("HDMI-A-1", 3016, 0, true);
arrangement.applyDraft();
return JSON.stringify(fixtureService.applied);
}
function keyboardFixture(): string {
arrangement.resetDraft();
arrangement.nudge("HDMI-A-1", -10, 0);
const afterArrow = arrangement.draftLayout.find(record => record.name === "HDMI-A-1").x;
arrangement.nudge("HDMI-A-1", -100, 0);
const afterShiftArrow = arrangement.draftLayout.find(record => record.name === "HDMI-A-1").x;
return JSON.stringify({ afterArrow, afterShiftArrow });
}
function primaryFixture(): string {
arrangement.resetDraft();
arrangement.makePrimary("HDMI-A-1");
return JSON.stringify(fixtureService.applied.map(record => ({
name: record.name, x: record.x, y: record.y, primary: record.primary
})));
}
function identify(): void { Displays.identify(); }
function identifying(): bool { return Displays.identifying; }
}
}
@@ -39,6 +39,49 @@ ShellRoot {
return Displays.apply(monitor.name, mode, scale, monitor.transform);
}
function transactionStatus(): string {
return JSON.stringify({
layout: Displays.currentLayout(),
pending: Displays.pendingRequestedLayout,
previous: Displays.pendingPreviousLayout,
reverting: Displays.revertExpectedLayout,
awaiting: Displays.awaitingConfirmation,
canConfirm: Displays.canConfirm,
busy: Displays.busy,
generation: Displays.operationGeneration,
revertGeneration: Displays.revertGeneration,
lastError: Displays.lastError
});
}
function applyLayoutFixture(secondX: int, secondY: int): bool {
const layout = Displays.currentLayout();
if (layout.length !== 2) return false;
layout[0].x = 0;
layout[0].y = 0;
layout[0].primary = true;
layout[1].x = secondX;
layout[1].y = secondY;
layout[1].primary = false;
return Displays.applyLayout(layout);
}
function makePrimaryFixture(output: string): bool {
return Displays.makePrimary(output);
}
function injectReadback(text: string, generation: int): void {
Displays.parse(text, generation);
}
function expireApplyVerification(): void {
Displays.verificationTimedOut();
}
function expireRevertVerification(): void {
Displays.revertVerificationTimedOut();
}
function refreshIdentityFixture(): string {
const modes = Displays.normaliseModes([
"[email protected]",
@@ -0,0 +1,318 @@
import QtQuick
import qs.config
import qs.widgets
import "../../services/DisplayLayout.js" as DisplayLayout
Item {
id: root
required property var displayService
property string selectedOutput: ""
property var draftLayout: []
property bool interactionEnabled: true
signal selectionRequested(string output)
implicitHeight: content.implicitHeight
readonly property var canvasData: DisplayLayout.canvasRects(
root.draftLayout, canvas.width, canvas.height, 18)
function copied(layout): var {
return (layout || []).map(record => Object.assign({}, record));
}
function resetDraft(): void {
root.draftLayout = root.copied(root.displayService.currentLayout());
}
function setDraftPosition(output: string, x: real, y: real, snapToEdges: bool): bool {
const next = root.copied(root.draftLayout);
const record = next.find(candidate => candidate.name === output);
if (!record)
return false;
record.x = Math.round(x);
record.y = Math.round(y);
root.draftLayout = snapToEdges ? DisplayLayout.snap(next, output, 16) : next;
return true;
}
function nudge(output: string, dx: int, dy: int): bool {
const record = root.draftLayout.find(candidate => candidate.name === output);
return !!record && root.setDraftPosition(output, record.x + dx, record.y + dy, false);
}
function applyDraft(): bool {
return root.displayService.applyLayout(root.copied(root.draftLayout));
}
function makePrimary(output: string): bool {
const next = root.copied(root.draftLayout);
if (!next.some(record => record.name === output))
return false;
for (const record of next)
record.primary = record.name === output;
root.draftLayout = DisplayLayout.normalize(next);
return root.applyDraft();
}
function canvasSnapshot(): var {
return {
bounds: root.canvasData.bounds,
scale: root.canvasData.scale,
rects: root.canvasData.rects.map(record => Object.assign({}, record))
};
}
Component.onCompleted: root.resetDraft()
Connections {
target: root.displayService
ignoreUnknownSignals: true
function onMonitorsChanged(): void {
if (!root.displayService.awaitingConfirmation)
root.resetDraft();
}
}
Column {
id: content
width: parent.width
spacing: 11
Rectangle {
id: canvas
width: parent.width
height: root.width >= 620 ? 232 : 190
radius: Theme.cardRadius
color: Theme.alpha(Theme.bgDark, 0.76)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
clip: true
// A restrained coordinate field makes the topology feel like a
// precision instrument without turning it into a technical graph.
Repeater {
model: 4
Rectangle {
required property int index
y: (index + 1) * canvas.height / 5
width: canvas.width
height: 1
color: Theme.alpha(Theme.fg, 0.025)
}
}
Repeater {
model: root.canvasData.rects
Rectangle {
id: tile
required property var modelData
readonly property bool selected: root.selectedOutput === modelData.name
readonly property var draft: root.draftLayout.find(
record => record.name === modelData.name)
x: modelData.x
y: modelData.y
width: Math.max(64, modelData.width)
height: Math.max(48, modelData.height)
radius: 11
color: tile.selected
? Theme.alpha(Theme.bgHighlight, 0.92)
: Theme.alpha(Theme.bgPanel, hover.hovered ? 0.94 : 0.78)
border.width: tile.selected || activeFocus ? 2 : 1
border.color: activeFocus
? Theme.accentSecondary
: (tile.selected ? Theme.accent : Theme.alpha(Theme.fg, 0.14))
opacity: root.interactionEnabled ? 1 : 0.5
activeFocusOnTab: root.interactionEnabled
Accessible.role: Accessible.Button
Accessible.name: "Move " + tile.modelData.name
Accessible.description: tile.draft && tile.draft.primary
? "Primary display. Drag or use the arrow keys to move it."
: "Drag or use the arrow keys to move this display."
Rectangle {
anchors.fill: parent
anchors.margins: 2
radius: parent.radius - 2
visible: tile.selected
opacity: 0.24
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.accent }
GradientStop { position: 1; color: Theme.accentSecondary }
}
}
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: 11
spacing: 2
Text {
width: parent.width
text: tile.modelData.name
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
width: parent.width
text: tile.draft
? `${Math.round(tile.draft.width / tile.draft.scale)} × ${Math.round(tile.draft.height / tile.draft.scale)}`
: ""
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
Rectangle {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 7
width: primaryText.implicitWidth + 12
height: 20
radius: Theme.pillRadius
visible: tile.draft && tile.draft.primary
color: Theme.alpha(Theme.accent, 0.2)
Text {
id: primaryText
anchors.centerIn: parent
text: "Primary"
color: Theme.accentAlt
font.family: Theme.fontFamily
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
font.weight: Font.DemiBold
}
}
HoverHandler {
id: hover
enabled: root.interactionEnabled
cursorShape: Qt.OpenHandCursor
}
TapHandler {
enabled: root.interactionEnabled
onTapped: {
root.selectionRequested(tile.modelData.name);
tile.forceActiveFocus();
}
}
DragHandler {
id: drag
target: null
enabled: root.interactionEnabled
property real initialX: 0
property real initialY: 0
property bool moved: false
onActiveChanged: {
if (active) {
const record = root.draftLayout.find(
candidate => candidate.name === tile.modelData.name);
initialX = record ? record.x : 0;
initialY = record ? record.y : 0;
moved = false;
root.selectionRequested(tile.modelData.name);
tile.forceActiveFocus();
} else if (moved) {
const record = root.draftLayout.find(
candidate => candidate.name === tile.modelData.name);
if (record) {
root.setDraftPosition(tile.modelData.name, record.x, record.y, true);
root.applyDraft();
}
}
}
onTranslationChanged: {
if (!active || root.canvasData.scale <= 0)
return;
moved = true;
root.setDraftPosition(
tile.modelData.name,
initialX + translation.x / root.canvasData.scale,
initialY + translation.y / root.canvasData.scale,
false);
}
}
Keys.onPressed: event => {
if (!root.interactionEnabled)
return;
const step = event.modifiers & Qt.ShiftModifier ? 100 : 10;
let handled = true;
if (event.key === Qt.Key_Left)
root.nudge(tile.modelData.name, -step, 0);
else if (event.key === Qt.Key_Right)
root.nudge(tile.modelData.name, step, 0);
else if (event.key === Qt.Key_Up)
root.nudge(tile.modelData.name, 0, -step);
else if (event.key === Qt.Key_Down)
root.nudge(tile.modelData.name, 0, step);
else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter)
root.applyDraft();
else if (event.key === Qt.Key_Escape)
root.resetDraft();
else
handled = false;
event.accepted = handled;
}
}
}
}
Row {
width: parent.width
spacing: 8
Text {
width: Math.max(0, parent.width - identifyButton.width
- primaryButton.width - applyButton.width - 24)
anchors.verticalCenter: parent.verticalCenter
text: root.width >= 600
? "Drag to arrange · arrows move 10 px · Shift moves 100 px"
: "Drag or use the arrow keys"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
SettingsButton {
id: identifyButton
text: "Identify"
enabled: root.interactionEnabled
onClicked: root.displayService.identify()
}
SettingsButton {
id: primaryButton
text: "Make primary"
enabled: root.interactionEnabled && root.selectedOutput !== ""
&& !root.draftLayout.find(record =>
record.name === root.selectedOutput)?.primary
onClicked: root.makePrimary(root.selectedOutput)
}
SettingsButton {
id: applyButton
text: "Apply"
enabled: root.interactionEnabled
onClicked: root.applyDraft()
}
}
}
}
@@ -0,0 +1,85 @@
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.services
import qs.widgets
Variants {
model: Quickshell.screens
PanelWindow {
id: win
property var modelData: null
readonly property string connector: win.modelData?.name ?? "Display"
readonly property int number: Math.max(1,
Displays.monitors.findIndex(monitor => monitor.name === win.connector) + 1)
readonly property string description: Displays.monitorNamed(win.connector)?.description ?? "Connected display"
screen: win.modelData
visible: Displays.identifying
implicitWidth: 260
implicitHeight: 172
color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore
mask: Region {}
WlrLayershell.namespace: "qs-display-identify"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
Rectangle {
anchors.fill: parent
radius: Theme.popoverRadius
color: Theme.alpha(Theme.bgPopover, 0.96)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.14)
PrismEdge {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
inset: parent.radius
}
Column {
anchors.centerIn: parent
width: parent.width - 32
spacing: 4
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: String(win.number)
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 68
font.weight: Font.DemiBold
}
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: win.connector
color: Theme.accentAlt
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: win.description
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
}
}
@@ -106,6 +106,20 @@ SettingsPage {
}
}
SettingsCard {
visible: Displays.monitors.length > 1
title: "Arrange displays"
subtitle: "Drag the screens into place. The primary display anchors the desktop at 0,0."
DisplayArrangement {
width: parent.width
displayService: Displays
selectedOutput: root.selectedOutput
interactionEnabled: !Displays.awaitingConfirmation && !Displays.busy
onSelectionRequested: output => root.selectedOutput = output
}
}
SettingsCard {
visible: Displays.monitors.length > 1
title: "Connected display"
@@ -83,6 +83,12 @@ clock, date, user name, and password-field presentation. **Power** owns when
the session locks, while **Privacy** keeps only the established timing mirrors
above. Visual controls must not be copied onto either page.
**Displays** is the sole owner of mode, scale, rotation, arrangement, and primary role.
Those values form one safety transaction: every connected output
is applied, verified, confirmed, or restored together. Other pages may link to
Displays, but must never expose a second geometry control or persist a partial
layout.
Mirrors must remain the same schema-backed control, never a second preference
or a copied default. Additions to this table require a concrete discoverability
reason and an update to `tests/quickshell/settings-ownership-contract.sh`.
@@ -42,6 +42,8 @@ DockAppPicker 1.0 DockAppPicker.qml
ShortcutCapture 1.0 ShortcutCapture.qml
ChoiceGrid 1.0 ChoiceGrid.qml
DisplayModePicker 1.0 DisplayModePicker.qml
DisplayArrangement 1.0 DisplayArrangement.qml
DisplayIdentify 1.0 DisplayIdentify.qml
WifiPanel 1.0 WifiPanel.qml
BluetoothPanel 1.0 BluetoothPanel.qml
PasswordField 1.0 PasswordField.qml
+19 -9
View File
@@ -118,15 +118,25 @@ load_preferences() {
esac
if [[ "$color_scheme" == light ]]; then
background_color='rgba(245, 246, 250, 1.0)'
foreground_color='rgba(55, 63, 87, 1.0)'
dim_color='rgba(111, 119, 151, 1.0)'
field_color='rgba(220, 223, 232, 0.88)'
background_color='rgba(225, 226, 231, 1.0)'
foreground_color='rgba(55, 96, 191, 1.0)'
dim_color='rgba(97, 114, 176, 1.0)'
accent_color='rgba(46, 125, 233, 1.0)'
accent_ring_color='rgba(46, 125, 233, 0.9)'
error_color='rgba(245, 42, 101, 1.0)'
field_color='rgba(208, 213, 227, 0.85)'
dim_hex='6172b0'
error_hex='f52a65'
else
background_color='rgba(34, 36, 54, 1.0)'
foreground_color='rgba(200, 211, 245, 1.0)'
dim_color='rgba(130, 139, 184, 1.0)'
accent_color='rgba(130, 170, 255, 1.0)'
accent_ring_color='rgba(130, 170, 255, 0.9)'
error_color='rgba(255, 117, 127, 1.0)'
field_color='rgba(46, 47, 61, 0.85)'
dim_hex='828bb8'
error_hex='ff757f'
fi
}
@@ -239,16 +249,16 @@ emit_config() {
printf ' valign = center\n'
printf ' outline_thickness = 2\n'
printf ' rounding = 26\n'
printf ' outer_color = rgba(130, 170, 255, 0.9)\n'
printf ' outer_color = %s\n' "$accent_ring_color"
printf ' inner_color = %s\n' "$field_color"
printf ' font_color = %s\n' "$foreground_color"
printf ' check_color = rgba(130, 170, 255, 1.0)\n'
printf ' fail_color = rgba(255, 117, 127, 1.0)\n'
printf ' check_color = %s\n' "$accent_color"
printf ' fail_color = %s\n' "$error_color"
printf ' dots_size = 0.25\n'
printf ' dots_spacing = 0.3\n'
printf ' dots_center = true\n'
printf ' placeholder_text = <span foreground="##828bb8"><i>Password</i></span>\n'
printf ' fail_text = <span foreground="##ff757f"><i>$FAIL ($ATTEMPTS)</i></span>\n'
printf ' placeholder_text = <span foreground="##%s"><i>Password</i></span>\n' "$dim_hex"
printf ' fail_text = <span foreground="##%s"><i>$FAIL ($ATTEMPTS)</i></span>\n' "$error_hex"
printf ' fade_on_empty = %s\n' "$fade_on_empty"
printf ' hide_input = false\n'
printf '}\n\n'
+172 -79
View File
@@ -21,6 +21,7 @@ import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import "DisplayLayout.js" as DisplayLayout
Singleton {
id: root
@@ -31,25 +32,25 @@ Singleton {
property string lastError: ""
// Set while a change is applied but not yet confirmed.
property string pendingOutput: ""
property var pendingPrevious: null
property var pendingRequested: null
property var pendingPreviousLayout: null
property var pendingRequestedLayout: null
property bool pendingVerified: false
property bool revertQueued: false
property var revertExpected: null
property var revertExpectedLayout: null
property string revertReason: ""
property bool revertVerificationActive: false
property int operationGeneration: 0
property int revertGeneration: -1
property bool externalChangeBlocked: false
property int secondsLeft: 0
property bool identifying: false
readonly property bool awaitingConfirmation: root.pendingOutput !== ""
readonly property bool awaitingConfirmation: root.pendingRequestedLayout !== null
readonly property bool canConfirm: root.awaitingConfirmation
&& root.pendingVerified
&& !root.busy
readonly property bool busy: query.running || applyRun.running || revertRun.running
|| root.revertExpected !== null
|| root.revertExpectedLayout !== null
readonly property int confirmSeconds: 15
@@ -123,6 +124,11 @@ Singleton {
return false;
}
function identify(): void {
root.identifying = true;
identifyTimer.restart();
}
function parse(text: string, generation: int): void {
try {
const raw = JSON.parse(text);
@@ -163,25 +169,26 @@ Singleton {
modes: modes
};
});
if (root.awaitingConfirmation && root.pendingRequested
&& root.matchesRequest(root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
if (root.awaitingConfirmation && root.pendingRequestedLayout
&& generation === root.operationGeneration
&& root.matchesLayout(root.monitors, root.pendingRequestedLayout)) {
root.pendingVerified = true;
verifyTimer.stop();
root.lastError = "";
} else if (root.revertVerificationActive
&& generation === root.revertGeneration
&& root.revertExpected
&& root.matchesRequest(root.monitorNamed(root.revertExpected.output), root.revertExpected)) {
&& root.revertExpectedLayout
&& root.matchesLayout(root.monitors, root.revertExpectedLayout)) {
revertVerifyTimer.stop();
root.revertVerificationActive = false;
root.revertGeneration = -1;
root.revertExpected = null;
root.revertExpectedLayout = null;
if (root.revertReason === "")
root.lastError = "";
else
root.lastError = root.revertReason;
root.revertReason = "";
} else if (!root.awaitingConfirmation && !root.revertExpected && (
} else if (!root.awaitingConfirmation && !root.revertExpectedLayout && (
root.lastError === "Could not read the connected displays."
|| root.lastError === "The display list could not be read.")) {
root.lastError = "";
@@ -274,16 +281,41 @@ Singleton {
choices[0]);
}
function matchesRequest(monitor: var, requested: var): bool {
if (!monitor || !requested || monitor.name !== requested.output)
function currentLayout(): var {
return root.monitors.map(monitor => ({
name: monitor.name,
width: monitor.width,
height: monitor.height,
refreshRate: monitor.refreshRate,
mode: monitor.mode,
scale: monitor.scale,
transform: monitor.transform,
x: monitor.x,
y: monitor.y,
primary: monitor.primary === true
}));
}
function matchesLayout(monitors: var, layout: var): bool {
if (!Array.isArray(monitors) || !Array.isArray(layout)
|| monitors.length !== layout.length)
return false;
const expected = Array.from(layout).sort((a, b) => a.name.localeCompare(b.name));
const actual = Array.from(monitors).sort((a, b) => a.name.localeCompare(b.name));
for (let index = 0; index < expected.length; index++) {
const requested = expected[index];
const monitor = actual[index];
const parts = root.modeParts(requested.mode);
return !!parts
&& monitor.width === parts.width
&& monitor.height === parts.height
&& Math.abs(monitor.refreshRate - parts.refresh) < 0.01
&& Math.abs(monitor.scale - requested.scale) < 0.001
&& monitor.transform === requested.transform;
if (!parts || monitor.name !== requested.name
|| monitor.width !== parts.width
|| monitor.height !== parts.height
|| Math.abs(monitor.refreshRate - parts.refresh) >= 0.01
|| Math.abs(monitor.scale - requested.scale) >= 0.001
|| monitor.transform !== requested.transform
|| monitor.x !== requested.x || monitor.y !== requested.y)
return false;
}
return true;
}
function modeIsCurrent(monitor: var, candidate: var): bool {
@@ -293,10 +325,48 @@ Singleton {
&& Math.abs(monitor.refreshRate - candidate.refresh) < 0.01;
}
// Applies immediately and starts the countdown. Nothing is stored yet: the
// settings file is only written by confirm().
function validRequestedLayout(layout: var): bool {
if (!DisplayLayout.validate(layout) || layout.length !== root.monitors.length)
return false;
const currentNames = root.monitors.map(monitor => monitor.name).sort();
const requestedNames = layout.map(record => record.name).sort();
if (JSON.stringify(currentNames) !== JSON.stringify(requestedNames))
return false;
return layout.every(record => {
const monitor = root.monitorNamed(record.name);
const parts = root.modeParts(record.mode);
return !!monitor && !!parts
&& record.width === parts.width && record.height === parts.height
&& monitor.modes.some(candidate => candidate.mode === record.mode)
&& root.isScaleClean(record.mode, record.scale)
&& root.transforms.some(candidate => candidate.value === record.transform);
});
}
// One-field controls remain callers of the complete-layout transaction.
// Their edit is cloned into the current layout so every output's position
// participates in apply, verification, and rollback.
function apply(output: string, mode: string, scale: real, transform: int): bool {
if (root.externalChangeBlocked) {
const layout = root.currentLayout();
const record = layout.find(candidate => candidate.name === output);
const parts = root.modeParts(mode);
if (!record || !parts) {
root.lastError = record ? "That display does not offer that mode." : "That display is not connected.";
return false;
}
record.mode = mode;
record.width = parts.width;
record.height = parts.height;
record.refreshRate = parts.refresh;
record.scale = scale;
record.transform = transform;
return root.applyLayout(layout);
}
// Applies immediately and starts the countdown. Nothing is stored yet: the
// complete connected layout is only written by confirm().
function applyLayout(layout: var, protectedOperation: bool): bool {
if (root.externalChangeBlocked && protectedOperation !== true) {
root.lastError = "Wait for Settings to finish restoring before changing a display.";
return false;
}
@@ -308,58 +378,53 @@ Singleton {
root.lastError = "Finish the current display change first.";
return false;
}
const monitor = root.monitorNamed(output);
if (!monitor) {
root.lastError = "That display is not connected.";
return false;
}
if (!monitor.modes.some(candidate => candidate.mode === mode)) {
root.lastError = "That display does not offer that mode.";
return false;
}
if (!root.isScaleClean(mode, scale)) {
root.lastError = "That scale does not divide this resolution cleanly.";
return false;
}
if (!root.transforms.some(candidate => candidate.value === transform)) {
root.lastError = "That rotation is not one Panama offers.";
const normalized = DisplayLayout.normalize(layout);
if (!root.validRequestedLayout(normalized)) {
root.lastError = "That complete display layout is not valid for the connected displays.";
return false;
}
root.pendingPrevious = {
output: output,
mode: monitor.mode,
scale: monitor.scale,
transform: monitor.transform
};
root.pendingPreviousLayout = root.currentLayout();
root.operationGeneration++;
root.pendingRequested = {
output: output,
mode: mode,
scale: scale,
transform: transform
};
root.pendingOutput = output;
root.pendingRequestedLayout = normalized;
root.pendingVerified = false;
root.revertQueued = false;
root.secondsLeft = root.confirmSeconds;
root.lastError = "";
countdown.restart();
root.push(output, mode, scale, transform);
root.pushLayout(normalized, applyRun);
return true;
}
function push(output: string, mode: string, scale: real, transform: int): void {
// Values are validated above and the output name comes from the
// compositor's own list, so nothing user-authored reaches the payload.
applyRun.exec(["hyprctl", "eval",
`hl.monitor({ output = "${output}", mode = "${mode}", scale = ${scale}, transform = ${transform} })`]);
// Settings restore holds the external-change lock while it proves a
// snapshot. This narrow entry point authorizes that one transaction while
// keeping every user-facing control blocked until restore settles.
function applyProtectedLayout(layout: var): bool {
return root.applyLayout(layout, true);
}
function makePrimary(output: string): bool {
const layout = root.currentLayout();
if (!layout.some(record => record.name === output)) {
root.lastError = "That display is not connected.";
return false;
}
for (const record of layout)
record.primary = record.name === output;
return root.applyLayout(DisplayLayout.normalize(layout));
}
function pushLayout(layout: var, runner: var): void {
const payload = layout.map(record =>
`hl.monitor({ output = "${record.name}", mode = "${record.mode}", position = "${record.x}x${record.y}", scale = ${record.scale}, transform = ${record.transform} })`
).join("; ");
runner.exec(["hyprctl", "eval", payload]);
}
function confirm(): bool {
if (!root.canConfirm || !root.matchesRequest(
root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
if (!root.canConfirm
|| !root.matchesLayout(root.monitors, root.pendingRequestedLayout)) {
if (root.awaitingConfirmation)
root.lastError = "Wait for the display to finish applying before keeping it.";
return false;
@@ -367,11 +432,16 @@ Singleton {
const stored = DesktopPreferences.get("displays");
const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {});
next[root.pendingOutput] = {
mode: root.pendingRequested.mode,
scale: root.pendingRequested.scale,
transform: root.pendingRequested.transform
for (const record of root.pendingRequestedLayout) {
next[record.name] = {
mode: record.mode,
scale: record.scale,
transform: record.transform,
x: record.x,
y: record.y,
primary: record.primary
};
}
if (!DesktopPreferences.set("displays", next)) {
root.lastError = "That display setting could not be saved. Revert it and try again.";
return false;
@@ -385,9 +455,8 @@ Singleton {
function clearPending(): void {
countdown.stop();
verifyTimer.stop();
root.pendingOutput = "";
root.pendingPrevious = null;
root.pendingRequested = null;
root.pendingPreviousLayout = null;
root.pendingRequestedLayout = null;
root.pendingVerified = false;
root.revertQueued = false;
root.secondsLeft = 0;
@@ -414,16 +483,25 @@ Singleton {
}
function performRevert(): void {
const previous = root.pendingPrevious;
const connected = {};
for (const monitor of root.monitors)
connected[monitor.name] = true;
const previous = (root.pendingPreviousLayout || [])
.filter(record => connected[record.name])
.map(record => Object.assign({}, record));
if (previous.length > 0 && !previous.some(record => record.primary)) {
const origin = previous.find(record => record.x === 0 && record.y === 0);
(origin || previous[0]).primary = true;
}
root.operationGeneration++;
root.revertGeneration = root.operationGeneration;
root.revertExpected = previous;
root.revertExpectedLayout = previous.length > 0 ? previous : null;
root.revertVerificationActive = false;
root.clearPending();
if (previous) {
revertRun.exec(["hyprctl", "eval",
`hl.monitor({ output = "${previous.output}", mode = "${previous.mode}", scale = ${previous.scale}, transform = ${previous.transform} })`]);
}
if (previous.length > 0)
root.pushLayout(previous, revertRun);
else
root.lastError = root.revertReason;
}
// Clears any stored override for an output so it returns to the value
@@ -442,6 +520,26 @@ Singleton {
return !!(stored && typeof stored === "object" && stored[output] !== undefined);
}
function verificationTimedOut(): void {
root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one.");
}
function revertVerificationTimedOut(): void {
revertVerifyTimer.stop();
root.revertVerificationActive = false;
root.revertGeneration = -1;
root.revertExpectedLayout = null;
root.revertReason = "";
root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually.";
}
Timer {
id: identifyTimer
interval: 3000
repeat: false
onTriggered: root.identifying = false
}
Timer {
id: verifyTimer
property int attempts: 0
@@ -451,7 +549,7 @@ Singleton {
onTriggered: {
ticks++;
if (ticks > 50) {
root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one.");
root.verificationTimedOut();
return;
}
if (root.refresh())
@@ -468,12 +566,7 @@ Singleton {
onTriggered: {
ticks++;
if (ticks > 50) {
stop();
root.revertVerificationActive = false;
root.revertGeneration = -1;
root.revertExpected = null;
root.revertReason = "";
root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually.";
root.revertVerificationTimedOut();
return;
}
if (root.refresh())
+115 -13
View File
@@ -41,7 +41,13 @@ Singleton {
property var readDisplays: function() { return DesktopPreferences.get("displays"); }
property var protectDisplays: function(value) { return DesktopPreferences.set("displays", value); }
property var displayBusy: function() { return Displays.busy || Displays.awaitingConfirmation; }
property var readLiveDisplayLayout: function() { return Displays.currentLayout(); }
property var applyDisplayLayout: function(layout) { return Displays.applyProtectedLayout(layout); }
property var displayCanConfirm: function() { return Displays.canConfirm; }
property var confirmDisplayLayout: function() { return Displays.confirm(); }
property var setDisplayBlocked: function(blocked) { Displays.externalChangeBlocked = blocked; }
property var applyIdle: function() { IdleLock.apply(); }
property var idleBusy: function() { return IdleLock.busy; }
property var applyCompositor: function() { SystemSettings.applyPersistedDisplayPolicy(); }
property var reloadKeybinds: function() { Keybinds.applyReload(); }
property var keybindsReloading: function() { return Keybinds.reloading; }
@@ -52,9 +58,11 @@ Singleton {
property var lockBusy: function() { return LockScreen.busy; }
property var reloadShell: function() { Quickshell.reload(false); }
property var protectedDisplays: ({})
property var protectedDisplayLayout: []
property var pendingRestoredLayout: null
readonly property bool busy: listQuery.running || actionRun.running
|| applyRestoredState.running || settleReload.running
|| settleDisplayRestore.running || applyRestoredState.running || settleReload.running
Process {
id: listQuery
@@ -89,18 +97,21 @@ Singleton {
if (actionRun.restoring) {
root.setDisplayBlocked(false);
root.protectedDisplays = ({});
root.protectedDisplayLayout = [];
}
return;
}
root.lastAction = actionRun.restoring ? "restored" : "saved";
if (actionRun.restoring) {
const homeReloaded = root.handleRestoreOutput(actionRun.outputText);
root.lastError = homeReloaded
? ""
: "Desktop settings were restored, but Home favourites could not be reloaded.";
if (!homeReloaded) {
const restoreAccepted = root.handleRestoreOutput(actionRun.outputText);
if (restoreAccepted)
root.lastError = "";
else if (root.lastError === "")
root.lastError = "Desktop settings were restored, but Home favourites could not be reloaded.";
if (!restoreAccepted) {
root.setDisplayBlocked(false);
root.protectedDisplays = ({});
root.protectedDisplayLayout = [];
}
} else
root.lastError = "";
@@ -108,6 +119,28 @@ Singleton {
}
}
Timer {
id: settleDisplayRestore
property int attempts: 0
interval: 100
repeat: true
onTriggered: {
attempts++;
if (root.displayCanConfirm()) {
stop();
if (!root.confirmDisplayLayout()) {
root.failDisplayRestore("The restored display layout could not be confirmed.");
return;
}
root.pendingRestoredLayout = null;
root.beginRestoredStateReplay();
} else if (!root.displayBusy() || attempts >= 180) {
stop();
root.failDisplayRestore("The restored display layout could not be verified.");
}
}
}
Timer {
id: applyRestoredState
interval: 80
@@ -116,10 +149,11 @@ Singleton {
// DesktopPreferences.reload() invalidates reactive shell bindings.
// These services also own state outside QML and need an explicit
// replay: compositor options, Lua-generated binds, and hyprpaper.
root.applyCompositor();
root.reloadKeybinds();
root.applyWallpaperPolicy();
root.applyIdle();
root.regenerateLock();
root.applyWallpaperPolicy();
root.reloadKeybinds();
root.applyCompositor();
settleReload.attempts = 0;
settleReload.restart();
@@ -136,11 +170,12 @@ Singleton {
// Let the current instances finish their external writes before a
// soft reload replaces them. The cap keeps a failed external tool
// from leaving restored Home state stale indefinitely.
if ((!root.keybindsReloading() && !root.systemBusy()
if ((!root.idleBusy() && !root.keybindsReloading() && !root.systemBusy()
&& !root.wallpaperBusy() && !root.lockBusy()) || attempts >= 30) {
stop();
root.setDisplayBlocked(false);
root.protectedDisplays = ({});
root.protectedDisplayLayout = [];
root.reloadShell();
}
}
@@ -179,11 +214,76 @@ Singleton {
if (!root.reloadHomeState(text))
return false;
root.reloadDesktop();
if (!root.protectDisplays(root.protectedDisplays))
return false;
applyRestoredState.restart();
const restoredLayout = root.layoutFromStoredDisplays(root.readDisplays());
if (restoredLayout === null || root.layoutsEqual(
restoredLayout, root.protectedDisplayLayout)) {
root.beginRestoredStateReplay();
return true;
}
root.pendingRestoredLayout = restoredLayout;
if (!root.applyDisplayLayout(restoredLayout))
return root.failDisplayRestore("The restored display layout was rejected.");
settleDisplayRestore.attempts = 0;
settleDisplayRestore.restart();
return true;
}
function beginRestoredStateReplay(): void {
applyRestoredState.restart();
}
function layoutFromStoredDisplays(stored: var): var {
if (!stored || typeof stored !== "object")
return null;
const current = root.readLiveDisplayLayout();
if (!Array.isArray(current) || current.length === 0)
return null;
const layout = [];
for (const live of current) {
const entry = stored[live.name];
const match = String(entry?.mode ?? "").match(
/^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/);
if (!entry || !match || !Number.isFinite(entry.scale) || entry.scale <= 0
|| !Number.isInteger(entry.transform)
|| entry.transform < 0 || entry.transform > 3
|| !Number.isInteger(entry.x) || !Number.isInteger(entry.y)
|| typeof entry.primary !== "boolean")
return null;
layout.push(Object.assign({}, live, {
width: Number(match[1]),
height: Number(match[2]),
refreshRate: Number(match[3]),
mode: entry.mode,
scale: entry.scale,
transform: entry.transform,
x: entry.x,
y: entry.y,
primary: entry.primary
}));
}
return layout.filter(record => record.primary).length === 1 ? layout : null;
}
function layoutsEqual(left: var, right: var): bool {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length)
return false;
const fields = ["name", "mode", "scale", "transform", "x", "y", "primary"];
const a = Array.from(left).sort((x, y) => x.name.localeCompare(y.name));
const b = Array.from(right).sort((x, y) => x.name.localeCompare(y.name));
return a.every((record, index) => fields.every(
field => record[field] === b[index][field]));
}
function failDisplayRestore(message: string): bool {
root.pendingRestoredLayout = null;
if (!root.protectDisplays(root.protectedDisplays))
message += " The original display preference also could not be restored.";
root.setDisplayBlocked(false);
root.protectedDisplays = ({});
root.protectedDisplayLayout = [];
root.lastError = message;
return false;
}
// Restore output carries the canonical Home state. Reconstructing through
// these methods keeps validation and persistence inside HomePreferences;
@@ -252,6 +352,8 @@ Singleton {
const currentDisplays = root.readDisplays();
root.protectedDisplays = JSON.parse(JSON.stringify(
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
root.protectedDisplayLayout = JSON.parse(JSON.stringify(
root.readLiveDisplayLayout() ?? []));
root.setDisplayBlocked(true);
actionRun.restoring = true;
actionRun.exec([root.helperPath, "restore", name]);
@@ -69,7 +69,10 @@ Singleton {
{ label: "Copy health report", detail: "Copy a redacted Panama doctor report", page: "services" },
{ label: "Lock screen background", detail: "Choose a blurred desktop, wallpaper, or solid colour", page: "appearance" },
{ label: "Password field", detail: "Choose whether the empty lock-screen field stays visible", page: "appearance" },
{ label: "Per-display wallpaper", detail: "Assign a different image to each connected display", page: "appearance" }
{ label: "Per-display wallpaper", detail: "Assign a different image to each connected display", page: "appearance" },
{ label: "Arrange displays", detail: "Drag connected displays into their physical positions", page: "displays" },
{ label: "Monitor position", detail: "Set where each display sits in the desktop", page: "displays" },
{ label: "Primary display", detail: "Choose the display that anchors the desktop", page: "displays" }
]
function pageFor(group: string): string {
@@ -509,16 +509,13 @@ Singleton {
return false;
}
const currentDisplays = root.readDisplays();
const protectedDisplays = JSON.parse(JSON.stringify(
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
root.setDisplayBlocked(true);
DesktopPreferences.resetDesktopDefaults();
if (!root.protectDisplays(protectedDisplays)) {
root.setDisplayBlocked(false);
root.lastError = "The current display setting could not be protected during reset.";
return false;
}
// Do not apply geometry during a reset: doing so would need the same
// visible confirmation transaction as the Displays page. Clearing the
// stored records is still important, though, so the next session uses
// Panama's shipped DP-2 placement and automatic placement elsewhere.
// Home accessories keep their own store (panama-home.json), so a reset
// that only cleared the schema store would silently leave a customised
@@ -15,7 +15,21 @@ ShellRoot {
property var homeFavorites: []
property bool displayOperationBusy: false
property bool displayBlocked: false
property var displayGeneration: ({ "DP-2": { mode: "4500x3000@60", scale: 1.5, transform: 0 } })
property bool displayApplyAccepted: true
property bool displayConfirmationReady: false
property var originalDisplays: ({
"DP-2": { mode: "4500x3000@60", scale: 1.5, transform: 0, x: 0, y: 0, primary: true },
"HDMI-A-1": { mode: "2560x1440@60", scale: 1, transform: 0, x: 3000, y: 0, primary: false }
})
property var restoredDisplays: ({
"DP-2": { mode: "4500x3000@60", scale: 1.5, transform: 0, x: -2560, y: 0, primary: false },
"HDMI-A-1": { mode: "2560x1440@60", scale: 1, transform: 0, x: 0, y: 0, primary: true }
})
property var displayGeneration: originalDisplays
property var liveLayout: [
{ name: "DP-2", width: 4500, height: 3000, refreshRate: 60, mode: "4500x3000@60", scale: 1.5, transform: 0, x: 0, y: 0, primary: true },
{ name: "HDMI-A-1", width: 2560, height: 1440, refreshRate: 60, mode: "2560x1440@60", scale: 1, transform: 0, x: 3000, y: 0, primary: false }
]
function record(name: string): void {
const next = root.calls.slice();
@@ -45,7 +59,10 @@ ShellRoot {
root.homeFavorites = root.homeFavorites.map(favorite =>
favorite.id === id ? { id: id, alias: alias } : favorite);
};
SettingsBackup.reloadDesktop = function() { root.record("desktop.reload"); };
SettingsBackup.reloadDesktop = function() {
root.record("desktop.reload");
root.displayGeneration = JSON.parse(JSON.stringify(root.restoredDisplays));
};
SettingsBackup.readDisplays = function() { return root.displayGeneration; };
SettingsBackup.protectDisplays = function(value) {
root.record("display.protect:" + JSON.stringify(value));
@@ -53,10 +70,31 @@ ShellRoot {
return true;
};
SettingsBackup.displayBusy = function() { return root.displayOperationBusy; };
SettingsBackup.readLiveDisplayLayout = function() {
return root.liveLayout.map(record => Object.assign({}, record));
};
SettingsBackup.applyDisplayLayout = function(layout) {
root.record("display.apply:" + JSON.stringify(layout));
if (!root.displayApplyAccepted)
return false;
root.liveLayout = layout.map(record => Object.assign({}, record));
root.displayOperationBusy = true;
root.displayConfirmationReady = true;
return true;
};
SettingsBackup.displayCanConfirm = function() { return root.displayConfirmationReady; };
SettingsBackup.confirmDisplayLayout = function() {
root.record("display.confirm");
root.displayOperationBusy = false;
root.displayConfirmationReady = false;
return true;
};
SettingsBackup.setDisplayBlocked = function(blocked) {
root.record("display.block:" + blocked);
root.displayBlocked = blocked;
};
SettingsBackup.applyIdle = function() { root.record("idle.apply"); };
SettingsBackup.idleBusy = function() { return false; };
SettingsBackup.applyCompositor = function() { root.record("system.apply"); };
SettingsBackup.reloadKeybinds = function() { root.record("keybinds.reload"); };
SettingsBackup.keybindsReloading = function() { return false; };
@@ -77,7 +115,15 @@ ShellRoot {
root.homeFavorites = [];
root.displayOperationBusy = false;
root.displayBlocked = false;
root.displayApplyAccepted = true;
root.displayConfirmationReady = false;
root.displayGeneration = JSON.parse(JSON.stringify(root.originalDisplays));
root.liveLayout = [
{ name: "DP-2", width: 4500, height: 3000, refreshRate: 60, mode: "4500x3000@60", scale: 1.5, transform: 0, x: 0, y: 0, primary: true },
{ name: "HDMI-A-1", width: 2560, height: 1440, refreshRate: 60, mode: "2560x1440@60", scale: 1, transform: 0, x: 3000, y: 0, primary: false }
];
SettingsBackup.protectedDisplays = root.displayGeneration;
SettingsBackup.protectedDisplayLayout = root.liveLayout;
}
function apply(output: string): bool {
@@ -90,6 +136,11 @@ ShellRoot {
return SettingsBackup.restore("settings-20260818-010203004.json");
}
function applyDisplayFailure(output: string): bool {
root.displayApplyAccepted = false;
return SettingsBackup.handleRestoreOutput(output);
}
function status(): string {
return JSON.stringify({
calls: root.calls,
+2
View File
@@ -86,6 +86,8 @@ ShellRoot {
Osd {}
}
DisplayIdentify {}
// ── Single-instance overlays ────────────────────────────────────────────
// These are always constructed but only *visible* when ShellState says so.
// They're cheap while hidden, and keeping them alive means opening the
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
component="$repo_dir/config/dot/quickshell/modules/settings/DisplayArrangement.qml"
page="$repo_dir/config/dot/quickshell/modules/settings/DisplaysPage.qml"
harness="$repo_dir/config/dot/quickshell/display-arrangement-harness.qml"
identify="$repo_dir/config/dot/quickshell/modules/settings/DisplayIdentify.qml"
service="$repo_dir/config/dot/quickshell/services/Displays.qml"
shell="$repo_dir/config/dot/quickshell/shell.qml"
fail() {
printf 'display arrangement contract: %s\n' "$1" >&2
exit 1
}
[[ -f "$component" ]] || fail 'DisplayArrangement.qml is missing'
for contract in \
'required property var displayService' \
'property var draftLayout:' \
'function setDraftPosition(' \
'function nudge(' \
'function applyDraft(' \
'function makePrimary(' \
'Accessible.name: "Move "' \
'DragHandler {' \
'Keys.onPressed:'; do
rg -Fq "$contract" "$component" \
|| fail "arrangement interaction is missing: $contract"
done
rg -Fq 'DisplayArrangement {' "$page" \
|| fail 'Displays page does not expose the arrangement canvas'
rg -Fq 'visible: Displays.monitors.length > 1' "$page" \
|| fail 'arrangement is shown for a single display'
[[ -f "$identify" ]] || fail 'DisplayIdentify.qml is missing'
for contract in \
'model: Quickshell.screens' \
'WlrLayershell.keyboardFocus: WlrKeyboardFocus.None' \
'mask: Region {}' \
'visible: Displays.identifying' \
'text: String(win.number)' \
'text: win.connector'; do
rg -Fq "$contract" "$identify" \
|| fail "display identification overlay is incomplete: $contract"
done
rg -Fq 'DisplayIdentify {}' "$shell" \
|| fail 'display identification overlays are not shell-owned'
rg -Fq 'function identify()' "$service" \
&& rg -Fq 'interval: 3000' "$service" \
|| fail 'display identification does not use one three-second service timer'
state_home="$(mktemp -d /tmp/panama-display-arrangement-state.XXXXXX)"
harness_pid=""
cleanup() {
[[ "$harness_pid" =~ ^[0-9]+$ ]] && kill "$harness_pid" 2>/dev/null || true
rm -rf "$state_home"
}
trap cleanup EXIT
XDG_STATE_HOME="$state_home" qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 60); do
harness_pid="$(qs list --all 2>/dev/null | awk -v expected="$harness" '
/^Instance / {pid=""} /^[[:space:]]*Process ID:/ {pid=$3}
/^[[:space:]]*Config path:/ {path=$0; sub(/^[[:space:]]*Config path: /,"",path); if(path==expected) print pid}' | head -1)"
[[ "$harness_pid" =~ ^[0-9]+$ ]] \
&& XDG_STATE_HOME="$state_home" qs -p "$harness" ipc --pid "$harness_pid" show 2>/dev/null \
| rg -q '^target display-arrangement-test$' && break
sleep 0.1
done
[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'arrangement harness did not start'
ipc() {
XDG_STATE_HOME="$state_home" qs -p "$harness" ipc --pid "$harness_pid" \
call display-arrangement-test "$@"
}
wide="$(ipc status 800)"
narrow="$(ipc status 500)"
for snapshot in "$wide" "$narrow"; do
jq -e '(.rects | length) == 2
and .scale > 0
and (.rects[0].width / .rects[0].height - 1.5 | fabs) < 0.0001
and (.rects[1].width / .rects[1].height - (2560 / 1440) | fabs) < 0.0001' \
<<<"$snapshot" >/dev/null \
|| fail "canvas lost monitor geometry at a supported width: $snapshot"
done
drag="$(ipc dragFixture)"
jq -e '.[0].x == 0 and .[1].x == 3000' <<<"$drag" >/dev/null \
|| fail "drag release did not snap and apply the complete layout: $drag"
keyboard="$(ipc keyboardFixture)"
jq -e '.afterArrow == 2990 and .afterShiftArrow == 2890' <<<"$keyboard" >/dev/null \
|| fail "keyboard movement did not use 10/100 logical-pixel steps: $keyboard"
primary="$(ipc primaryFixture)"
jq -e '. == [
{"name":"DP-2","x":-3000,"y":0,"primary":false},
{"name":"HDMI-A-1","x":0,"y":0,"primary":true}
]' <<<"$primary" >/dev/null \
|| fail "Make primary did not normalize the selected output to 0,0: $primary"
ipc identify >/dev/null
[[ "$(ipc identifying)" == "true" ]] \
|| fail 'identify did not reveal the overlays'
sleep 3.2
[[ "$(ipc identifying)" == "false" ]] \
|| fail 'identify overlays did not disappear after one three-second timer'
printf 'display arrangement contract: PASS\n'
+264
View File
@@ -0,0 +1,264 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
service="$repo_dir/config/dot/quickshell/services/Displays.qml"
harness="$repo_dir/config/dot/quickshell/displays-harness.qml"
fail() {
printf 'display transaction contract: %s\n' "$1" >&2
exit 1
}
for contract in \
'property var pendingPreviousLayout: null' \
'property var pendingRequestedLayout: null' \
'property var revertExpectedLayout: null' \
'function currentLayout()' \
'function applyLayout(layout:' \
'function makePrimary(output:' \
'function matchesLayout(monitors:' \
'function pushLayout(layout:'; do
rg -Fq "$contract" "$service" \
|| fail "complete-layout service boundary is missing: $contract"
done
rg -Fq 'position = "${record.x}x${record.y}"' "$service" \
|| fail 'the compositor payload does not include explicit positions'
rg -Fq 'generation === root.operationGeneration' "$service" \
|| fail 'stale monitor readback can settle a newer transaction'
rg -Fq 'function applyLayoutFixture(' "$harness" \
|| fail 'the fixture cannot exercise complete layout transactions'
rg -Fq 'function injectReadback(' "$harness" \
|| fail 'the fixture cannot prove stale readback isolation'
fixture="$(mktemp -d /tmp/panama-display-transaction.XXXXXX)"
state_home="$fixture/state-home"
config_home="$fixture/config-home"
fake_bin="$fixture/bin"
monitor_state="$fixture/monitors.json"
eval_log="$fixture/eval.log"
harness_pid=""
mkdir -p "$state_home" "$config_home" "$fake_bin"
cat >"$monitor_state" <<'JSON'
[
{
"name":"DP-2","description":"Primary fixture","width":4500,"height":3000,
"refreshRate":60,"scale":1.5,"transform":0,"x":0,"y":0,
"availableModes":["[email protected]"]
},
{
"name":"HDMI-A-1","description":"Second fixture","width":2560,"height":1440,
"refreshRate":60,"scale":1,"transform":0,"x":3000,"y":0,
"availableModes":["[email protected]"]
}
]
JSON
cat >"$fake_bin/hyprctl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
fixture="${PANAMA_DISPLAY_FIXTURE:?}"
if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then
cat "$fixture/monitors.json"
exit 0
fi
if [[ "${1:-}" != "eval" ]]; then
exit 2
fi
payload="${2:-}"
printf '%s\n' "$payload" >>"$fixture/eval.log"
if [[ -f "$fixture/fail-once" ]]; then
rm -f "$fixture/fail-once"
exit 1
fi
[[ -f "$fixture/no-apply" ]] && exit 0
python3 - "$fixture/monitors.json" "$payload" "$fixture/wrong-y" <<'PY'
import json
import pathlib
import re
import sys
state_path = pathlib.Path(sys.argv[1])
payload = sys.argv[2]
wrong_y = pathlib.Path(sys.argv[3]).exists()
monitors = json.loads(state_path.read_text(encoding="utf-8"))
by_name = {monitor["name"]: monitor for monitor in monitors}
for block in re.findall(r"hl\.monitor\(\{([^}]*)\}\)", payload):
def field(pattern: str) -> str:
match = re.search(pattern, block)
if not match:
raise SystemExit(f"missing field {pattern}: {block}")
return match.group(1)
name = field(r'output\s*=\s*"([A-Za-z0-9_.-]+)"')
mode = field(r'mode\s*=\s*"(\d+x\d+@\d+(?:\.\d+)?)"')
position = re.search(r'position\s*=\s*"(-?\d+)x(-?\d+)"', block)
if not position or name not in by_name:
raise SystemExit(f"bad output or position: {block}")
width, height, refresh = re.match(r"(\d+)x(\d+)@(\d+(?:\.\d+)?)", mode).groups()
monitor = by_name[name]
monitor.update({
"width": int(width),
"height": int(height),
"refreshRate": float(refresh),
"scale": float(field(r"scale\s*=\s*([0-9.]+)")),
"transform": int(field(r"transform\s*=\s*(\d+)")),
"x": int(position.group(1)),
"y": int(position.group(2)),
})
if wrong_y and name == "HDMI-A-1":
monitor["y"] += 1
state_path.write_text(json.dumps(monitors), encoding="utf-8")
PY
SH
chmod +x "$fake_bin/hyprctl"
export PANAMA_DISPLAY_FIXTURE="$fixture"
run() {
PATH="$fake_bin:$PATH" XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" \
qs -p "$harness" "$@"
}
transaction_status() {
run ipc --pid "$harness_pid" call displays-test transactionStatus
}
cleanup() {
[[ "$harness_pid" =~ ^[0-9]+$ ]] && kill "$harness_pid" 2>/dev/null || true
rm -rf "$fixture"
}
trap cleanup EXIT
PATH="$fake_bin:$PATH" XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" \
qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 60); do
harness_pid="$(qs list --all 2>/dev/null | awk -v expected="$harness" '
/^Instance / {pid=""} /^[[:space:]]*Process ID:/ {pid=$3}
/^[[:space:]]*Config path:/ {path=$0; sub(/^[[:space:]]*Config path: /,"",path); if(path==expected) print pid}' | head -1)"
if [[ "$harness_pid" =~ ^[0-9]+$ ]]; then
ready="$(transaction_status 2>/dev/null || true)"
jq -e '.layout | length == 2' <<<"$ready" >/dev/null 2>&1 && break
fi
sleep 0.1
done
[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'fixture shell did not start'
wait_for() {
local expression="$1"
local value=""
for _ in $(seq 1 80); do
value="$(transaction_status)"
jq -e "$expression" <<<"$value" >/dev/null && { printf '%s' "$value"; return 0; }
sleep 0.1
done
fail "timed out waiting for $expression: $value"
}
# A complete request is one evaluator call carrying every connected output.
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 100)" == "true" ]] \
|| fail 'valid complete layout was refused'
wait_for '.canConfirm == true' >/dev/null
first_payload="$(sed -n '1p' "$eval_log")"
[[ "$(rg -o 'hl\.monitor' <<<"$first_payload" | wc -l)" == "2" ]] \
|| fail "layout was not sent as one complete payload: $first_payload"
rg -Fq 'output = "DP-2"' <<<"$first_payload" \
&& rg -Fq 'position = "0x0"' <<<"$first_payload" \
&& rg -Fq 'output = "HDMI-A-1"' <<<"$first_payload" \
&& rg -Fq 'position = "3000x100"' <<<"$first_payload" \
|| fail "layout payload omitted a literal output position: $first_payload"
[[ "$(run ipc --pid "$harness_pid" call displays-test confirmChange)" == "true" ]] \
|| fail 'verified complete layout could not be kept'
store="$config_home/panama/settings.json"
jq -e '.displays | length == 2
and .["DP-2"].x == 0 and .["DP-2"].y == 0 and .["DP-2"].primary == true
and .["HDMI-A-1"].x == 3000 and .["HDMI-A-1"].y == 100
and .["HDMI-A-1"].primary == false' "$store" >/dev/null \
|| fail 'confirmation did not persist one complete layout with one primary'
# Wrong readback never enables Keep; explicit revert restores both outputs.
touch "$fixture/wrong-y"
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 200)" == "true" ]] \
|| fail 'wrong-readback fixture could not start'
wait_for '.busy == false and .awaiting == true' >/dev/null
jq -e '.canConfirm == false' <<<"$(transaction_status)" >/dev/null \
|| fail 'Keep enabled while one output had the wrong y coordinate'
rm -f "$fixture/wrong-y"
run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null
wait_for '.busy == false and .awaiting == false' >/dev/null
jq -e '.[0].x == 0 and .[0].y == 0 and .[1].x == 3000 and .[1].y == 100' \
"$monitor_state" >/dev/null || fail 'explicit revert did not restore the complete previous layout'
# A stale pre-operation query may update visible data, but cannot settle the
# current generation or make Keep available.
touch "$fixture/no-apply"
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 250)" == "true" ]] \
|| fail 'stale-generation fixture could not start'
wait_for '.busy == false and .awaiting == true' >/dev/null
generation="$(transaction_status | jq -r .generation)"
stale_json="$(jq '.[1].y = 250' "$monitor_state")"
run ipc --pid "$harness_pid" call displays-test injectReadback "$stale_json" "$((generation - 1))" >/dev/null
jq -e '.canConfirm == false' <<<"$(transaction_status)" >/dev/null \
|| fail 'stale readback confirmed a newer operation'
rm -f "$fixture/no-apply"
run ipc --pid "$harness_pid" call displays-test expireApplyVerification >/dev/null
wait_for '.busy == false and .awaiting == false' >/dev/null
# A non-zero evaluator exit follows the same whole-layout recovery path.
touch "$fixture/fail-once"
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 300)" == "true" ]] \
|| fail 'failed-evaluator fixture could not start'
failed_state="$(wait_for '.busy == false and .awaiting == false')"
jq -e '.lastError | contains("rejected")' <<<"$failed_state" >/dev/null \
|| fail "failed apply did not retain a useful recovery message: $failed_state"
# If an output disconnects while a change is pending, rollback sends one
# transaction containing every output that is still connected.
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 320)" == "true" ]] \
|| fail 'disconnect fixture could not start'
wait_for '.canConfirm == true' >/dev/null
jq '.[0:1]' "$monitor_state" >"$fixture/connected.json"
mv "$fixture/connected.json" "$monitor_state"
run ipc --pid "$harness_pid" call displays-test refresh >/dev/null
wait_for '.layout | length == 1' >/dev/null
run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null
wait_for '.busy == false and .awaiting == false' >/dev/null
disconnect_payload="$(tail -1 "$eval_log")"
[[ "$(rg -o 'hl\.monitor' <<<"$disconnect_payload" | wc -l)" == "1" ]] \
&& ! rg -Fq 'HDMI-A-1' <<<"$disconnect_payload" \
|| fail "disconnect rollback targeted an absent output: $disconnect_payload"
# Restore the second fixture output without touching the real compositor.
jq '. + [{
"name":"HDMI-A-1","description":"Second fixture","width":2560,"height":1440,
"refreshRate":60,"scale":1,"transform":0,"x":3000,"y":100,
"availableModes":["[email protected]"]
}]' "$monitor_state" >"$fixture/reconnected.json"
mv "$fixture/reconnected.json" "$monitor_state"
run ipc --pid "$harness_pid" call displays-test refresh >/dev/null
wait_for '.layout | length == 2' >/dev/null
# A revert that exits zero but reads back wrong remains an explicit manual
# recovery error rather than pretending the desktop was restored.
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 400)" == "true" ]] \
|| fail 'bad-revert fixture could not start'
wait_for '.canConfirm == true' >/dev/null
touch "$fixture/wrong-y"
run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null
wait_for '.reverting != null' >/dev/null
run ipc --pid "$harness_pid" call displays-test expireRevertVerification >/dev/null
manual_state="$(transaction_status)"
jq -e '.busy == false and (.lastError | contains("restore it manually"))' \
<<<"$manual_state" >/dev/null \
|| fail "wrong revert readback was reported as restored: $manual_state"
printf 'display transaction contract: PASS\n'
+15 -7
View File
@@ -35,12 +35,12 @@ fail() {
# Keep is unavailable until compositor readback exactly matches the request.
for contract in \
'property var pendingRequested:' \
'property var revertExpected:' \
'property var pendingRequestedLayout:' \
'property var revertExpectedLayout:' \
'property bool revertVerificationActive:' \
'property int revertGeneration:' \
'readonly property bool canConfirm:' \
'function matchesRequest(' \
'function matchesLayout(' \
'function scalesForMode(' \
'function isScaleClean(' \
'x: Number.isInteger(monitor.x)' \
@@ -170,6 +170,8 @@ original_transform=""
original_width=""
original_height=""
original_refresh=""
original_x=""
original_y=""
monitor_name=""
monitor_state() {
@@ -186,15 +188,18 @@ display_is_restored() {
--argjson refresh "$original_refresh" \
--argjson scale "$original_scale" \
--argjson transform "$original_transform" \
--argjson x "$original_x" \
--argjson y "$original_y" \
'.width == $width and .height == $height
and ((.refreshRate - $refresh) | fabs) < 0.01
and ((.scale - $scale) | fabs) < 0.001
and .transform == $transform' <<<"$current" >/dev/null
and .transform == $transform
and .x == $x and .y == $y' <<<"$current" >/dev/null
}
restore_display() {
[[ -n "$original_mode" ]] || return 0
hyprctl eval "hl.monitor({ output = \"$monitor_name\", mode = \"$original_mode\", scale = $original_scale, transform = $original_transform })" >/dev/null \
hyprctl eval "hl.monitor({ output = \"$monitor_name\", mode = \"$original_mode\", position = \"${original_x}x${original_y}\", scale = $original_scale, transform = $original_transform })" >/dev/null \
|| return 1
for _ in $(seq 1 50); do
display_is_restored && return 0
@@ -215,8 +220,9 @@ cleanup() {
local status=$?
trap - EXIT
if ! restore_display; then
printf 'displays contract: FAILED to restore %s to %s scale %s transform %s\n' \
"$monitor_name" "$original_mode" "$original_scale" "$original_transform" >&2
printf 'displays contract: FAILED to restore %s to %s at %sx%s scale %s transform %s\n' \
"$monitor_name" "$original_mode" "$original_x" "$original_y" \
"$original_scale" "$original_transform" >&2
status=1
fi
stop_harness
@@ -262,6 +268,8 @@ original_height="$(jq -r .height <<<"$state")"
original_refresh="$(jq -r .refresh <<<"$state")"
original_scale="$(jq -r .scale <<<"$state")"
original_transform="$(jq -r .transform <<<"$state")"
original_x="$(jq -r .x <<<"$state")"
original_y="$(jq -r .y <<<"$state")"
[[ "$(jq -r .modes <<<"$state")" -gt 0 ]] || fail 'the display reported no usable modes'
@@ -13,6 +13,7 @@ test_bin="$fixture/bin"
settings="$config_home/panama/settings.json"
generated="$state_home/panama/hyprlock.conf"
hyprlock_log="$fixture/hyprlock.log"
theme_helper="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps"
fail() {
printf 'lock screen helper contract: %s\n' "$1" >&2
@@ -27,7 +28,12 @@ trap cleanup EXIT
mkdir -p "$fixture_home/Pictures/Wallpapers" "$config_home/panama" \
"$config_home/hypr" "$state_home" "$test_bin"
cp "$repo_dir/config/dot/hypr/hyprlock.conf" "$config_home/hypr/hyprlock.conf"
cp "$repo_dir/config/dot/hypr/hyprlock.conf.template" \
"$config_home/hypr/hyprlock.conf.template"
HOME="$fixture_home" XDG_CONFIG_HOME="$config_home" \
"$theme_helper" dark >/dev/null
[[ -s "$config_home/hypr/hyprlock.conf" ]] \
|| fail 'the themed fallback renderer did not create hyprlock.conf'
cat >"$test_bin/hyprctl" <<'EOF'
#!/usr/bin/env bash
@@ -91,7 +97,14 @@ write_settings '{
"colorScheme":"light"
}'
run_helper generate
rg -Fq 'color = rgba(245, 246, 250, 1.0)' "$generated" || fail 'light solid mode has the wrong colour'
rg -Fq 'color = rgba(225, 226, 231, 1.0)' "$generated" || fail 'light solid mode does not match Theme.bg'
rg -Fq 'inner_color = rgba(208, 213, 227, 0.85)' "$generated" || fail 'light password field does not match the themed fallback'
rg -Fq 'font_color = rgba(55, 96, 191, 1.0)' "$generated" || fail 'light foreground does not match Theme.fg'
rg -Fq 'outer_color = rgba(46, 125, 233, 0.9)' "$generated" || fail 'light focus ring does not match Theme.accent'
rg -Fq 'check_color = rgba(46, 125, 233, 1.0)' "$generated" || fail 'light success colour does not match Theme.accent'
rg -Fq 'fail_color = rgba(245, 42, 101, 1.0)' "$generated" || fail 'light error colour does not match Theme.red'
rg -Fq 'foreground="##6172b0"' "$generated" || fail 'light placeholder markup retained the dark muted colour'
rg -Fq 'foreground="##f52a65"' "$generated" || fail 'light failure markup retained the dark error colour'
rg -Fq 'blur_passes = 0' "$generated" || fail 'blur level zero did not disable passes'
rg -Fq 'blur_size = 1' "$generated" || fail 'blur level zero did not use the safe size'
rg -Fq 'fade_on_empty = true' "$generated" || fail 'fade-on-empty setting was ignored'
+10 -1
View File
@@ -8,6 +8,7 @@ set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
doctor="$repo_dir/config/dot/quickshell/scripts/panama-doctor"
theme_helper="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps"
fixture_root="$repo_dir/tests/quickshell/fixtures/doctor"
fail() {
@@ -148,11 +149,19 @@ check_status "$snapshot" desktop.hyprlock ok
printf '%s\n' '{"generated":false,"path":"/fixture-secret-wallpaper.jpg","fallback":true,"error":"fixture-secret-token"}' \
>"$state_home/panama/hyprlock-status.json"
# A linked source checkout contains the template; installation renders the
# ignored machine-local fallback beside it. Model that installed state without
# writing an untracked file into the worktree under test.
rm "$config_home/hypr"
mkdir "$config_home/hypr"
cp "$repo_dir/config/dot/hypr/hyprlock.conf.template" \
"$config_home/hypr/hyprlock.conf.template"
HOME="$home" XDG_CONFIG_HOME="$config_home" "$theme_helper" dark >/dev/null
fallback_lock="$(run_doctor --json)"
check_status "$fallback_lock" desktop.hyprlock warning
assert_schema_and_redaction "$fallback_lock"
rm "$config_home/hypr"
rm -rf "$config_home/hypr"
missing_lock="$(run_doctor --json)"
check_status "$missing_lock" desktop.hyprlock error
assert_schema_and_redaction "$missing_lock"
@@ -44,6 +44,11 @@ for mapping in \
'HomePreferences.setAlias(id, alias);' \
'DesktopPreferences.reload();' \
'DesktopPreferences.set("displays", value);' \
'Displays.currentLayout();' \
'Displays.applyProtectedLayout(layout);' \
'Displays.canConfirm;' \
'Displays.confirm();' \
'IdleLock.apply();' \
'Displays.externalChangeBlocked = blocked;' \
'SystemSettings.applyPersistedDisplayPolicy();' \
'Keybinds.applyReload();' \
@@ -85,11 +90,13 @@ jq -e '
"home.alias:light.desk=Desk",
"home.alias:light.office=Office",
"desktop.reload",
"display.protect:{\"DP-2\":{\"mode\":\"4500x3000@60\",\"scale\":1.5,\"transform\":0}}",
"system.apply",
"keybinds.reload",
"wallpaper.apply-policy",
"display.apply:[{\"name\":\"DP-2\",\"width\":4500,\"height\":3000,\"refreshRate\":60,\"mode\":\"4500x3000@60\",\"scale\":1.5,\"transform\":0,\"x\":-2560,\"y\":0,\"primary\":false},{\"name\":\"HDMI-A-1\",\"width\":2560,\"height\":1440,\"refreshRate\":60,\"mode\":\"2560x1440@60\",\"scale\":1,\"transform\":0,\"x\":0,\"y\":0,\"primary\":true}]",
"display.confirm",
"idle.apply",
"lock.regenerate",
"wallpaper.apply-policy",
"keybinds.reload",
"system.apply",
"display.block:false",
"shell.reload"
]
@@ -124,11 +131,13 @@ jq -e '
.calls == [
"home.reset",
"desktop.reload",
"display.protect:{\"DP-2\":{\"mode\":\"4500x3000@60\",\"scale\":1.5,\"transform\":0}}",
"system.apply",
"keybinds.reload",
"wallpaper.apply-policy",
"display.apply:[{\"name\":\"DP-2\",\"width\":4500,\"height\":3000,\"refreshRate\":60,\"mode\":\"4500x3000@60\",\"scale\":1.5,\"transform\":0,\"x\":-2560,\"y\":0,\"primary\":false},{\"name\":\"HDMI-A-1\",\"width\":2560,\"height\":1440,\"refreshRate\":60,\"mode\":\"2560x1440@60\",\"scale\":1,\"transform\":0,\"x\":0,\"y\":0,\"primary\":true}]",
"display.confirm",
"idle.apply",
"lock.regenerate",
"wallpaper.apply-policy",
"keybinds.reload",
"system.apply",
"display.block:false",
"shell.reload"
]
@@ -136,6 +145,25 @@ jq -e '
and .favorites == []
' <<<"$status" >/dev/null || fail "absent Home handoff was wrong: $status"
# If the restored complete layout is rejected before it can be verified, the
# original persisted layout is put back and the shell is not reloaded over an
# unproven display state.
qs_test ipc call settings-backup-behavior reset >/dev/null
[[ "$(qs_test ipc call settings-backup-behavior applyDisplayFailure "$payload")" == "false" ]] \
|| fail 'a rejected restored display layout was reported as successful'
status="$(qs_test ipc call settings-backup-behavior status)"
jq -e '.calls == [
"home.reset",
"home.initialize:light.desk,light.office",
"home.alias:light.desk=Desk",
"home.alias:light.office=Office",
"desktop.reload",
"display.apply:[{\"name\":\"DP-2\",\"width\":4500,\"height\":3000,\"refreshRate\":60,\"mode\":\"4500x3000@60\",\"scale\":1.5,\"transform\":0,\"x\":-2560,\"y\":0,\"primary\":false},{\"name\":\"HDMI-A-1\",\"width\":2560,\"height\":1440,\"refreshRate\":60,\"mode\":\"2560x1440@60\",\"scale\":1,\"transform\":0,\"x\":0,\"y\":0,\"primary\":true}]",
"display.protect:{\"DP-2\":{\"mode\":\"4500x3000@60\",\"scale\":1.5,\"transform\":0,\"x\":0,\"y\":0,\"primary\":true},\"HDMI-A-1\":{\"mode\":\"2560x1440@60\",\"scale\":1,\"transform\":0,\"x\":3000,\"y\":0,\"primary\":false}}",
"display.block:false"
] and (.lastError | contains("display layout"))' <<<"$status" >/dev/null \
|| fail "rejected display restore did not retain the original layout: $status"
# Restore refuses before launching the helper while a display apply/recovery is
# active, so no snapshot can race the confirmation boundary.
qs_test ipc call settings-backup-behavior reset >/dev/null
@@ -39,8 +39,8 @@ rg -Fq 'HomePreferences.resetHomeDefaults();' "$system_settings" \
if rg -q 'HomePreferences\.(favorites|initialized)\s*=' "$system_settings"; then
fail 'restoreDefaults mutates Home aliases instead of using resetHomeDefaults'
fi
rg -Fq 'root.protectDisplays(protectedDisplays)' "$system_settings" \
|| fail 'restoreDefaults can apply unconfirmed display geometry during reload'
rg -Fq 'DesktopPreferences.resetDesktopDefaults();' "$system_settings" \
|| fail 'restoreDefaults does not clear confirmed display layout fields'
rg -Fq 'Keybinds.applyReload();' "$system_settings" \
|| fail 'restoreDefaults does not replay shipped keybindings'
rg -Fq 'root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? ""));' "$system_settings" \
@@ -120,7 +120,7 @@ before="$(qs_for_harness ipc call settings-system-test stored windowRounding)"
# ── Reset spans every store, not just the schema one ─────────────────────────
qs_for_harness ipc call settings-system-test seedHome >/dev/null
qs_for_harness ipc call settings-system-test commit dockHideDelayMs 900 >/dev/null
display_fixture='{"DP-2":{"mode":"4500x3000@60","scale":1.5,"transform":0}}'
display_fixture='{"DP-2":{"mode":"4500x3000@60","scale":1.5,"transform":0,"x":0,"y":0,"primary":true},"HDMI-A-1":{"mode":"2560x1440@60","scale":1,"transform":0,"x":3000,"y":0,"primary":false}}'
[[ "$(qs_for_harness ipc call settings-system-test commit displays "$display_fixture")" == "true" ]] \
|| fail 'the protected display fixture did not apply'
sleep 0.4
@@ -138,7 +138,6 @@ sleep 0.6
reset_state="$(qs_for_harness ipc call settings-system-test resetState)"
jq -e '.calls == [
"display.block:true",
"display.protect",
"keybinds.reload",
"wallpaper.set:",
"lock.regenerate",
@@ -164,8 +163,8 @@ for default_case in \
[[ "$(qs_for_harness ipc call settings-system-test stored "$key")" == "$expected" ]] \
|| fail "reset did not restore $key to $expected"
done
[[ "$(qs_for_harness ipc call settings-system-test stored displays | jq -cS .)" == "$(jq -cS . <<<"$display_fixture")" ]] \
|| fail 'reset replaced confirmed display geometry without confirmation'
[[ "$(qs_for_harness ipc call settings-system-test stored displays | jq -cS .)" == '{}' ]] \
|| fail 'reset retained confirmed arrangement fields instead of returning startup to shipped placement'
home_after="$(qs_for_harness ipc call settings-system-test homeState)"
jq -e '.count == 0 and .initialized == false' <<<"$home_after" >/dev/null \
@@ -102,7 +102,8 @@ for needle in \
'`inactiveOpacity`' \
'`lockMinutes`' \
'`lockOnSleep`' \
'scheme-relative role'; do
'scheme-relative role' \
'mode, scale, rotation, arrangement, and primary role'; do
rg -Fq "$needle" "$readme" || fail "README is missing $needle"
done
@@ -65,6 +65,9 @@ password field|Password field|appearance
slideshow|Wallpaper mode|appearance
shuffle|Shuffle|appearance
per-display wallpaper|Per-display wallpaper|appearance
arrange displays|Arrange displays|displays
monitor position|Monitor position|displays
primary display|Primary display|displays
CASES
! rg -Fq 'Startup & Services' "$repo_dir/config/dot/quickshell/services/SettingsSearch.qml" \