Rebuild Displays around the canvas, and let the transaction keep color
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -31,8 +31,106 @@ for contract in \
|
||||
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'
|
||||
|
||||
# Flipped by the displays redesign, deliberately and in this direction.
|
||||
#
|
||||
# The canvas used to be hidden below two displays. That left a laptop opening
|
||||
# its Displays page on a picker offering a list of one, and made the page's
|
||||
# most legible surface the one thing a single-display machine never saw. It is
|
||||
# the hero of the page now: a solo display is rendered in it, and only dragging
|
||||
# goes away, because there is nothing to arrange that display against.
|
||||
# The bindings of one QML element, found by matching its braces. A fixed-size
|
||||
# window around the element would either miss a binding or catch a neighbour's,
|
||||
# and both of those are the wrong answer here: the page's selector chips and its
|
||||
# Workspaces card are legitimately gated on having two displays.
|
||||
element_binding() { # file, element, pattern, present|absent
|
||||
python3 - "$@" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
path, opener, pattern, expectation = sys.argv[1:5]
|
||||
text = open(path, encoding="utf-8").read()
|
||||
start = text.find(opener)
|
||||
if start < 0:
|
||||
raise SystemExit(1)
|
||||
depth = 0
|
||||
end = len(text)
|
||||
for index in range(start + len(opener) - 1, len(text)):
|
||||
if text[index] == "{":
|
||||
depth += 1
|
||||
elif text[index] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = index
|
||||
break
|
||||
found = re.search(pattern, text[start:end]) is not None
|
||||
raise SystemExit(0 if found == (expectation == "present") else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
python3 - "$page" <<'PY' \
|
||||
|| fail 'the arrangement canvas is gated on a monitor count again -- on the canvas itself or on the card holding it -- so a single display sees no canvas'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
start = text.find("DisplayArrangement {")
|
||||
if start < 0:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def own_bindings(open_brace, stop):
|
||||
"""The block's own bindings, with nested elements left out: a sibling
|
||||
card's gate is its business, and this is only about the canvas."""
|
||||
kept = []
|
||||
depth = 0
|
||||
for char in text[open_brace + 1:stop]:
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth < 0:
|
||||
break
|
||||
elif depth == 0:
|
||||
kept.append(char)
|
||||
return "".join(kept)
|
||||
|
||||
|
||||
# The canvas's own bindings, and those of whatever element holds it: a gate on
|
||||
# either one is a canvas a single display never sees. This lived on the card
|
||||
# rather than on the canvas before the redesign, which is exactly why the
|
||||
# element alone is not enough to look at.
|
||||
stack = []
|
||||
for index, char in enumerate(text[:start]):
|
||||
if char == "{":
|
||||
stack.append(index)
|
||||
elif char == "}" and stack:
|
||||
stack.pop()
|
||||
if not stack:
|
||||
raise SystemExit(1)
|
||||
|
||||
regions = [own_bindings(start + len("DisplayArrangement {") - 1, len(text)),
|
||||
own_bindings(stack[-1], start)]
|
||||
gate = re.compile(r"visible:.*monitors\.length")
|
||||
raise SystemExit(1 if any(gate.search(region) for region in regions) else 0)
|
||||
PY
|
||||
|
||||
for contract in \
|
||||
'One display connected' \
|
||||
'draggable'; do
|
||||
rg -Fq "$contract" "$component" \
|
||||
|| fail "a solo display is not explained or not protected from dragging: $contract"
|
||||
done
|
||||
element_binding "$component" 'DragHandler {' 'enabled:' present \
|
||||
|| fail 'the drag handler is unconditional, so a solo display can be dragged around a canvas with nothing to arrange against'
|
||||
|
||||
# Mirroring has no position of its own: the compositor stacks a mirrored output
|
||||
# on its target. The canvas says which display it is mirroring rather than
|
||||
# drawing it wherever its stale coordinates happen to point.
|
||||
rg -Fq 'Mirrors ' "$component" \
|
||||
|| fail 'a mirrored display is drawn with no badge saying what it mirrors'
|
||||
rg -Fq 'mirrorOf' "$component" \
|
||||
|| fail 'the canvas does not read the mirror flag off its rects'
|
||||
[[ -f "$identify" ]] || fail 'DisplayIdentify.qml is missing'
|
||||
for contract in \
|
||||
'model: Quickshell.screens' \
|
||||
@@ -94,6 +192,17 @@ 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"
|
||||
|
||||
solo="$(ipc soloFixture)"
|
||||
jq -e '(.rects | length) == 1 and .scale > 0 and .draggable == false' <<<"$solo" >/dev/null \
|
||||
|| fail "a single display did not render solo with dragging disabled: $solo"
|
||||
|
||||
mirror="$(ipc mirrorFixture)"
|
||||
jq -e '(.rects | length) == 2
|
||||
and (.rects[1].mirrorOf == "DP-2")
|
||||
and (.rects[1].x == .rects[0].x) and (.rects[1].y == .rects[0].y)' \
|
||||
<<<"$mirror" >/dev/null \
|
||||
|| fail "a mirrored display was not stacked on its target and flagged: $mirror"
|
||||
|
||||
primary="$(ipc primaryFixture)"
|
||||
jq -e '. == [
|
||||
{"name":"DP-2","x":-3000,"y":0,"primary":false},
|
||||
|
||||
@@ -54,4 +54,34 @@ jq -e '.valid == true
|
||||
invalid="$(qs_for_harness ipc call display-layout-test invalid)"
|
||||
jq -e 'all(. == false)' <<<"$invalid" >/dev/null || fail "an invalid layout was accepted: $invalid"
|
||||
|
||||
# Mirroring is the one arrangement where the position on file is not the
|
||||
# position on the desk. Hyprland stacks a mirrored output on its target and
|
||||
# ignores whatever the rule asked for, so the geometry here has to agree:
|
||||
# a mirrored display contributes nothing to the desktop's bounds, and the
|
||||
# canvas draws it on top of what it mirrors rather than at stale coordinates.
|
||||
mirror="$(qs_for_harness ipc call display-layout-test mirror)"
|
||||
#
|
||||
# The mirrored record keeps the coordinates it was stored with -- normalize
|
||||
# shifts everything by the primary's anchor, and applying that to a position
|
||||
# nothing reads back would be arithmetic for its own sake. It contributes
|
||||
# nothing to the bounds, so the desktop measures 3000 x 2000: one display.
|
||||
jq -e '.valid == true
|
||||
and .normalized == [
|
||||
{"name":"DP-2","x":0,"y":0,"primary":true,"mirrorOf":""},
|
||||
{"name":"HDMI-A-1","x":3140,"y":80,"primary":false,"mirrorOf":"DP-2"}]
|
||||
and .bounds == {"x":0,"y":0,"width":3000,"height":2000}
|
||||
and (.rects | length) == 2
|
||||
and .rects[1].mirrorOf == "DP-2" and .rects[1].mirrored == true
|
||||
and .rects[0].mirrored == false
|
||||
and .rects[1].x == .rects[0].x and .rects[1].y == .rects[0].y
|
||||
and .rects[1].width == .rects[0].width' \
|
||||
<<<"$mirror" >/dev/null || fail "mirrored geometry was wrong: $mirror"
|
||||
|
||||
# A mirror that names itself, an absent output, or a display that is itself
|
||||
# mirroring is not a layout with a cosmetic problem -- it is one the compositor
|
||||
# will answer in a way nobody predicted.
|
||||
invalid_mirrors="$(qs_for_harness ipc call display-layout-test invalidMirrors)"
|
||||
jq -e 'all(. == false)' <<<"$invalid_mirrors" >/dev/null \
|
||||
|| fail "an impossible mirror was accepted: $invalid_mirrors"
|
||||
|
||||
printf 'display layout contract: PASS\n'
|
||||
|
||||
@@ -28,10 +28,34 @@ 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'
|
||||
|
||||
# The extended record. Every one of these is carried through currentLayout by
|
||||
# readback, which is the whole clobber fix: an apply that changes the scale
|
||||
# must not silently drop the bit depth monitors.lua asked for.
|
||||
for contract in \
|
||||
'function applyRecord(output:' \
|
||||
'vrrMode' \
|
||||
'colorProfile' \
|
||||
'bitdepth' \
|
||||
'sdrBrightness' \
|
||||
'sdrSaturation' \
|
||||
'mirrorOf'; do
|
||||
rg -Fq "$contract" "$service" \
|
||||
|| fail "the extended display record is missing: $contract"
|
||||
done
|
||||
rg -q 'vrrMode\s*(!==\s*-1|>\s*-1|>=\s*0)' "$service" \
|
||||
|| fail 'nothing in the service decides when the vrr key is omitted, so following the global policy would be written as an override'
|
||||
|
||||
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'
|
||||
rg -Fq 'function applyRecordFixture(' "$harness" \
|
||||
|| fail 'the fixture cannot exercise a partial record change'
|
||||
rg -Fq 'function layoutMatch(' "$harness" \
|
||||
|| fail 'the fixture cannot probe the readback carve-outs'
|
||||
rg -Fq 'function persistedEntryValid(' "$harness" \
|
||||
|| fail 'the fixture cannot prove an old stored blob still validates'
|
||||
|
||||
fixture="$(mktemp -d /tmp/panama-display-transaction.XXXXXX)"
|
||||
state_home="$fixture/state-home"
|
||||
@@ -47,11 +71,15 @@ 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,
|
||||
"currentFormat":"XRGB8888","colorManagementPreset":"auto",
|
||||
"sdrBrightness":1.0,"sdrSaturation":1.0,"mirrorOf":"none","vrr":false,
|
||||
"availableModes":["[email protected]"]
|
||||
},
|
||||
{
|
||||
"name":"HDMI-A-1","description":"Second fixture","width":2560,"height":1440,
|
||||
"refreshRate":60,"scale":1,"transform":0,"x":3000,"y":0,
|
||||
"currentFormat":"XRGB8888","colorManagementPreset":"auto",
|
||||
"sdrBrightness":1.0,"sdrSaturation":1.0,"mirrorOf":"none","vrr":false,
|
||||
"availableModes":["[email protected]"]
|
||||
}
|
||||
]
|
||||
@@ -78,7 +106,7 @@ if [[ -f "$fixture/fail-once" ]]; then
|
||||
fi
|
||||
[[ -f "$fixture/no-apply" ]] && exit 0
|
||||
|
||||
python3 - "$fixture/monitors.json" "$payload" "$fixture/wrong-y" <<'PY'
|
||||
python3 - "$fixture/monitors.json" "$payload" "$fixture/wrong-y" "$fixture/odd-format" <<'PY'
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
@@ -87,6 +115,7 @@ import sys
|
||||
state_path = pathlib.Path(sys.argv[1])
|
||||
payload = sys.argv[2]
|
||||
wrong_y = pathlib.Path(sys.argv[3]).exists()
|
||||
odd_format = pathlib.Path(sys.argv[4]).exists()
|
||||
monitors = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
by_name = {monitor["name"]: monitor for monitor in monitors}
|
||||
|
||||
@@ -97,10 +126,17 @@ for block in re.findall(r"hl\.monitor\(\{([^}]*)\}\)", payload):
|
||||
raise SystemExit(f"missing field {pattern}: {block}")
|
||||
return match.group(1)
|
||||
|
||||
def optional(pattern: str):
|
||||
match = re.search(pattern, block)
|
||||
return match.group(1) if match else None
|
||||
|
||||
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:
|
||||
# A mirrored rule asks for "auto" instead of coordinates, because the
|
||||
# compositor is the one that decides where a mirror lands.
|
||||
placement = field(r'position\s*=\s*"([^"]+)"')
|
||||
position = re.match(r"^(-?\d+)x(-?\d+)$", placement)
|
||||
if name not in by_name or (position is None and placement != "auto"):
|
||||
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]
|
||||
@@ -110,9 +146,44 @@ for block in re.findall(r"hl\.monitor\(\{([^}]*)\}\)", payload):
|
||||
"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 position is not None:
|
||||
monitor["x"] = int(position.group(1))
|
||||
monitor["y"] = int(position.group(2))
|
||||
|
||||
# The compositor answers about colour with the names Hyprland uses in
|
||||
# `hyprctl -j monitors`, not with the keys the rule was written in.
|
||||
preset = optional(r'cm\s*=\s*"([a-z]+)"')
|
||||
if preset is not None:
|
||||
monitor["colorManagementPreset"] = preset
|
||||
depth = optional(r"bitdepth\s*=\s*(\d+)")
|
||||
if depth is not None:
|
||||
monitor["currentFormat"] = "XRGB2101010" if depth == "10" else "XRGB8888"
|
||||
if odd_format:
|
||||
# A format Panama has no mapping for. The request is not wrong; the
|
||||
# readback is simply not evidence either way.
|
||||
monitor["currentFormat"] = "XBGR16161616F"
|
||||
sdr_brightness = optional(r"sdrbrightness\s*=\s*([0-9.]+)")
|
||||
if sdr_brightness is not None:
|
||||
monitor["sdrBrightness"] = float(sdr_brightness)
|
||||
sdr_saturation = optional(r"sdrsaturation\s*=\s*([0-9.]+)")
|
||||
if sdr_saturation is not None:
|
||||
monitor["sdrSaturation"] = float(sdr_saturation)
|
||||
|
||||
# Mirroring is where the compositor stops taking instructions: a mirrored
|
||||
# output lands on top of its target whatever position the rule carried.
|
||||
# Panama must not hold the requested x/y against it.
|
||||
# Hyprland says "none" rather than an empty string, and Panama has to know
|
||||
# that is not the name of a monitor.
|
||||
mirror = optional(r'mirror\s*=\s*"([A-Za-z0-9_.-]*)"')
|
||||
monitor["mirrorOf"] = mirror or "none"
|
||||
if mirror and mirror in by_name:
|
||||
monitor["x"] = by_name[mirror]["x"]
|
||||
monitor["y"] = by_name[mirror]["y"]
|
||||
|
||||
# vrr is deliberately not reflected here: the readback field is live
|
||||
# adaptive-sync state, and nothing in the service may verify against it.
|
||||
|
||||
if wrong_y and name == "HDMI-A-1":
|
||||
monitor["y"] += 1
|
||||
|
||||
@@ -251,6 +322,133 @@ mv "$fixture/reconnected.json" "$monitor_state"
|
||||
run ipc --pid "$harness_pid" call displays-test refresh >/dev/null
|
||||
wait_for '.layout | length == 2' >/dev/null
|
||||
|
||||
# ── The extended record ──────────────────────────────────────────────────────
|
||||
#
|
||||
# Colour, bit depth, the SDR trim and mirroring ride the same keep-or-revert
|
||||
# transaction as geometry. vrr cannot: `hyprctl -j monitors` reports whether
|
||||
# adaptive sync is live at this instant, not what the config asked for, so it
|
||||
# is applied and never verified -- and a display that follows the global gaming
|
||||
# policy must not emit the key at all, because emitting it IS the override.
|
||||
|
||||
b64() { printf '%s' "$1" | base64 -w0; }
|
||||
apply_record() {
|
||||
run ipc --pid "$harness_pid" call displays-test applyRecordFixture "$1" "$(b64 "$2")"
|
||||
}
|
||||
probe() {
|
||||
run ipc --pid "$harness_pid" call displays-test "$1" "$(b64 "$2")" "$(b64 "$3")"
|
||||
}
|
||||
settle() { wait_for '.busy == false and .awaiting == false and .reverting == null' >/dev/null; }
|
||||
|
||||
[[ "$(apply_record HDMI-A-1 '{"colorProfile":"srgb","bitdepth":10,"sdrBrightness":1.4,"sdrSaturation":1.0,"vrrMode":-1}')" == "true" ]] \
|
||||
|| fail 'an extended display record was refused'
|
||||
wait_for '.canConfirm == true' >/dev/null
|
||||
color_payload="$(tail -1 "$eval_log")"
|
||||
if rg -q 'vrr\s*=' <<<"$color_payload"; then
|
||||
fail "a display following the global VRR policy emitted a vrr override anyway: $color_payload"
|
||||
fi
|
||||
# Neutral saturation is the absence of a request. A rule that names 1.0 pins
|
||||
# the display to it, which is not the same thing as leaving it alone.
|
||||
if rg -q 'sdrsaturation\s*=' <<<"$color_payload"; then
|
||||
fail "neutral SDR saturation was written as a rule: $color_payload"
|
||||
fi
|
||||
rg -Fq 'cm = "srgb"' <<<"$color_payload" \
|
||||
&& rg -q 'bitdepth\s*=\s*10' <<<"$color_payload" \
|
||||
&& rg -q 'sdrbrightness\s*=\s*1\.4\b' <<<"$color_payload" \
|
||||
|| fail "the colour fields never reached the compositor payload: $color_payload"
|
||||
# The clobber this exists for: an apply that changes one field must carry the
|
||||
# rest of the record, or monitors.lua's 10-bit request dies on the next apply.
|
||||
rg -Fq 'output = "DP-2"' <<<"$color_payload" && rg -q 'bitdepth\s*=' <<<"$color_payload" \
|
||||
|| fail "the untouched display lost its colour fields on somebody else's apply: $color_payload"
|
||||
|
||||
[[ "$(run ipc --pid "$harness_pid" call displays-test confirmChange)" == "true" ]] \
|
||||
|| fail 'a verified colour change could not be kept'
|
||||
jq -e '.displays["HDMI-A-1"] | .colorProfile == "srgb" and .bitdepth == 10
|
||||
and .vrrMode == -1 and .mirrorOf == "" and (.sdrBrightness - 1.4 | fabs) < 0.001' \
|
||||
"$store" >/dev/null \
|
||||
|| fail 'confirmation did not persist the extended record'
|
||||
|
||||
# vrr applied, never verified: Keep must still become available.
|
||||
[[ "$(apply_record HDMI-A-1 '{"vrrMode":2}')" == "true" ]] \
|
||||
|| fail 'a VRR override was refused'
|
||||
wait_for '.canConfirm == true' >/dev/null
|
||||
rg -q 'vrr\s*=\s*2' <<<"$(tail -1 "$eval_log")" \
|
||||
|| fail "an explicit VRR override did not emit the vrr key: $(tail -1 "$eval_log")"
|
||||
run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null
|
||||
settle
|
||||
|
||||
# A framebuffer format with no 8/10 mapping is not evidence against the
|
||||
# request. Asserting it anyway would leave Keep permanently unavailable on
|
||||
# hardware that reports a format Panama has never heard of.
|
||||
touch "$fixture/odd-format"
|
||||
[[ "$(apply_record HDMI-A-1 '{"bitdepth":8}')" == "true" ]] \
|
||||
|| fail 'a bit depth change was refused'
|
||||
wait_for '.canConfirm == true' >/dev/null
|
||||
rm -f "$fixture/odd-format"
|
||||
run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null
|
||||
settle
|
||||
|
||||
# Mirroring: the compositor puts a mirrored output on top of its target and
|
||||
# ignores the position the rule carried, so x/y are the two assertions that
|
||||
# must be skipped for that record -- and only for that record.
|
||||
[[ "$(apply_record HDMI-A-1 '{"mirrorOf":"DP-2"}')" == "true" ]] \
|
||||
|| fail 'a valid mirror request was refused'
|
||||
wait_for '.canConfirm == true' >/dev/null
|
||||
mirror_payload="$(tail -1 "$eval_log")"
|
||||
rg -Fq 'mirror = "DP-2"' <<<"$mirror_payload" \
|
||||
|| fail "the mirror key never reached the compositor: $mirror_payload"
|
||||
# The mirrored rule asks for "auto", not for the coordinates on file: the
|
||||
# position it was stored with is not one the compositor will honour.
|
||||
rg -q 'output = "HDMI-A-1", mode = "[^"]+", position = "auto"' <<<"$mirror_payload" \
|
||||
|| fail "a mirrored display still asked for a position of its own: $mirror_payload"
|
||||
jq -e '.[1].mirrorOf == "DP-2" and .[1].x == .[0].x and .[1].y == .[0].y' \
|
||||
"$monitor_state" >/dev/null \
|
||||
|| fail 'the fixture compositor did not stack the mirrored output on its target'
|
||||
run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null
|
||||
settle
|
||||
|
||||
# The carve-out is exactly one record wide. Same readback, same layout, with
|
||||
# the mirror flag removed: now the position disagreement is a real one.
|
||||
#
|
||||
# Both fixtures are in the shape the service holds after parsing a readback,
|
||||
# not in hyprctl's own: this probes the comparison, not the parser.
|
||||
mirrored_readback='[{"name":"DP-2","width":4500,"height":3000,"refreshRate":60,"scale":1.5,"transform":0,"x":0,"y":0,"primary":true,"colorPreset":"auto","bitdepth":8,"sdrBrightness":1.0,"sdrSaturation":1.0,"mirrorOf":""},{"name":"HDMI-A-1","width":2560,"height":1440,"refreshRate":60,"scale":1,"transform":0,"x":0,"y":0,"primary":false,"colorPreset":"auto","bitdepth":8,"sdrBrightness":1.0,"sdrSaturation":1.0,"mirrorOf":"DP-2"}]'
|
||||
mirrored_request='[{"name":"DP-2","mode":"[email protected]","scale":1.5,"transform":0,"x":0,"y":0,"primary":true,"colorProfile":"auto","bitdepth":8,"sdrBrightness":1.0,"sdrSaturation":1.0,"mirrorOf":"","vrrMode":-1},{"name":"HDMI-A-1","mode":"[email protected]","scale":1,"transform":0,"x":3000,"y":100,"primary":false,"colorProfile":"auto","bitdepth":8,"sdrBrightness":1.0,"sdrSaturation":1.0,"mirrorOf":"DP-2","vrrMode":-1}]'
|
||||
unmirror='(.[] | select(.name == "HDMI-A-1") | .mirrorOf) = ""'
|
||||
[[ "$(probe layoutMatch "$mirrored_readback" "$mirrored_request")" == "true" ]] \
|
||||
|| fail 'a mirrored output was held to the position the compositor overrode'
|
||||
[[ "$(probe layoutMatch \
|
||||
"$(jq -c "$unmirror" <<<"$mirrored_readback")" \
|
||||
"$(jq -c "$unmirror" <<<"$mirrored_request")")" == "false" ]] \
|
||||
|| fail 'the position carve-out leaked to a record that is not mirrored'
|
||||
|
||||
# Anything the table does not allow is refused before the desktop moves.
|
||||
while IFS='|' read -r output patch reason; do
|
||||
[[ "$(apply_record "$output" "$patch")" == "false" ]] || fail "$reason"
|
||||
[[ "$(transaction_status | jq -r .awaiting)" == "false" ]] \
|
||||
|| fail "$reason (and it left a change pending)"
|
||||
done <<'CASES'
|
||||
HDMI-A-1|{"mirrorOf":"HDMI-A-1"}|a display was allowed to mirror itself
|
||||
DP-2|{"mirrorOf":"HDMI-A-1"}|the primary display was allowed to mirror another
|
||||
HDMI-A-1|{"mirrorOf":"NOPE-1"}|a display was allowed to mirror an output that is not connected
|
||||
HDMI-A-1|{"vrrMode":7}|an out-of-range VRR mode was accepted
|
||||
HDMI-A-1|{"colorProfile":"neon"}|an unknown colour profile was accepted
|
||||
HDMI-A-1|{"bitdepth":12}|an unsupported bit depth was accepted
|
||||
HDMI-A-1|{"sdrBrightness":4}|an out-of-range SDR brightness was accepted
|
||||
CASES
|
||||
|
||||
# Every machine that has this installed already has a settings.json with none
|
||||
# of the new fields in it. Those entries must keep validating, or a docking
|
||||
# station restores nothing on the next start.
|
||||
old_shape='{"mode":"[email protected]","scale":1,"transform":0,"x":3000,"y":100,"primary":false}'
|
||||
new_shape='{"mode":"[email protected]","scale":1,"transform":0,"x":3000,"y":100,"primary":false,"vrrMode":-1,"colorProfile":"srgb","bitdepth":10,"sdrBrightness":1.0,"sdrSaturation":1.0,"mirrorOf":""}'
|
||||
bad_shape='{"mode":"[email protected]","scale":1,"transform":0,"x":3000,"y":100,"primary":false,"vrrMode":7}'
|
||||
[[ "$(run ipc --pid "$harness_pid" call displays-test persistedEntryValid "$(b64 "$old_shape")")" == "true" ]] \
|
||||
|| fail 'a settings.json written before the colour fields existed stopped validating'
|
||||
[[ "$(run ipc --pid "$harness_pid" call displays-test persistedEntryValid "$(b64 "$new_shape")")" == "true" ]] \
|
||||
|| fail 'a stored entry carrying the extended record was rejected'
|
||||
[[ "$(run ipc --pid "$harness_pid" call displays-test persistedEntryValid "$(b64 "$bad_shape")")" == "false" ]] \
|
||||
|| fail 'a stored entry with an impossible VRR mode was accepted'
|
||||
|
||||
# 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" ]] \
|
||||
|
||||
@@ -48,6 +48,15 @@ for contract in \
|
||||
'primary: monitor.name === primaryName'; do
|
||||
rg -Fq "$contract" "$service" || fail "display service contract is missing: $contract"
|
||||
done
|
||||
# The rest of the record rides the same transaction. Colour and mirroring are
|
||||
# verified by readback like geometry; the VRR override is applied and never
|
||||
# verified, because the readback field is live adaptive-sync state rather than
|
||||
# the configured policy. Either way they are part of currentLayout, which is
|
||||
# what stops one apply from clobbering another field's value.
|
||||
for contract in 'colorProfile' 'bitdepth' 'sdrBrightness' 'mirrorOf' 'vrrMode'; do
|
||||
rg -Fq "$contract" "$service" || fail "the extended display record is missing: $contract"
|
||||
done
|
||||
|
||||
rg -Fq 'enabled: Displays.canConfirm' "$page" \
|
||||
|| fail 'Keep is enabled before the display change is verified'
|
||||
rg -Fq 'options: Displays.scalesForMode(' "$page" \
|
||||
@@ -67,7 +76,8 @@ rg -Fq 'if (root.busy)' "$service" \
|
||||
|
||||
# Stored JSON is untyped at field level, so the Lua startup consumer is the
|
||||
# final validation boundary and must support every named output it accepts.
|
||||
for contract in 'valid_mode' 'valid_scale' 'valid_transform' 'valid_position' 'valid_primary' 'pairs(displays)'; do
|
||||
for contract in 'valid_mode' 'valid_scale' 'valid_transform' 'valid_position' 'valid_primary' \
|
||||
'color_profile' 'bitdepth_value' 'vrr_value' 'sdr_value' 'mirror_value' 'pairs(displays)'; do
|
||||
rg -Fq "$contract" "$monitors_lua" || fail "monitor startup validation is missing: $contract"
|
||||
done
|
||||
|
||||
@@ -93,6 +103,7 @@ package.preload["prefs"] = function()
|
||||
["HDMI-A-1"] = {
|
||||
mode = "2560x1440@60", scale = 1, transform = 1,
|
||||
x = 3000, y = 0, primary = false,
|
||||
vrrMode = -1,
|
||||
},
|
||||
["LEGACY-1"] = { mode = "1920x1080@60", scale = 1.5, transform = 0 },
|
||||
["PARTIAL-1"] = {
|
||||
@@ -107,6 +118,24 @@ package.preload["prefs"] = function()
|
||||
mode = "1920x1080@60", scale = 1, transform = 0,
|
||||
x = 4440, y = 0, primary = false,
|
||||
},
|
||||
-- The extended record, as Settings stores it.
|
||||
["DP-3"] = {
|
||||
mode = "1920x1080@60", scale = 1, transform = 0,
|
||||
x = 4440, y = 0, primary = false,
|
||||
colorProfile = "srgb", bitdepth = 10,
|
||||
sdrBrightness = 1.2, sdrSaturation = 1.0,
|
||||
vrrMode = 2, mirrorOf = "DP-2",
|
||||
},
|
||||
-- Every new field impossible at once. Geometry is fine, so the
|
||||
-- display is still configured; the bad fields drop out one by
|
||||
-- one exactly as an invalid position does.
|
||||
["DP-4"] = {
|
||||
mode = "1920x1080@60", scale = 1, transform = 0,
|
||||
x = 6360, y = 0, primary = false,
|
||||
colorProfile = "neon", bitdepth = 12,
|
||||
sdrBrightness = 9, sdrSaturation = -1,
|
||||
vrrMode = 7, mirrorOf = "BAD OUTPUT",
|
||||
},
|
||||
}
|
||||
end,
|
||||
}
|
||||
@@ -139,9 +168,38 @@ assert(by_output["PARTIAL-1"] == nil)
|
||||
assert(by_output["BAD-PRIMARY"] == nil)
|
||||
assert(by_output["BAD OUTPUT"] == nil)
|
||||
assert(by_output[""] ~= nil)
|
||||
|
||||
-- The extended record reaches the compositor under Hyprland's own key names.
|
||||
assert(by_output["DP-3"].cm == "srgb")
|
||||
assert(by_output["DP-3"].bitdepth == 10)
|
||||
assert(by_output["DP-3"].sdrbrightness == 1.2)
|
||||
assert(by_output["DP-3"].vrr == 2)
|
||||
assert(by_output["DP-3"].mirror == "DP-2")
|
||||
-- A mirrored output shows its target's picture in its target's place, so the
|
||||
-- saved position is not ours to ask for.
|
||||
assert(by_output["DP-3"].position == "auto")
|
||||
-- Neutral SDR saturation is left out rather than written: a rule that names it
|
||||
-- pins the display to it.
|
||||
assert(by_output["DP-3"].sdrsaturation == nil)
|
||||
|
||||
-- -1 means "follow the global policy", which is the absence of an override,
|
||||
-- not an override with a special value. Writing a vrr key here would silently
|
||||
-- take this display out of the gaming policy it is meant to follow.
|
||||
assert(by_output["HDMI-A-1"].vrr == nil)
|
||||
|
||||
-- Bad fields drop, the display survives. This is the same fallback invalid
|
||||
-- geometry gets, and it matters more here: refusing the whole entry over an
|
||||
-- unreadable colour profile would lose the resolution too.
|
||||
assert(by_output["DP-4"] ~= nil)
|
||||
assert(by_output["DP-4"].position == "6360x0")
|
||||
assert(by_output["DP-4"].cm == nil)
|
||||
assert(by_output["DP-4"].bitdepth == nil)
|
||||
assert(by_output["DP-4"].sdrbrightness == nil)
|
||||
assert(by_output["DP-4"].vrr == nil)
|
||||
assert(by_output["DP-4"].mirror == nil)
|
||||
LUA
|
||||
|
||||
rg -Fq 'Resolution, scale, rotation, position, and primary display' \
|
||||
rg -Fq 'Resolution, scale, rotation, position, primary display, color, VRR override, and mirroring' \
|
||||
"$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" \
|
||||
|| fail 'the display preference does not document complete layout persistence'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user