2 # DSL - easy Display Setup for Laptops
3 # Copyright (C) 2012-2015 Ralf Jung <post@ralfj.de>
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.
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.
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.
21 from fractions import Fraction
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:
30 yield line.decode("utf-8")
32 raise Exception("Error executing "+str(args))
33 def processOutputIt(*args):
34 return list(processOutputGen(*args)) # list() iterates over the generator
38 class RelativeScreenPosition(Enum):
39 '''Represents the relative position of the external screen to the internal one'''
45 def __init__(self, text):
48 self._value_ = len(cls.__members__)
52 '''Represents a resolution of a screen'''
53 def __init__(self, width, height):
57 def __eq__(self, other):
58 if not isinstance(other, Resolution):
60 return self.width == other.width and self.height == other.height
62 def __ne__(self, other):
63 return not self.__eq__(other)
66 return hash(("Resolution",self.width,self.height))
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)
75 return 'screen.Resolution('+self.forXrandr()+')'
78 return self.width * self.height
81 return str(self.width)+'x'+str(self.height)
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)
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
97 def getInternalArgs(self):
98 if self.intResolution is None:
100 args = ["--mode", self.intResolution.forXrandr()] # set internal screen to desired resolution
101 if not self.extIsPrimary:
102 args.append('--primary')
105 def getExternalArgs(self, intName):
106 if self.extResolution is None:
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:
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]
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
131 return str(self.name)
134 return """<Connector "%s" EDID="%s" resolutions="%s">""" % (str(self.name), str(self.edid), ", ".join(str(r) for r in self.getResolutionList()))
136 def isConnected(self):
137 assert (self.edid is None) == (len(self._resolutions)==0)
138 return self.edid is not None
140 def addResolution(self, resolution):
141 assert isinstance(resolution, Resolution)
142 self._resolutions.add(resolution)
144 def appendToEdid(self, s):
145 if self.edid is None:
150 def getResolutionList(self):
151 return sorted(self._resolutions, key=lambda r: (0 if r==self.preferredResolution else 1, -r.pixelCount()))
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
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:
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)
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
185 for line in processOutputGen("xrandr", "-q", "--verbose"):
187 m = re.match(r'^\s*([0-9a-f]+)\s*$', line)
189 connector.appendToEdid(m.group(1))
193 # fallthrough to the rest of the loop for parsing of this line
195 m = re.search(r'^Screen [0-9]+: ', line)
196 if m is not None: # ignore this line
200 m = re.search(r'^([\w\-]+) (dis)?connected ', line)
202 connector = Connector(m.group(1))
203 assert not any(c.name == connector.name for c in self.connectors)
204 self.connectors.append(connector)
207 m = re.search(r'^\s*([\d]+)x([\d]+)', line)
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
216 m = re.search(r'^\s*EDID:\s*$', 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)
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):
230 # return available internal resolutions
231 def internalResolutions(self):
232 return self.internalConnector.getResolutionList()
234 # return available external resolutions (or None, if there is no external screen connected)
235 def externalResolutions(self):
236 if self.externalConnector is None:
238 return self.externalConnector.getResolutionList()
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())
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)
258 assert setup.extResolution is None, "There's no external screen to set a resolution for"
259 # now compose the arguments
261 for name in connectorArgs:
262 call += ["--output", name] + connectorArgs[name]