]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blob - autorandr.py
Added --skip-options to ignore certain xrandr options
[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 __eq__(self, other):
356         return self.edid_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
357
358 def xrandr_version():
359     "Return the version of XRandR that this system uses"
360     if getattr(xrandr_version, "version", False) is False:
361         version_string = os.popen("xrandr -v").read()
362         try:
363             version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
364             xrandr_version.version = Version(version)
365         except AttributeError:
366             xrandr_version.version = Version("1.3.0")
367
368     return xrandr_version.version
369
370 def debug_regexp(pattern, string):
371     "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
372     try:
373         import regex
374         bounds = ( 0, len(string) )
375         while bounds[0] != bounds[1]:
376             half = int((bounds[0] + bounds[1]) / 2)
377             if half == bounds[0]:
378                 break
379             bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
380         partial_length = bounds[0]
381         return ("Regular expression matched until position "
382               "%d, ..'%s', and did not match from '%s'.." % (partial_length, string[max(0, partial_length-20):partial_length],
383                                                              string[partial_length:partial_length+10]))
384     except ImportError:
385         pass
386     return "Debug information would be available if the `regex' module was installed."
387
388 def parse_xrandr_output():
389     "Parse the output of `xrandr --verbose' into a list of outputs"
390     xrandr_output = os.popen("xrandr -q --verbose").read()
391     if not xrandr_output:
392         raise AutorandrException("Failed to run xrandr")
393
394     # We are not interested in screens
395     xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
396
397     # Split at output boundaries and instanciate an XrandrOutput per output
398     split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
399     if len(split_xrandr_output) < 2:
400         raise AutorandrException("No output boundaries found", report_bug=True)
401     outputs = OrderedDict()
402     modes = OrderedDict()
403     for i in range(1, len(split_xrandr_output), 2):
404         output_name = split_xrandr_output[i].split()[0]
405         output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i+2]))
406         outputs[output_name] = output
407         if output_modes:
408             modes[output_name] = output_modes
409
410     return outputs, modes
411
412 def load_profiles(profile_path):
413     "Load the stored profiles"
414
415     profiles = {}
416     for profile in os.listdir(profile_path):
417         config_name = os.path.join(profile_path, profile, "config")
418         setup_name  = os.path.join(profile_path, profile, "setup")
419         if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
420             continue
421
422         edids = dict([ x.strip().split() for x in open(setup_name).readlines() if x.strip() ])
423
424         config = {}
425         buffer = []
426         for line in chain(open(config_name).readlines(), ["output"]):
427             if line[:6] == "output" and buffer:
428                 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
429                 buffer = [ line ]
430             else:
431                 buffer.append(line)
432
433         for output_name in list(config.keys()):
434             if config[output_name].edid is None:
435                 del config[output_name]
436
437         profiles[profile] = { "config": config, "path": os.path.join(profile_path, profile), "config-mtime": os.stat(config_name).st_mtime }
438
439     return profiles
440
441 def find_profiles(current_config, profiles):
442     "Find profiles matching the currently connected outputs"
443     detected_profiles = []
444     for profile_name, profile in profiles.items():
445         config = profile["config"]
446         matches = True
447         for name, output in config.items():
448             if not output.edid:
449                 continue
450             if name not in current_config or not output.edid_equals(current_config[name]):
451                 matches = False
452                 break
453         if not matches or any(( name not in config.keys() for name in current_config.keys() if current_config[name].edid )):
454             continue
455         if matches:
456             detected_profiles.append(profile_name)
457     return detected_profiles
458
459 def profile_blocked(profile_path):
460     "Check if a profile is blocked"
461     script = os.path.join(profile_path, "block")
462     if not os.access(script, os.X_OK | os.F_OK):
463         return False
464     return subprocess.call(script) == 0
465
466 def output_configuration(configuration, config):
467     "Write a configuration file"
468     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
469     for output in outputs:
470         print(configuration[output].option_string, file=config)
471
472 def output_setup(configuration, setup):
473     "Write a setup (fingerprint) file"
474     outputs = sorted(configuration.keys())
475     for output in outputs:
476         if configuration[output].edid:
477             print(output, configuration[output].edid, file=setup)
478
479 def save_configuration(profile_path, configuration):
480     "Save a configuration into a profile"
481     if not os.path.isdir(profile_path):
482         os.makedirs(profile_path)
483     with open(os.path.join(profile_path, "config"), "w") as config:
484         output_configuration(configuration, config)
485     with open(os.path.join(profile_path, "setup"), "w") as setup:
486         output_setup(configuration, setup)
487
488 def update_mtime(filename):
489     "Update a file's mtime"
490     try:
491         os.utime(filename, None)
492         return True
493     except:
494         return False
495
496 def apply_configuration(new_configuration, current_configuration, dry_run=False):
497     "Apply a configuration"
498     outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
499     if dry_run:
500         base_argv = [ "echo", "xrandr" ]
501     else:
502         base_argv = [ "xrandr" ]
503
504     # There are several xrandr / driver bugs we need to take care of here:
505     # - We cannot enable more than two screens at the same time
506     #   See https://github.com/phillipberndt/autorandr/pull/6
507     #   and commits f4cce4d and 8429886.
508     # - We cannot disable all screens
509     #   See https://github.com/phillipberndt/autorandr/pull/20
510     # - We should disable screens before enabling others, because there's
511     #   a limit on the number of enabled screens
512     # - We must make sure that the screen at 0x0 is activated first,
513     #   or the other (first) screen to be activated would be moved there.
514     # - If an active screen already has a transformation and remains active,
515     #   the xrandr call fails with an invalid RRSetScreenSize parameter error.
516     #   Update the configuration in 3 passes in that case.  (On Haswell graphics,
517     #   at least.)
518
519     auxiliary_changes_pre = []
520     disable_outputs = []
521     enable_outputs = []
522     remain_active_count = 0
523     for output in outputs:
524         if not new_configuration[output].edid or "off" in new_configuration[output].options:
525             disable_outputs.append(new_configuration[output].option_vector)
526         else:
527             if "off" not in current_configuration[output].options:
528                 remain_active_count += 1
529             enable_outputs.append(new_configuration[output].option_vector)
530             if xrandr_version() >= Version("1.3.0") and "transform" in current_configuration[output].options:
531                 auxiliary_changes_pre.append(["--output", output, "--transform", "none"])
532
533     # Perform pe-change auxiliary changes
534     if auxiliary_changes_pre:
535         argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
536         if subprocess.call(argv) != 0:
537             raise AutorandrException("Command failed: %s" % " ".join(argv))
538
539     # Disable unused outputs, but make sure that there always is at least one active screen
540     disable_keep = 0 if remain_active_count else 1
541     if len(disable_outputs) > disable_keep:
542         if subprocess.call(base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))) != 0:
543             # Disabling the outputs failed. Retry with the next command:
544             # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
545             # This does not occur if simultaneously the primary screen is reset.
546             pass
547         else:
548             disable_outputs = disable_outputs[-1:] if disable_keep else []
549
550     # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
551     # disable the last two screens. This is a problem, so if this would happen, instead disable only
552     # one screen in the first call below.
553     if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
554         # In the context of a xrandr call that changes the display state, `--query' should do nothing
555         disable_outputs.insert(0, ['--query'])
556
557     # Enable the remaining outputs in pairs of two operations
558     operations = disable_outputs + enable_outputs
559     for index in range(0, len(operations), 2):
560         argv = base_argv + list(chain.from_iterable(operations[index:index+2]))
561         if subprocess.call(argv) != 0:
562             raise AutorandrException("Command failed: %s" % " ".join(argv))
563
564 def add_unused_outputs(source_configuration, target_configuration):
565     "Add outputs that are missing in target to target, in 'off' state"
566     for output_name, output in source_configuration.items():
567         if output_name not in target_configuration:
568             target_configuration[output_name] = XrandrOutput(output_name, output.edid, { "off": None })
569
570 def remove_irrelevant_outputs(source_configuration, target_configuration):
571     "Remove outputs from target that ought to be 'off' and already are"
572     for output_name, output in source_configuration.items():
573         if "off" in output.options and output_name in target_configuration and "off" in target_configuration[output_name].options:
574             del target_configuration[output_name]
575
576 def generate_virtual_profile(configuration, modes, profile_name):
577     "Generate one of the virtual profiles"
578     configuration = copy.deepcopy(configuration)
579     if profile_name == "common":
580         common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output )) for output in modes.values() ]
581         common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
582         common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
583         if common_resolution:
584             for output in configuration:
585                 configuration[output].options = {}
586                 if output in modes:
587                     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]
588                     configuration[output].options["pos"] = "0x0"
589                 else:
590                     configuration[output].options["off"] = None
591     elif profile_name in ("horizontal", "vertical"):
592         shift = 0
593         if profile_name == "horizontal":
594             shift_index = "width"
595             pos_specifier = "%sx0"
596         else:
597             shift_index = "height"
598             pos_specifier = "0x%s"
599
600         for output in configuration:
601             configuration[output].options = {}
602             if output in modes:
603                 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
604                 configuration[output].options["mode"] = mode["name"]
605                 configuration[output].options["rate"] = mode["rate"]
606                 configuration[output].options["pos"] = pos_specifier % shift
607                 shift += int(mode[shift_index])
608             else:
609                 configuration[output].options["off"] = None
610     return configuration
611
612 def exit_help():
613     "Print help and exit"
614     print(help_text)
615     for profile in virtual_profiles:
616         print("  %-10s %s" % profile[:2])
617     sys.exit(0)
618
619 def exec_scripts(profile_path, script_name):
620     "Run userscripts"
621     for script in (os.path.join(profile_path, script_name), os.path.join(os.path.dirname(profile_path), script_name)):
622         if os.access(script, os.X_OK | os.F_OK):
623             subprocess.call(script)
624
625 def main(argv):
626     try:
627        options = dict(getopt.getopt(argv[1:], "s:l:d:cfh", [ "dry-run", "change", "default=", "save=", "load=", "force", "fingerprint", "config", "skip-options=", "help" ])[0])
628     except getopt.GetoptError as e:
629         print("Failed to parse options: {0}.\n"
630               "Use --help to get usage information.".format(str(e)),
631               file=sys.stderr)
632         sys.exit(posix.EX_USAGE)
633
634     profiles = {}
635     try:
636         # Load profiles from each XDG config directory
637         for directory in os.environ.get("XDG_CONFIG_DIRS", "").split(":"):
638             system_profile_path = os.path.join(directory, "autorandr")
639             if os.path.isdir(system_profile_path):
640                 profiles.update(load_profiles(system_profile_path))
641         # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
642         # profile_path is also used later on to store configurations
643         profile_path = os.path.expanduser("~/.autorandr")
644         if not os.path.isdir(profile_path):
645             # Elsewise, follow the XDG specification
646             profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
647         if os.path.isdir(profile_path):
648             profiles.update(load_profiles(profile_path))
649         # Sort by descending mtime
650         profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
651     except Exception as e:
652         raise AutorandrException("Failed to load profiles", e)
653
654     config, modes = parse_xrandr_output()
655
656     if "--fingerprint" in options:
657         output_setup(config, sys.stdout)
658         sys.exit(0)
659
660     if "--config" in options:
661         output_configuration(config, sys.stdout)
662         sys.exit(0)
663
664     if "--skip-options" in options:
665         skip_options = [ y[2:] if y[:2] == "--" else y for y in ( x.strip() for x in options["--skip-options"].split(",") ) ]
666         for profile in profiles.values():
667             for output in profile["config"].values():
668                 output.set_ignored_options(skip_options)
669         for output in config.values():
670             output.set_ignored_options(skip_options)
671
672     if "-s" in options:
673         options["--save"] = options["-s"]
674     if "--save" in options:
675         if options["--save"] in ( x[0] for x in virtual_profiles ):
676             raise AutorandrException("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
677         try:
678             save_configuration(os.path.join(profile_path, options["--save"]), config)
679         except Exception as e:
680             raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
681         print("Saved current configuration as profile '%s'" % options["--save"])
682         sys.exit(0)
683
684     if "-h" in options or "--help" in options:
685         exit_help()
686
687     detected_profiles = find_profiles(config, profiles)
688     load_profile = False
689
690     if "-l" in options:
691         options["--load"] = options["-l"]
692     if "--load" in options:
693         load_profile = options["--load"]
694     else:
695         for profile_name in profiles.keys():
696             if profile_blocked(os.path.join(profile_path, profile_name)):
697                 print("%s (blocked)" % profile_name, file=sys.stderr)
698                 continue
699             if profile_name in detected_profiles:
700                 print("%s (detected)" % profile_name, file=sys.stderr)
701                 if ("-c" in options or "--change" in options) and not load_profile:
702                     load_profile = profile_name
703             else:
704                 print(profile_name, file=sys.stderr)
705
706     if "-d" in options:
707         options["--default"] = options["-d"]
708     if not load_profile and "--default" in options:
709         load_profile = options["--default"]
710
711     if load_profile:
712         if load_profile in ( x[0] for x in virtual_profiles ):
713             load_config = generate_virtual_profile(config, modes, load_profile)
714             scripts_path = os.path.join(profile_path, load_profile)
715         else:
716             try:
717                 profile = profiles[load_profile]
718                 load_config = profile["config"]
719                 scripts_path = profile["path"]
720             except KeyError:
721                 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
722             if load_profile in detected_profiles and detected_profiles[0] != load_profile:
723                 update_mtime(os.path.join(scripts_path, "config"))
724         add_unused_outputs(config, load_config)
725         if load_config == dict(config) and not "-f" in options and not "--force" in options:
726             print("Config already loaded", file=sys.stderr)
727             sys.exit(0)
728         remove_irrelevant_outputs(config, load_config)
729
730         try:
731             if "--dry-run" in options:
732                 apply_configuration(load_config, config, True)
733             else:
734                 exec_scripts(scripts_path, "preswitch")
735                 apply_configuration(load_config, config, False)
736                 exec_scripts(scripts_path, "postswitch")
737         except Exception as e:
738             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
739
740     sys.exit(0)
741
742 if __name__ == '__main__':
743     try:
744         main(sys.argv)
745     except AutorandrException as e:
746         print(e, file=sys.stderr)
747         sys.exit(1)
748     except Exception as e:
749         if not len(str(e)):  # BdbQuit
750             print("Exception: {0}".format(e.__class__.__name__))
751             sys.exit(2)
752
753         print("Unhandled exception ({0}). Please report this as a bug.".format(e), file=sys.stderr)
754         raise