Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63f4bdb547 | ||
|
|
1a93a788fa | ||
|
|
1debd37d01 | ||
|
|
4341628b2f | ||
|
|
7777f5c1aa | ||
|
|
bc7bf3185c | ||
|
|
f3d91c14a1 | ||
|
|
21beb066e1 | ||
|
|
d2898d8806 | ||
|
|
0c3935f818 | ||
|
|
70a9d27c98 | ||
|
|
5cf2943ccf |
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 parts = root.modeParts(requested.mode);
|
const expected = Array.from(layout).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
return !!parts
|
const actual = Array.from(monitors).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
&& monitor.width === parts.width
|
for (let index = 0; index < expected.length; index++) {
|
||||||
&& monitor.height === parts.height
|
const requested = expected[index];
|
||||||
&& Math.abs(monitor.refreshRate - parts.refresh) < 0.01
|
const monitor = actual[index];
|
||||||
&& Math.abs(monitor.scale - requested.scale) < 0.001
|
const parts = root.modeParts(requested.mode);
|
||||||
&& 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 {
|
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())
|
||||||
|
|||||||
@@ -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,12 +214,77 @@ 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;
|
||||||
|
}
|
||||||
|
root.pendingRestoredLayout = restoredLayout;
|
||||||
|
if (!root.applyDisplayLayout(restoredLayout))
|
||||||
|
return root.failDisplayRestore("The restored display layout was rejected.");
|
||||||
|
settleDisplayRestore.attempts = 0;
|
||||||
|
settleDisplayRestore.restart();
|
||||||
return true;
|
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;
|
||||||
// this service never mutates its aliases or private FileView directly.
|
// this service never mutates its aliases or private FileView directly.
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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'
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Executable
+100
@@ -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" \
|
||||||
|
|||||||
Reference in New Issue
Block a user