#!/usr/bin/env python3 import argparse import dataclasses import json import logging from os.path import expanduser import subprocess from typing import Any 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") @dataclasses.dataclass class Monitor: output: str xrandr_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) @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): # 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) class MonitorMenu: def __init__(self, profiles: list[Profile]) -> None: self.profiles = profiles self.d = dialog.Dialog(autowidgetsize=True) def run(self): choices = [] i = 0 for p in self.profiles: choices.append((str(i), p.name)) i += 1 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() except IndexError: raise NoMatchingProfile from None def main(): parser = argparse.ArgumentParser() parser.add_argument("--log-level", "--loglevel", type=LogLevel, default="info") subparser = parser.add_subparsers(dest="command") subparser.add_parser("run") 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) config_file = '~/.config/monitor-profiles.json' with open(expanduser(config_file)) as fh: data = json.load(fh) profiles = [Profile.from_json_dict(item) for item in data] match args.command: case "run" | None: MonitorMenu(profiles).run() case "apply": if args.index is not None: try: profiles[args.index].apply() except IndexError: raise NoMatchingProfile from None else: try: next(filter(lambda p: p.identifier == args.id, profiles)).apply() except StopIteration: raise NoMatchingProfile from None if __name__ == '__main__': main()