#!/usr/bin/env python3 import argparse import dataclasses import enum import json import logging import pathlib import subprocess from typing import Any import yaml import dialog class NoMatchingProfile(ValueError): pass class LogLevel: def __init__(self, value: str) -> None: try: self.numerical_level = logging.getLevelNamesMapping()[value.upper()] except KeyError: raise ValueError("invalid value") class GraphicalServer(enum.StrEnum): XORG = enum.auto() WAYLAND = enum.auto() @dataclasses.dataclass class Monitor: output: str xrandr_opts: list[str] = dataclasses.field(default_factory=list) wlr_randr_opts: list[str] = dataclasses.field(default_factory=list) position: str | None = None background: str | None = None primary: bool = False @classmethod def from_json_dict(cls, json_dict: dict[str, Any]) -> "Monitor": def convert_key_name(name: str) -> str: return name.replace("-", "_") return cls(**{convert_key_name(key): value for key, value in json_dict.items()}) @dataclasses.dataclass class Profile: name: str monitors: list[Monitor] = dataclasses.field(default_factory=list) identifier: str | None = None xrandr_opts: list[str] = dataclasses.field(default_factory=list) wlr_randr_opts: list[str] = dataclasses.field(default_factory=list) @classmethod def from_json_dict(cls, json_dict: dict[str, Any]) -> "Profile": def convert_key_name(name: str) -> str: return name.replace("-", "_") kwargs = {} for key, value in json_dict.items(): key = convert_key_name(key) if key == "monitors": value = [Monitor.from_json_dict(item) for item in value] kwargs[key] = value return cls(**kwargs) def apply(self, graphical_server: GraphicalServer | None): match graphical_server: case GraphicalServer.XORG: self.apply_xorg() case GraphicalServer.WAYLAND: self.apply_wayland() case None: try: subprocess.run(["xrandr"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except (subprocess.CalledProcessError, FileNotFoundError): self.apply_wayland() else: self.apply_xorg() def apply_xorg(self): # We build the command line starting from just "xrandr" and adding # arguments. xrandr_cmd = ['xrandr'] feh_cmd = ['feh', '--bg-fill'] try: xrandr_cmd += self.xrandr_opts except KeyError: pass for monitor in self.monitors: xrandr_cmd.extend(monitor.xrandr_opts) if monitor.background is not None: feh_cmd.append(monitor.background) logging.debug("Executing: %s", xrandr_cmd) subprocess.run(xrandr_cmd, check=False) logging.debug("Executing: %s", feh_cmd) subprocess.run(feh_cmd, check=False) def apply_wayland(self): # We build the command line starting from just "xrandr" and adding # arguments. wlr_randr_cmd = ['wlr-randr'] try: wlr_randr_cmd += self.wlr_randr_opts except KeyError: pass for monitor in self.monitors: wlr_randr_cmd.extend(monitor.wlr_randr_opts) logging.debug("Executing: %s", wlr_randr_cmd) subprocess.run(wlr_randr_cmd, check=False) class MonitorMenu: def __init__(self, profiles: list[Profile]) -> None: self.profiles = profiles self.d = dialog.Dialog(autowidgetsize=True) def run(self, *, graphical_server: GraphicalServer | None = None, apply_exit=False): choices = [] i = 0 for p in self.profiles: choices.append((str(i), p.name)) i += 1 while True: code, profile_idx = self.d.menu( 'Select the profile you want to use.', choices=choices) if code in (self.d.ESC, self.d.CANCEL): return try: self.profiles[int(profile_idx)].apply(graphical_server=graphical_server) if apply_exit: return except IndexError: raise NoMatchingProfile from None def load_profiles_config(path: pathlib.Path | None): if path is not None: with path.open() as fh: if path.suffix == ".json": return json.load(fh) # Expect YAML by default return yaml.safe_load(fh) # Try monitor-profiles.yaml and revert to monitor-profiles.json if the # first one does not exist. try: with pathlib.Path("~/.config/monitor-profiles.yaml").expanduser().open() as fh: return yaml.safe_load(fh) except FileNotFoundError: with pathlib.Path("~/.config/monitor-profiles.json").expanduser().open() as fh: return json.load(fh) def main(): parser = argparse.ArgumentParser() parser.add_argument("--log-level", "--loglevel", type=LogLevel, default="info") parser.add_argument("--graphical-server", choices=list(GraphicalServer), type=GraphicalServer) parser.add_argument( "--profiles-config", metavar="PATH", type=pathlib.Path, help="Configuration file to use." " By default $HOME/.config/monitor-profiles.yaml (preferred)" " or $HOME/.config/monitor-profiles.json") subparser = parser.add_subparsers(dest="command") run_parser = subparser.add_parser("run") run_parser.add_argument("--apply-exit", action="store_true") apply_parser = subparser.add_parser("apply") identifier_group = apply_parser.add_mutually_exclusive_group(required=True) identifier_group.add_argument("--index", type=int) identifier_group.add_argument("--id", type=str) args = parser.parse_args() logging.basicConfig(level=args.log_level.numerical_level) data = load_profiles_config(args.profiles_config) profiles = [Profile.from_json_dict(item) for item in data] match args.command: case None: MonitorMenu(profiles).run(graphical_server=args.graphical_server) case "run": MonitorMenu(profiles).run(apply_exit=args.apply_exit, graphical_server=args.graphical_server) case "apply": if args.index is not None: try: profiles[args.index].apply(graphical_server=args.graphical_server) except IndexError: raise NoMatchingProfile from None else: try: next(filter(lambda p: p.identifier == args.id, profiles)).apply(graphical_server=args.graphical_server) except StopIteration: raise NoMatchingProfile from None if __name__ == '__main__': main()