-#!/usr/bin/env python
+#!/usr/bin/env python3
# encoding: utf-8
#
# autorandr.py
import binascii
import copy
-import fnmatch
import getopt
import hashlib
+import math
import os
import posix
import pwd
import re
+import shlex
import subprocess
import sys
import shutil
import time
+import glob
from collections import OrderedDict
-from distutils.version import LooseVersion as Version
from functools import reduce
from itertools import chain
+
if sys.version_info.major == 2:
import ConfigParser as configparser
else:
import configparser
-__version__ = "1.5"
+__version__ = "1.14"
try:
input = raw_input
("clone-largest", "Clone all connected outputs with the largest resolution (scaled down if necessary)", None),
("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
("vertical", "Stack all connected outputs vertically at their largest resolution", None),
+ ("horizontal-reverse", "Stack all connected outputs horizontally at their largest resolution in reverse order", None),
+ ("vertical-reverse", "Stack all connected outputs vertically at their largest resolution in reverse order", None),
+]
+
+properties = [
+ "Colorspace",
+ "max bpc",
+ "aspect ratio",
+ "Broadcast RGB",
+ "audio",
+ "non-desktop",
+ "TearFree",
+ "underscan vborder",
+ "underscan hborder",
+ "underscan",
+ "scaling mode",
]
help_text = """
--batch run autorandr for all users with active X11 sessions
--current only list current (active) configuration(s)
--config dump your current xrandr setup
+--cycle automatically load the next detected profile
--debug enable verbose output
--detected only list detected (available) configuration(s)
--dry-run don't change anything, only print the xrandr commands
--fingerprint fingerprint your current hardware setup
---force force (re)loading of a profile
+--ignore-lid treat outputs as connected even if their lids are closed
+--match-edid match displays based on edid instead of name
+--force force (re)loading of a profile / overwrite exiting files
+--list list configurations
--skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
to skip both in detecting changes and applying a profile
--version show version information and exit
""".strip()
+class Version(object):
+ def __init__(self, version):
+ self._version = version
+ self._version_parts = re.split("([0-9]+)", version)
+
+ def __eq__(self, other):
+ return self._version_parts == other._version_parts
+
+ def __lt__(self, other):
+ for my, theirs in zip(self._version_parts, other._version_parts):
+ if my.isnumeric() and theirs.isnumeric():
+ my = int(my)
+ theirs = int(theirs)
+ if my < theirs:
+ return True
+ return len(theirs) > len(my)
+
+ def __ge__(self, other):
+ return not (self < other)
+
+ def __ne__(self, other):
+ return not (self == other)
+
+ def __le__(self, other):
+ return (self < other) or (self == other)
+
+ def __gt__(self, other):
+ return self >= other and not (self == other)
+
+def is_closed_lid(output):
+ if not re.match(r'(eDP(-?[0-9]\+)*|LVDS(-?[0-9]\+)*)', output):
+ return False
+ lids = glob.glob("/proc/acpi/button/lid/*/state")
+ if len(lids) == 1:
+ state_file = lids[0]
+ with open(state_file) as f:
+ content = f.read()
+ return "close" in content
+ return False
+
+
class AutorandrException(Exception):
def __init__(self, message, original_exception=None, report_bug=False):
self.message = message
class XrandrOutput(object):
"Represents an XRandR output"
+ XRANDR_PROPERTIES_REGEXP = "|".join(
+ [r"{}:\s*(?P<{}>[\S ]*\S+)"
+ .format(re.sub(r"\s", r"\\\g<0>", p), re.sub(r"\W+", "_", p.lower()))
+ for p in properties])
+
# This regular expression is used to parse an output in `xrandr --verbose'
XRANDR_OUTPUT_REGEXP = """(?x)
- ^(?P<output>[^ ]+)\s+ # Line starts with output name
+ ^\s*(?P<output>\S[^ ]*)\s+ # Line starts with output name
(?: # Differentiate disconnected and connected
disconnected | # in first line
unknown\ connection |
(?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Tracking information
(?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))? # Border information
(?:\s*(?: # Properties of the output
- Gamma: (?P<gamma>(?:inf|[0-9\.: e])+) | # Gamma value
+ Gamma: (?P<gamma>(?:inf|-?[0-9\.\-: e])+) | # Gamma value
+ CRTC:\s*(?P<crtc>[0-9]) | # CRTC value
Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) | # Transformation matrix
+ filter:\s+(?P<filter>bilinear|nearest) | # Transformation filter
EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) | # EDID of the output
+ """ + XRANDR_PROPERTIES_REGEXP + """ | # Properties to include in the profile
(?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)* # Other properties
))+
\s*
EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
def __repr__(self):
- return "<%s%s %s>" % (self.output, self.short_edid, " ".join(self.option_vector))
+ return "<%s%s %s>" % (self.output, self.fingerprint, " ".join(self.option_vector))
@property
def short_edid(self):
if xrandr_version() >= Version("1.2"):
options.update(self.XRANDR_12_DEFAULTS)
options.update(self.options)
+ if "set" in self.ignored_options:
+ options = {a: b for a, b in options.items() if not a.startswith("x-prop")}
return {a: b for a, b in options.items() if a not in self.ignored_options}
@property
def filtered_options(self):
"Return a dictionary of options without ignored options"
- return {a: b for a, b in self.options.items() if a not in self.ignored_options}
+ options = {a: b for a, b in self.options.items() if a not in self.ignored_options}
+ if "set" in self.ignored_options:
+ options = {a: b for a, b in options.items() if not a.startswith("x-prop")}
+ return options
@property
def option_vector(self):
"Return the command line parameters for XRandR for this instance"
args = ["--output", self.output]
for option, arg in sorted(self.options_with_defaults.items()):
- args.append("--%s" % option)
+ if option.startswith("x-prop-"):
+ prop_found = False
+ for prop, xrandr_prop in [(re.sub(r"\W+", "_", p.lower()), p) for p in properties]:
+ if prop == option[7:]:
+ args.append("--set")
+ args.append(xrandr_prop)
+ prop_found = True
+ break
+ if not prop_found:
+ print("Warning: Unknown property `%s' in config file. Skipping." % option[7:], file=sys.stderr)
+ continue
+ elif option.startswith("x-"):
+ print("Warning: Unknown option `%s' in config file. Skipping." % option, file=sys.stderr)
+ continue
+ else:
+ args.append("--%s" % option)
if arg:
args.append(arg)
return args
return x + 10000 * y
def __init__(self, output, edid, options):
- "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
+ "Instantiate using output name, edid and a dictionary of XRandR command line parameters"
self.output = output
self.edid = edid
self.options = options
self.ignored_options = []
+ self.parse_serial_from_edid()
self.remove_default_option_values()
+ def parse_serial_from_edid(self):
+ self.serial = None
+ if self.edid:
+ if self.EDID_UNAVAILABLE in self.edid:
+ return
+ if "*" in self.edid:
+ return
+ # Thx to pyedid project, the following code was
+ # copied (and modified) from pyedid/__init__py:21 [parse_edid()]
+ raw = bytes.fromhex(self.edid)
+ # Check EDID header, and checksum
+ if raw[:8] != b'\x00\xff\xff\xff\xff\xff\xff\x00' or sum(raw) % 256 != 0:
+ return
+ serial_no = int.from_bytes(raw[15:11:-1], byteorder='little')
+
+ serial_text = None
+ # Offsets of standard timing information descriptors 1-4
+ # (see https://en.wikipedia.org/wiki/Extended_Display_Identification_Data#EDID_1.4_data_format)
+ for timing_bytes in (raw[54:72], raw[72:90], raw[90:108], raw[108:126]):
+ if timing_bytes[0:2] == b'\x00\x00':
+ timing_type = timing_bytes[3]
+ if timing_type == 0xFF:
+ buffer = timing_bytes[5:]
+ buffer = buffer.partition(b'\x0a')[0]
+ serial_text = buffer.decode('cp437')
+ self.serial = serial_text if serial_text else "0x{:x}".format(serial_no) if serial_no != 0 else None
+
def set_ignored_options(self, options):
"Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
self.ignored_options = list(options)
def remove_default_option_values(self):
- "Remove values from the options dictionary that are superflous"
+ "Remove values from the options dictionary that are superfluous"
if "off" in self.options and len(self.options.keys()) > 1:
self.options = {"off": None}
return
@classmethod
def from_xrandr_output(cls, xrandr_output):
- """Instanciate an XrandrOutput from the output of `xrandr --verbose'
+ """Instantiate an XrandrOutput from the output of `xrandr --verbose'
This method also returns a list of modes supported by the output.
"""
else:
edid = "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
+ # An output can be disconnected but still have a mode configured. This can only happen
+ # as a residual situation after a disconnect, you cannot associate a mode with an disconnected
+ # output.
+ #
+ # This code needs to be careful not to mix the two. An output should only be configured to
+ # "off" if it doesn't have a mode associated with it, which is modelled as "not a width" here.
if not match["width"]:
options["off"] = None
else:
options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
else:
if match["rotate"] not in ("left", "right"):
- options["mode"] = "%sx%s" % (match["width"], match["height"])
+ options["mode"] = "%sx%s" % (match["width"] or 0, match["height"] or 0)
else:
- options["mode"] = "%sx%s" % (match["height"], match["width"])
- options["rotate"] = match["rotate"]
+ options["mode"] = "%sx%s" % (match["height"] or 0, match["width"] or 0)
+ if match["rotate"]:
+ options["rotate"] = match["rotate"]
if match["primary"]:
options["primary"] = None
if match["reflect"] == "X":
options["reflect"] = "y"
elif match["reflect"] == "X and Y":
options["reflect"] = "xy"
- options["pos"] = "%sx%s" % (match["x"], match["y"])
+ if match["x"] or match["y"]:
+ options["pos"] = "%sx%s" % (match["x"] or "0", match["y"] or "0")
if match["panning"]:
panning = [match["panning"]]
if match["tracking"]:
# I doubt that this special case is actually required.
print("Warning: Output %s has a transformation applied. Could not determine correct mode! "
"Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
+ if match["filter"]:
+ options["filter"] = match["filter"]
if match["gamma"]:
gamma = match["gamma"].strip()
# xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
# so we approximate by 1e-10.
gamma = ":".join([str(max(1e-10, round(1. / float(x), 3))) for x in gamma.split(":")])
options["gamma"] = gamma
+ if match["crtc"]:
+ options["crtc"] = match["crtc"]
if match["rate"]:
options["rate"] = match["rate"]
+ for prop in [re.sub(r"\W+", "_", p.lower()) for p in properties]:
+ if match[prop]:
+ options["x-prop-" + prop] = match[prop]
return XrandrOutput(match["output"], edid, options), modes
@classmethod
- def from_config_file(cls, edid_map, configuration):
- "Instanciate an XrandrOutput from the contents of a configuration file"
+ def from_config_file(cls, profile, edid_map, configuration):
+ "Instantiate an XrandrOutput from the contents of a configuration file"
options = {}
for line in configuration.split("\n"):
if line:
if fuzzy_output in fuzzy_edid_map:
edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
elif "off" not in options:
- raise AutorandrException("Failed to find an EDID for output `%s' in setup file, required as `%s' "
- "is not off in config file." % (options["output"], options["output"]))
+ raise AutorandrException("Profile `%s': Failed to find an EDID for output `%s' in setup file, required "
+ "as `%s' is not off in config file." % (profile, options["output"], options["output"]))
output = options["output"]
del options["output"]
return XrandrOutput(output, edid, options)
+ @property
+ def fingerprint(self):
+ return str(self.serial) if self.serial else self.short_edid
+
+ def fingerprint_equals(self, other):
+ if self.serial and other.serial:
+ return self.serial == other.serial
+ else:
+ return self.edid_equals(other)
+
def edid_equals(self, other):
"Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
if self.edid and other.edid:
if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
if "*" in self.edid:
- return fnmatch.fnmatch(other.edid, self.edid)
+ return match_asterisk(self.edid, other.edid) > 0
elif "*" in other.edid:
- return fnmatch.fnmatch(self.edid, other.edid)
+ return match_asterisk(other.edid, self.edid) > 0
return self.edid == other.edid
def __ne__(self, other):
return not (self == other)
def __eq__(self, other):
- return self.edid_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
+ return self.fingerprint_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
def verbose_diff(self, other):
"Compare to another XrandrOutput and return a list of human readable differences"
diffs = []
- if not self.edid_equals(other):
- diffs.append("EDID `%s' differs from `%s'" % (self.short_edid, other.short_edid))
+ if not self.fingerprint_equals(other):
+ diffs.append("EDID `%s' differs from `%s'" % (self.fingerprint, other.fingerprint))
if self.output != other.output:
diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
if "off" in self.options and "off" not in other.options:
return "Debug information would be available if the `regex' module was installed."
-def parse_xrandr_output():
+def parse_xrandr_output(
+ *,
+ ignore_lid,
+):
"Parse the output of `xrandr --verbose' into a list of outputs"
xrandr_output = os.popen("xrandr -q --verbose").read()
if not xrandr_output:
# We are not interested in screens
xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
- # Split at output boundaries and instanciate an XrandrOutput per output
+ # Split at output boundaries and instantiate an XrandrOutput per output
split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
if len(split_xrandr_output) < 2:
raise AutorandrException("No output boundaries found", report_bug=True)
if output_modes:
modes[output_name] = output_modes
+ # consider a closed lid as disconnected if other outputs are connected
+ if not ignore_lid and sum(
+ o.edid != None
+ for o
+ in outputs.values()
+ ) > 1:
+ for output_name in outputs.keys():
+ if is_closed_lid(output_name):
+ outputs[output_name].edid = None
+
return outputs, modes
buffer = []
for line in chain(open(config_name).readlines(), ["output"]):
if line[:6] == "output" and buffer:
- config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
+ config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(profile, edids, "".join(buffer))
buffer = [line]
else:
buffer.append(line)
return symlinks
+def match_asterisk(pattern, data):
+ """Match data against a pattern
+
+ The difference to fnmatch is that this function only accepts patterns with a single
+ asterisk and that it returns a "closeness" number, which is larger the better the match.
+ Zero indicates no match at all.
+ """
+ if "*" not in pattern:
+ return 1 if pattern == data else 0
+ parts = pattern.split("*")
+ if len(parts) > 2:
+ raise ValueError("Only patterns with a single asterisk are supported, %s is invalid" % pattern)
+ if not data.startswith(parts[0]):
+ return 0
+ if not data.endswith(parts[1]):
+ return 0
+ matched = len(pattern)
+ total = len(data) + 1
+ return matched * 1. / total
+
+
+def update_profiles_edid(profiles, config):
+ fp_map = {}
+ for c in config:
+ if config[c].fingerprint is not None:
+ fp_map[config[c].fingerprint] = c
+
+ for p in profiles:
+ profile_config = profiles[p]["config"]
+
+ for fingerprint in fp_map:
+ for c in list(profile_config.keys()):
+ if profile_config[c].fingerprint != fingerprint or c == fp_map[fingerprint]:
+ continue
+
+ print("%s: renaming display %s to %s" % (p, c, fp_map[fingerprint]), file=sys.stderr)
+
+ tmp_disp = profile_config[c]
+
+ if fp_map[fingerprint] in profile_config:
+ # Swap the two entries
+ profile_config[c] = profile_config[fp_map[fingerprint]]
+ profile_config[c].output = c
+ else:
+ # Object is reassigned to another key, drop this one
+ del profile_config[c]
+
+ profile_config[fp_map[fingerprint]] = tmp_disp
+ profile_config[fp_map[fingerprint]].output = fp_map[fingerprint]
+
+
def find_profiles(current_config, profiles):
- "Find profiles matching the currently connected outputs"
+ "Find profiles matching the currently connected outputs, sorting asterisk matches to the back"
detected_profiles = []
for profile_name, profile in profiles.items():
config = profile["config"]
matches = True
for name, output in config.items():
- if not output.edid:
+ if not output.fingerprint:
continue
- if name not in current_config or not output.edid_equals(current_config[name]):
+ if name not in current_config or not output.fingerprint_equals(current_config[name]):
matches = False
break
- if not matches or any((name not in config.keys() for name in current_config.keys() if current_config[name].edid)):
+ if not matches or any((name not in config.keys() for name in current_config.keys() if current_config[name].fingerprint)):
continue
if matches:
- detected_profiles.append(profile_name)
+ closeness = max(match_asterisk(output.edid, current_config[name].edid), match_asterisk(
+ current_config[name].edid, output.edid))
+ detected_profiles.append((closeness, profile_name))
+ detected_profiles = [o[1] for o in sorted(detected_profiles, key=lambda x: -x[0])]
return detected_profiles
return not exec_scripts(profile_path, "block", meta_information)
+def check_configuration_pre_save(configuration):
+ "Check that a configuration is safe for saving."
+ outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
+ for output in outputs:
+ if "off" not in configuration[output].options and not configuration[output].edid:
+ return ("`%(o)s' is not off (has a mode configured) but is disconnected (does not have an EDID).\n"
+ "This typically means that it has been recently unplugged and then not properly disabled\n"
+ "by the user. Please disable it (e.g. using `xrandr --output %(o)s --off`) and then rerun\n"
+ "this command.") % {"o": output}
+
+
def output_configuration(configuration, config):
"Write a configuration file"
outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
print(output, configuration[output].edid, file=setup)
-def save_configuration(profile_path, configuration):
+def save_configuration(profile_path, profile_name, configuration, forced=False):
"Save a configuration into a profile"
if not os.path.isdir(profile_path):
os.makedirs(profile_path)
- with open(os.path.join(profile_path, "config"), "w") as config:
+ config_path = os.path.join(profile_path, "config")
+ setup_path = os.path.join(profile_path, "setup")
+ if os.path.isfile(config_path) and not forced:
+ raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
+ if os.path.isfile(setup_path) and not forced:
+ raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
+
+ with open(config_path, "w") as config:
output_configuration(configuration, config)
- with open(os.path.join(profile_path, "setup"), "w") as setup:
+ with open(setup_path, "w") as setup:
output_setup(configuration, setup)
waits a second and then retries once. This mitigates #47,
a timing issue with some drivers.
"""
- if "dry_run" in kwargs:
- dry_run = kwargs["dry_run"]
- del kwargs["dry_run"]
+ if kwargs.pop("dry_run", False):
+ for arg in args[0]:
+ print(shlex.quote(arg), end=" ")
+ print()
+ return 0
else:
- dry_run = False
- kwargs_redirected = dict(kwargs)
- if not dry_run:
if hasattr(subprocess, "DEVNULL"):
- kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
+ kwargs["stdout"] = getattr(subprocess, "DEVNULL")
else:
- kwargs_redirected["stdout"] = open(os.devnull, "w")
- kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
- retval = subprocess.call(*args, **kwargs_redirected)
- if retval != 0:
- time.sleep(1)
+ kwargs["stdout"] = open(os.devnull, "w")
+ kwargs["stderr"] = kwargs["stdout"]
retval = subprocess.call(*args, **kwargs)
- return retval
+ if retval != 0:
+ time.sleep(1)
+ retval = subprocess.call(*args, **kwargs)
+ return retval
def get_fb_dimensions(configuration):
if "off" in output.options or not output.edid:
continue
# This won't work with all modes -- but it's a best effort.
- o_width, o_height = map(int, output.options["mode"].split("x"))
+ match = re.search("[0-9]{3,}x[0-9]{3,}", output.options["mode"])
+ if not match:
+ return None
+ o_mode = match.group(0)
+ o_width, o_height = map(int, o_mode.split("x"))
if "transform" in output.options:
a, b, c, d, e, f, g, h, i = map(float, output.options["transform"].split(","))
w = (g * o_width + h * o_height + i)
x = (a * o_width + b * o_height + c) / w
y = (d * o_width + e * o_height + f) / w
o_width, o_height = x, y
+ if "rotate" in output.options:
+ if output.options["rotate"] in ("left", "right"):
+ o_width, o_height = o_height, o_width
if "pos" in output.options:
o_left, o_top = map(int, output.options["pos"].split("x"))
o_width += o_left
if "panning" in output.options:
match = re.match("(?P<w>[0-9]+)x(?P<h>[0-9]+)(?:\+(?P<x>[0-9]+))?(?:\+(?P<y>[0-9]+))?.*", output.options["panning"])
if match:
- detail = match.groupdict()
- o_width = int(detail.get("w")) + int(detail.get("x", "0"))
- o_height = int(detail.get("h")) + int(detail.get("y", "0"))
+ detail = match.groupdict(default="0")
+ o_width = int(detail.get("w")) + int(detail.get("x"))
+ o_height = int(detail.get("h")) + int(detail.get("y"))
width = max(width, o_width)
height = max(height, o_height)
- return int(width), int(height)
+ return math.ceil(width), math.ceil(height)
def apply_configuration(new_configuration, current_configuration, dry_run=False):
"Apply a configuration"
+ found_top_left_monitor = False
+ found_left_monitor = False
+ found_top_monitor = False
outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
- if dry_run:
- base_argv = ["echo", "xrandr"]
- else:
- base_argv = ["xrandr"]
+ base_argv = ["xrandr"]
# There are several xrandr / driver bugs we need to take care of here:
# - We cannot enable more than two screens at the same time
fb_dimensions = get_fb_dimensions(new_configuration)
try:
- base_argv += ["--fb", "%dx%d" % fb_dimensions]
+ fb_args = ["--fb", "%dx%d" % fb_dimensions]
except:
# Failed to obtain frame-buffer size. Doesn't matter, xrandr will choose for the user.
- pass
+ fb_args = []
auxiliary_changes_pre = []
disable_outputs = []
if not new_configuration[output].edid or "off" in new_configuration[output].options:
disable_outputs.append(new_configuration[output].option_vector)
else:
+ if output not in current_configuration:
+ raise AutorandrException("New profile configures output %s which does not exist in current xrandr --verbose output. "
+ "Don't know how to proceed." % output)
if "off" not in current_configuration[output].options:
remain_active_count += 1
option_vector = option_vector[:option_index] + option_vector[option_index + 2:]
except ValueError:
pass
-
- enable_outputs.append(option_vector)
+ if not found_top_left_monitor:
+ position = new_configuration[output].options.get("pos", "0x0")
+ if position == "0x0":
+ found_top_left_monitor = True
+ enable_outputs.insert(0, option_vector)
+ elif not found_left_monitor and position.startswith("0x"):
+ found_left_monitor = True
+ enable_outputs.insert(0, option_vector)
+ elif not found_top_monitor and position.endswith("x0"):
+ found_top_monitor = True
+ enable_outputs.insert(0, option_vector)
+ else:
+ enable_outputs.append(option_vector)
+ else:
+ enable_outputs.append(option_vector)
# Perform pe-change auxiliary changes
if auxiliary_changes_pre:
argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
if call_and_retry(argv, dry_run=dry_run) != 0:
- raise AutorandrException("Command failed: %s" % " ".join(argv))
+ raise AutorandrException("Command failed: %s" % " ".join(map(shlex.quote, argv)))
+
+ # Starting here, fix the frame buffer size
+ # Do not do this earlier, as disabling scaling might temporarily make the framebuffer
+ # dimensions larger than they will finally be.
+ base_argv += fb_args
# Disable unused outputs, but make sure that there always is at least one active screen
disable_keep = 0 if remain_active_count else 1
# In the context of a xrandr call that changes the display state, `--query' should do nothing
disable_outputs.insert(0, ['--query'])
+ # If we did not find a candidate, we might need to inject a call
+ # If there is no output to disable, we will enable 0x and x0 at the same time
+ if not found_top_left_monitor and len(disable_outputs) > 0:
+ # If the call to 0x and x0 is split, inject one of them
+ if found_top_monitor and found_left_monitor:
+ enable_outputs.insert(0, enable_outputs[0])
+
# Enable the remaining outputs in pairs of two operations
operations = disable_outputs + enable_outputs
for index in range(0, len(operations), 2):
argv = base_argv + list(chain.from_iterable(operations[index:index + 2]))
if call_and_retry(argv, dry_run=dry_run) != 0:
- raise AutorandrException("Command failed: %s" % " ".join(argv))
+ raise AutorandrException("Command failed: %s" % " ".join(map(shlex.quote, argv)))
+
+ # Adjust the frame buffer to match (see #319)
+ if fb_args:
+ argv = base_argv
+ if call_and_retry(argv, dry_run=dry_run) != 0:
+ raise AutorandrException("Command failed: %s" % " ".join(map(shlex.quote, argv)))
+
def is_equal_configuration(source_configuration, target_configuration):
return False
for output in source_configuration.keys():
if "off" in source_configuration[output].options:
- if output in target_configuration and "off" not in target_configuration.options:
+ if output in target_configuration and "off" not in target_configuration[output].options:
return False
else:
if output not in target_configuration:
configuration[output].options["pos"] = "0x0"
else:
configuration[output].options["off"] = None
- elif profile_name in ("horizontal", "vertical"):
+ elif profile_name in ("horizontal", "vertical", "horizontal-reverse", "vertical-reverse"):
shift = 0
- if profile_name == "horizontal":
+ if profile_name.startswith("horizontal"):
shift_index = "width"
pos_specifier = "%sx0"
else:
shift_index = "height"
pos_specifier = "0x%s"
-
- for output in configuration:
+
+ config_iter = reversed(configuration) if "reverse" in profile_name else iter(configuration)
+
+ for output in config_iter:
configuration[output].options = {}
if output in modes and configuration[output].edid:
def key(a):
if not os.path.isdir(user_profile_path):
user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
- candidate_directories = [user_profile_path]
- for config_dir in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"):
- candidate_directories.append(os.path.join(config_dir, "autorandr"))
+ candidate_directories = []
if profile_path:
candidate_directories.append(profile_path)
+ candidate_directories.append(user_profile_path)
+ for config_dir in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"):
+ candidate_directories.append(os.path.join(config_dir, "autorandr"))
for folder in candidate_directories:
if script_name not in ran_scripts:
if os.access(script, os.X_OK | os.F_OK):
try:
all_ok &= subprocess.call(script, env=env) != 0
- except:
- raise AutorandrException("Failed to execute user command: %s" % (script,))
+ except Exception as e:
+ raise AutorandrException("Failed to execute user command: %s. Error: %s" % (script, str(e)))
ran_scripts.add(script_name)
script_folder = os.path.join(folder, "%s.d" % script_name)
if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
- for file_name in os.listdir(script_folder):
+ for file_name in sorted(os.listdir(script_folder)):
check_name = "d/%s" % (file_name,)
if check_name not in ran_scripts:
script = os.path.join(script_folder, file_name)
if os.access(script, os.X_OK | os.F_OK):
try:
all_ok &= subprocess.call(script, env=env) != 0
- except:
- raise AutorandrException("Failed to execute user command: %s" % (script,))
+ except Exception as e:
+ raise AutorandrException("Failed to execute user command: %s. Error: %s" % (script, str(e)))
ran_scripts.add(check_name)
return all_ok
# so it should be safe. Also, note that since the environment
# is taken from a process owned by the user, reusing it should
# not leak any information.
- os.setgroups([])
+ try:
+ os.setgroups(os.getgrouplist(pwent.pw_name, pwent.pw_gid))
+ except AttributeError:
+ # Python 2 doesn't have getgrouplist
+ os.setgroups([])
os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
os.chdir(pwent.pw_dir)
os.environ.clear()
os.environ.update(process_environ)
- os.execl(autorandr_binary, autorandr_binary, *argv[1:])
- os.exit(1)
+ if sys.executable != "" and sys.executable != None:
+ os.execl(sys.executable, sys.executable, autorandr_binary, *argv[1:])
+ else:
+ os.execl(autorandr_binary, autorandr_binary, *argv[1:])
+ sys.exit(1)
os.waitpid(child_pid, 0)
+ # The following line assumes that user accounts start at 1000 and that no
+ # one works using the root or another system account. This is rather
+ # restrictive, but de facto default. If this breaks your use case, set the
+ # env var AUTORANDR_UID_MIN as appropriate. (Alternatives would be to use
+ # the UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf; but
+ # effectively, both values aren't binding in any way.)
+ uid_min = 1000
+ if 'AUTORANDR_UID_MIN' in os.environ:
+ uid_min = int(os.environ['AUTORANDR_UID_MIN'])
+
for directory in os.listdir("/proc"):
directory = os.path.join("/proc/", directory)
if not os.path.isdir(directory):
continue
uid = os.stat(environ_file).st_uid
- # The following line assumes that user accounts start at 1000 and that
- # no one works using the root or another system account. This is rather
- # restrictive, but de facto default. Alternatives would be to use the
- # UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf;
- # but effectively, both values aren't binding in any way.
- # If this breaks your use case, please file a bug on Github.
- if uid < 1000:
+ if uid < uid_min:
continue
process_environ = {}
- for environ_entry in open(environ_file).read().split("\0"):
+ for environ_entry in open(environ_file, 'rb').read().split(b"\0"):
+ try:
+ environ_entry = environ_entry.decode("ascii")
+ except UnicodeDecodeError:
+ continue
name, sep, value = environ_entry.partition("=")
if name and sep:
if name == "DISPLAY" and "." in value:
# Cannot work with this environment, skip.
continue
+ if "WAYLAND_DISPLAY" in process_environ and process_environ["WAYLAND_DISPLAY"]:
+ if "--debug" in argv:
+ print("Detected Wayland session '{0}'. Skipping.".format(process_environ["WAYLAND_DISPLAY"]))
+ continue
+
# To allow scripts to detect batch invocation (especially useful for predetect)
process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
process_environ["UID"] = str(uid)
def main(argv):
try:
- opts, args = getopt.getopt(argv[1:], "s:r:l:d:cfh",
- ["batch", "dry-run", "change", "default=", "save=", "remove=", "load=",
- "force", "fingerprint", "config", "debug", "skip-options=", "help",
- "current", "detected", "version"])
+ opts, args = getopt.getopt(
+ argv[1:],
+ "s:r:l:d:cfh",
+ [
+ "batch",
+ "dry-run",
+ "change",
+ "cycle",
+ "default=",
+ "save=",
+ "remove=",
+ "load=",
+ "force",
+ "fingerprint",
+ "config",
+ "debug",
+ "skip-options=",
+ "help",
+ "list",
+ "current",
+ "detected",
+ "version",
+ "match-edid",
+ "ignore-lid"
+ ]
+ )
except getopt.GetoptError as e:
print("Failed to parse options: {0}.\n"
"Use --help to get usage information.".format(str(e)),
user = pwd.getpwuid(os.getuid())
user = user.pw_name if user else "#%d" % os.getuid()
print("autorandr running as user %s (started from batch instance)" % user)
+ if ("WAYLAND_DISPLAY" in os.environ and os.environ["WAYLAND_DISPLAY"]):
+ print("Detected Wayland session '{0}'. Exiting.".format(os.environ["WAYLAND_DISPLAY"]), file=sys.stderr)
+ sys.exit(1)
profiles = {}
profile_symlinks = {}
profiles.update(load_profiles(profile_path))
profile_symlinks.update(get_symlinks(profile_path))
read_config(options, profile_path)
- # Sort by descending mtime
- profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
except Exception as e:
raise AutorandrException("Failed to load profiles", e)
- profile_symlinks = {k: v for k, v in profile_symlinks.items() if v in (x[0] for x in virtual_profiles) or v in profiles}
-
exec_scripts(None, "predetect")
- config, modes = parse_xrandr_output()
+
+ ignore_lid = "--ignore-lid" in options
+
+ config, modes = parse_xrandr_output(
+ ignore_lid=ignore_lid,
+ )
+
+ if "--match-edid" in options:
+ update_profiles_edid(profiles, config)
+
+ # Sort by mtime
+ sort_direction = -1
+ if "--cycle" in options:
+ # When cycling through profiles, put the profile least recently used to the top of the list
+ sort_direction = 1
+ profiles = OrderedDict(sorted(profiles.items(), key=lambda x: sort_direction * x[1]["config-mtime"]))
+ profile_symlinks = {k: v for k, v in profile_symlinks.items() if v in (x[0] for x in virtual_profiles) or v in profiles}
if "--fingerprint" in options:
output_setup(config, sys.stdout)
if options["--save"] in (x[0] for x in virtual_profiles):
raise AutorandrException("Cannot save current configuration as profile '%s':\n"
"This configuration name is a reserved virtual configuration." % options["--save"])
+ error = check_configuration_pre_save(config)
+ if error:
+ print("Cannot save current configuration as profile '%s':" % options["--save"])
+ print(error)
+ sys.exit(1)
try:
profile_folder = os.path.join(profile_path, options["--save"])
- save_configuration(profile_folder, config)
+ save_configuration(profile_folder, options['--save'], config, forced="--force" in options)
exec_scripts(profile_folder, "postsave", {
"CURRENT_PROFILE": options["--save"],
"PROFILE_FOLDER": profile_folder,
"MONITORS": ":".join(enabled_monitors(config)),
})
+ except AutorandrException as e:
+ raise e
except Exception as e:
raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
print("Saved current configuration as profile '%s'" % options["--save"])
"CURRENT_PROFILES": ":".join(current_profiles)
}
+ best_index = 9999
for profile_name in profiles.keys():
if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
- if "--current" not in options and "--detected" not in options:
+ if not any(opt in options for opt in ("--current", "--detected", "--list")):
print("%s (blocked)" % profile_name)
continue
props = []
+ is_current_profile = profile_name in current_profiles
if profile_name in detected_profiles:
- props.append("(detected)")
- if ("-c" in options or "--change" in options) and not load_profile:
- load_profile = profile_name
+ if len(detected_profiles) == 1:
+ index = 1
+ props.append("(detected)")
+ else:
+ index = detected_profiles.index(profile_name) + 1
+ props.append("(detected) (%d%s match)" % (index, ["st", "nd", "rd"][index - 1] if index < 4 else "th"))
+ if index < best_index:
+ if "-c" in options or "--change" in options or ("--cycle" in options and not is_current_profile):
+ load_profile = profile_name
+ best_index = index
elif "--detected" in options:
continue
- if profile_name in current_profiles:
+ if is_current_profile:
props.append("(current)")
elif "--current" in options:
continue
- if "--current" in options or "--detected" in options:
+ if any(opt in options for opt in ("--current", "--detected", "--list")):
print("%s" % (profile_name, ))
else:
print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)))
if "-d" in options:
options["--default"] = options["-d"]
- if not load_profile and "--default" in options and ("-c" in options or "--change" in options):
+ if not load_profile and "--default" in options and ("-c" in options or "--change" in options or "--cycle" in options):
load_profile = options["--default"]
if load_profile:
scripts_path = profile["path"]
except KeyError:
raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
- if load_profile in detected_profiles and detected_profiles[0] != load_profile:
+ if "--dry-run" not in options:
update_mtime(os.path.join(scripts_path, "config"))
add_unused_outputs(config, load_config)
if load_config == dict(config) and "-f" not in options and "--force" not in options:
raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
if "--dry-run" not in options and "--debug" in options:
- new_config, _ = parse_xrandr_output()
+ new_config, _ = parse_xrandr_output(
+ ignore_lid=ignore_lid,
+ )
+ if "--skip-options" in options:
+ for output in new_config.values():
+ output.set_ignored_options(skip_options)
if not is_equal_configuration(new_config, load_config):
print("The configuration change did not go as expected:")
print_profile_differences(new_config, load_config)