]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blob - autorandr.py
Show (current) after the current profile in the default output
[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 import copy
27 import getopt
28
29 import binascii
30 import hashlib
31 import os
32 import re
33 import subprocess
34 import sys
35 from distutils.version import LooseVersion as Version
36
37 from functools import reduce
38 from itertools import chain
39 from collections import OrderedDict
40
41 import posix
42
43
44 virtual_profiles = [
45     # (name, description, callback)
46     ("common", "Clone all connected outputs at the largest common resolution", None),
47     ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
48     ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
49 ]
50
51 help_text = """
52 Usage: autorandr [options]
53
54 -h, --help              get this small help
55 -c, --change            reload current setup
56 -s, --save <profile>    save your current setup to profile <profile>
57 -l, --load <profile>    load profile <profile>
58 -d, --default <profile> make profile <profile> the default profile
59 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
60                         to skip both in detecting changes and applying a profile
61 --force                 force (re)loading of a profile
62 --fingerprint           fingerprint your current hardware setup
63 --config                dump your current xrandr setup
64 --dry-run               don't change anything, only print the xrandr commands
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, (" %s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else "", " ".join(self.option_vector))
175
176     @property
177     def options_with_defaults(self):
178         "Return the options dictionary, augmented with the default values that weren't set"
179         if "off" in self.options:
180             return self.options
181         options = {}
182         if xrandr_version() >= Version("1.3"):
183             options.update(self.XRANDR_13_DEFAULTS)
184         if xrandr_version() >= Version("1.2"):
185             options.update(self.XRANDR_12_DEFAULTS)
186         options.update(self.options)
187         return { a: b for a, b in options.items() if a not in self.ignored_options }
188
189     @property
190     def filtered_options(self):
191         "Return a dictionary of options without ignored options"
192         return { a: b for a, b in self.options.items() if a not in self.ignored_options }
193
194     @property
195     def option_vector(self):
196         "Return the command line parameters for XRandR for this instance"
197         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()))], [])
198
199     @property
200     def option_string(self):
201         "Return the command line parameters in the configuration file format"
202         return "\n".join([ " ".join(option) if option[1] else option[0] for option in chain((("output", self.output),), sorted(self.filtered_options.items()))])
203
204     @property
205     def sort_key(self):
206         "Return a key to sort the outputs for xrandr invocation"
207         if not self.edid:
208             return -2
209         if "off" in self.options:
210             return -1
211         if "pos" in self.options:
212             x, y = map(float, self.options["pos"].split("x"))
213         else:
214             x, y = 0, 0
215         return x + 10000 * y
216
217     def __init__(self, output, edid, options):
218         "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
219         self.output = output
220         self.edid = edid
221         self.options = options
222         self.ignored_options = []
223         self.remove_default_option_values()
224
225     def set_ignored_options(self, options):
226         "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
227         self.ignored_options = list(options)
228
229     def remove_default_option_values(self):
230         "Remove values from the options dictionary that are superflous"
231         if "off" in self.options and len(self.options.keys()) > 1:
232             self.options = { "off": None }
233             return
234         for option, default_value in self.XRANDR_DEFAULTS.items():
235             if option in self.options and self.options[option] == default_value:
236                 del self.options[option]
237
238     @classmethod
239     def from_xrandr_output(cls, xrandr_output):
240         """Instanciate an XrandrOutput from the output of `xrandr --verbose'
241
242         This method also returns a list of modes supported by the output.
243         """
244         try:
245             xrandr_output = xrandr_output.replace("\r\n", "\n")
246             match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
247         except:
248             raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.", report_bug = True)
249         if not match_object:
250             debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
251             raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug, report_bug = True)
252         remainder = xrandr_output[len(match_object.group(0)):]
253         if remainder:
254             raise AutorandrException(("Parsing XRandR output failed, %d bytes left unmatched after regular expression, "
255                                 "starting at byte %d with ..'%s'.") % (len(remainder), len(match_object.group(0)), remainder[:10]), report_bug=True)
256
257         match = match_object.groupdict()
258
259         modes = []
260         if match["modes"]:
261             modes = [ x.groupdict() for x in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]) if x.group("name") ]
262             if not modes:
263                 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
264
265         options = {}
266         if not match["connected"]:
267             edid = None
268         else:
269             edid = "".join(match["edid"].strip().split()) if match["edid"] else "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
270
271         if not match["width"]:
272             options["off"] = None
273         else:
274             if match["mode_name"]:
275                 options["mode"] = match["mode_name"]
276             elif match["mode_width"]:
277                 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
278             else:
279                 if match["rotate"] not in ("left", "right"):
280                     options["mode"] = "%sx%s" % (match["width"], match["height"])
281                 else:
282                     options["mode"] = "%sx%s" % (match["height"], match["width"])
283             options["rotate"] = match["rotate"]
284             if match["primary"]:
285                 options["primary"] = None
286             if match["reflect"] == "X":
287                 options["reflect"] = "x"
288             elif match["reflect"] == "Y":
289                 options["reflect"] = "y"
290             elif match["reflect"] == "X and Y":
291                 options["reflect"] = "xy"
292             options["pos"] = "%sx%s" % (match["x"], match["y"])
293             if match["panning"]:
294                 panning = [ match["panning"] ]
295                 if match["tracking"]:
296                     panning += [ "/", match["tracking"] ]
297                     if match["border"]:
298                         panning += [ "/", match["border"] ]
299                 options["panning"] = "".join(panning)
300             if match["transform"]:
301                 transformation = ",".join(match["transform"].strip().split())
302                 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
303                     options["transform"] = transformation
304                     if not match["mode_name"]:
305                         # TODO We'd need to apply the reverse transformation here. Let's see if someone complains, I doubt that this
306                         # special case is actually required.
307                         print("Warning: Output %s has a transformation applied. Could not determine correct mode! Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
308             if match["gamma"]:
309                 gamma = match["gamma"].strip()
310                 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
311                 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
312                 # so we approximate by 1e-10.
313                 gamma = ":".join([ str(max(1e-10, round(1./float(x), 3))) for x in gamma.split(":") ])
314                 options["gamma"] = gamma
315             if match["rate"]:
316                 options["rate"] = match["rate"]
317
318         return XrandrOutput(match["output"], edid, options), modes
319
320     @classmethod
321     def from_config_file(cls, edid_map, configuration):
322         "Instanciate an XrandrOutput from the contents of a configuration file"
323         options = {}
324         for line in configuration.split("\n"):
325             if line:
326                 line = line.split(None, 1)
327                 options[line[0]] = line[1] if len(line) > 1 else None
328
329         edid = None
330
331         if options["output"] in edid_map:
332             edid = edid_map[options["output"]]
333         else:
334             # This fuzzy matching is for legacy autorandr that used sysfs output names
335             fuzzy_edid_map = [ re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys() ]
336             fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
337             if fuzzy_output in fuzzy_edid_map:
338                 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
339             elif "off" not in options:
340                 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"]))
341         output = options["output"]
342         del options["output"]
343
344         return XrandrOutput(output, edid, options)
345
346     def edid_equals(self, other):
347         "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
348         if self.edid and other.edid:
349             if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
350                 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
351             if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
352                 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
353         return self.edid == other.edid
354
355     def __ne__(self, other):
356         return not (self == other)
357
358     def __eq__(self, other):
359         return self.edid_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
360
361 def xrandr_version():
362     "Return the version of XRandR that this system uses"
363     if getattr(xrandr_version, "version", False) is False:
364         version_string = os.popen("xrandr -v").read()
365         try:
366             version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
367             xrandr_version.version = Version(version)
368         except AttributeError:
369             xrandr_version.version = Version("1.3.0")
370
371     return xrandr_version.version
372
373 def debug_regexp(pattern, string):
374     "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
375     try:
376         import regex
377         bounds = ( 0, len(string) )
378         while bounds[0] != bounds[1]:
379             half = int((bounds[0] + bounds[1]) / 2)
380             if half == bounds[0]:
381                 break
382             bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
383         partial_length = bounds[0]
384         return ("Regular expression matched until position "
385               "%d, ..'%s', and did not match from '%s'.." % (partial_length, string[max(0, partial_length-20):partial_length],
386                                                              string[partial_length:partial_length+10]))
387     except ImportError:
388         pass
389     return "Debug information would be available if the `regex' module was installed."
390
391 def parse_xrandr_output():
392     "Parse the output of `xrandr --verbose' into a list of outputs"
393     xrandr_output = os.popen("xrandr -q --verbose").read()
394     if not xrandr_output:
395         raise AutorandrException("Failed to run xrandr")
396
397     # We are not interested in screens
398     xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
399
400     # Split at output boundaries and instanciate an XrandrOutput per output
401     split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
402     if len(split_xrandr_output) < 2:
403         raise AutorandrException("No output boundaries found", report_bug=True)
404     outputs = OrderedDict()
405     modes = OrderedDict()
406     for i in range(1, len(split_xrandr_output), 2):
407         output_name = split_xrandr_output[i].split()[0]
408         output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i+2]))
409         outputs[output_name] = output
410         if output_modes:
411             modes[output_name] = output_modes
412
413     return outputs, modes
414
415 def load_profiles(profile_path):
416     "Load the stored profiles"
417
418     profiles = {}
419     for profile in os.listdir(profile_path):
420         config_name = os.path.join(profile_path, profile, "config")
421         setup_name  = os.path.join(profile_path, profile, "setup")
422         if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
423             continue
424
425         edids = dict([ x.strip().split() for x in open(setup_name).readlines() if x.strip() ])
426
427         config = {}
428         buffer = []
429         for line in chain(open(config_name).readlines(), ["output"]):
430             if line[:6] == "output" and buffer:
431                 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
432                 buffer = [ line ]
433             else:
434                 buffer.append(line)
435
436         for output_name in list(config.keys()):
437             if config[output_name].edid is None:
438                 del config[output_name]
439
440         profiles[profile] = { "config": config, "path": os.path.join(profile_path, profile), "config-mtime": os.stat(config_name).st_mtime }
441
442     return profiles
443
444 def find_profiles(current_config, profiles):
445     "Find profiles matching the currently connected outputs"
446     detected_profiles = []
447     for profile_name, profile in profiles.items():
448         config = profile["config"]
449         matches = True
450         for name, output in config.items():
451             if not output.edid:
452                 continue
453             if name not in current_config or not output.edid_equals(current_config[name]):
454                 matches = False
455                 break
456         if not matches or any(( name not in config.keys() for name in current_config.keys() if current_config[name].edid )):
457             continue
458         if matches:
459             detected_profiles.append(profile_name)
460     return detected_profiles
461
462 def profile_blocked(profile_path):
463     "Check if a profile is blocked"
464     script = os.path.join(profile_path, "block")
465     if not os.access(script, os.X_OK | os.F_OK):
466         return False
467     return subprocess.call(script) == 0
468
469 def output_configuration(configuration, config):
470     "Write a configuration file"
471     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
472     for output in outputs:
473         print(configuration[output].option_string, file=config)
474
475 def output_setup(configuration, setup):
476     "Write a setup (fingerprint) file"
477     outputs = sorted(configuration.keys())
478     for output in outputs:
479         if configuration[output].edid:
480             print(output, configuration[output].edid, file=setup)
481
482 def save_configuration(profile_path, configuration):
483     "Save a configuration into a profile"
484     if not os.path.isdir(profile_path):
485         os.makedirs(profile_path)
486     with open(os.path.join(profile_path, "config"), "w") as config:
487         output_configuration(configuration, config)
488     with open(os.path.join(profile_path, "setup"), "w") as setup:
489         output_setup(configuration, setup)
490
491 def update_mtime(filename):
492     "Update a file's mtime"
493     try:
494         os.utime(filename, None)
495         return True
496     except:
497         return False
498
499 def apply_configuration(new_configuration, current_configuration, dry_run=False):
500     "Apply a configuration"
501     outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
502     if dry_run:
503         base_argv = [ "echo", "xrandr" ]
504     else:
505         base_argv = [ "xrandr" ]
506
507     # There are several xrandr / driver bugs we need to take care of here:
508     # - We cannot enable more than two screens at the same time
509     #   See https://github.com/phillipberndt/autorandr/pull/6
510     #   and commits f4cce4d and 8429886.
511     # - We cannot disable all screens
512     #   See https://github.com/phillipberndt/autorandr/pull/20
513     # - We should disable screens before enabling others, because there's
514     #   a limit on the number of enabled screens
515     # - We must make sure that the screen at 0x0 is activated first,
516     #   or the other (first) screen to be activated would be moved there.
517     # - If an active screen already has a transformation and remains active,
518     #   the xrandr call fails with an invalid RRSetScreenSize parameter error.
519     #   Update the configuration in 3 passes in that case.  (On Haswell graphics,
520     #   at least.)
521
522     auxiliary_changes_pre = []
523     disable_outputs = []
524     enable_outputs = []
525     remain_active_count = 0
526     for output in outputs:
527         if not new_configuration[output].edid or "off" in new_configuration[output].options:
528             disable_outputs.append(new_configuration[output].option_vector)
529         else:
530             if "off" not in current_configuration[output].options:
531                 remain_active_count += 1
532             enable_outputs.append(new_configuration[output].option_vector)
533             if xrandr_version() >= Version("1.3.0") and "transform" in current_configuration[output].options:
534                 auxiliary_changes_pre.append(["--output", output, "--transform", "none"])
535
536     # Perform pe-change auxiliary changes
537     if auxiliary_changes_pre:
538         argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
539         if subprocess.call(argv) != 0:
540             raise AutorandrException("Command failed: %s" % " ".join(argv))
541
542     # Disable unused outputs, but make sure that there always is at least one active screen
543     disable_keep = 0 if remain_active_count else 1
544     if len(disable_outputs) > disable_keep:
545         if subprocess.call(base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))) != 0:
546             # Disabling the outputs failed. Retry with the next command:
547             # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
548             # This does not occur if simultaneously the primary screen is reset.
549             pass
550         else:
551             disable_outputs = disable_outputs[-1:] if disable_keep else []
552
553     # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
554     # disable the last two screens. This is a problem, so if this would happen, instead disable only
555     # one screen in the first call below.
556     if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
557         # In the context of a xrandr call that changes the display state, `--query' should do nothing
558         disable_outputs.insert(0, ['--query'])
559
560     # Enable the remaining outputs in pairs of two operations
561     operations = disable_outputs + enable_outputs
562     for index in range(0, len(operations), 2):
563         argv = base_argv + list(chain.from_iterable(operations[index:index+2]))
564         if subprocess.call(argv) != 0:
565             raise AutorandrException("Command failed: %s" % " ".join(argv))
566
567 def is_equal_configuration(source_configuration, target_configuration):
568     "Check if all outputs from target are already configured correctly in source"
569     for output in target_configuration.keys():
570         if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
571             return False
572     return True
573
574 def add_unused_outputs(source_configuration, target_configuration):
575     "Add outputs that are missing in target to target, in 'off' state"
576     for output_name, output in source_configuration.items():
577         if output_name not in target_configuration:
578             target_configuration[output_name] = XrandrOutput(output_name, output.edid, { "off": None })
579
580 def remove_irrelevant_outputs(source_configuration, target_configuration):
581     "Remove outputs from target that ought to be 'off' and already are"
582     for output_name, output in source_configuration.items():
583         if "off" in output.options and output_name in target_configuration and "off" in target_configuration[output_name].options:
584             del target_configuration[output_name]
585
586 def generate_virtual_profile(configuration, modes, profile_name):
587     "Generate one of the virtual profiles"
588     configuration = copy.deepcopy(configuration)
589     if profile_name == "common":
590         common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output )) for output in modes.values() ]
591         common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
592         common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
593         if common_resolution:
594             for output in configuration:
595                 configuration[output].options = {}
596                 if output in modes:
597                     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]
598                     configuration[output].options["pos"] = "0x0"
599                 else:
600                     configuration[output].options["off"] = None
601     elif profile_name in ("horizontal", "vertical"):
602         shift = 0
603         if profile_name == "horizontal":
604             shift_index = "width"
605             pos_specifier = "%sx0"
606         else:
607             shift_index = "height"
608             pos_specifier = "0x%s"
609
610         for output in configuration:
611             configuration[output].options = {}
612             if output in modes:
613                 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
614                 configuration[output].options["mode"] = mode["name"]
615                 configuration[output].options["rate"] = mode["rate"]
616                 configuration[output].options["pos"] = pos_specifier % shift
617                 shift += int(mode[shift_index])
618             else:
619                 configuration[output].options["off"] = None
620     return configuration
621
622 def exit_help():
623     "Print help and exit"
624     print(help_text)
625     for profile in virtual_profiles:
626         print("  %-10s %s" % profile[:2])
627     sys.exit(0)
628
629 def exec_scripts(profile_path, script_name):
630     "Run userscripts"
631     for script in (os.path.join(profile_path, script_name), os.path.join(os.path.dirname(profile_path), script_name)):
632         if os.access(script, os.X_OK | os.F_OK):
633             subprocess.call(script)
634
635 def main(argv):
636     try:
637        options = dict(getopt.getopt(argv[1:], "s:l:d:cfh", [ "dry-run", "change", "default=", "save=", "load=", "force", "fingerprint", "config", "skip-options=", "help" ])[0])
638     except getopt.GetoptError as e:
639         print("Failed to parse options: {0}.\n"
640               "Use --help to get usage information.".format(str(e)),
641               file=sys.stderr)
642         sys.exit(posix.EX_USAGE)
643
644     profiles = {}
645     try:
646         # Load profiles from each XDG config directory
647         for directory in os.environ.get("XDG_CONFIG_DIRS", "").split(":"):
648             system_profile_path = os.path.join(directory, "autorandr")
649             if os.path.isdir(system_profile_path):
650                 profiles.update(load_profiles(system_profile_path))
651         # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
652         # profile_path is also used later on to store configurations
653         profile_path = os.path.expanduser("~/.autorandr")
654         if not os.path.isdir(profile_path):
655             # Elsewise, follow the XDG specification
656             profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
657         if os.path.isdir(profile_path):
658             profiles.update(load_profiles(profile_path))
659         # Sort by descending mtime
660         profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
661     except Exception as e:
662         raise AutorandrException("Failed to load profiles", e)
663
664     config, modes = parse_xrandr_output()
665
666     if "--fingerprint" in options:
667         output_setup(config, sys.stdout)
668         sys.exit(0)
669
670     if "--config" in options:
671         output_configuration(config, sys.stdout)
672         sys.exit(0)
673
674     if "--skip-options" in options:
675         skip_options = [ y[2:] if y[:2] == "--" else y for y in ( x.strip() for x in options["--skip-options"].split(",") ) ]
676         for profile in profiles.values():
677             for output in profile["config"].values():
678                 output.set_ignored_options(skip_options)
679         for output in config.values():
680             output.set_ignored_options(skip_options)
681
682     if "-s" in options:
683         options["--save"] = options["-s"]
684     if "--save" in options:
685         if options["--save"] in ( x[0] for x in virtual_profiles ):
686             raise AutorandrException("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
687         try:
688             save_configuration(os.path.join(profile_path, options["--save"]), config)
689         except Exception as e:
690             raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
691         print("Saved current configuration as profile '%s'" % options["--save"])
692         sys.exit(0)
693
694     if "-h" in options or "--help" in options:
695         exit_help()
696
697     detected_profiles = find_profiles(config, profiles)
698     load_profile = False
699
700     if "-l" in options:
701         options["--load"] = options["-l"]
702     if "--load" in options:
703         load_profile = options["--load"]
704     else:
705         for profile_name in profiles.keys():
706             if profile_blocked(os.path.join(profile_path, profile_name)):
707                 print("%s (blocked)" % profile_name, file=sys.stderr)
708                 continue
709             props = []
710             if profile_name in detected_profiles:
711                 props.append("(detected)")
712                 if ("-c" in options or "--change" in options) and not load_profile:
713                     load_profile = profile_name
714             if is_equal_configuration(config, profiles[profile_name]["config"]):
715                 props.append("(current)")
716             print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)), file=sys.stderr)
717
718     if "-d" in options:
719         options["--default"] = options["-d"]
720     if not load_profile and "--default" in options:
721         load_profile = options["--default"]
722
723     if load_profile:
724         if load_profile in ( x[0] for x in virtual_profiles ):
725             load_config = generate_virtual_profile(config, modes, load_profile)
726             scripts_path = os.path.join(profile_path, load_profile)
727         else:
728             try:
729                 profile = profiles[load_profile]
730                 load_config = profile["config"]
731                 scripts_path = profile["path"]
732             except KeyError:
733                 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
734             if load_profile in detected_profiles and detected_profiles[0] != load_profile:
735                 update_mtime(os.path.join(scripts_path, "config"))
736         add_unused_outputs(config, load_config)
737         if load_config == dict(config) and not "-f" in options and not "--force" in options:
738             print("Config already loaded", file=sys.stderr)
739             sys.exit(0)
740         remove_irrelevant_outputs(config, load_config)
741
742         try:
743             if "--dry-run" in options:
744                 apply_configuration(load_config, config, True)
745             else:
746                 exec_scripts(scripts_path, "preswitch")
747                 apply_configuration(load_config, config, False)
748                 exec_scripts(scripts_path, "postswitch")
749         except Exception as e:
750             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
751
752     sys.exit(0)
753
754 if __name__ == '__main__':
755     try:
756         main(sys.argv)
757     except AutorandrException as e:
758         print(e, file=sys.stderr)
759         sys.exit(1)
760     except Exception as e:
761         if not len(str(e)):  # BdbQuit
762             print("Exception: {0}".format(e.__class__.__name__))
763             sys.exit(2)
764
765         print("Unhandled exception ({0}). Please report this as a bug.".format(e), file=sys.stderr)
766         raise