refactor pretty much everything. some features are still missing.
[lilass.git] / screen.py
diff --git a/screen.py b/screen.py
new file mode 100644 (file)
index 0000000..a543fea
--- /dev/null
+++ b/screen.py
@@ -0,0 +1,210 @@
+#!/usr/bin/python3
+# DSL - easy Display Setup for Laptops
+# Copyright (C) 2012-2015 Ralf Jung <post@ralfj.de>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+import re, subprocess
+from enum import Enum
+
+## utility functions
+
+# execute a process, return output as iterator, throw exception if there was an error
+# you *must* iterate to the end if you use this!
+def processOutputGen(*args):
+    with subprocess.Popen(args, stdout=subprocess.PIPE) as p:
+        for line in p.stdout:
+            yield line.decode("utf-8")
+    if p.returncode != 0:
+        raise Exception("Error executing "+str(args))
+def processOutputIt(*args):
+    return list(processOutputGen(*args)) # list() iterates over the generator
+
+## the classes
+
+class RelativeScreenPosition(Enum):
+    '''Represents the relative position of the external screen to the internal one'''
+    LEFT      = 0
+    RIGHT     = 1
+    ABOVE     = 2
+    BELOW     = 3
+    MIRROR    = 4
+
+
+class Resolution:
+    '''Represents a resolution of a screen'''
+    def __init__(self, width, height):
+        self.width = width
+        self.height = height
+    
+    def __eq__(self, other):
+        if not isinstance(other, Resolution):
+            return False
+        return self.width == other.width and self.height == other.height
+    
+    def __ne__(self, other):
+        return not self.__eq__(other)
+    
+    def __str__(self):
+        # get ratio
+        ratio = int(round(16.0*self.height/self.width))
+        if ratio == 12: # 16:12 = 4:3
+            strRatio = '4:3'
+        elif ratio == 13: # 16:12.8 = 5:4
+            strRatio = '5:4'
+        else: # let's just hope this will never be 14 or more...
+            strRatio = '16:%d' % ratio
+        return '%dx%d (%s)' %(self.width, self.height, strRatio)
+    
+    def __repr__(self):
+        return 'screen.Resolution('+self.forXrandr()+')'
+    
+    def forXrandr(self):
+        return str(self.width)+'x'+str(self.height)
+
+
+class ScreenSetup:
+    '''Represents a screen configuration (relative to some notion of an "internal" and an "external" screen): Which screens are enabled with which resolution, how
+       are they positioned, which is the primary screen.'''
+    def __init__(self, intResolution, extResolution, relPosition = None, extIsPrimary = True):
+        '''The resolutions can be None to disable the screen, instances of Resolution. The last two arguments only matter if both screens are enabled.'''
+        assert intResolution is None or isinstance(intResolution, Resolution)
+        assert extResolution is None or isinstance(extResolution, Resolution)
+        
+        self.intResolution = intResolution
+        self.extResolution = extResolution
+        self.relPosition = relPosition
+        self.extIsPrimary = extIsPrimary or self.intResolution is None # external is always primary if it is the only one
+    
+    def getInternalArgs(self):
+        if self.intResolution is None:
+            return ["--off"]
+        args = ["--mode", self.intResolution.forXrandr()] # set internal screen to desired resolution
+        if not self.extIsPrimary:
+            args.append('--primary')
+        return args
+    
+    def getExternalArgs(self, intName):
+        if self.extResolution is None:
+            return ["--off"]
+        args = ["--mode", self.extResolution.forXrandr()] # set external screen to desired resolution
+        if self.extIsPrimary:
+            args.append('--primary')
+        if self.intResolution is None:
+            return args
+        # set position
+        args += [{
+                RelativeScreenPosition.LEFT  : '--left-of',
+                RelativeScreenPosition.RIGHT : '--right-of',
+                RelativeScreenPosition.ABOVE : '--above',
+                RelativeScreenPosition.BELOW : '--below',
+                RelativeScreenPosition.MIRROR: '--same-as',
+            }[self.relPosition], intName]
+        return args
+
+
+class ScreenSituation:
+    connectors = {} # maps connector names to lists of Resolution (empty list -> disabled connector)
+    internalConnector = None # name of the internal connector (will be an enabled one)
+    externalConnector = None # name of the used external connector (an enabled one), or None
+    
+    '''Represents the "screen situation" a machine can be in: Which connectors exist, which resolutions do they have, what are the names for the internal and external screen'''
+    def __init__(self, internalConnectorNames, externalConnectorNames = None):
+        '''Both arguments are lists of connector names. The first one which exists and has a screen attached is chosen for that class. <externalConnectorNames> can be None to
+           just choose any remaining connector.'''
+        # which connectors are there?
+        self._getXrandrInformation()
+        print(self.connectors)
+        # figure out which is the internal connector
+        self.internalConnector = self._findAvailableConnector(internalConnectorNames)
+        if self.internalConnector is None:
+            raise Exception("Could not automatically find internal connector, please use ~/.dsl.conf to specify it manually.")
+        print(self.internalConnector)
+        # and the external one
+        if externalConnectorNames is None:
+            externalConnectorNames = list(self.connectors.keys())
+            externalConnectorNames.remove(self.internalConnector)
+        self.externalConnector = self._findAvailableConnector(externalConnectorNames)
+        print(self.externalConnector)
+    
+    # Run xrandr and fill the dict of connector names mapped to lists of available resolutions.
+    def _getXrandrInformation(self):
+        connector = None # current connector
+        for line in processOutputGen("xrandr", "-q"):
+            # screen?
+            m = re.search(r'^Screen [0-9]+: ', line)
+            if m is not None: # ignore this line
+                connector = None
+                continue
+            # new connector?
+            m = re.search(r'^([\w\-]+) (dis)?connected ', line)
+            if m is not None:
+                connector = m.groups()[0]
+                assert connector not in self.connectors
+                self.connectors[connector] = []
+                continue
+            # new resolution?
+            m = re.search(r'^   ([\d]+)x([\d]+) +', line)
+            if m is not None:
+                resolution = Resolution(int(m.groups()[0]), int(m.groups()[1]))
+                assert connector is not None
+                self.connectors[connector].append(resolution)
+                continue
+            # unknown line
+            # not fatal, e.g. xrandr shows strange stuff when a display is enabled, but not connected
+            print("Warning: Unknown xrandr line %s" % line)
+    
+    # return the first available connector from those listed in <tryConnectors>, skipping disabled connectors
+    def _findAvailableConnector(self, tryConnectors):
+        for connector in tryConnectors:
+            if connector in self.connectors and len(self.connectors[connector]): # if the connector exists and is active (i.e. there is a resolution)
+                return connector
+        return None
+    
+    # return available internal resolutions
+    def internalResolutions(self):
+        return self.connectors[self.internalConnector]
+    
+    # return available external resolutions (or None, if there is no external screen connected)
+    def externalResolutions(self):
+        if self.externalConnector is None:
+            return None
+        return self.connectors[self.externalConnector]
+    
+    # return resolutions available for both internal and external screen
+    def commonResolutions(self):
+        internalRes = self.internalResolutions()
+        externalRes = self.externalResolutions()
+        assert externalRes is not None
+        return [res for res in externalRes if res in internalRes]
+    
+    # compute the xrandr call
+    def forXrandr(self, setup):
+        # turn all screens off
+        connectorArgs = {} # maps connector names to xrand arguments
+        for c in self.connectors.keys():
+            connectorArgs[c] = ["--off"]
+        # set arguments for the relevant ones
+        connectorArgs[self.internalConnector] = setup.getInternalArgs()
+        if self.externalConnector is not None:
+            connectorArgs[self.externalConnector] = setup.getExternalArgs(self.internalConnector)
+        else:
+            assert setup.extResolution is None, "There's no external screen to set a resolution for"
+        # now compose the arguments
+        call = ["xrandr"]
+        for name in connectorArgs:
+            call += ["--output", name] + connectorArgs[name]
+        return call
+