]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blob - autorandr.py
Set default value of $XDG_CONFIG_DIRS to be standard compliant
[deb_pkgs/autorandr.git] / autorandr.py
1 #!/usr/bin/env python
2 # encoding: utf-8
3 #
4 # autorandr.py
5 # Copyright (c) 2015, Phillip Berndt
6 #
7 # Autorandr rewrite in Python
8 #
9 # This script aims to be fully compatible with the original autorandr.
10 #
11 # This program is free software: you can redistribute it and/or modify
12 # it under the terms of the GNU General Public License as published by
13 # the Free Software Foundation, either version 3 of the License, or
14 # (at your option) any later version.
15 #
16 # This program is distributed in the hope that it will be useful,
17 # but WITHOUT ANY WARRANTY; without even the implied warranty of
18 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 # GNU General Public License for more details.
20 #
21 # You should have received a copy of the GNU General Public License
22 # along with this program. If not, see <http://www.gnu.org/licenses/>.
23 #
24
25 from __future__ import print_function
26
27 import binascii
28 import copy
29 import getopt
30 import hashlib
31 import os
32 import posix
33 import re
34 import subprocess
35 import sys
36 import shutil
37 import time
38
39 from collections import OrderedDict
40 from distutils.version import LooseVersion as Version
41 from functools import reduce
42 from itertools import chain
43
44 try:
45     input = raw_input
46 except NameError:
47     pass
48
49 virtual_profiles = [
50     # (name, description, callback)
51     ("common", "Clone all connected outputs at the largest common resolution", None),
52     ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
53     ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
54 ]
55
56 help_text = """
57 Usage: autorandr [options]
58
59 -h, --help              get this small help
60 -c, --change            reload current setup
61 -s, --save <profile>    save your current setup to profile <profile>
62 -r, --remove <profile>  remove profile <profile>
63 -l, --load <profile>    load profile <profile>
64 -d, --default <profile> make profile <profile> the default profile
65 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
66                         to skip both in detecting changes and applying a profile
67 --force                 force (re)loading of a profile
68 --fingerprint           fingerprint your current hardware setup
69 --config                dump your current xrandr setup
70 --dry-run               don't change anything, only print the xrandr commands
71 --debug                 enable verbose output
72
73  To prevent a profile from being loaded, place a script call "block" in its
74  directory. The script is evaluated before the screen setup is inspected, and
75  in case of it returning a value of 0 the profile is skipped. This can be used
76  to query the status of a docking station you are about to leave.
77
78  If no suitable profile can be identified, the current configuration is kept.
79  To change this behaviour and switch to a fallback configuration, specify
80  --default <profile>.
81
82  Another script called "postswitch" can be placed in the directory
83  ~/.config/autorandr (or ~/.autorandr if you have an old installation) as well
84  as in any profile directories: The scripts are executed after a mode switch
85  has taken place and can notify window managers.
86
87  The following virtual configurations are available:
88 """.strip()
89
90 class AutorandrException(Exception):
91     def __init__(self, message, original_exception=None, report_bug=False):
92         self.message = message
93         self.report_bug = report_bug
94         if original_exception:
95             self.original_exception = original_exception
96             trace = sys.exc_info()[2]
97             while trace.tb_next:
98                 trace = trace.tb_next
99             self.line = trace.tb_lineno
100         else:
101             try:
102                 import inspect
103                 self.line = inspect.currentframe().f_back.f_lineno
104             except:
105                 self.line = None
106             self.original_exception = None
107
108     def __str__(self):
109         retval = [ self.message ]
110         if self.line:
111             retval.append(" (line %d)" % self.line)
112         if self.original_exception:
113             retval.append(":\n  ")
114             retval.append(str(self.original_exception).replace("\n", "\n  "))
115         if self.report_bug:
116             retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream:"
117                           "\nhttps://github.com/phillipberndt/autorandr/issues"
118                          "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
119         return "".join(retval)
120
121 class XrandrOutput(object):
122     "Represents an XRandR output"
123
124     # This regular expression is used to parse an output in `xrandr --verbose'
125     XRANDR_OUTPUT_REGEXP = """(?x)
126         ^(?P<output>[^ ]+)\s+                                                           # Line starts with output name
127         (?:                                                                             # Differentiate disconnected and connected in first line
128             disconnected |
129             unknown\ connection |
130             (?P<connected>connected)
131         )
132         \s*
133         (?P<primary>primary\ )?                                                         # Might be primary screen
134         (?:\s*
135             (?P<width>[0-9]+)x(?P<height>[0-9]+)                                        # Resolution (might be overridden below!)
136             \+(?P<x>-?[0-9]+)\+(?P<y>-?[0-9]+)\s+                                       # Position
137             (?:\(0x[0-9a-fA-F]+\)\s+)?                                                  # XID
138             (?P<rotate>(?:normal|left|right|inverted))\s+                               # Rotation
139             (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)?                                       # Reflection
140         )?                                                                              # .. but everything of the above only if the screen is in use.
141         (?:[\ \t]*\([^\)]+\))(?:\s*[0-9]+mm\sx\s[0-9]+mm)?
142         (?:[\ \t]*panning\ (?P<panning>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))?                 # Panning information
143         (?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))?               # Tracking information
144         (?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))?                            # Border information
145         (?:\s*(?:                                                                       # Properties of the output
146             Gamma: (?P<gamma>(?:inf|[0-9\.: e])+) |                                     # Gamma value
147             Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) |                           # Transformation matrix
148             EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) |                               # EDID of the output
149             (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)*                                 # Other properties
150         ))+
151         \s*
152         (?P<modes>(?:
153             (?P<mode_name>\S+).+?\*current.*\s+                                         # Interesting (current) resolution: Extract rate
154              h:\s+width\s+(?P<mode_width>[0-9]+).+\s+
155              v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
156             \S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s*                                     # Other resolutions
157         )*)
158     """
159
160     XRANDR_OUTPUT_MODES_REGEXP = """(?x)
161         (?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
162          h:\s+width\s+(?P<width>[0-9]+).+\s+
163          v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
164     """
165
166     XRANDR_13_DEFAULTS = {
167         "transform": "1,0,0,0,1,0,0,0,1",
168         "panning": "0x0",
169     }
170
171     XRANDR_12_DEFAULTS = {
172         "reflect": "normal",
173         "rotate": "normal",
174         "gamma": "1.0:1.0:1.0",
175     }
176
177     XRANDR_DEFAULTS = dict(list(XRANDR_13_DEFAULTS.items()) + list(XRANDR_12_DEFAULTS.items()))
178
179     EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
180
181     def __repr__(self):
182         return "<%s%s %s>" % (self.output, self.short_edid, " ".join(self.option_vector))
183
184     @property
185     def short_edid(self):
186         return ("%s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else ""
187
188     @property
189     def options_with_defaults(self):
190         "Return the options dictionary, augmented with the default values that weren't set"
191         if "off" in self.options:
192             return self.options
193         options = {}
194         if xrandr_version() >= Version("1.3"):
195             options.update(self.XRANDR_13_DEFAULTS)
196         if xrandr_version() >= Version("1.2"):
197             options.update(self.XRANDR_12_DEFAULTS)
198         options.update(self.options)
199         return { a: b for a, b in options.items() if a not in self.ignored_options }
200
201     @property
202     def filtered_options(self):
203         "Return a dictionary of options without ignored options"
204         return { a: b for a, b in self.options.items() if a not in self.ignored_options }
205
206     @property
207     def option_vector(self):
208         "Return the command line parameters for XRandR for this instance"
209         return sum([["--%s" % option[0], option[1]] if option[1] else ["--%s" % option[0]] for option in chain((("output", self.output),), sorted(self.options_with_defaults.items()))], [])
210
211     @property
212     def option_string(self):
213         "Return the command line parameters in the configuration file format"
214         return "\n".join([ " ".join(option) if option[1] else option[0] for option in chain((("output", self.output),), sorted(self.filtered_options.items()))])
215
216     @property
217     def sort_key(self):
218         "Return a key to sort the outputs for xrandr invocation"
219         if not self.edid:
220             return -2
221         if "off" in self.options:
222             return -1
223         if "pos" in self.options:
224             x, y = map(float, self.options["pos"].split("x"))
225         else:
226             x, y = 0, 0
227         return x + 10000 * y
228
229     def __init__(self, output, edid, options):
230         "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
231         self.output = output
232         self.edid = edid
233         self.options = options
234         self.ignored_options = []
235         self.remove_default_option_values()
236
237     def set_ignored_options(self, options):
238         "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
239         self.ignored_options = list(options)
240
241     def remove_default_option_values(self):
242         "Remove values from the options dictionary that are superflous"
243         if "off" in self.options and len(self.options.keys()) > 1:
244             self.options = { "off": None }
245             return
246         for option, default_value in self.XRANDR_DEFAULTS.items():
247             if option in self.options and self.options[option] == default_value:
248                 del self.options[option]
249
250     @classmethod
251     def from_xrandr_output(cls, xrandr_output):
252         """Instanciate an XrandrOutput from the output of `xrandr --verbose'
253
254         This method also returns a list of modes supported by the output.
255         """
256         try:
257             xrandr_output = xrandr_output.replace("\r\n", "\n")
258             match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
259         except:
260             raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.", report_bug = True)
261         if not match_object:
262             debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
263             raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug, report_bug = True)
264         remainder = xrandr_output[len(match_object.group(0)):]
265         if remainder:
266             raise AutorandrException(("Parsing XRandR output failed, %d bytes left unmatched after regular expression, "
267                                 "starting at byte %d with ..'%s'.") % (len(remainder), len(match_object.group(0)), remainder[:10]), report_bug=True)
268
269         match = match_object.groupdict()
270
271         modes = []
272         if match["modes"]:
273             modes = [ x.groupdict() for x in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]) if x.group("name") ]
274             if not modes:
275                 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
276
277         options = {}
278         if not match["connected"]:
279             edid = None
280         else:
281             edid = "".join(match["edid"].strip().split()) if match["edid"] else "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
282
283         if not match["width"]:
284             options["off"] = None
285         else:
286             if match["mode_name"]:
287                 options["mode"] = match["mode_name"]
288             elif match["mode_width"]:
289                 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
290             else:
291                 if match["rotate"] not in ("left", "right"):
292                     options["mode"] = "%sx%s" % (match["width"], match["height"])
293                 else:
294                     options["mode"] = "%sx%s" % (match["height"], match["width"])
295             options["rotate"] = match["rotate"]
296             if match["primary"]:
297                 options["primary"] = None
298             if match["reflect"] == "X":
299                 options["reflect"] = "x"
300             elif match["reflect"] == "Y":
301                 options["reflect"] = "y"
302             elif match["reflect"] == "X and Y":
303                 options["reflect"] = "xy"
304             options["pos"] = "%sx%s" % (match["x"], match["y"])
305             if match["panning"]:
306                 panning = [ match["panning"] ]
307                 if match["tracking"]:
308                     panning += [ "/", match["tracking"] ]
309                     if match["border"]:
310                         panning += [ "/", match["border"] ]
311                 options["panning"] = "".join(panning)
312             if match["transform"]:
313                 transformation = ",".join(match["transform"].strip().split())
314                 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
315                     options["transform"] = transformation
316                     if not match["mode_name"]:
317                         # TODO We'd need to apply the reverse transformation here. Let's see if someone complains, I doubt that this
318                         # special case is actually required.
319                         print("Warning: Output %s has a transformation applied. Could not determine correct mode! Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
320             if match["gamma"]:
321                 gamma = match["gamma"].strip()
322                 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
323                 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
324                 # so we approximate by 1e-10.
325                 gamma = ":".join([ str(max(1e-10, round(1./float(x), 3))) for x in gamma.split(":") ])
326                 options["gamma"] = gamma
327             if match["rate"]:
328                 options["rate"] = match["rate"]
329
330         return XrandrOutput(match["output"], edid, options), modes
331
332     @classmethod
333     def from_config_file(cls, edid_map, configuration):
334         "Instanciate an XrandrOutput from the contents of a configuration file"
335         options = {}
336         for line in configuration.split("\n"):
337             if line:
338                 line = line.split(None, 1)
339                 options[line[0]] = line[1] if len(line) > 1 else None
340
341         edid = None
342
343         if options["output"] in edid_map:
344             edid = edid_map[options["output"]]
345         else:
346             # This fuzzy matching is for legacy autorandr that used sysfs output names
347             fuzzy_edid_map = [ re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys() ]
348             fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
349             if fuzzy_output in fuzzy_edid_map:
350                 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
351             elif "off" not in options:
352                 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"]))
353         output = options["output"]
354         del options["output"]
355
356         return XrandrOutput(output, edid, options)
357
358     def edid_equals(self, other):
359         "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
360         if self.edid and other.edid:
361             if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
362                 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
363             if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
364                 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
365         return self.edid == other.edid
366
367     def __ne__(self, other):
368         return not (self == other)
369
370     def __eq__(self, other):
371         return self.edid_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
372
373     def verbose_diff(self, other):
374         "Compare to another XrandrOutput and return a list of human readable differences"
375         diffs = []
376         if not self.edid_equals(other):
377             diffs.append("EDID `%s' differs from `%s'" % (self.short_edid, other.short_edid))
378         if self.output != other.output:
379             diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
380         if "off" in self.options and "off" not in other.options:
381             diffs.append("The output is disabled currently, but active in the new configuration")
382         elif "off" in other.options and "off" not in self.options:
383             diffs.append("The output is currently enabled, but inactive in the new configuration")
384         else:
385             for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
386                 if name not in other.options:
387                     diffs.append("Option --%s %sis not present in the new configuration" % (name, "(= `%s') " % self.options[name] if self.options[name] else ""))
388                 elif name not in self.options:
389                     diffs.append("Option --%s (`%s' in the new configuration) is not present currently" % (name, other.options[name]))
390                 elif self.options[name] != other.options[name]:
391                     diffs.append("Option --%s %sis `%s' in the new configuration" % (name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
392         return diffs
393
394 def xrandr_version():
395     "Return the version of XRandR that this system uses"
396     if getattr(xrandr_version, "version", False) is False:
397         version_string = os.popen("xrandr -v").read()
398         try:
399             version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
400             xrandr_version.version = Version(version)
401         except AttributeError:
402             xrandr_version.version = Version("1.3.0")
403
404     return xrandr_version.version
405
406 def debug_regexp(pattern, string):
407     "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
408     try:
409         import regex
410         bounds = ( 0, len(string) )
411         while bounds[0] != bounds[1]:
412             half = int((bounds[0] + bounds[1]) / 2)
413             if half == bounds[0]:
414                 break
415             bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
416         partial_length = bounds[0]
417         return ("Regular expression matched until position "
418               "%d, ..'%s', and did not match from '%s'.." % (partial_length, string[max(0, partial_length-20):partial_length],
419                                                              string[partial_length:partial_length+10]))
420     except ImportError:
421         pass
422     return "Debug information would be available if the `regex' module was installed."
423
424 def parse_xrandr_output():
425     "Parse the output of `xrandr --verbose' into a list of outputs"
426     xrandr_output = os.popen("xrandr -q --verbose").read()
427     if not xrandr_output:
428         raise AutorandrException("Failed to run xrandr")
429
430     # We are not interested in screens
431     xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
432
433     # Split at output boundaries and instanciate an XrandrOutput per output
434     split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
435     if len(split_xrandr_output) < 2:
436         raise AutorandrException("No output boundaries found", report_bug=True)
437     outputs = OrderedDict()
438     modes = OrderedDict()
439     for i in range(1, len(split_xrandr_output), 2):
440         output_name = split_xrandr_output[i].split()[0]
441         output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i+2]))
442         outputs[output_name] = output
443         if output_modes:
444             modes[output_name] = output_modes
445
446     return outputs, modes
447
448 def load_profiles(profile_path):
449     "Load the stored profiles"
450
451     profiles = {}
452     for profile in os.listdir(profile_path):
453         config_name = os.path.join(profile_path, profile, "config")
454         setup_name  = os.path.join(profile_path, profile, "setup")
455         if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
456             continue
457
458         edids = dict([ x.strip().split() for x in open(setup_name).readlines() if x.strip() ])
459
460         config = {}
461         buffer = []
462         for line in chain(open(config_name).readlines(), ["output"]):
463             if line[:6] == "output" and buffer:
464                 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
465                 buffer = [ line ]
466             else:
467                 buffer.append(line)
468
469         for output_name in list(config.keys()):
470             if config[output_name].edid is None:
471                 del config[output_name]
472
473         profiles[profile] = { "config": config, "path": os.path.join(profile_path, profile), "config-mtime": os.stat(config_name).st_mtime }
474
475     return profiles
476
477 def find_profiles(current_config, profiles):
478     "Find profiles matching the currently connected outputs"
479     detected_profiles = []
480     for profile_name, profile in profiles.items():
481         config = profile["config"]
482         matches = True
483         for name, output in config.items():
484             if not output.edid:
485                 continue
486             if name not in current_config or not output.edid_equals(current_config[name]):
487                 matches = False
488                 break
489         if not matches or any(( name not in config.keys() for name in current_config.keys() if current_config[name].edid )):
490             continue
491         if matches:
492             detected_profiles.append(profile_name)
493     return detected_profiles
494
495 def profile_blocked(profile_path, meta_information=None):
496     """Check if a profile is blocked.
497
498     meta_information is expected to be an dictionary. It will be passed to the block scripts
499     in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
500     """
501     return not exec_scripts(profile_path, "block", meta_information)
502
503 def output_configuration(configuration, config):
504     "Write a configuration file"
505     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
506     for output in outputs:
507         print(configuration[output].option_string, file=config)
508
509 def output_setup(configuration, setup):
510     "Write a setup (fingerprint) file"
511     outputs = sorted(configuration.keys())
512     for output in outputs:
513         if configuration[output].edid:
514             print(output, configuration[output].edid, file=setup)
515
516 def save_configuration(profile_path, configuration):
517     "Save a configuration into a profile"
518     if not os.path.isdir(profile_path):
519         os.makedirs(profile_path)
520     with open(os.path.join(profile_path, "config"), "w") as config:
521         output_configuration(configuration, config)
522     with open(os.path.join(profile_path, "setup"), "w") as setup:
523         output_setup(configuration, setup)
524
525 def update_mtime(filename):
526     "Update a file's mtime"
527     try:
528         os.utime(filename, None)
529         return True
530     except:
531         return False
532
533 def call_and_retry(*args, **kwargs):
534     """Wrapper around subprocess.call that retries failed calls.
535
536     This function calls subprocess.call and on non-zero exit states,
537     waits a second and then retries once. This mitigates #47,
538     a timing issue with some drivers.
539     """
540     kwargs_redirected = dict(kwargs)
541     if hasattr(subprocess, "DEVNULL"):
542         kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
543     else:
544         kwargs_redirected["stdout"] = open(os.devnull, "w")
545     kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
546     retval = subprocess.call(*args, **kwargs_redirected)
547     if retval != 0:
548         time.sleep(1)
549         retval = subprocess.call(*args, **kwargs)
550     return retval
551
552 def apply_configuration(new_configuration, current_configuration, dry_run=False):
553     "Apply a configuration"
554     outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
555     if dry_run:
556         base_argv = [ "echo", "xrandr" ]
557     else:
558         base_argv = [ "xrandr" ]
559
560     # There are several xrandr / driver bugs we need to take care of here:
561     # - We cannot enable more than two screens at the same time
562     #   See https://github.com/phillipberndt/autorandr/pull/6
563     #   and commits f4cce4d and 8429886.
564     # - We cannot disable all screens
565     #   See https://github.com/phillipberndt/autorandr/pull/20
566     # - We should disable screens before enabling others, because there's
567     #   a limit on the number of enabled screens
568     # - We must make sure that the screen at 0x0 is activated first,
569     #   or the other (first) screen to be activated would be moved there.
570     # - If an active screen already has a transformation and remains active,
571     #   the xrandr call fails with an invalid RRSetScreenSize parameter error.
572     #   Update the configuration in 3 passes in that case.  (On Haswell graphics,
573     #   at least.)
574     # - Some implementations can not handle --transform at all, so avoid it unless
575     #   necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
576
577     auxiliary_changes_pre = []
578     disable_outputs = []
579     enable_outputs = []
580     remain_active_count = 0
581     for output in outputs:
582         if not new_configuration[output].edid or "off" in new_configuration[output].options:
583             disable_outputs.append(new_configuration[output].option_vector)
584         else:
585             if "off" not in current_configuration[output].options:
586                 remain_active_count += 1
587
588             option_vector = new_configuration[output].option_vector
589             if xrandr_version() >= Version("1.3.0"):
590                 if "transform" in current_configuration[output].options:
591                     auxiliary_changes_pre.append(["--output", output, "--transform", "none"])
592                 else:
593                     try:
594                         transform_index = option_vector.index("--transform")
595                         if option_vector[transform_index+1] == XrandrOutput.XRANDR_DEFAULTS["transform"]:
596                             option_vector = option_vector[:transform_index] + option_vector[transform_index+2:]
597                     except ValueError:
598                         pass
599
600             enable_outputs.append(option_vector)
601
602     # Perform pe-change auxiliary changes
603     if auxiliary_changes_pre:
604         argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
605         if call_and_retry(argv) != 0:
606             raise AutorandrException("Command failed: %s" % " ".join(argv))
607
608     # Disable unused outputs, but make sure that there always is at least one active screen
609     disable_keep = 0 if remain_active_count else 1
610     if len(disable_outputs) > disable_keep:
611         if call_and_retry(base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))) != 0:
612             # Disabling the outputs failed. Retry with the next command:
613             # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
614             # This does not occur if simultaneously the primary screen is reset.
615             pass
616         else:
617             disable_outputs = disable_outputs[-1:] if disable_keep else []
618
619     # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
620     # disable the last two screens. This is a problem, so if this would happen, instead disable only
621     # one screen in the first call below.
622     if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
623         # In the context of a xrandr call that changes the display state, `--query' should do nothing
624         disable_outputs.insert(0, ['--query'])
625
626     # Enable the remaining outputs in pairs of two operations
627     operations = disable_outputs + enable_outputs
628     for index in range(0, len(operations), 2):
629         argv = base_argv + list(chain.from_iterable(operations[index:index+2]))
630         if call_and_retry(argv) != 0:
631             raise AutorandrException("Command failed: %s" % " ".join(argv))
632
633 def is_equal_configuration(source_configuration, target_configuration):
634     "Check if all outputs from target are already configured correctly in source"
635     for output in target_configuration.keys():
636         if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
637             return False
638     return True
639
640 def add_unused_outputs(source_configuration, target_configuration):
641     "Add outputs that are missing in target to target, in 'off' state"
642     for output_name, output in source_configuration.items():
643         if output_name not in target_configuration:
644             target_configuration[output_name] = XrandrOutput(output_name, output.edid, { "off": None })
645
646 def remove_irrelevant_outputs(source_configuration, target_configuration):
647     "Remove outputs from target that ought to be 'off' and already are"
648     for output_name, output in source_configuration.items():
649         if "off" in output.options and output_name in target_configuration and "off" in target_configuration[output_name].options:
650             del target_configuration[output_name]
651
652 def generate_virtual_profile(configuration, modes, profile_name):
653     "Generate one of the virtual profiles"
654     configuration = copy.deepcopy(configuration)
655     if profile_name == "common":
656         common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output_modes )) for output, output_modes in modes.items() if configuration[output].edid ]
657         common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
658         common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
659         if common_resolution:
660             for output in configuration:
661                 configuration[output].options = {}
662                 if output in modes and configuration[output].edid:
663                     configuration[output].options["mode"] = [ x["name"] for x in sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1) if x["width"] == common_resolution[-1][0] and x["height"] == common_resolution[-1][1] ][0]
664                     configuration[output].options["pos"] = "0x0"
665                 else:
666                     configuration[output].options["off"] = None
667     elif profile_name in ("horizontal", "vertical"):
668         shift = 0
669         if profile_name == "horizontal":
670             shift_index = "width"
671             pos_specifier = "%sx0"
672         else:
673             shift_index = "height"
674             pos_specifier = "0x%s"
675
676         for output in configuration:
677             configuration[output].options = {}
678             if output in modes and configuration[output].edid:
679                 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
680                 configuration[output].options["mode"] = mode["name"]
681                 configuration[output].options["rate"] = mode["rate"]
682                 configuration[output].options["pos"] = pos_specifier % shift
683                 shift += int(mode[shift_index])
684             else:
685                 configuration[output].options["off"] = None
686     return configuration
687
688 def print_profile_differences(one, another):
689     "Print the differences between two profiles for debugging"
690     if one == another:
691         return
692     print("| Differences between the two profiles:", file=sys.stderr)
693     for output in set(chain.from_iterable((one.keys(), another.keys()))):
694         if output not in one:
695             if "off" not in another[output].options:
696                 print("| Output `%s' is missing from the active configuration" % output, file=sys.stderr)
697         elif output not in another:
698             if "off" not in one[output].options:
699                 print("| Output `%s' is missing from the new configuration" % output, file=sys.stderr)
700         else:
701             for line in one[output].verbose_diff(another[output]):
702                 print("| [Output %s] %s" % (output, line), file=sys.stderr)
703     print ("\\-", file=sys.stderr)
704
705 def exit_help():
706     "Print help and exit"
707     print(help_text)
708     for profile in virtual_profiles:
709         print("  %-10s %s" % profile[:2])
710     sys.exit(0)
711
712 def exec_scripts(profile_path, script_name, meta_information=None):
713     """"Run userscripts
714
715     This will run all executables from the profile folder, and global per-user
716     and system-wide configuration folders, named script_name or residing in
717     subdirectories named script_name.d.
718
719     meta_information is expected to be an dictionary. It will be passed to the block scripts
720     in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
721
722     Returns True unless any of the scripts exited with non-zero exit status.
723     """
724     all_ok = True
725     if meta_information:
726         env = os.environ.copy()
727         env.update({ "AUTORANDR_%s" % str(key).upper(): str(value) for (key, value) in meta_information.items() })
728     else:
729         env = os.environ.copy()
730
731     # If there are multiple candidates, the XDG spec tells to only use the first one.
732     ran_scripts = set()
733
734     user_profile_path = os.path.expanduser("~/.autorandr")
735     if not os.path.isdir(user_profile_path):
736         user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
737
738     for folder in chain((profile_path, os.path.dirname(profile_path), user_profile_path),
739                         (os.path.join(x, "autorandr") for x in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"))):
740
741         if script_name not in ran_scripts:
742             script = os.path.join(folder, script_name)
743             if os.access(script, os.X_OK | os.F_OK):
744                 all_ok &= subprocess.call(script, env=env) != 0
745                 ran_scripts.add(script_name)
746
747         script_folder = os.path.join(folder, "%s.d" % script_name)
748         if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
749             for file_name in os.listdir(script_folder):
750                 check_name = "d/%s" % (file_name,)
751                 if check_name not in ran_scripts:
752                     script = os.path.join(script_folder, file_name)
753                     if os.access(script, os.X_OK | os.F_OK):
754                         all_ok &= subprocess.call(script, env=env) != 0
755                         ran_scripts.add(check_name)
756
757     return all_ok
758
759 def main(argv):
760     try:
761         options = dict(getopt.getopt(argv[1:], "s:r:l:d:cfh", [ "dry-run", "change", "default=", "save=", "remove=", "load=", "force", "fingerprint", "config", "debug", "skip-options=", "help" ])[0])
762     except getopt.GetoptError as e:
763         print("Failed to parse options: {0}.\n"
764               "Use --help to get usage information.".format(str(e)),
765               file=sys.stderr)
766         sys.exit(posix.EX_USAGE)
767
768     profiles = {}
769     try:
770         # Load profiles from each XDG config directory
771         # The XDG spec says that earlier entries should take precedence, so reverse the order
772         for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
773             system_profile_path = os.path.join(directory, "autorandr")
774             if os.path.isdir(system_profile_path):
775                 profiles.update(load_profiles(system_profile_path))
776         # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
777         # profile_path is also used later on to store configurations
778         profile_path = os.path.expanduser("~/.autorandr")
779         if not os.path.isdir(profile_path):
780             # Elsewise, follow the XDG specification
781             profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
782         if os.path.isdir(profile_path):
783             profiles.update(load_profiles(profile_path))
784         # Sort by descending mtime
785         profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
786     except Exception as e:
787         raise AutorandrException("Failed to load profiles", e)
788
789     config, modes = parse_xrandr_output()
790
791     if "--fingerprint" in options:
792         output_setup(config, sys.stdout)
793         sys.exit(0)
794
795     if "--config" in options:
796         output_configuration(config, sys.stdout)
797         sys.exit(0)
798
799     if "--skip-options" in options:
800         skip_options = [ y[2:] if y[:2] == "--" else y for y in ( x.strip() for x in options["--skip-options"].split(",") ) ]
801         for profile in profiles.values():
802             for output in profile["config"].values():
803                 output.set_ignored_options(skip_options)
804         for output in config.values():
805             output.set_ignored_options(skip_options)
806
807     if "-s" in options:
808         options["--save"] = options["-s"]
809     if "--save" in options:
810         if options["--save"] in ( x[0] for x in virtual_profiles ):
811             raise AutorandrException("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
812         try:
813             profile_folder = os.path.join(profile_path, options["--save"])
814             save_configuration(profile_folder, config)
815             exec_scripts(profile_folder, "postsave", {"CURRENT_PROFILE": options["--save"], "PROFILE_FOLDER": profile_folder})
816         except Exception as e:
817             raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
818         print("Saved current configuration as profile '%s'" % options["--save"])
819         sys.exit(0)
820
821     if "-r" in options:
822         options["--remove"] = options["-r"]
823     if "--remove" in options:
824         if options["--remove"] in ( x[0] for x in virtual_profiles ):
825             raise AutorandrException("Cannot remove profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--remove"])
826         if options["--remove"] not in profiles.keys():
827             raise AutorandrException("Cannot remove profile '%s':\nThis profile does not exist." % options["--remove"])
828         try:
829             remove = True
830             profile_folder = os.path.join(profile_path, options["--remove"])
831             profile_dirlist = os.listdir(profile_folder)
832             profile_dirlist.remove("config")
833             profile_dirlist.remove("setup")
834             if profile_dirlist:
835                 print("Profile folder '%s' contains the following additional files:\n---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
836                 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
837                 if response != "yes":
838                     remove = False
839             if remove is True:
840                 shutil.rmtree(profile_folder)
841                 print("Removed profile '%s'" % options["--remove"])
842             else:
843                 print("Profile '%s' was not removed" % options["--remove"])
844         except Exception as e:
845             raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
846         sys.exit(0)
847
848     if "-h" in options or "--help" in options:
849         exit_help()
850
851     detected_profiles = find_profiles(config, profiles)
852     load_profile = False
853
854     if "-l" in options:
855         options["--load"] = options["-l"]
856     if "--load" in options:
857         load_profile = options["--load"]
858     else:
859         # Find the active profile(s) first, for the block script (See #42)
860         current_profiles = []
861         for profile_name in profiles.keys():
862             configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
863             if configs_are_equal:
864                 current_profiles.append(profile_name)
865         block_script_metadata = {
866             "CURRENT_PROFILE":  "".join(current_profiles[:1]),
867             "CURRENT_PROFILES": ":".join(current_profiles)
868         }
869
870         for profile_name in profiles.keys():
871             if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
872                 print("%s (blocked)" % profile_name, file=sys.stderr)
873                 continue
874             props = []
875             if profile_name in detected_profiles:
876                 props.append("(detected)")
877                 if ("-c" in options or "--change" in options) and not load_profile:
878                     load_profile = profile_name
879             if profile_name in current_profiles:
880                 props.append("(current)")
881             print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)), file=sys.stderr)
882             if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
883                 print_profile_differences(config, profiles[profile_name]["config"])
884
885     if "-d" in options:
886         options["--default"] = options["-d"]
887     if not load_profile and "--default" in options:
888         load_profile = options["--default"]
889
890     if load_profile:
891         if load_profile in ( x[0] for x in virtual_profiles ):
892             load_config = generate_virtual_profile(config, modes, load_profile)
893             scripts_path = os.path.join(profile_path, load_profile)
894         else:
895             try:
896                 profile = profiles[load_profile]
897                 load_config = profile["config"]
898                 scripts_path = profile["path"]
899             except KeyError:
900                 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
901             if load_profile in detected_profiles and detected_profiles[0] != load_profile:
902                 update_mtime(os.path.join(scripts_path, "config"))
903         add_unused_outputs(config, load_config)
904         if load_config == dict(config) and not "-f" in options and not "--force" in options:
905             print("Config already loaded", file=sys.stderr)
906             sys.exit(0)
907         if "--debug" in options and load_config != dict(config):
908             print("Loading profile '%s'" % load_profile)
909             print_profile_differences(config, load_config)
910
911         remove_irrelevant_outputs(config, load_config)
912
913         try:
914             if "--dry-run" in options:
915                 apply_configuration(load_config, config, True)
916             else:
917                 script_metadata = {
918                     "CURRENT_PROFILE": load_profile,
919                     "PROFILE_FOLDER": scripts_path,
920                 }
921                 exec_scripts(scripts_path, "preswitch", script_metadata)
922                 if "--debug" in options:
923                     print("Going to run:")
924                     apply_configuration(load_config, config, True)
925                 apply_configuration(load_config, config, False)
926                 exec_scripts(scripts_path, "postswitch", script_metadata)
927         except Exception as e:
928             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
929
930         if "--dry-run" not in options and "--debug" in options:
931             new_config, _ = parse_xrandr_output()
932             if not is_equal_configuration(new_config, load_config):
933                 print("The configuration change did not go as expected:")
934                 print_profile_differences(new_config, load_config)
935
936     sys.exit(0)
937
938 if __name__ == '__main__':
939     try:
940         main(sys.argv)
941     except AutorandrException as e:
942         print(e, file=sys.stderr)
943         sys.exit(1)
944     except Exception as e:
945         if not len(str(e)):  # BdbQuit
946             print("Exception: {0}".format(e.__class__.__name__))
947             sys.exit(2)
948
949         print("Unhandled exception ({0}). Please report this as a bug at https://github.com/phillipberndt/autorandr/issues.".format(e), file=sys.stderr)
950         raise