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