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