diff --git a/docs/superpowers/contracts/monitor-autoconfig-2026-11-11.json b/docs/superpowers/contracts/monitor-autoconfig-2026-11-11.json new file mode 100644 index 00000000..47ff8b75 --- /dev/null +++ b/docs/superpowers/contracts/monitor-autoconfig-2026-11-11.json @@ -0,0 +1,11 @@ +{ + "title": "Monitors: best mode automatically on hotplug", + "objective": "A systemd user service keeps every connected monitor at its native resolution and highest refresh rate, DP-0 primary and leftmost, others to its right, re-applied whenever a monitor is connected or disconnected.", + "acceptance_criteria": [ + "plan() turns the 2026-11-11 DP-0-off state into DP-0 3840x2160@144 at 0x0 plus HDMI-0 2560x1440@143.98 at 3840x0, and returns [] when already there", + "The watcher handles one burst of output events with one apply, survives a failed xrandr, and waits for late EDID modes", + "setup_monitor_autoconfig.sh leaves monitor-autoconfig.service enabled and active" + ], + "out_of_scope": ["Undoing deliberate mode changes by games or manual xrandr", "Wayland"], + "verifier": "python3 -m pytest linux_configuration/tests/test_monitor_layout.py linux_configuration/tests/test_monitor_autoconfig.py; pre-commit run --files " +} diff --git a/docs/superpowers/evidence/monitor-autoconfig-2026-11-11.json b/docs/superpowers/evidence/monitor-autoconfig-2026-11-11.json new file mode 100644 index 00000000..a9266bf0 --- /dev/null +++ b/docs/superpowers/evidence/monitor-autoconfig-2026-11-11.json @@ -0,0 +1,12 @@ +{ + "intent": "Every monitor kuhy connects runs at its native resolution and highest refresh rate automatically, with DP-0 primary on the left, without running xrandr by hand.", + "scope": ["linux_configuration/features/monitor_layout.py, monitor_autoconfig.py, setup_monitor_autoconfig.sh, systemd/monitor-autoconfig.service", "linux_configuration/tests/test_monitor_layout.py, test_monitor_autoconfig.py, conftest.py; meta/pyproject.toml pylint init-hook path", "Non-goal: reverting deliberate mode changes (games, manual xrandr); only connect/disconnect triggers a re-layout"], + "changes": ["monitor_layout.py: parses xrandr --query and plans one row: native size at max refresh, preferred primary leftmost, disconnected outputs holding a CRTC switched off; returns [] when already there", "monitor_autoconfig.py: systemd user service; RandR version handshake, listens for RROutputChangeNotify, settles 1 s, retries while a connected monitor lists no modes, logs and survives a failed xrandr", "setup_monitor_autoconfig.sh installs deps, the user unit (WantedBy graphical-session.target) and starts it"], + "verification": [ + {"command": "python3 -m pytest linux_configuration/tests/test_monitor_layout.py linux_configuration/tests/test_monitor_autoconfig.py --cov=monitor_layout --cov=monitor_autoconfig --cov-branch", "result": "pass", "evidence": "23 passed, 100% line+branch coverage on both modules"}, + {"command": "linux_configuration/features/setup_monitor_autoconfig.sh", "result": "pass", "evidence": "unit enabled and active; layout DP-0 3840x2160@144 + HDMI-0 2560x1440@143.98 left unchanged (dry-run plan empty)"}, + {"command": "RandR probe with/without xrandr_query_version", "result": "pass", "evidence": "no events before the handshake, CrtcChange/ScreenChange events after it"} + ], + "risks": ["Physical hotplug not exercised on the real machine (kuhy declined the replug check); relies on the NVIDIA driver emitting RROutputChangeNotify on connect"], + "rollback": ["systemctl --user disable --now monitor-autoconfig.service; git revert", "xrandr layout stays as last applied"] +} diff --git a/docs/superpowers/evidence/phone-vd-record-guard-2026-10-03.json b/docs/superpowers/evidence/phone-vd-record-guard-2026-10-03.json deleted file mode 100644 index 03ac0c68..00000000 --- a/docs/superpowers/evidence/phone-vd-record-guard-2026-10-03.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "intent": "Pin that `phone_vd.sh record start|stop` (new in ~/.claude: real MP4 video of a session's own virtual display via a read-only scrcpy) passes the phone_guard PreToolUse hook as a lease-free read, while the adb input that drives the recorded app still takes that app's lease.", - "scope": [ - "python_pkg/phone_guard/tests/test_hook.py", - "Non-goal: changing phone_guard classification -- the hook only inspects commands containing 'adb', so `phone_vd.sh record` never reached it and needed no change" - ], - "changes": [ - "TestLeases.test_recording_own_display_is_a_read: record start/stop take no lease and are not blocked; `phone_vd.sh record start a.mp4 && adb shell input -d 9 swipe ...` on the session's own display takes exactly pkg:" - ], - "verification": [ - { - "command": "capped.sh python3 -m pytest python_pkg/phone_guard -q --cov-branch --cov-report=term-missing", - "result": "pass", - "evidence": "68 passed; every python_pkg/phone_guard module 100% statements and branches" - }, - { - "command": "phone_vd.sh record start ~/data/claude-scratch/phone-record/vd37_final.mp4; 5 x adb shell input -d 37 swipe; phone_vd.sh record stop (Pixel 6a 23181JEGR08034, VD 37 running com.android.settings)", - "result": "pass", - "evidence": "ffprobe: h264 1080x2400, duration 10.511 s, 307 frames (~29 fps avg, ~35 fps while scrolling); the hook let every call through (the unit test pins that the swipe maps to pkg:; not observed live)" - } - ], - "risks": [ - "A raw `scrcpy --display-id=` still bypasses the guard (no 'adb' in the command); phone_vd.sh only ever records the caller's own display" - ], - "rollback": [ - "git revert this commit; it only adds a test and this evidence file", - "Run python3 -m pytest python_pkg/phone_guard afterwards" - ] -} diff --git a/linux_configuration/features/monitor_autoconfig.py b/linux_configuration/features/monitor_autoconfig.py new file mode 100755 index 00000000..b2350ee5 --- /dev/null +++ b/linux_configuration/features/monitor_autoconfig.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Keep every connected monitor at its best mode, re-applied on hotplug. + +Runs as the ``monitor-autoconfig`` systemd user service (installed by +``setup_monitor_autoconfig.sh``). It listens for X RandR output-change +events instead of a udev rule: no root, nothing in /etc, and a systemd +unit cannot be (re)started from a udev ``change`` event reliably. + +Usage: + monitor_autoconfig.py # apply now, then watch for hotplug + monitor_autoconfig.py --once # apply now and exit + monitor_autoconfig.py --dry-run # print the xrandr command only +""" + +from __future__ import annotations + +import argparse +import importlib +import subprocess +import sys +import time +from typing import TYPE_CHECKING, Protocol + +from monitor_layout import parse_xrandr, plan + +if TYPE_CHECKING: + from collections.abc import Callable + + from monitor_layout import Output + +# Xlib.ext.randr.RROutputChangeNotifyMask; fires on connect and disconnect. +# CRTC events are left out on purpose: they fire on every mode switch, so +# listening to them would undo a game's or kuhy's own deliberate xrandr change. +OUTPUT_CHANGE_MASK = 4 +# A freshly plugged monitor can take a moment before its EDID modes show up. +SETTLE_SECONDS = 1.0 +# How many settle periods to wait for a connected monitor to list any mode. +MODE_TRIES = 5 + + +class _Root(Protocol): + """Root window: takes the RandR event mask.""" + + def xrandr_select_input(self, mask: int) -> object: + """Subscribe to RandR events.""" + + +class _Screen(Protocol): + """Screen: exposes its root window.""" + + root: _Root + + +class Display(Protocol): + """The slice of ``Xlib.display.Display`` the watcher uses.""" + + def screen(self) -> _Screen: + """Default screen; its ``root`` window takes the event mask.""" + + def next_event(self) -> object: + """Block until the next X event arrives.""" + + def pending_events(self) -> int: + """Number of events already queued.""" + + def xrandr_query_version(self) -> object: + """RandR version handshake.""" + + +def _run(argv: list[str]) -> str: + """Run a command, return stdout; raise on non-zero exit.""" + return subprocess.run(argv, check=True, capture_output=True, text=True).stdout + + +def _settle() -> None: + """Give a just-plugged monitor time to publish its EDID modes.""" + time.sleep(SETTLE_SECONDS) + + +def _query( + run: Callable[[list[str]], str], settle: Callable[[], None], tries: int +) -> list[Output]: + """Query xrandr, re-asking while a connected monitor still lists no mode.""" + outputs = parse_xrandr(run(["xrandr", "--query"])) + for _ in range(tries - 1): + if all(o.modes for o in outputs if o.connected): + break + settle() + outputs = parse_xrandr(run(["xrandr", "--query"])) + return outputs + + +def apply_once( + primary: str, + run: Callable[[list[str]], str] = _run, + settle: Callable[[], None] = _settle, + tries: int = MODE_TRIES, +) -> bool: + """Apply the best layout; return whether xrandr had to change anything.""" + args = plan(_query(run, settle, tries), primary) + if not args: + return False + run(["xrandr", *args]) + sys.stdout.write(" ".join(["applied: xrandr", *args]) + "\n") + sys.stdout.flush() + return True + + +def _safe_apply( + primary: str, run: Callable[[list[str]], str], settle: Callable[[], None] +) -> None: + """Apply, but log a failed xrandr instead of dying. + + One bad monitor must not stop every later hotplug from being handled. + """ + try: + apply_once(primary, run, settle) + except subprocess.CalledProcessError as error: + sys.stderr.write(f"xrandr failed ({error.returncode}): {error.stderr}\n") + + +def _open_display() -> Display: + """Connect to ``$DISPLAY`` (Xlib imported lazily; tests never need it).""" + display: Display = importlib.import_module("Xlib.display").Display() + return display + + +def watch( + primary: str, + display: Display | None = None, + run: Callable[[list[str]], str] = _run, + settle: Callable[[], None] = _settle, +) -> None: + """Apply now, then once per burst of RandR output events, forever.""" + display = display or _open_display() + # Without announcing RandR >= 1.2 the server never sends output events. + display.xrandr_query_version() + display.screen().root.xrandr_select_input(OUTPUT_CHANGE_MASK) + _safe_apply(primary, run, settle) + while True: + display.next_event() + settle() + # Our own xrandr call and multi-output plugs emit several events; + # drain them so one burst costs one re-plan. + while display.pending_events(): + display.next_event() + _safe_apply(primary, run, settle) + + +def main(argv: list[str] | None = None, run: Callable[[list[str]], str] = _run) -> int: + """CLI entry point.""" + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--primary", default="DP-0", help="leftmost primary output") + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--once", action="store_true", help="apply and exit") + mode.add_argument("--dry-run", action="store_true", help="print, change nothing") + args = parser.parse_args(argv) + if args.dry_run: + planned = plan(_query(run, _settle, MODE_TRIES), args.primary) + sys.stdout.write(" ".join(["xrandr", *planned]) + "\n") + elif args.once: + apply_once(args.primary, run) + else: + watch(args.primary) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/linux_configuration/features/monitor_layout.py b/linux_configuration/features/monitor_layout.py new file mode 100755 index 00000000..b8f4cb7f --- /dev/null +++ b/linux_configuration/features/monitor_layout.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Plan an xrandr layout: every monitor at its best mode, side by side. + +Pure functions only (text in, argument list out) so the policy is testable +without an X server; ``monitor_autoconfig.py`` does the I/O. Run directly, +``xrandr --query | monitor_layout.py`` prints the plan for any xrandr dump. + +Policy, agreed 2026-11-11: +- best mode = the monitor's native (EDID-preferred, ``+``) size at the + highest refresh rate offered for that size. A bigger listed size is a + downscale, not "better". Without a preferred mode: largest area, then + fastest, interlaced modes skipped. +- layout = one row at y=0; the preferred primary (default DP-0) leftmost, + the rest in xrandr order to its right. If it is absent, the first + connected output is primary. +- disconnected outputs that still hold a CRTC are switched off, so no gap. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import re +import sys + +_HEADER = re.compile( + r"^(?P\S+) (?Pconnected|disconnected)(?P primary)?" + r"(?: (?P\d+x\d+\+\d+\+\d+))?" +) +_MODE_LINE = re.compile(r"^\s+(?P\d+)x(?P\d+)(?P\S*)\s+(?P.*)$") +# "59.95 +", "143.98*", "60.00*+": the + may sit one space after the rate. +_RATE = re.compile(r"(?P\d+\.\d+)(?P\*?) ?(?P\+?)") + + +@dataclass(frozen=True) +class Mode: + """One resolution + refresh rate, rate kept as xrandr prints it.""" + + width: int + height: int + rate: str + + @property + def size(self) -> str: + """``WxH`` as xrandr's ``--mode`` expects.""" + return f"{self.width}x{self.height}" + + +@dataclass +class Output: + """One xrandr output and the modes its monitor offers.""" + + name: str + connected: bool + primary: bool + geometry: str | None + modes: list[Mode] = field(default_factory=list) + current: Mode | None = None + preferred_size: tuple[int, int] | None = None + + +def _add_mode_line(output: Output, match: re.Match[str]) -> None: + """Record every rate of one mode line, plus its ``*``/``+`` flags.""" + width, height = int(match["w"]), int(match["h"]) + interlaced = match["suffix"].startswith("i") + for rate in _RATE.finditer(match["rates"]): + mode = Mode(width, height, rate["rate"]) + if not interlaced: + output.modes.append(mode) + if rate["current"]: + output.current = mode + if rate["preferred"]: + output.preferred_size = (width, height) + + +def parse_xrandr(text: str) -> list[Output]: + """Parse ``xrandr --query`` output into outputs, in xrandr order.""" + outputs: list[Output] = [] + for line in text.splitlines(): + header = _HEADER.match(line) + if header: + outputs.append( + Output( + name=header["name"], + connected=header["state"] == "connected", + primary=bool(header["primary"]), + geometry=header["geometry"], + ) + ) + continue + mode_line = _MODE_LINE.match(line) + if mode_line and outputs: + _add_mode_line(outputs[-1], mode_line) + return outputs + + +def pick_mode(output: Output) -> Mode | None: + """Native size at max refresh; else largest area at max refresh.""" + candidates = output.modes + if output.preferred_size: + native = [m for m in candidates if (m.width, m.height) == output.preferred_size] + candidates = native or candidates + if not candidates: + return None + return max(candidates, key=lambda m: (m.width * m.height, float(m.rate))) + + +def _ordered(outputs: list[Output], primary: str) -> list[tuple[Output, Mode]]: + """Connected outputs with a usable mode, preferred primary first.""" + usable = [(o, m) for o in outputs if o.connected and (m := pick_mode(o))] + usable.sort(key=lambda pair: pair[0].name != primary) + return usable + + +def plan(outputs: list[Output], primary: str) -> list[str]: + """Return the xrandr arguments to reach the policy, ``[]`` if already there.""" + row = _ordered(outputs, primary) + args: list[str] = [] + changed = False + x = 0 + for index, (output, mode) in enumerate(row): + is_primary = index == 0 + args += ["--output", output.name, "--mode", mode.size, "--rate", mode.rate] + args += ["--pos", f"{x}x0"] + (["--primary"] if is_primary else []) + wanted_geometry = f"{mode.size}+{x}+0" + changed |= output.current != mode or output.geometry != wanted_geometry + changed |= output.primary != is_primary + x += mode.width + for output in outputs: + if not output.connected and output.geometry: + args += ["--output", output.name, "--off"] + changed = True + return args if changed else [] + + +def main() -> int: + """Print the plan for ``xrandr --query`` text read from stdin.""" + sys.stdout.write(" ".join(plan(parse_xrandr(sys.stdin.read()), "DP-0")) + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/linux_configuration/features/setup_monitor_autoconfig.sh b/linux_configuration/features/setup_monitor_autoconfig.sh new file mode 100755 index 00000000..07718d84 --- /dev/null +++ b/linux_configuration/features/setup_monitor_autoconfig.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# setup_monitor_autoconfig.sh — every monitor at its best mode, automatically. +# +# Installs the monitor-autoconfig systemd user service. It applies the layout +# at login and again whenever a monitor is plugged or unplugged: +# - each monitor runs its native resolution at the highest refresh rate; +# - DP-0 is primary and leftmost, the others sit to its right; +# - an unplugged monitor is switched off so no gap is left. +# Policy lives in monitor_layout.py; the watcher in monitor_autoconfig.py. +# +# Run as your normal user (never with sudo). Idempotent: rerun after edits. +# Uninstall: systemctl --user disable --now monitor-autoconfig.service + +set -euo pipefail + +HERE="$(dirname "$(readlink -f "$0")")" +readonly HERE +readonly UNIT=monitor-autoconfig.service +readonly UNIT_DIR="$HOME/.config/systemd/user" + +log() { printf '[monitor-autoconfig] %s\n' "$*"; } +die() { + printf '[monitor-autoconfig] ERROR: %s\n' "$*" >&2 + exit 1 +} + +install_deps() { + local pkg missing=() + for pkg in python-xlib xorg-xrandr; do + pacman -Q "$pkg" >/dev/null 2>&1 || missing+=("$pkg") + done + if ((${#missing[@]})); then + log "installing ${missing[*]}" + sudo pacman -S --needed --noconfirm "${missing[@]}" + fi +} + +install_unit() { + mkdir -p "$UNIT_DIR" + sed "s|@FEATURES_DIR@|$HERE|" "$HERE/systemd/$UNIT" >"$UNIT_DIR/$UNIT" + systemctl --user daemon-reload + systemctl --user enable "$UNIT" >/dev/null + systemctl --user restart "$UNIT" +} + +main() { + ((EUID != 0)) || die "run as your normal user, not root" + install_deps + install_unit + systemctl --user is-active --quiet "$UNIT" || die "$UNIT failed to start" + log "active; layout now: $(xrandr --listmonitors | tail -n +2 | awk '{print $4}' | paste -sd' ')" +} + +main "$@" diff --git a/linux_configuration/features/systemd/monitor-autoconfig.service b/linux_configuration/features/systemd/monitor-autoconfig.service new file mode 100644 index 00000000..32e5c5e0 --- /dev/null +++ b/linux_configuration/features/systemd/monitor-autoconfig.service @@ -0,0 +1,17 @@ +[Unit] +Description=Every monitor at its best resolution and refresh rate, on hotplug +After=graphical-session.target +# An X restart kills the watcher (exit 1) and Restart= brings it back; a +# genuinely broken setup trips this limit and shows as a failed unit. +StartLimitIntervalSec=300 +StartLimitBurst=5 + +[Service] +Type=simple +Environment=DISPLAY=:0 +ExecStart=/usr/bin/python3 @FEATURES_DIR@/monitor_autoconfig.py +Restart=on-failure +RestartSec=3s + +[Install] +WantedBy=graphical-session.target diff --git a/linux_configuration/tests/conftest.py b/linux_configuration/tests/conftest.py index c16f86e3..f94d95e3 100644 --- a/linux_configuration/tests/conftest.py +++ b/linux_configuration/tests/conftest.py @@ -24,6 +24,7 @@ _REPO_ROOT = Path(__file__).resolve().parents[2] _SCRIPT_DIRS = ( _REPO_ROOT / "meta" / "scripts", # validate_evidence, validate_contract _REPO_ROOT / "linux_configuration" / "utils", # fast_count + _REPO_ROOT / "linux_configuration" / "features", # monitor_layout _REPO_ROOT / "linux_configuration" / "fixes", # g29_shifter_capture _REPO_ROOT / "linux_configuration" diff --git a/linux_configuration/tests/test_monitor_autoconfig.py b/linux_configuration/tests/test_monitor_autoconfig.py new file mode 100644 index 00000000..a30a9555 --- /dev/null +++ b/linux_configuration/tests/test_monitor_autoconfig.py @@ -0,0 +1,189 @@ +"""Tests for linux_configuration/features/monitor_autoconfig.py (I/O shell).""" + +from __future__ import annotations + +import subprocess +from types import SimpleNamespace +from typing import TYPE_CHECKING + +import monitor_autoconfig +from monitor_autoconfig import apply_once, main, watch +import pytest + +if TYPE_CHECKING: + from collections.abc import Callable + +OFF = """\ +DP-0 connected primary (normal left inverted right x axis y axis) + 3840x2160 60.00 + 144.00 +HDMI-0 connected 2560x1440+3840+0 (normal left inverted right x axis y axis) + 2560x1440 59.95 + 143.98* +""" +BEST = """\ +DP-0 connected primary 3840x2160+0+0 (normal left inverted right x axis y axis) + 3840x2160 60.00 + 144.00* +HDMI-0 connected 2560x1440+3840+0 (normal left inverted right x axis y axis) + 2560x1440 59.95 + 143.98* +""" +WANT = [ + "xrandr", "--output", "DP-0", "--mode", "3840x2160", "--rate", "144.00", + "--pos", "0x0", "--primary", "--output", "HDMI-0", "--mode", "2560x1440", + "--rate", "143.98", "--pos", "3840x0", +] # fmt: skip + + +NO_MODES_YET = """\ +DP-0 connected primary (normal left inverted right x axis y axis) +HDMI-0 connected 2560x1440+3840+0 (normal left inverted right x axis y axis) + 2560x1440 59.95 + 143.98* +""" + + +class FakeXrandr: + """Serves queued ``xrandr --query`` texts and records apply calls.""" + + def __init__(self, *queries: str, fail_apply: bool = False) -> None: + self.queries = list(queries) + self.applied: list[list[str]] = [] + self.fail_apply = fail_apply + + def __call__(self, argv: list[str]) -> str: + if argv == ["xrandr", "--query"]: + return self.queries.pop(0) + self.applied.append(argv) + if self.fail_apply: + raise subprocess.CalledProcessError(1, argv, stderr="crtc failed") + return "" + + +class FakeDisplay: + """Xlib display double: ``events`` batches, then the X server goes away.""" + + def __init__(self, batches: list[int]) -> None: + self.batches = batches + self.pending = 0 + self.selected: list[int] = [] + self.handshakes = 0 + root = type("Root", (), {"xrandr_select_input": self.selected.append})() + self.screen: Callable[[], object] = type("S", (), {"root": root}) + + def next_event(self) -> None: + if self.pending: + self.pending -= 1 + return + if not self.batches: + raise ConnectionError + self.pending = self.batches.pop(0) - 1 + + def pending_events(self) -> int: + return self.pending + + def xrandr_query_version(self) -> None: + """X only sends RandR 1.2+ output events after this handshake.""" + self.handshakes += 1 + + +def test_apply_once_runs_planned_xrandr() -> None: + """An off monitor gets switched on with one xrandr call.""" + fake = FakeXrandr(OFF) + assert apply_once("DP-0", run=fake) is True + assert fake.applied == [WANT] + + +def test_apply_once_noop_when_best() -> None: + """Already-best layout means no xrandr call (no flicker on stray events).""" + fake = FakeXrandr(BEST) + assert apply_once("DP-0", run=fake) is False + assert fake.applied == [] + + +def test_watch_applies_at_start_and_once_per_burst() -> None: + """A burst of 3 hotplug events is drained and handled by one apply.""" + fake = FakeXrandr(OFF, BEST) + display = FakeDisplay([3]) + with pytest.raises(ConnectionError): + watch("DP-0", display=display, run=fake, settle=lambda: None) + assert display.handshakes == 1 + assert display.selected == [monitor_autoconfig.OUTPUT_CHANGE_MASK] + assert fake.applied == [WANT] + assert fake.queries == [] + + +def test_main_dry_run_prints_command(capsys: pytest.CaptureFixture[str]) -> None: + """``--dry-run`` prints the xrandr command and changes nothing.""" + fake = FakeXrandr(OFF) + assert main(["--dry-run"], run=fake) == 0 + assert capsys.readouterr().out.split() == WANT + assert fake.applied == [] + + +def test_main_once_applies(monkeypatch: pytest.MonkeyPatch) -> None: + """``--once`` applies the layout and exits without watching.""" + fake = FakeXrandr(OFF) + monkeypatch.setattr(monitor_autoconfig, "watch", pytest.fail) + assert main(["--once", "--primary", "DP-0"], run=fake) == 0 + assert fake.applied == [WANT] + + +def test_main_default_watches(monkeypatch: pytest.MonkeyPatch) -> None: + """With no mode flag, ``main`` hands off to ``watch``.""" + seen: list[str] = [] + monkeypatch.setattr(monitor_autoconfig, "watch", seen.append) + assert main([]) == 0 + assert seen == ["DP-0"] + + +def test_apply_once_waits_for_late_edid_modes() -> None: + """A just-plugged monitor with no modes yet is re-queried, then applied.""" + fake = FakeXrandr(NO_MODES_YET, OFF) + settles: list[None] = [] + assert apply_once("DP-0", run=fake, settle=lambda: settles.append(None)) + assert settles == [None] + assert fake.applied == [WANT] + + +def test_apply_once_gives_up_waiting_after_tries() -> None: + """Modes that never appear stop the retry; the rest is still laid out.""" + fake = FakeXrandr(NO_MODES_YET, NO_MODES_YET) + assert apply_once("DP-0", run=fake, settle=lambda: None, tries=2) is True + assert fake.applied[0][:3] == ["xrandr", "--output", "HDMI-0"] + + +def test_watch_survives_failed_xrandr(capsys: pytest.CaptureFixture[str]) -> None: + """A failing apply is logged and the watcher keeps handling events.""" + fake = FakeXrandr(OFF, OFF, fail_apply=True) + with pytest.raises(ConnectionError): + watch("DP-0", display=FakeDisplay([1]), run=fake, settle=lambda: None) + assert fake.applied == [WANT, WANT] + assert "crtc failed" in capsys.readouterr().err + + +def test_run_returns_stdout(monkeypatch: pytest.MonkeyPatch) -> None: + """``_run`` is a checked subprocess call returning stdout.""" + calls: list[tuple[list[str], dict[str, object]]] = [] + + def fake_run(argv: list[str], **kwargs: object) -> SimpleNamespace: + calls.append((argv, kwargs)) + return SimpleNamespace(stdout="out") + + monkeypatch.setattr(monitor_autoconfig.subprocess, "run", fake_run) + assert monitor_autoconfig._run(["xrandr"]) == "out" + assert calls[0][1]["check"] is True + + +def test_open_display_uses_xlib(monkeypatch: pytest.MonkeyPatch) -> None: + """The real display comes from ``Xlib.display.Display()``.""" + sentinel = object() + fake_module = SimpleNamespace(Display=lambda: sentinel) + monkeypatch.setattr( + monitor_autoconfig.importlib, "import_module", lambda name: fake_module + ) + assert monitor_autoconfig._open_display() is sentinel + + +def test_settle_sleeps_settle_seconds(monkeypatch: pytest.MonkeyPatch) -> None: + """The real settle waits ``SETTLE_SECONDS`` for EDID to arrive.""" + slept: list[float] = [] + monkeypatch.setattr(monitor_autoconfig.time, "sleep", slept.append) + monitor_autoconfig._settle() + assert slept == [monitor_autoconfig.SETTLE_SECONDS] diff --git a/linux_configuration/tests/test_monitor_layout.py b/linux_configuration/tests/test_monitor_layout.py new file mode 100644 index 00000000..9337bee3 --- /dev/null +++ b/linux_configuration/tests/test_monitor_layout.py @@ -0,0 +1,144 @@ +"""Tests for linux_configuration/features/monitor_layout.py (xrandr planner).""" + +from __future__ import annotations + +import io + +import monitor_layout +from monitor_layout import Mode, parse_xrandr, pick_mode, plan +import pytest + +AXES = "(normal left inverted right x axis y axis)" + +# Real `xrandr --query` from 2026-11-11: DP-0 connected but switched off, the +# state that made kuhy's manual `xrandr --size` attempt fail. +DP0_OFF = f"""\ +Screen 0: minimum 8 x 8, current 6400 x 1440, maximum 32767 x 32767 +HDMI-0 connected 2560x1440+3840+0 {AXES} 698mm x 393mm + 2560x1440 59.95 + 143.98* 120.00 + 3840x2160 59.94 50.00 + 1920x1080 120.00 119.88 100.00 60.00 59.94 50.00 +DP-0 connected primary {AXES} + 3840x2160 60.00 + 144.00 119.88 100.00 59.94 50.00 + 2560x1440 144.00 120.00 + 1920x1080 119.88 60.00 59.94 50.00 +DP-1 disconnected {AXES} +DP-3 disconnected {AXES} +""" + +# The same machine after the fix: both outputs at native size, max refresh. +BOTH_BEST = f"""\ +Screen 0: minimum 8 x 8, current 6400 x 2160, maximum 32767 x 32767 +HDMI-0 connected 2560x1440+3840+0 {AXES} 698mm x 393mm + 2560x1440 59.95 + 143.98* 120.00 + 3840x2160 59.94 50.00 +DP-0 connected primary 3840x2160+0+0 {AXES} 620mm x 340mm + 3840x2160 60.00 + 144.00* 119.88 +DP-1 disconnected {AXES} +""" + +# DP-0 unplugged while still holding a CRTC; HDMI-0 sits at +3840. +DP0_UNPLUGGED = f"""\ +Screen 0: minimum 8 x 8, current 6400 x 2160, maximum 32767 x 32767 +HDMI-0 connected 2560x1440+3840+0 {AXES} 698mm x 393mm + 2560x1440 59.95 + 143.98* 120.00 +DP-0 disconnected primary 3840x2160+0+0 {AXES} 0mm x 0mm +""" + +# A monitor whose EDID marks no preferred mode, with an interlaced mode too. +NO_PREFERRED = f"""\ +Screen 0: minimum 8 x 8, current 3840 x 2160, maximum 32767 x 32767 +DP-0 connected primary 3840x2160+0+0 {AXES} 620mm x 340mm + 3840x2160 60.00*+ +DP-1 connected {AXES} + 1920x1080 60.00 75.00 + 1920x1080i 120.00 + 1280x1024 85.00 +""" + +HDMI_BEST = ["--output", "HDMI-0", "--mode", "2560x1440", "--rate", "143.98"] + + +class TestParse: + """``parse_xrandr`` reads connection, primary, geometry and modes.""" + + def test_connection_primary_and_geometry(self) -> None: + """Off outputs have no geometry; on outputs report size and position.""" + outputs = {o.name: o for o in parse_xrandr(DP0_OFF)} + assert outputs["DP-0"].connected + assert outputs["DP-0"].primary + assert outputs["DP-0"].geometry is None + assert outputs["HDMI-0"].geometry == "2560x1440+3840+0" + assert not outputs["DP-1"].connected + + def test_flags_separated_by_space(self) -> None: + """``59.95 +`` marks 59.95 preferred; ``143.98*`` marks it current.""" + hdmi = next(o for o in parse_xrandr(DP0_OFF) if o.name == "HDMI-0") + assert hdmi.preferred_size == (2560, 1440) + assert hdmi.current == Mode(2560, 1440, "143.98") + + def test_combined_flags(self) -> None: + """``60.00*+`` is both current and preferred.""" + dp0 = parse_xrandr(NO_PREFERRED)[0] + assert dp0.current == Mode(3840, 2160, "60.00") + assert dp0.preferred_size == (3840, 2160) + + +class TestPickMode: + """Best mode = native (preferred) size at its highest refresh rate.""" + + def test_native_size_beats_bigger_listed_size(self) -> None: + """HDMI-0 lists 3840x2160@60 but its native 1440p@144 wins.""" + hdmi = next(o for o in parse_xrandr(DP0_OFF) if o.name == "HDMI-0") + assert pick_mode(hdmi) == Mode(2560, 1440, "143.98") + + def test_highest_rate_at_preferred_size(self) -> None: + """DP-0's preferred is 4K@60; 144 Hz at the same size is picked.""" + dp0 = next(o for o in parse_xrandr(DP0_OFF) if o.name == "DP-0") + assert pick_mode(dp0) == Mode(3840, 2160, "144.00") + + def test_no_preferred_falls_back_to_largest_progressive(self) -> None: + """Without ``+``: largest area, then fastest; interlaced skipped.""" + dp1 = parse_xrandr(NO_PREFERRED)[1] + assert pick_mode(dp1) == Mode(1920, 1080, "75.00") + + +class TestPlan: + """``plan`` returns xrandr args, or ``[]`` when nothing needs changing.""" + + def test_turns_on_off_output_left_of_others(self) -> None: + """The failing 2026-11-11 state: DP-0 on at 0x0, HDMI-0 to its right.""" + assert plan(parse_xrandr(DP0_OFF), "DP-0") == [ + "--output", "DP-0", "--mode", "3840x2160", "--rate", "144.00", + "--pos", "0x0", "--primary", + *HDMI_BEST, "--pos", "3840x0", + ] # fmt: skip + + def test_already_best_is_noop(self) -> None: + """No xrandr call when every output already runs its best mode.""" + assert plan(parse_xrandr(BOTH_BEST), "DP-0") == [] + + def test_unplugged_primary_is_turned_off_and_gap_closed(self) -> None: + """HDMI-0 slides to 0x0 and becomes primary; DP-0 releases its CRTC.""" + assert plan(parse_xrandr(DP0_UNPLUGGED), "DP-0") == [ + *HDMI_BEST, "--pos", "0x0", "--primary", + "--output", "DP-0", "--off", + ] # fmt: skip + + def test_unknown_primary_uses_first_connected(self) -> None: + """A missing preferred primary falls back to xrandr order.""" + args = plan(parse_xrandr(DP0_OFF), "DP-9") + assert args[:9] == [*HDMI_BEST, "--pos", "0x0", "--primary"] + + def test_nothing_connected_is_noop(self) -> None: + """No connected output means no xrandr call at all.""" + assert plan(parse_xrandr("Screen 0: minimum 8 x 8\n"), "DP-0") == [] + + +def test_main_prints_plan_for_stdin( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """``xrandr | monitor_layout.py`` prints the plan without touching X.""" + monkeypatch.setattr(monitor_layout.sys, "stdin", io.StringIO(DP0_OFF)) + assert monitor_layout.main() == 0 + assert capsys.readouterr().out.split()[:3] == ["--output", "DP-0", "--mode"] diff --git a/meta/pyproject.toml b/meta/pyproject.toml index 8c0748f8..e6d5b5cf 100644 --- a/meta/pyproject.toml +++ b/meta/pyproject.toml @@ -173,7 +173,7 @@ disallow_untyped_decorators = false # bare name (the same dirs linux_configuration/tests/conftest.py adds to # sys.path at runtime) resolve under static analysis instead of raising E0401. # Paths are relative to the repo root, which is pre-commit's working directory. -init-hook = "import sys; sys.path[:0] = ['meta/scripts', 'python_pkg', 'linux_configuration/utils', 'linux_configuration/fixes', 'linux_configuration/misc/tools']" +init-hook = "import sys; sys.path[:0] = ['meta/scripts', 'python_pkg', 'linux_configuration/utils', 'linux_configuration/features', 'linux_configuration/fixes', 'linux_configuration/misc/tools']" # Analyse import fallback blocks analyse-fallback-blocks = true # Pickle collected data for later comparisons