made aspect ratio accurate
[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 from fractions import Fraction
22
23 ## utility functions
24
25 # execute a process, return output as iterator, throw exception if there was an error
26 # you *must* iterate to the end if you use this!
27 def processOutputGen(*args):
28     with subprocess.Popen(args, stdout=subprocess.PIPE) as p:
29         for line in p.stdout:
30             yield line.decode("utf-8")
31     if p.returncode != 0:
32         raise Exception("Error executing "+str(args))
33 def processOutputIt(*args):
34     return list(processOutputGen(*args)) # list() iterates over the generator
35
36 ## the classes
37
38 class RelativeScreenPosition(Enum):
39     '''Represents the relative position of the external screen to the internal one'''
40     LEFT      = ("left of")
41     RIGHT     = ("right of")
42     ABOVE     = ("above")
43     BELOW     = ("below")
44     MIRROR    = ("same as")
45     def __init__(self, text):
46         # auto numbering
47         cls = self.__class__
48         self._value_ = len(cls.__members__)
49         self.text = text
50
51 class Resolution:
52     '''Represents a resolution of a screen'''
53     def __init__(self, width, height):
54         self.width = width
55         self.height = height
56     
57     def __eq__(self, other):
58         if not isinstance(other, Resolution):
59             return False
60         return self.width == other.width and self.height == other.height
61     
62     def __ne__(self, other):
63         return not self.__eq__(other)
64     
65     def __hash__(self):
66         return hash(("Resolution",self.width,self.height))
67     
68     def __str__(self):
69         # get ratio
70         ratio = Fraction(self.width, self.height) # automatically divides by the gcd
71         strRatio = "%d:%d" % (ratio.numerator, ratio.denominator)
72         return '%dx%d (%s)' %(self.width, self.height, strRatio)
73     
74     def __repr__(self):
75         return 'screen.Resolution('+self.forXrandr()+')'
76     
77     def pixelCount(self):
78         return self.width * self.height
79     
80     def forXrandr(self):
81         return str(self.width)+'x'+str(self.height)
82
83
84 class ScreenSetup:
85     '''Represents a screen configuration (relative to some notion of an "internal" and an "external" screen): Which screens are enabled with which resolution, how
86        are they positioned, which is the primary screen.'''
87     def __init__(self, intResolution, extResolution, relPosition = None, extIsPrimary = True):
88         '''The resolutions can be None to disable the screen, instances of Resolution. The last two arguments only matter if both screens are enabled.'''
89         assert intResolution is None or isinstance(intResolution, Resolution)
90         assert extResolution is None or isinstance(extResolution, Resolution)
91         
92         self.intResolution = intResolution
93         self.extResolution = extResolution
94         self.relPosition = relPosition
95         self.extIsPrimary = extIsPrimary or self.intResolution is None # external is always primary if it is the only one
96     
97     def getInternalArgs(self):
98         if self.intResolution is None:
99             return ["--off"]
100         args = ["--mode", self.intResolution.forXrandr()] # set internal screen to desired resolution
101         if not self.extIsPrimary:
102             args.append('--primary')
103         return args
104     
105     def getExternalArgs(self, intName):
106         if self.extResolution is None:
107             return ["--off"]
108         args = ["--mode", self.extResolution.forXrandr()] # set external screen to desired resolution
109         if self.extIsPrimary:
110             args.append('--primary')
111         if self.intResolution is None:
112             return args
113         # set position
114         args += [{
115                 RelativeScreenPosition.LEFT  : '--left-of',
116                 RelativeScreenPosition.RIGHT : '--right-of',
117                 RelativeScreenPosition.ABOVE : '--above',
118                 RelativeScreenPosition.BELOW : '--below',
119                 RelativeScreenPosition.MIRROR: '--same-as',
120             }[self.relPosition], intName]
121         return args
122
123 class Connector:
124     def __init__(self, name=None):
125         self.name = name # connector name, e.g. "HDMI1"
126         self.edid = None # EDID string for the connector, or None if disconnected
127         self._resolutions = set() # list of Resolution objects, empty if disconnected
128         self.preferredResolution = None
129     
130     def __str__(self):
131         return str(self.name)
132     
133     def __repr__(self):
134         return """<Connector "%s" EDID="%s" resolutions="%s">""" % (str(self.name), str(self.edid), ", ".join(str(r) for r in self.getResolutionList()))
135     
136     def isConnected(self):
137         assert (self.edid is None) == (len(self._resolutions)==0)
138         return self.edid is not None
139     
140     def addResolution(self, resolution):
141         assert isinstance(resolution, Resolution)
142         self._resolutions.add(resolution)
143     
144     def appendToEdid(self, s):
145         if self.edid is None:
146             self.edid = s
147         else:
148             self.edid += s
149     
150     def getResolutionList(self):
151         return sorted(self._resolutions, key=lambda r: (0 if r==self.preferredResolution else 1, -r.pixelCount()))
152
153 class ScreenSituation:
154     connectors = [] # contains all the Connector objects
155     internalConnector = None # the internal Connector object (will be an enabled one)
156     externalConnector = None # the used external Connector object (an enabled one), or None
157     
158     '''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'''
159     def __init__(self, internalConnectorNames, externalConnectorNames = None):
160         '''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
161            just choose any remaining connector.'''
162         # which connectors are there?
163         self._getXrandrInformation()
164         for c in self.connectors:
165             print(repr(c))
166             print()
167         # figure out which is the internal connector
168         self.internalConnector = self._findAvailableConnector(internalConnectorNames)
169         if self.internalConnector is None:
170             raise Exception("Could not automatically find internal connector, please use (or fix) ~/.dsl.conf to specify it manually.")
171         print("Detected internal connector:",self.internalConnector)
172         # and the external one
173         if externalConnectorNames is None:
174             externalConnectorNames = map(lambda c: c.name, self.connectors)
175             externalConnectorNames = set(filter(lambda name: name != self.internalConnector.name, externalConnectorNames))
176         self.externalConnector = self._findAvailableConnector(externalConnectorNames)
177         if self.internalConnector == self.externalConnector:
178             raise Exception("Internal and external connector are the same. This must not happen. Please fix ~/.dsl.conf.");
179         print("Detected external connector:",self.externalConnector)
180     
181     # Run xrandr and fill the dict of connector names mapped to lists of available resolutions.
182     def _getXrandrInformation(self):
183         connector = None # current connector
184         readingEdid = False
185         for line in processOutputGen("xrandr", "-q", "--verbose"):
186             if readingEdid:
187                 m = re.match(r'^\s*([0-9a-f]+)\s*$', line)
188                 if m is not None:
189                     connector.appendToEdid(m.group(1))
190                     continue
191                 else:
192                     readingEdid = False
193                     # fallthrough to the rest of the loop for parsing of this line
194             # screen?
195             m = re.search(r'^Screen [0-9]+: ', line)
196             if m is not None: # ignore this line
197                 connector = None
198                 continue
199             # new connector?
200             m = re.search(r'^([\w\-]+) (dis)?connected ', line)
201             if m is not None:
202                 connector = Connector(m.group(1))
203                 assert not any(c.name == connector.name for c in self.connectors)
204                 self.connectors.append(connector)
205                 continue
206             # new resolution?
207             m = re.search(r'^\s*([\d]+)x([\d]+)', line)
208             if m is not None:
209                 resolution = Resolution(int(m.group(1)), int(m.group(2)))
210                 assert connector is not None
211                 connector.addResolution(resolution)
212                 if '+preferred' in line:
213                     connector.preferredResolution = resolution
214                 continue
215             # EDID?
216             m = re.search(r'^\s*EDID:\s*$', line)
217             if m is not None:
218                 readingEdid = True
219                 continue
220             # unknown line
221             # not fatal, e.g. xrandr shows strange stuff when a display is enabled, but not connected
222             #print("Warning: Unknown xrandr line %s" % line)
223     
224     # return the first available connector from those listed in <tryConnectorNames>, skipping disabled connectors
225     def _findAvailableConnector(self, tryConnectorNames):
226         for c in filter(lambda c: c.name in tryConnectorNames and c.isConnected(), self.connectors):
227             return c
228         return None
229     
230     # return available internal resolutions
231     def internalResolutions(self):
232         return self.internalConnector.getResolutionList()
233     
234     # return available external resolutions (or None, if there is no external screen connected)
235     def externalResolutions(self):
236         if self.externalConnector is None:
237             return None
238         return self.externalConnector.getResolutionList()
239     
240     # return resolutions available for both internal and external screen
241     def commonResolutions(self):
242         internalRes = self.internalResolutions()
243         externalRes = self.externalResolutions()
244         assert externalRes is not None
245         return sorted(set(externalRes).intersection(internalRes), key=lambda r: -r.pixelCount())
246     
247     # compute the xrandr call
248     def forXrandr(self, setup):
249         # turn all screens off
250         connectorArgs = {} # maps connector names to xrand arguments
251         for c in self.connectors:
252             connectorArgs[c.name] = ["--off"]
253         # set arguments for the relevant ones
254         connectorArgs[self.internalConnector.name] = setup.getInternalArgs()
255         if self.externalConnector is not None:
256             connectorArgs[self.externalConnector.name] = setup.getExternalArgs(self.internalConnector.name)
257         else:
258             assert setup.extResolution is None, "There's no external screen to set a resolution for"
259         # now compose the arguments
260         call = ["xrandr"]
261         for name in connectorArgs:
262             call += ["--output", name] + connectorArgs[name]
263         return call
264