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