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