]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blobdiff - autorandr.py
Respect aspect ratio
[deb_pkgs/autorandr.git] / autorandr.py
index 07febc3f65370aab504c0519b5a63faabcd8b882..7463b19f38c2225e6d4347e88c4cc174ad1d330e 100755 (executable)
@@ -50,6 +50,7 @@ except NameError:
 virtual_profiles = [
     # (name, description, callback)
     ("common", "Clone all connected outputs at the largest common resolution", None),
+    ("clone", "Clone all connected outputs with the largest resolution and scaled down in the others", None),
     ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
     ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
 ]
@@ -72,7 +73,7 @@ Usage: autorandr [options]
 --debug                 enable verbose output
 --batch                 run autorandr for all users with active X11 sessions
 
- To prevent a profile from being loaded, place a script call "block" in its
+ To prevent a profile from being loaded, place a script called "block" in its
  directory. The script is evaluated before the screen setup is inspected, and
  in case of it returning a value of 0 the profile is skipped. This can be used
  to query the status of a docking station you are about to leave.
@@ -99,18 +100,25 @@ class AutorandrException(Exception):
             while trace.tb_next:
                 trace = trace.tb_next
             self.line = trace.tb_lineno
+            self.file_name = trace.tb_frame.f_code.co_filename
         else:
             try:
                 import inspect
-                self.line = inspect.currentframe().f_back.f_lineno
+                frame = inspect.currentframe().f_back
+                self.line = frame.f_lineno
+                self.file_name = frame.f_code.co_filename
             except:
                 self.line = None
+                self.file_name = None
             self.original_exception = None
 
+        if os.path.abspath(self.file_name) == os.path.abspath(sys.argv[0]):
+            self.file_name = None
+
     def __str__(self):
         retval = [ self.message ]
         if self.line:
-            retval.append(" (line %d)" % self.line)
+            retval.append(" (line %d%s)" % (self.line, ("; %s" % self.file_name) if self.file_name else ""))
         if self.original_exception:
             retval.append(":\n  ")
             retval.append(str(self.original_exception).replace("\n", "\n  "))
@@ -338,6 +346,8 @@ class XrandrOutput(object):
         for line in configuration.split("\n"):
             if line:
                 line = line.split(None, 1)
+                if line and line[0].startswith("#"):
+                    continue
                 options[line[0]] = line[1] if len(line) > 1 else None
 
         edid = None
@@ -457,7 +467,7 @@ def load_profiles(profile_path):
         if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
             continue
 
-        edids = dict([ x.strip().split() for x in open(setup_name).readlines() if x.strip() ])
+        edids = dict([ x.split() for x in (y.strip() for y in open(setup_name).readlines()) if x and x[0] != "#" ])
 
         config = {}
         buffer = []
@@ -550,12 +560,18 @@ def call_and_retry(*args, **kwargs):
     waits a second and then retries once. This mitigates #47,
     a timing issue with some drivers.
     """
-    kwargs_redirected = dict(kwargs)
-    if hasattr(subprocess, "DEVNULL"):
-        kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
+    if "dry_run" in kwargs:
+        dry_run = kwargs["dry_run"]
+        del kwargs["dry_run"]
     else:
-        kwargs_redirected["stdout"] = open(os.devnull, "w")
-    kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
+        dry_run = False
+    kwargs_redirected = dict(kwargs)
+    if not dry_run:
+        if hasattr(subprocess, "DEVNULL"):
+            kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
+        else:
+            kwargs_redirected["stdout"] = open(os.devnull, "w")
+        kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
     retval = subprocess.call(*args, **kwargs_redirected)
     if retval != 0:
         time.sleep(1)
@@ -586,6 +602,9 @@ def apply_configuration(new_configuration, current_configuration, dry_run=False)
     #   at least.)
     # - Some implementations can not handle --transform at all, so avoid it unless
     #   necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
+    # - Some implementations can not handle --panning without specifying --fb
+    #   explicitly, so avoid it unless necessary.
+    #   (See https://github.com/phillipberndt/autorandr/issues/72)
 
     auxiliary_changes_pre = []
     disable_outputs = []
@@ -600,28 +619,29 @@ def apply_configuration(new_configuration, current_configuration, dry_run=False)
 
             option_vector = new_configuration[output].option_vector
             if xrandr_version() >= Version("1.3.0"):
-                if "transform" in current_configuration[output].options:
-                    auxiliary_changes_pre.append(["--output", output, "--transform", "none"])
-                else:
-                    try:
-                        transform_index = option_vector.index("--transform")
-                        if option_vector[transform_index+1] == XrandrOutput.XRANDR_DEFAULTS["transform"]:
-                            option_vector = option_vector[:transform_index] + option_vector[transform_index+2:]
-                    except ValueError:
-                        pass
+                for option in ("transform", "panning"):
+                    if option in current_configuration[output].options:
+                        auxiliary_changes_pre.append(["--output", output, "--%s" % option, "none"])
+                    else:
+                        try:
+                            option_index = option_vector.index("--%s" % option)
+                            if option_vector[option_index+1] == XrandrOutput.XRANDR_DEFAULTS[option]:
+                                option_vector = option_vector[:option_index] + option_vector[option_index+2:]
+                        except ValueError:
+                            pass
 
             enable_outputs.append(option_vector)
 
     # Perform pe-change auxiliary changes
     if auxiliary_changes_pre:
         argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
-        if call_and_retry(argv) != 0:
+        if call_and_retry(argv, dry_run=dry_run) != 0:
             raise AutorandrException("Command failed: %s" % " ".join(argv))
 
     # Disable unused outputs, but make sure that there always is at least one active screen
     disable_keep = 0 if remain_active_count else 1
     if len(disable_outputs) > disable_keep:
-        if call_and_retry(base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))) != 0:
+        if call_and_retry(base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs)), dry_run=dry_run) != 0:
             # Disabling the outputs failed. Retry with the next command:
             # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
             # This does not occur if simultaneously the primary screen is reset.
@@ -640,7 +660,7 @@ def apply_configuration(new_configuration, current_configuration, dry_run=False)
     operations = disable_outputs + enable_outputs
     for index in range(0, len(operations), 2):
         argv = base_argv + list(chain.from_iterable(operations[index:index+2]))
-        if call_and_retry(argv) != 0:
+        if call_and_retry(argv, dry_run=dry_run) != 0:
             raise AutorandrException("Command failed: %s" % " ".join(argv))
 
 def is_equal_configuration(source_configuration, target_configuration):
@@ -696,6 +716,19 @@ def generate_virtual_profile(configuration, modes, profile_name):
                 shift += int(mode[shift_index])
             else:
                 configuration[output].options["off"] = None
+    elif profile_name == "clone":
+        biggest_resolution = sorted([output_modes[0] for output, output_modes in modes.items()], key=lambda x: int(x["width"])*int(x["height"]), reverse=True)[0]
+        for output in configuration:
+            configuration[output].options = {}
+            if output in modes and configuration[output].edid:
+                mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
+                configuration[output].options["mode"] = mode["name"]
+                configuration[output].options["rate"] = mode["rate"]
+                configuration[output].options["pos"] = "0x0"
+                scale = max(float(biggest_resolution["width"]) / float(mode["width"]) ,float(biggest_resolution["height"]) / float(mode["height"]))
+                configuration[output].options["scale"] = "{}x{}".format(scale, scale)
+            else:
+                configuration[output].options["off"] = None
     return configuration
 
 def print_profile_differences(one, another):
@@ -729,6 +762,8 @@ def exec_scripts(profile_path, script_name, meta_information=None):
     and system-wide configuration folders, named script_name or residing in
     subdirectories named script_name.d.
 
+    If profile_path is None, only global scripts will be invoked.
+
     meta_information is expected to be an dictionary. It will be passed to the block scripts
     in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
 
@@ -748,13 +783,19 @@ def exec_scripts(profile_path, script_name, meta_information=None):
     if not os.path.isdir(user_profile_path):
         user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
 
-    for folder in chain((profile_path, os.path.dirname(profile_path), user_profile_path),
-                        (os.path.join(x, "autorandr") for x in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"))):
+    candidate_directories = chain((user_profile_path,), (os.path.join(x, "autorandr") for x in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")))
+    if profile_path:
+        candidate_directories = chain((profile_path,), candidate_directories)
+
+    for folder in candidate_directories:
 
         if script_name not in ran_scripts:
             script = os.path.join(folder, script_name)
             if os.access(script, os.X_OK | os.F_OK):
-                all_ok &= subprocess.call(script, env=env) != 0
+                try:
+                    all_ok &= subprocess.call(script, env=env) != 0
+                except:
+                    raise AutorandrException("Failed to execute user command: %s" % (script,))
                 ran_scripts.add(script_name)
 
         script_folder = os.path.join(folder, "%s.d" % script_name)
@@ -764,7 +805,10 @@ def exec_scripts(profile_path, script_name, meta_information=None):
                 if check_name not in ran_scripts:
                     script = os.path.join(script_folder, file_name)
                     if os.access(script, os.X_OK | os.F_OK):
-                        all_ok &= subprocess.call(script, env=env) != 0
+                        try:
+                            all_ok &= subprocess.call(script, env=env) != 0
+                        except:
+                            raise AutorandrException("Failed to execute user command: %s" % (script,))
                         ran_scripts.add(check_name)
 
     return all_ok
@@ -798,7 +842,14 @@ def dispatch_call_to_sessions(argv):
         if not os.path.isfile(environ_file):
             continue
         uid = os.stat(environ_file).st_uid
-        if uid == 0:
+
+        # The following line assumes that user accounts start at 1000 and that
+        # no one works using the root or another system account. This is rather
+        # restrictive, but de facto default. Alternatives would be to use the
+        # UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf;
+        # but effectively, both values aren't binding in any way.
+        # If this breaks your use case, please file a bug on Github.
+        if uid < 1000:
             continue
 
         process_environ = {}
@@ -810,6 +861,9 @@ def dispatch_call_to_sessions(argv):
                 process_environ[name] = value
         display = process_environ["DISPLAY"] if "DISPLAY" in process_environ else None
 
+        # To allow scripts to detect batch invocation (especially useful for predetect)
+        process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
+
         if display and display not in X11_displays_done:
             try:
                 pwent = pwd.getpwuid(uid)
@@ -845,6 +899,9 @@ def main(argv):
               file=sys.stderr)
         sys.exit(posix.EX_USAGE)
 
+    if "-h" in options or "--help" in options:
+        exit_help()
+
     # Batch mode
     if "--batch" in options:
         if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
@@ -879,6 +936,7 @@ def main(argv):
 
     profile_symlinks = { k: v for k, v in profile_symlinks.items() if v in (x[0] for x in virtual_profiles) or v in profiles }
 
+    exec_scripts(None, "predetect")
     config, modes = parse_xrandr_output()
 
     if "--fingerprint" in options:
@@ -938,9 +996,6 @@ def main(argv):
             raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
         sys.exit(0)
 
-    if "-h" in options or "--help" in options:
-        exit_help()
-
     detected_profiles = find_profiles(config, profiles)
     load_profile = False
 
@@ -1022,6 +1077,8 @@ def main(argv):
                     apply_configuration(load_config, config, True)
                 apply_configuration(load_config, config, False)
                 exec_scripts(scripts_path, "postswitch", script_metadata)
+        except AutorandrException as e:
+            raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
         except Exception as e:
             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
 
@@ -1033,7 +1090,7 @@ def main(argv):
 
     sys.exit(0)
 
-if __name__ == '__main__':
+def exception_handled_main(argv=sys.argv):
     try:
         main(sys.argv)
     except AutorandrException as e:
@@ -1046,3 +1103,6 @@ if __name__ == '__main__':
 
         print("Unhandled exception ({0}). Please report this as a bug at https://github.com/phillipberndt/autorandr/issues.".format(e), file=sys.stderr)
         raise
+
+if __name__ == '__main__':
+    exception_handled_main()