#!/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) 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, config_file='~/.config/monitor-profiles.json'): with open(expanduser(config_file)) as fh: data = json.load(fh) self.profiles = [Profile.from_json_dict(item) for item in data] self.d = dialog.Dialog(autowidgetsize=True) def run(self, profile_idx=None): choices = [] if profile_idx is None: 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") parser.add_argument("--index", type=int, required=False) args = parser.parse_args() logging.basicConfig(level=args.log_level.numerical_level) menu = MonitorMenu() if args.index is not None: menu.run(profile_idx=args.index) else: menu.run() if __name__ == '__main__': main()