25 changed files with 1494 additions and 144 deletions
@@ -467,7 +467,7 @@ Singleton {
hypr: { path: ["input", "repeat_rate"], option: "input:repeat_rate", readAs: "int" } 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", label: "Pointer focus",
detail: "What moving the pointer does to which window is focused", detail: "What moving the pointer does to which window is focused",
// These labels were wrong, and wrong in the worst way: value 1 was // 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, key: "pointerSensitivity", type: "real", def: 0.0, min: -1.0, max: 1.0, step: 0.05,
group: "input", group: "pointer",
label: "Pointer speed", label: "Pointer speed",
detail: "Zero is flat, unaccelerated response", detail: "Zero is flat, unaccelerated response",
hypr: { path: ["input", "sensitivity"], option: "input:sensitivity", readAs: "float" } 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); 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 { function refreshIdentityFixture(): string {
const modes = Displays.normaliseModes([ const modes = Displays.normaliseModes([
"[email protected]", "[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 { SettingsCard {
visible: Displays.monitors.length > 1 visible: Displays.monitors.length > 1
title: "Connected display" 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 the session locks, while **Privacy** keeps only the established timing mirrors
above. Visual controls must not be copied onto either page. 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 Mirrors must remain the same schema-backed control, never a second preference
or a copied default. Additions to this table require a concrete discoverability or a copied default. Additions to this table require a concrete discoverability
reason and an update to `tests/quickshell/settings-ownership-contract.sh`. 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 ShortcutCapture 1.0 ShortcutCapture.qml
ChoiceGrid 1.0 ChoiceGrid.qml ChoiceGrid 1.0 ChoiceGrid.qml
DisplayModePicker 1.0 DisplayModePicker.qml DisplayModePicker 1.0 DisplayModePicker.qml
DisplayArrangement 1.0 DisplayArrangement.qml
DisplayIdentify 1.0 DisplayIdentify.qml
WifiPanel 1.0 WifiPanel.qml WifiPanel 1.0 WifiPanel.qml
BluetoothPanel 1.0 BluetoothPanel.qml BluetoothPanel 1.0 BluetoothPanel.qml
PasswordField 1.0 PasswordField.qml PasswordField 1.0 PasswordField.qml
+19 -9
View File
@@ -118,15 +118,25 @@ load_preferences() {
esac esac
if [[ "$color_scheme" == light ]]; then if [[ "$color_scheme" == light ]]; then
background_color='rgba(245, 246, 250, 1.0)' background_color='rgba(225, 226, 231, 1.0)'
foreground_color='rgba(55, 63, 87, 1.0)' foreground_color='rgba(55, 96, 191, 1.0)'
dim_color='rgba(111, 119, 151, 1.0)' dim_color='rgba(97, 114, 176, 1.0)'
field_color='rgba(220, 223, 232, 0.88)' 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 else
background_color='rgba(34, 36, 54, 1.0)' background_color='rgba(34, 36, 54, 1.0)'
foreground_color='rgba(200, 211, 245, 1.0)' foreground_color='rgba(200, 211, 245, 1.0)'
dim_color='rgba(130, 139, 184, 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)' field_color='rgba(46, 47, 61, 0.85)'
dim_hex='828bb8'
error_hex='ff757f'
fi fi
} }
@@ -239,16 +249,16 @@ emit_config() {
printf ' valign = center\n' printf ' valign = center\n'
printf ' outline_thickness = 2\n' printf ' outline_thickness = 2\n'
printf ' rounding = 26\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 ' inner_color = %s\n' "$field_color"
printf ' font_color = %s\n' "$foreground_color" printf ' font_color = %s\n' "$foreground_color"
printf ' check_color = rgba(130, 170, 255, 1.0)\n' printf ' check_color = %s\n' "$accent_color"
printf ' fail_color = rgba(255, 117, 127, 1.0)\n' printf ' fail_color = %s\n' "$error_color"
printf ' dots_size = 0.25\n' printf ' dots_size = 0.25\n'
printf ' dots_spacing = 0.3\n' printf ' dots_spacing = 0.3\n'
printf ' dots_center = true\n' printf ' dots_center = true\n'
printf ' placeholder_text = <span foreground="##828bb8"><i>Password</i></span>\n' printf ' placeholder_text = <span foreground="##%s"><i>Password</i></span>\n' "$dim_hex"
printf ' fail_text = <span foreground="##ff757f"><i>$FAIL ($ATTEMPTS)</i></span>\n' printf ' fail_text = <span foreground="##%s"><i>$FAIL ($ATTEMPTS)</i></span>\n' "$error_hex"
printf ' fade_on_empty = %s\n' "$fade_on_empty" printf ' fade_on_empty = %s\n' "$fade_on_empty"
printf ' hide_input = false\n' printf ' hide_input = false\n'
printf '}\n\n' printf '}\n\n'
+172 -79
View File
@@ -21,6 +21,7 @@ import Quickshell
import Quickshell.Io import Quickshell.Io
import QtQuick import QtQuick
import qs.config import qs.config
import "DisplayLayout.js" as DisplayLayout
Singleton { Singleton {
id: root id: root
@@ -31,25 +32,25 @@ Singleton {
property string lastError: "" property string lastError: ""
// Set while a change is applied but not yet confirmed. // Set while a change is applied but not yet confirmed.
property string pendingOutput: "" property var pendingPreviousLayout: null
property var pendingPrevious: null property var pendingRequestedLayout: null
property var pendingRequested: null
property bool pendingVerified: false property bool pendingVerified: false
property bool revertQueued: false property bool revertQueued: false
property var revertExpected: null property var revertExpectedLayout: null
property string revertReason: "" property string revertReason: ""
property bool revertVerificationActive: false property bool revertVerificationActive: false
property int operationGeneration: 0 property int operationGeneration: 0
property int revertGeneration: -1 property int revertGeneration: -1
property bool externalChangeBlocked: false property bool externalChangeBlocked: false
property int secondsLeft: 0 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 readonly property bool canConfirm: root.awaitingConfirmation
&& root.pendingVerified && root.pendingVerified
&& !root.busy && !root.busy
readonly property bool busy: query.running || applyRun.running || revertRun.running readonly property bool busy: query.running || applyRun.running || revertRun.running
|| root.revertExpected !== null || root.revertExpectedLayout !== null
readonly property int confirmSeconds: 15 readonly property int confirmSeconds: 15
@@ -123,6 +124,11 @@ Singleton {
return false; return false;
} }
function identify(): void {
root.identifying = true;
identifyTimer.restart();
}
function parse(text: string, generation: int): void { function parse(text: string, generation: int): void {
try { try {
const raw = JSON.parse(text); const raw = JSON.parse(text);
@@ -163,25 +169,26 @@ Singleton {
modes: modes modes: modes
}; };
}); });
if (root.awaitingConfirmation && root.pendingRequested if (root.awaitingConfirmation && root.pendingRequestedLayout
&& root.matchesRequest(root.monitorNamed(root.pendingOutput), root.pendingRequested)) { && generation === root.operationGeneration
&& root.matchesLayout(root.monitors, root.pendingRequestedLayout)) {
root.pendingVerified = true; root.pendingVerified = true;
verifyTimer.stop(); verifyTimer.stop();
root.lastError = ""; root.lastError = "";
} else if (root.revertVerificationActive } else if (root.revertVerificationActive
&& generation === root.revertGeneration && generation === root.revertGeneration
&& root.revertExpected && root.revertExpectedLayout
&& root.matchesRequest(root.monitorNamed(root.revertExpected.output), root.revertExpected)) { && root.matchesLayout(root.monitors, root.revertExpectedLayout)) {
revertVerifyTimer.stop(); revertVerifyTimer.stop();
root.revertVerificationActive = false; root.revertVerificationActive = false;
root.revertGeneration = -1; root.revertGeneration = -1;
root.revertExpected = null; root.revertExpectedLayout = null;
if (root.revertReason === "") if (root.revertReason === "")
root.lastError = ""; root.lastError = "";
else else
root.lastError = root.revertReason; root.lastError = root.revertReason;
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 === "Could not read the connected displays."
|| root.lastError === "The display list could not be read.")) { || root.lastError === "The display list could not be read.")) {
root.lastError = ""; root.lastError = "";
@@ -274,16 +281,41 @@ Singleton {
choices[0]); choices[0]);
} }
function matchesRequest(monitor: var, requested: var): bool { function currentLayout(): var {
if (!monitor || !requested || monitor.name !== requested.output) 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; 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); const parts = root.modeParts(requested.mode);
return !!parts if (!parts || monitor.name !== requested.name
&& monitor.width === parts.width || monitor.width !== parts.width
&& monitor.height === parts.height || monitor.height !== parts.height
&& Math.abs(monitor.refreshRate - parts.refresh) < 0.01 || Math.abs(monitor.refreshRate - parts.refresh) >= 0.01
&& Math.abs(monitor.scale - requested.scale) < 0.001 || Math.abs(monitor.scale - requested.scale) >= 0.001
&& monitor.transform === requested.transform; || monitor.transform !== requested.transform
|| monitor.x !== requested.x || monitor.y !== requested.y)
return false;
}
return true;
} }
function modeIsCurrent(monitor: var, candidate: var): bool { function modeIsCurrent(monitor: var, candidate: var): bool {
@@ -293,10 +325,48 @@ Singleton {
&& Math.abs(monitor.refreshRate - candidate.refresh) < 0.01; && Math.abs(monitor.refreshRate - candidate.refresh) < 0.01;
} }
// Applies immediately and starts the countdown. Nothing is stored yet: the function validRequestedLayout(layout: var): bool {
// settings file is only written by confirm(). 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 { 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."; root.lastError = "Wait for Settings to finish restoring before changing a display.";
return false; return false;
} }
@@ -308,58 +378,53 @@ Singleton {
root.lastError = "Finish the current display change first."; root.lastError = "Finish the current display change first.";
return false; return false;
} }
const monitor = root.monitorNamed(output); const normalized = DisplayLayout.normalize(layout);
if (!monitor) { if (!root.validRequestedLayout(normalized)) {
root.lastError = "That display is not connected."; root.lastError = "That complete display layout is not valid for the connected displays.";
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.";
return false; return false;
} }
root.pendingPrevious = { root.pendingPreviousLayout = root.currentLayout();
output: output,
mode: monitor.mode,
scale: monitor.scale,
transform: monitor.transform
};
root.operationGeneration++; root.operationGeneration++;
root.pendingRequested = { root.pendingRequestedLayout = normalized;
output: output,
mode: mode,
scale: scale,
transform: transform
};
root.pendingOutput = output;
root.pendingVerified = false; root.pendingVerified = false;
root.revertQueued = false; root.revertQueued = false;
root.secondsLeft = root.confirmSeconds; root.secondsLeft = root.confirmSeconds;
root.lastError = ""; root.lastError = "";
countdown.restart(); countdown.restart();
root.push(output, mode, scale, transform); root.pushLayout(normalized, applyRun);
return true; return true;
} }
function push(output: string, mode: string, scale: real, transform: int): void { // Settings restore holds the external-change lock while it proves a
// Values are validated above and the output name comes from the // snapshot. This narrow entry point authorizes that one transaction while
// compositor's own list, so nothing user-authored reaches the payload. // keeping every user-facing control blocked until restore settles.
applyRun.exec(["hyprctl", "eval", function applyProtectedLayout(layout: var): bool {
`hl.monitor({ output = "${output}", mode = "${mode}", scale = ${scale}, transform = ${transform} })`]); 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 { function confirm(): bool {
if (!root.canConfirm || !root.matchesRequest( if (!root.canConfirm
root.monitorNamed(root.pendingOutput), root.pendingRequested)) { || !root.matchesLayout(root.monitors, root.pendingRequestedLayout)) {
if (root.awaitingConfirmation) if (root.awaitingConfirmation)
root.lastError = "Wait for the display to finish applying before keeping it."; root.lastError = "Wait for the display to finish applying before keeping it.";
return false; return false;
@@ -367,11 +432,16 @@ 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 : {});
next[root.pendingOutput] = { for (const record of root.pendingRequestedLayout) {
mode: root.pendingRequested.mode, next[record.name] = {
scale: root.pendingRequested.scale, mode: record.mode,
transform: root.pendingRequested.transform scale: record.scale,
transform: record.transform,
x: record.x,
y: record.y,
primary: record.primary
}; };
}
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.";
return false; return false;
@@ -385,9 +455,8 @@ Singleton {
function clearPending(): void { function clearPending(): void {
countdown.stop(); countdown.stop();
verifyTimer.stop(); verifyTimer.stop();
root.pendingOutput = ""; root.pendingPreviousLayout = null;
root.pendingPrevious = null; root.pendingRequestedLayout = null;
root.pendingRequested = null;
root.pendingVerified = false; root.pendingVerified = false;
root.revertQueued = false; root.revertQueued = false;
root.secondsLeft = 0; root.secondsLeft = 0;
@@ -414,16 +483,25 @@ Singleton {
} }
function performRevert(): void { 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.operationGeneration++;
root.revertGeneration = root.operationGeneration; root.revertGeneration = root.operationGeneration;
root.revertExpected = previous; root.revertExpectedLayout = previous.length > 0 ? previous : null;
root.revertVerificationActive = false; root.revertVerificationActive = false;
root.clearPending(); root.clearPending();
if (previous) { if (previous.length > 0)
revertRun.exec(["hyprctl", "eval", root.pushLayout(previous, revertRun);
`hl.monitor({ output = "${previous.output}", mode = "${previous.mode}", scale = ${previous.scale}, transform = ${previous.transform} })`]); else
} 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
@@ -442,6 +520,26 @@ Singleton {
return !!(stored && typeof stored === "object" && stored[output] !== undefined); 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 { Timer {
id: verifyTimer id: verifyTimer
property int attempts: 0 property int attempts: 0
@@ -451,7 +549,7 @@ Singleton {
onTriggered: { onTriggered: {
ticks++; ticks++;
if (ticks > 50) { if (ticks > 50) {
root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one."); root.verificationTimedOut();
return; return;
} }
if (root.refresh()) if (root.refresh())
@@ -468,12 +566,7 @@ Singleton {
onTriggered: { onTriggered: {
ticks++; ticks++;
if (ticks > 50) { if (ticks > 50) {
stop(); root.revertVerificationTimedOut();
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.";
return; return;
} }
if (root.refresh()) if (root.refresh())
+115 -13
View File
@@ -41,7 +41,13 @@ Singleton {
property var readDisplays: function() { return DesktopPreferences.get("displays"); } property var readDisplays: function() { return DesktopPreferences.get("displays"); }
property var protectDisplays: function(value) { return DesktopPreferences.set("displays", value); } property var protectDisplays: function(value) { return DesktopPreferences.set("displays", value); }
property var displayBusy: function() { return Displays.busy || Displays.awaitingConfirmation; } 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 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 applyCompositor: function() { SystemSettings.applyPersistedDisplayPolicy(); }
property var reloadKeybinds: function() { Keybinds.applyReload(); } property var reloadKeybinds: function() { Keybinds.applyReload(); }
property var keybindsReloading: function() { return Keybinds.reloading; } property var keybindsReloading: function() { return Keybinds.reloading; }
@@ -52,9 +58,11 @@ Singleton {
property var lockBusy: function() { return LockScreen.busy; } property var lockBusy: function() { return LockScreen.busy; }
property var reloadShell: function() { Quickshell.reload(false); } property var reloadShell: function() { Quickshell.reload(false); }
property var protectedDisplays: ({}) property var protectedDisplays: ({})
property var protectedDisplayLayout: []
property var pendingRestoredLayout: null
readonly property bool busy: listQuery.running || actionRun.running readonly property bool busy: listQuery.running || actionRun.running
|| applyRestoredState.running || settleReload.running || settleDisplayRestore.running || applyRestoredState.running || settleReload.running
Process { Process {
id: listQuery id: listQuery
@@ -89,18 +97,21 @@ Singleton {
if (actionRun.restoring) { if (actionRun.restoring) {
root.setDisplayBlocked(false); root.setDisplayBlocked(false);
root.protectedDisplays = ({}); root.protectedDisplays = ({});
root.protectedDisplayLayout = [];
} }
return; return;
} }
root.lastAction = actionRun.restoring ? "restored" : "saved"; root.lastAction = actionRun.restoring ? "restored" : "saved";
if (actionRun.restoring) { if (actionRun.restoring) {
const homeReloaded = root.handleRestoreOutput(actionRun.outputText); const restoreAccepted = root.handleRestoreOutput(actionRun.outputText);
root.lastError = homeReloaded if (restoreAccepted)
? "" root.lastError = "";
: "Desktop settings were restored, but Home favourites could not be reloaded."; else if (root.lastError === "")
if (!homeReloaded) { root.lastError = "Desktop settings were restored, but Home favourites could not be reloaded.";
if (!restoreAccepted) {
root.setDisplayBlocked(false); root.setDisplayBlocked(false);
root.protectedDisplays = ({}); root.protectedDisplays = ({});
root.protectedDisplayLayout = [];
} }
} else } else
root.lastError = ""; 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 { Timer {
id: applyRestoredState id: applyRestoredState
interval: 80 interval: 80
@@ -116,10 +149,11 @@ Singleton {
// DesktopPreferences.reload() invalidates reactive shell bindings. // DesktopPreferences.reload() invalidates reactive shell bindings.
// These services also own state outside QML and need an explicit // These services also own state outside QML and need an explicit
// replay: compositor options, Lua-generated binds, and hyprpaper. // replay: compositor options, Lua-generated binds, and hyprpaper.
root.applyCompositor(); root.applyIdle();
root.reloadKeybinds();
root.applyWallpaperPolicy();
root.regenerateLock(); root.regenerateLock();
root.applyWallpaperPolicy();
root.reloadKeybinds();
root.applyCompositor();
settleReload.attempts = 0; settleReload.attempts = 0;
settleReload.restart(); settleReload.restart();
@@ -136,11 +170,12 @@ Singleton {
// Let the current instances finish their external writes before a // Let the current instances finish their external writes before a
// soft reload replaces them. The cap keeps a failed external tool // soft reload replaces them. The cap keeps a failed external tool
// from leaving restored Home state stale indefinitely. // 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) { && !root.wallpaperBusy() && !root.lockBusy()) || attempts >= 30) {
stop(); stop();
root.setDisplayBlocked(false); root.setDisplayBlocked(false);
root.protectedDisplays = ({}); root.protectedDisplays = ({});
root.protectedDisplayLayout = [];
root.reloadShell(); root.reloadShell();
} }
} }
@@ -179,11 +214,76 @@ Singleton {
if (!root.reloadHomeState(text)) if (!root.reloadHomeState(text))
return false; return false;
root.reloadDesktop(); root.reloadDesktop();
if (!root.protectDisplays(root.protectedDisplays)) const restoredLayout = root.layoutFromStoredDisplays(root.readDisplays());
return false; if (restoredLayout === null || root.layoutsEqual(
applyRestoredState.restart(); restoredLayout, root.protectedDisplayLayout)) {
root.beginRestoredStateReplay();
return true; 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 // Restore output carries the canonical Home state. Reconstructing through
// these methods keeps validation and persistence inside HomePreferences; // these methods keeps validation and persistence inside HomePreferences;
@@ -252,6 +352,8 @@ Singleton {
const currentDisplays = root.readDisplays(); const currentDisplays = root.readDisplays();
root.protectedDisplays = JSON.parse(JSON.stringify( root.protectedDisplays = JSON.parse(JSON.stringify(
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {})); currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
root.protectedDisplayLayout = JSON.parse(JSON.stringify(
root.readLiveDisplayLayout() ?? []));
root.setDisplayBlocked(true); root.setDisplayBlocked(true);
actionRun.restoring = true; actionRun.restoring = true;
actionRun.exec([root.helperPath, "restore", name]); 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: "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: "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: "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 { function pageFor(group: string): string {
@@ -509,16 +509,13 @@ Singleton {
return false; return false;
} }
const currentDisplays = root.readDisplays();
const protectedDisplays = JSON.parse(JSON.stringify(
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
root.setDisplayBlocked(true); root.setDisplayBlocked(true);
DesktopPreferences.resetDesktopDefaults(); DesktopPreferences.resetDesktopDefaults();
if (!root.protectDisplays(protectedDisplays)) {
root.setDisplayBlocked(false); // Do not apply geometry during a reset: doing so would need the same
root.lastError = "The current display setting could not be protected during reset."; // visible confirmation transaction as the Displays page. Clearing the
return false; // 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 // Home accessories keep their own store (panama-home.json), so a reset
// that only cleared the schema store would silently leave a customised // that only cleared the schema store would silently leave a customised
@@ -15,7 +15,21 @@ ShellRoot {
property var homeFavorites: [] property var homeFavorites: []
property bool displayOperationBusy: false property bool displayOperationBusy: false
property bool displayBlocked: 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 { function record(name: string): void {
const next = root.calls.slice(); const next = root.calls.slice();
@@ -45,7 +59,10 @@ ShellRoot {
root.homeFavorites = root.homeFavorites.map(favorite => root.homeFavorites = root.homeFavorites.map(favorite =>
favorite.id === id ? { id: id, alias: alias } : 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.readDisplays = function() { return root.displayGeneration; };
SettingsBackup.protectDisplays = function(value) { SettingsBackup.protectDisplays = function(value) {
root.record("display.protect:" + JSON.stringify(value)); root.record("display.protect:" + JSON.stringify(value));
@@ -53,10 +70,31 @@ ShellRoot {
return true; return true;
}; };
SettingsBackup.displayBusy = function() { return root.displayOperationBusy; }; 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) { SettingsBackup.setDisplayBlocked = function(blocked) {
root.record("display.block:" + blocked); root.record("display.block:" + blocked);
root.displayBlocked = blocked; root.displayBlocked = blocked;
}; };
SettingsBackup.applyIdle = function() { root.record("idle.apply"); };
SettingsBackup.idleBusy = function() { return false; };
SettingsBackup.applyCompositor = function() { root.record("system.apply"); }; SettingsBackup.applyCompositor = function() { root.record("system.apply"); };
SettingsBackup.reloadKeybinds = function() { root.record("keybinds.reload"); }; SettingsBackup.reloadKeybinds = function() { root.record("keybinds.reload"); };
SettingsBackup.keybindsReloading = function() { return false; }; SettingsBackup.keybindsReloading = function() { return false; };
@@ -77,7 +115,15 @@ ShellRoot {
root.homeFavorites = []; root.homeFavorites = [];
root.displayOperationBusy = false; root.displayOperationBusy = false;
root.displayBlocked = 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.protectedDisplays = root.displayGeneration;
SettingsBackup.protectedDisplayLayout = root.liveLayout;
} }
function apply(output: string): bool { function apply(output: string): bool {
@@ -90,6 +136,11 @@ ShellRoot {
return SettingsBackup.restore("settings-20260818-010203004.json"); return SettingsBackup.restore("settings-20260818-010203004.json");
} }
function applyDisplayFailure(output: string): bool {
root.displayApplyAccepted = false;
return SettingsBackup.handleRestoreOutput(output);
}
function status(): string { function status(): string {
return JSON.stringify({ return JSON.stringify({
calls: root.calls, calls: root.calls,
+2
View File
@@ -86,6 +86,8 @@ ShellRoot {
Osd {} Osd {}
} }
DisplayIdentify {}
// ── Single-instance overlays ──────────────────────────────────────────── // ── Single-instance overlays ────────────────────────────────────────────
// These are always constructed but only *visible* when ShellState says so. // These are always constructed but only *visible* when ShellState says so.
// They're cheap while hidden, and keeping them alive means opening the // 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'
+266
View File
@@ -0,0 +1,266 @@
#!/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'
[[ "$(run ipc --pid "$harness_pid" call displays-test confirmChange)" == "false" ]] \
|| fail 'Keep accepted a display change without a generation-matched readback'
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'
+23 -10
View File
@@ -35,12 +35,12 @@ fail() {
# Keep is unavailable until compositor readback exactly matches the request. # Keep is unavailable until compositor readback exactly matches the request.
for contract in \ for contract in \
'property var pendingRequested:' \ 'property var pendingRequestedLayout:' \
'property var revertExpected:' \ 'property var revertExpectedLayout:' \
'property bool revertVerificationActive:' \ 'property bool revertVerificationActive:' \
'property int revertGeneration:' \ 'property int revertGeneration:' \
'readonly property bool canConfirm:' \ 'readonly property bool canConfirm:' \
'function matchesRequest(' \ 'function matchesLayout(' \
'function scalesForMode(' \ 'function scalesForMode(' \
'function isScaleClean(' \ 'function isScaleClean(' \
'x: Number.isInteger(monitor.x)' \ 'x: Number.isInteger(monitor.x)' \
@@ -170,6 +170,8 @@ original_transform=""
original_width="" original_width=""
original_height="" original_height=""
original_refresh="" original_refresh=""
original_x=""
original_y=""
monitor_name="" monitor_name=""
monitor_state() { monitor_state() {
@@ -186,15 +188,18 @@ display_is_restored() {
--argjson refresh "$original_refresh" \ --argjson refresh "$original_refresh" \
--argjson scale "$original_scale" \ --argjson scale "$original_scale" \
--argjson transform "$original_transform" \ --argjson transform "$original_transform" \
--argjson x "$original_x" \
--argjson y "$original_y" \
'.width == $width and .height == $height '.width == $width and .height == $height
and ((.refreshRate - $refresh) | fabs) < 0.01 and ((.refreshRate - $refresh) | fabs) < 0.01
and ((.scale - $scale) | fabs) < 0.001 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() { restore_display() {
[[ -n "$original_mode" ]] || return 0 [[ -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 || return 1
for _ in $(seq 1 50); do for _ in $(seq 1 50); do
display_is_restored && return 0 display_is_restored && return 0
@@ -215,8 +220,9 @@ cleanup() {
local status=$? local status=$?
trap - EXIT trap - EXIT
if ! restore_display; then if ! restore_display; then
printf 'displays contract: FAILED to restore %s to %s scale %s transform %s\n' \ printf 'displays contract: FAILED to restore %s to %s at %sx%s scale %s transform %s\n' \
"$monitor_name" "$original_mode" "$original_scale" "$original_transform" >&2 "$monitor_name" "$original_mode" "$original_x" "$original_y" \
"$original_scale" "$original_transform" >&2
status=1 status=1
fi fi
stop_harness stop_harness
@@ -262,6 +268,8 @@ original_height="$(jq -r .height <<<"$state")"
original_refresh="$(jq -r .refresh <<<"$state")" original_refresh="$(jq -r .refresh <<<"$state")"
original_scale="$(jq -r .scale <<<"$state")" original_scale="$(jq -r .scale <<<"$state")"
original_transform="$(jq -r .transform <<<"$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' [[ "$(jq -r .modes <<<"$state")" -gt 0 ]] || fail 'the display reported no usable modes'
@@ -327,9 +335,14 @@ done
[[ "$(status | jq -r .overridden)" == "false" ]] || fail 'an unconfirmed change was written to the settings store' [[ "$(status | jq -r .overridden)" == "false" ]] || fail 'an unconfirmed change was written to the settings store'
# ── A confirmed change is what writes ──────────────────────────────────────── # ── A confirmed change is what writes ────────────────────────────────────────
run ipc call displays-test applyScale "$target_scale" >/dev/null [[ "$(run ipc call displays-test applyScale "$target_scale")" == "true" ]] \
[[ "$(run ipc call displays-test confirmChange)" == "false" ]] \ || fail 'the confirmed-change fixture could not apply'
|| fail 'Keep accepted a display change before compositor readback'
# Hyprland can apply and read back a change faster than two IPC round trips, so
# this live test cannot reliably observe the pre-readback state. The
# fake-compositor contract deterministically holds that boundary open and
# proves Keep refuses it; this path proves a real readback eventually enables
# and persists Keep on the physical display.
verified=false verified=false
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
[[ "$(status | jq -r .canConfirm)" == "true" ]] && { verified=true; break; } [[ "$(status | jq -r .canConfirm)" == "true" ]] && { verified=true; break; }
@@ -13,6 +13,7 @@ test_bin="$fixture/bin"
settings="$config_home/panama/settings.json" settings="$config_home/panama/settings.json"
generated="$state_home/panama/hyprlock.conf" generated="$state_home/panama/hyprlock.conf"
hyprlock_log="$fixture/hyprlock.log" hyprlock_log="$fixture/hyprlock.log"
theme_helper="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps"
fail() { fail() {
printf 'lock screen helper contract: %s\n' "$1" >&2 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" \ mkdir -p "$fixture_home/Pictures/Wallpapers" "$config_home/panama" \
"$config_home/hypr" "$state_home" "$test_bin" "$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' cat >"$test_bin/hyprctl" <<'EOF'
#!/usr/bin/env bash #!/usr/bin/env bash
@@ -91,7 +97,14 @@ write_settings '{
"colorScheme":"light" "colorScheme":"light"
}' }'
run_helper generate 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_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 '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' 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)" repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
doctor="$repo_dir/config/dot/quickshell/scripts/panama-doctor" 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" fixture_root="$repo_dir/tests/quickshell/fixtures/doctor"
fail() { 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"}' \ printf '%s\n' '{"generated":false,"path":"/fixture-secret-wallpaper.jpg","fallback":true,"error":"fixture-secret-token"}' \
>"$state_home/panama/hyprlock-status.json" >"$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)" fallback_lock="$(run_doctor --json)"
check_status "$fallback_lock" desktop.hyprlock warning check_status "$fallback_lock" desktop.hyprlock warning
assert_schema_and_redaction "$fallback_lock" assert_schema_and_redaction "$fallback_lock"
rm "$config_home/hypr" rm -rf "$config_home/hypr"
missing_lock="$(run_doctor --json)" missing_lock="$(run_doctor --json)"
check_status "$missing_lock" desktop.hyprlock error check_status "$missing_lock" desktop.hyprlock error
assert_schema_and_redaction "$missing_lock" assert_schema_and_redaction "$missing_lock"
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# A search result must open the page that actually contains the setting.
#
# Every schema group routes to one page in services/SettingsSearch.qml, and the
# ownership rule in modules/settings/README.md derives a setting's owner from
# that route. Nothing checked the two agreed, and two groups had drifted:
#
# weather routed to Appearance while every weather control lived on Home, so
# searching "temperature unit" opened a page without it.
#
# vitals routed to Appearance, but the refresh interval sat on Home while the
# toggles it governs sat on Appearance -- one concept, two pages, which is
# precisely what the ownership rule forbids.
#
# Neither is visible from the code: each page is coherent on its own, and only
# following a search result reveals the mismatch.
#
# Groups whose settings are driven by bespoke UI rather than schema-bound rows
# are skipped, because there is nothing to locate.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
pages_dir="$repo_dir/config/dot/quickshell/modules/settings"
fail() {
printf 'search routing contract: %s\n' "$1" >&2
exit 1
}
routes="$(grep -oE '"[a-zA-Z]+": "[a-z-]+"' "$search" | tr -d '"' | tr ':' ' ')"
[[ -n "$routes" ]] || fail 'no group routes found -- this contract is not reading SettingsSearch correctly'
# page id -> Page component file, as SettingsShell maps them.
page_file() {
case "$1" in
home) printf 'HomePage.qml' ;;
appearance) printf 'AppearancePage.qml' ;;
displays) printf 'DisplaysPage.qml' ;;
connectivity) printf 'ConnectivityPage.qml' ;;
home-phone) printf 'HomePhonePage.qml' ;;
desktop) printf 'DesktopPage.qml' ;;
sound) printf 'SoundPage.qml' ;;
notifications) printf 'NotificationsPage.qml' ;;
screen-intelligence) printf 'ScreenIntelligencePage.qml' ;;
shortcuts) printf 'ShortcutsPage.qml' ;;
mouse) printf 'MousePage.qml' ;;
privacy) printf 'PrivacyPage.qml' ;;
region) printf 'RegionPage.qml' ;;
accounts) printf 'OnlineAccountsPage.qml' ;;
accessibility) printf 'AccessibilityPage.qml' ;;
power) printf 'PowerPage.qml' ;;
datetime) printf 'DateTimePage.qml' ;;
applications) printf 'ApplicationsPage.qml' ;;
services) printf 'HealthPage.qml' ;;
about) printf 'AboutPage.qml' ;;
*) printf '' ;;
esac
}
violations=0
checked=0
while read -r group page; do
[[ -n "$group" && -n "$page" ]] || continue
target="$(page_file "$page")"
[[ -n "$target" ]] || fail "group \"$group\" routes to \"$page\", which is not a known page"
[[ -r "$pages_dir/$target" ]] || fail "group \"$group\" routes to \"$page\" but $target does not exist"
# Keys belonging to this group.
keys="$(awk -v g="\"$group\"" '
/key: "/ { match($0, /key: "[a-zA-Z]+"/); k = substr($0, RSTART+6, RLENGTH-7) }
$0 ~ "group: " g { if (k != "") print k; k = "" }
' "$schema" | sort -u)"
while read -r key; do
[[ -n "$key" ]] || continue
# Only settings rendered as schema-bound rows can be located at all.
homes="$(grep -rl "setting: \"$key\"" "$pages_dir"/*Page.qml 2>/dev/null | xargs -n1 basename 2>/dev/null || true)"
[[ -n "$homes" ]] || continue
checked=$((checked + 1))
grep -qx "$target" <<<"$homes" && continue
printf ' %s (group "%s") routes to %s but appears only on: %s\n' \
"$key" "$group" "$page" "$(tr '\n' ' ' <<<"$homes")" >&2
violations=$((violations + 1))
done <<<"$keys"
done <<<"$routes"
if (( violations > 0 )); then
fail "$violations setting(s) route to a page that does not contain them, so searching for them opens the wrong page"
fi
printf 'search routing contract: PASS (%d routed settings)\n' "$checked"
@@ -44,6 +44,11 @@ for mapping in \
'HomePreferences.setAlias(id, alias);' \ 'HomePreferences.setAlias(id, alias);' \
'DesktopPreferences.reload();' \ 'DesktopPreferences.reload();' \
'DesktopPreferences.set("displays", value);' \ 'DesktopPreferences.set("displays", value);' \
'Displays.currentLayout();' \
'Displays.applyProtectedLayout(layout);' \
'Displays.canConfirm;' \
'Displays.confirm();' \
'IdleLock.apply();' \
'Displays.externalChangeBlocked = blocked;' \ 'Displays.externalChangeBlocked = blocked;' \
'SystemSettings.applyPersistedDisplayPolicy();' \ 'SystemSettings.applyPersistedDisplayPolicy();' \
'Keybinds.applyReload();' \ 'Keybinds.applyReload();' \
@@ -85,11 +90,13 @@ jq -e '
"home.alias:light.desk=Desk", "home.alias:light.desk=Desk",
"home.alias:light.office=Office", "home.alias:light.office=Office",
"desktop.reload", "desktop.reload",
"display.protect:{\"DP-2\":{\"mode\":\"4500x3000@60\",\"scale\":1.5,\"transform\":0}}", "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}]",
"system.apply", "display.confirm",
"keybinds.reload", "idle.apply",
"wallpaper.apply-policy",
"lock.regenerate", "lock.regenerate",
"wallpaper.apply-policy",
"keybinds.reload",
"system.apply",
"display.block:false", "display.block:false",
"shell.reload" "shell.reload"
] ]
@@ -124,11 +131,13 @@ jq -e '
.calls == [ .calls == [
"home.reset", "home.reset",
"desktop.reload", "desktop.reload",
"display.protect:{\"DP-2\":{\"mode\":\"4500x3000@60\",\"scale\":1.5,\"transform\":0}}", "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}]",
"system.apply", "display.confirm",
"keybinds.reload", "idle.apply",
"wallpaper.apply-policy",
"lock.regenerate", "lock.regenerate",
"wallpaper.apply-policy",
"keybinds.reload",
"system.apply",
"display.block:false", "display.block:false",
"shell.reload" "shell.reload"
] ]
@@ -136,6 +145,25 @@ jq -e '
and .favorites == [] and .favorites == []
' <<<"$status" >/dev/null || fail "absent Home handoff was wrong: $status" ' <<<"$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 # Restore refuses before launching the helper while a display apply/recovery is
# active, so no snapshot can race the confirmation boundary. # active, so no snapshot can race the confirmation boundary.
qs_test ipc call settings-backup-behavior reset >/dev/null 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 if rg -q 'HomePreferences\.(favorites|initialized)\s*=' "$system_settings"; then
fail 'restoreDefaults mutates Home aliases instead of using resetHomeDefaults' fail 'restoreDefaults mutates Home aliases instead of using resetHomeDefaults'
fi fi
rg -Fq 'root.protectDisplays(protectedDisplays)' "$system_settings" \ rg -Fq 'DesktopPreferences.resetDesktopDefaults();' "$system_settings" \
|| fail 'restoreDefaults can apply unconfirmed display geometry during reload' || fail 'restoreDefaults does not clear confirmed display layout fields'
rg -Fq 'Keybinds.applyReload();' "$system_settings" \ rg -Fq 'Keybinds.applyReload();' "$system_settings" \
|| fail 'restoreDefaults does not replay shipped keybindings' || fail 'restoreDefaults does not replay shipped keybindings'
rg -Fq 'root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? ""));' "$system_settings" \ 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 ───────────────────────── # ── 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 seedHome >/dev/null
qs_for_harness ipc call settings-system-test commit dockHideDelayMs 900 >/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" ]] \ [[ "$(qs_for_harness ipc call settings-system-test commit displays "$display_fixture")" == "true" ]] \
|| fail 'the protected display fixture did not apply' || fail 'the protected display fixture did not apply'
sleep 0.4 sleep 0.4
@@ -138,7 +138,6 @@ sleep 0.6
reset_state="$(qs_for_harness ipc call settings-system-test resetState)" reset_state="$(qs_for_harness ipc call settings-system-test resetState)"
jq -e '.calls == [ jq -e '.calls == [
"display.block:true", "display.block:true",
"display.protect",
"keybinds.reload", "keybinds.reload",
"wallpaper.set:", "wallpaper.set:",
"lock.regenerate", "lock.regenerate",
@@ -164,8 +163,8 @@ for default_case in \
[[ "$(qs_for_harness ipc call settings-system-test stored "$key")" == "$expected" ]] \ [[ "$(qs_for_harness ipc call settings-system-test stored "$key")" == "$expected" ]] \
|| fail "reset did not restore $key to $expected" || fail "reset did not restore $key to $expected"
done done
[[ "$(qs_for_harness ipc call settings-system-test stored displays | jq -cS .)" == "$(jq -cS . <<<"$display_fixture")" ]] \ [[ "$(qs_for_harness ipc call settings-system-test stored displays | jq -cS .)" == '{}' ]] \
|| fail 'reset replaced confirmed display geometry without confirmation' || fail 'reset retained confirmed arrangement fields instead of returning startup to shipped placement'
home_after="$(qs_for_harness ipc call settings-system-test homeState)" home_after="$(qs_for_harness ipc call settings-system-test homeState)"
jq -e '.count == 0 and .initialized == false' <<<"$home_after" >/dev/null \ jq -e '.count == 0 and .initialized == false' <<<"$home_after" >/dev/null \
@@ -102,7 +102,8 @@ for needle in \
'`inactiveOpacity`' \ '`inactiveOpacity`' \
'`lockMinutes`' \ '`lockMinutes`' \
'`lockOnSleep`' \ '`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" rg -Fq "$needle" "$readme" || fail "README is missing $needle"
done done
@@ -65,6 +65,9 @@ password field|Password field|appearance
slideshow|Wallpaper mode|appearance slideshow|Wallpaper mode|appearance
shuffle|Shuffle|appearance shuffle|Shuffle|appearance
per-display wallpaper|Per-display wallpaper|appearance per-display wallpaper|Per-display wallpaper|appearance
arrange displays|Arrange displays|displays
monitor position|Monitor position|displays
primary display|Primary display|displays
CASES CASES
! rg -Fq 'Startup & Services' "$repo_dir/config/dot/quickshell/services/SettingsSearch.qml" \ ! rg -Fq 'Startup & Services' "$repo_dir/config/dot/quickshell/services/SettingsSearch.qml" \