reintroduce configuration; fix CLI arguments; add --external-only
[lilass.git] / screen.py
1 #!/usr/bin/python3
2 # DSL - easy Display Setup for Laptops
3 # Copyright (C) 2012-2015 Ralf Jung <post@ralfj.de>
4 #
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 2 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with this program; if not, write to the Free Software
17 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18
19 import re, subprocess
20 from enum import Enum
21
22 ## utility functions
23
24 # execute a process, return output as iterator, throw exception if there was an error
25 # you *must* iterate to the end if you use this!
26 def processOutputGen(*args):
27     with subprocess.Popen(args, stdout=subprocess.PIPE) as p:
28         for line in p.stdout:
29             yield line.decode("utf-8")
30     if p.returncode != 0:
31         raise Exception("Error executing "+str(args))
32 def processOutputIt(*args):
33     return list(processOutputGen(*args)) # list() iterates over the generator
34
35 ## the classes
36
37 class RelativeScreenPosition(Enum):
38     '''Represents the relative position of the external screen to the internal one'''
39     LEFT      = 0
40     RIGHT     = 1
41     ABOVE     = 2
42     BELOW     = 3
43     MIRROR    = 4
44
45
46 class Resolution:
47     '''Represents a resolution of a screen'''
48     def __init__(self, width, height):
49         self.width = width
50         self.height = height
51     
52     def __eq__(self, other):
53         if not isinstance(other, Resolution):
54             return False
55         return self.width == other.width and self.height == other.height
56     
57     def __ne__(self, other):
58         return not self.__eq__(other)
59     
60     def __str__(self):
61         # get ratio
62         ratio = int(round(16.0*self.height/self.width))
63         if ratio == 12: # 16:12 = 4:3
64             strRatio = '4:3'
65         elif ratio == 13: # 16:12.8 = 5:4
66             strRatio = '5:4'
67         else: # let's just hope this will never be 14 or more...
68             strRatio = '16:%d' % ratio
69         return '%dx%d (%s)' %(self.width, self.height, strRatio)
70     
71     def __repr__(self):
72         return 'screen.Resolution('+self.forXrandr()+')'
73     
74     def forXrandr(self):
75         return str(self.width)+'x'+str(self.height)
76
77
78 class ScreenSetup:
79     '''Represents a screen configuration (relative to some notion of an "internal" and an "external" screen): Which screens are enabled with which resolution, how
80        are they positioned, which is the primary screen.'''
81     def __init__(self, intResolution, extResolution, relPosition = None, extIsPrimary = True):
82         '''The resolutions can be None to disable the screen, instances of Resolution. The last two arguments only matter if both screens are enabled.'''
83         assert intResolution is None or isinstance(intResolution, Resolution)
84         assert extResolution is None or isinstance(extResolution, Resolution)
85         
86         self.intResolution = intResolution
87         self.extResolution = extResolution
88         self.relPosition = relPosition
89         self.extIsPrimary = extIsPrimary or self.intResolution is None # external is always primary if it is the only one
90     
91     def getInternalArgs(self):
92         if self.intResolution is None:
93             return ["--off"]
94         args = ["--mode", self.intResolution.forXrandr()] # set internal screen to desired resolution
95         if not self.extIsPrimary:
96             args.append('--primary')
97         return args
98     
99     def getExternalArgs(self, intName):
100         if self.extResolution is None:
101             return ["--off"]
102         args = ["--mode", self.extResolution.forXrandr()] # set external screen to desired resolution
103         if self.extIsPrimary:
104             args.append('--primary')
105         if self.intResolution is None:
106             return args
107         # set position
108         args += [{
109                 RelativeScreenPosition.LEFT  : '--left-of',
110                 RelativeScreenPosition.RIGHT : '--right-of',
111                 RelativeScreenPosition.ABOVE : '--above',
112                 RelativeScreenPosition.BELOW : '--below',
113                 RelativeScreenPosition.MIRROR: '--same-as',
114             }[self.relPosition], intName]
115         return args
116
117
118 class ScreenSituation:
119     connectors = {} # maps connector names to lists of Resolution (empty list -> disabled connector)
120     internalConnector = None # name of the internal connector (will be an enabled one)
121     externalConnector = None # name of the used external connector (an enabled one), or None
122     
123     '''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'''
124     def __init__(self, internalConnectorNames, externalConnectorNames = None):
125         '''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
126            just choose any remaining connector.'''
127         # which connectors are there?
128         self._getXrandrInformation()
129         # figure out which is the internal connector
130         self.internalConnector = self._findAvailableConnector(internalConnectorNames)
131         if self.internalConnector is None:
132             raise Exception("Could not automatically find internal connector, please use (or fix) ~/.dsl.conf to specify it manually.")
133         print("Detected internal connector:",self.internalConnector)
134         # and the external one
135         if externalConnectorNames is None:
136             externalConnectorNames = list(self.connectors.keys())
137             externalConnectorNames.remove(self.internalConnector)
138         self.externalConnector = self._findAvailableConnector(externalConnectorNames)
139         if self.internalConnector == self.externalConnector:
140             raise Exception("Internal and external connector are the same. This must not happen. Please fix ~/.dsl.conf.");
141         print("Detected external connector:",self.externalConnector)
142     
143     # Run xrandr and fill the dict of connector names mapped to lists of available resolutions.
144     def _getXrandrInformation(self):
145         connector = None # current connector
146         for line in processOutputGen("xrandr", "-q"):
147             # screen?
148             m = re.search(r'^Screen [0-9]+: ', line)
149             if m is not None: # ignore this line
150                 connector = None
151                 continue
152             # new connector?
153             m = re.search(r'^([\w\-]+) (dis)?connected ', line)
154             if m is not None:
155                 connector = m.groups()[0]
156                 assert connector not in self.connectors
157                 self.connectors[connector] = []
158                 continue
159             # new resolution?
160             m = re.search(r'^   ([\d]+)x([\d]+) +', line)
161             if m is not None:
162                 resolution = Resolution(int(m.groups()[0]), int(m.groups()[1]))
163                 assert connector is not None
164                 self.connectors[connector].append(resolution)
165                 continue
166             # unknown line
167             # not fatal, e.g. xrandr shows strange stuff when a display is enabled, but not connected
168             print("Warning: Unknown xrandr line %s" % line)
169     
170     # return the first available connector from those listed in <tryConnectors>, skipping disabled connectors
171     def _findAvailableConnector(self, tryConnectors):
172         for connector in tryConnectors:
173             if connector in self.connectors and len(self.connectors[connector]): # if the connector exists and is active (i.e. there is a resolution)
174                 return connector
175         return None
176     
177     # return available internal resolutions
178     def internalResolutions(self):
179         return self.connectors[self.internalConnector]
180     
181     # return available external resolutions (or None, if there is no external screen connected)
182     def externalResolutions(self):
183         if self.externalConnector is None:
184             return None
185         return self.connectors[self.externalConnector]
186     
187     # return resolutions available for both internal and external screen
188     def commonResolutions(self):
189         internalRes = self.internalResolutions()
190         externalRes = self.externalResolutions()
191         assert externalRes is not None
192         return [res for res in externalRes if res in internalRes]
193     
194     # compute the xrandr call
195     def forXrandr(self, setup):
196         # turn all screens off
197         connectorArgs = {} # maps connector names to xrand arguments
198         for c in self.connectors.keys():
199             connectorArgs[c] = ["--off"]
200         # set arguments for the relevant ones
201         connectorArgs[self.internalConnector] = setup.getInternalArgs()
202         if self.externalConnector is not None:
203             connectorArgs[self.externalConnector] = setup.getExternalArgs(self.internalConnector)
204         else:
205             assert setup.extResolution is None, "There's no external screen to set a resolution for"
206         # now compose the arguments
207         call = ["xrandr"]
208         for name in connectorArgs:
209             call += ["--output", name] + connectorArgs[name]
210         return call
211