From ffc98865eecc0de34e0a33d7d83461ed9d4ee29b Mon Sep 17 00:00:00 2001 From: Ralf Jung Date: Sun, 8 Feb 2015 12:55:05 +0100 Subject: [PATCH] refactor pretty much everything. some features are still missing. --- dsl.py | 230 +++++++-------------------------------------- gui.py | 10 +- qt_dialogue.py | 182 +++++++++++++++++------------------ qt_dialogue.ui | 189 +++++++++++++++++++++++++++++++++++++ screen.py | 210 +++++++++++++++++++++++++++++++++++++++++ zenity_dialogue.py | 11 ++- 6 files changed, 528 insertions(+), 304 deletions(-) create mode 100644 qt_dialogue.ui create mode 100644 screen.py diff --git a/dsl.py b/dsl.py index 707a5c0..41db08c 100755 --- a/dsl.py +++ b/dsl.py @@ -1,6 +1,6 @@ #!/usr/bin/python3 # DSL - easy Display Setup for Laptops -# Copyright (C) 2012-2014 Ralf Jung +# Copyright (C) 2012-2015 Ralf Jung # # 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 @@ -17,58 +17,18 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. import argparse, sys, os, re, subprocess -from gui import getFrontend -frontend = getFrontend("cli") # the fallback, until we got a proper frontend. This is guaranteed to be available. +from enum import Enum +import gui, screen +frontend = gui.getFrontend("cli") # the fallback, until we got a proper frontend. This is guaranteed to be available. # for auto-config: common names of internal connectors commonInternalConnectorPrefixes = ['LVDS', 'eDP'] commonInternalConnectorSuffices = ['', '0', '1', '-0', '-1'] +def commonInternalConnectorNames(): + for prefix in commonInternalConnectorPrefixes: + for suffix in commonInternalConnectorSuffices: + yield prefix+suffix -# this is as close as one can get to an enum in Python -class RelativeScreenPosition: - LEFT = 0 - RIGHT = 1 - EXTERNAL_ONLY = 2 - MIRROR = 3 - - __names__ = { - 'left': LEFT, - 'right': RIGHT, - 'external-only': EXTERNAL_ONLY, - 'mirror': MIRROR - } - -# storing what's necessary for screen setup -class ScreenSetup: - def __init__(self, relPosition, intResolution, extResolution, extIsPrimary = False): - '''relPosition must be one of the RelativeScreenPosition members, the resolutions must be (width, height) pairs''' - self.relPosition = relPosition - self.intResolution = intResolution # value doesn't matter if the internal screen is disabled - self.extResolution = extResolution - self.extIsPrimary = extIsPrimary or self.relPosition == RelativeScreenPosition.EXTERNAL_ONLY # external is always primary if it is the only one - - def getInternalArgs(self): - if self.relPosition == RelativeScreenPosition.EXTERNAL_ONLY: - return ["--off"] - args = ["--mode", res2xrandr(self.intResolution)] # set internal screen to desired resolution - if not self.extIsPrimary: - args.append('--primary') - return args - - def getExternalArgs(self, intName): - args = ["--mode", res2xrandr(self.extResolution)] # set external screen to desired resolution - if self.extIsPrimary: - args.append('--primary') - # set position - if self.relPosition == RelativeScreenPosition.LEFT: - args += ['--left-of', intName] - elif self.relPosition == RelativeScreenPosition.RIGHT: - args += ['--right-of', intName] - elif self.relPosition == RelativeScreenPosition.MIRROR: - args += ['--same-as', intName] - else: - assert self.relPosition == RelativeScreenPosition.EXTERNAL_ONLY - return args # Load a section-less config file: maps parameter names to space-separated lists of strings (with shell quotation) def loadConfigFile(filename): @@ -93,109 +53,18 @@ def loadConfigFile(filename): # add some convencience get functions return result -# iterator yielding common names of internal connectors -def commonInternalConnectorNames(): - for prefix in commonInternalConnectorPrefixes: - for suffix in commonInternalConnectorSuffices: - yield prefix+suffix - -# helper function: 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 - -# Run xrandr and return a dict of output names mapped to lists of available resolutions, each being a (width, height) pair. -# An empty list indicates that the connector is disabled. -def getXrandrInformation(): - connectors = {} # map of connector names to a list of resolutions - 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 connectors - connectors[connector] = [] - continue - # new resolution? - m = re.search(r'^ ([\d]+)x([\d]+) +', line) - if m is not None: - assert connector is not None - connectors[connector].append((int(m.groups()[0]), int(m.groups()[1]))) - continue - # unknown line - # not fatal as my xrandr shows strange stuff when a display is enabled, but not connected - #raise Exception("Unknown line in xrandr output:\n"+line) - print("Warning: Unknown xrandr line %s" % line) - return connectors -# convert a (width, height) pair into a string accepted by xrandr as argument for --mode -def res2xrandr(res): - (w, h) = res - return str(w)+'x'+str(h) - -# convert a (width, height) pair into a string to be displayed to the user -def res2user(res): - (w, h) = res - # get ratio - ratio = int(round(16.0*h/w)) - 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)' %(w, h, strRatio) - -# return the first available connector from those listed in tryConnectors, skipping disabled connectors -def findAvailableConnector(tryConnectors, allConnectors): - for connector in tryConnectors: - if connector in allConnectors and allConnectors[connector]: # if the connector exists and is active (i.e. there is a resolution) - return connector - return None +# Make sure the backlight is turned on +def turnOnBacklight(): + try: + backlight = float(subprocess.check_output(["xbacklight", "-get"]).strip()) + if backlight == 0: # it's completely turned off, we better enable it + subprocess.check_call(["xbacklight", "-set", "100"]) + except FileNotFoundError: + print("xbacklight has not been found, unable to turn your laptop backlight on.") + except subprocess.CalledProcessError: + print("xbacklight returned an error while attempting to turn your laptop backlight on.") -# Return a (internalConnector, externalConnectors) pair: The name of the internal connector, and a list of external connectors. -# Use the config file at ~/.dsl.conf and fall back to auto-detection -def classifyConnectors(allConnectors): - config = loadConfigFile(os.getenv('HOME') + '/.dsl.conf') - # find internal connector - if 'internalConnector' in config: - if len(config['internalConnector']) != 1: - raise Exception("You must specify exactly one internal connector.") - internalConnector = config['internalConnector'][0] - if not internalConnector in allConnectors: - raise Exception("Connector %s does not exist, there is an error in your config file." % internalConnector) - else: - # auto-config - internalConnector = findAvailableConnector(commonInternalConnectorNames(), allConnectors) - if internalConnector is None: - raise Exception("Could not automatically find internal connector, please use ~/.dsl.conf to specify it manually.") - # all the rest is external then, obviously - unless the user wants to do that manually - if 'externalConnectors' in config: - externalConnectors = config['externalConnectors'] - for connector in externalConnectors: - if not connector in allConnectors: - raise Exception("Connector %s does not exist, there is an error in your config file." % connector) - if connector == internalConnector: - raise Exception("%s is both internal and external, that doesn't make sense." % connector) - else: - externalConnectors = list(allConnectors.keys()) - externalConnectors.remove(internalConnector) - if not externalConnectors: - raise Exception("No external connector found - either your config is wrong, or your machine has only one connector.") - # done! - return (internalConnector, externalConnectors) # if we run top-level if __name__ == "__main__": @@ -205,67 +74,36 @@ if __name__ == "__main__": dest="frontend", help="The frontend to be used for user interaction") parser.add_argument("-r", "--relative-position", - dest="rel_position", choices=RelativeScreenPosition.__names__.keys(), + dest="rel_position", choices=list(map(str.lower, screen.RelativeScreenPosition.__members__.keys())), help="Position of external screen relative to internal one") parser.add_argument("-i", "--internal-only", dest="internal_only", action='store_true', help="Enable internal screen, disable all the others (as if no external screen was connected") cmdArgs = parser.parse_args() - # load frontend - frontend = getFrontend(cmdArgs.frontend) + # load frontend early (for error mssages) + frontend = gui.getFrontend(cmdArgs.frontend) try: - # load connectors and classify them - connectors = getXrandrInformation() - (internalConnector, externalConnectors) = classifyConnectors(connectors) + # see what situation we are in + situation = screen.ScreenSituation(commonInternalConnectorNames()) - # default: screen off - connectorArgs = {} # maps connector names to xrand arguments - for c in externalConnectors+[internalConnector]: - connectorArgs[c] = ["--off"] - - # check whether we got an external screen or not - usedExternalConnector = findAvailableConnector(externalConnectors, connectors) # *the* external connector which is actually used - hasExternal = not cmdArgs.internal_only and usedExternalConnector is not None - if hasExternal: - # compute the list of resolutons available on both - commonRes = [res for res in connectors[usedExternalConnector] if res in connectors[internalConnector]] - # there's an external screen connected, we need to get a setup - if cmdArgs.rel_position is not None: - # use command-line arguments - relPosition = RelativeScreenPosition.__names__[cmdArgs.rel_position] - if relPosition == RelativeScreenPosition.MIRROR: - setup = ScreenSetup(relPosition, commonRes[0], commonRes[0]) # use default resolutions - else: - setup = ScreenSetup(relPosition, connectors[internalConnector][0], connectors[usedExternalConnector][0]) # use default resolutions - else: - # use GUI - setup = frontend.setup(connectors[internalConnector], connectors[usedExternalConnector], commonRes) + # construct the ScreenSetup + setup = None + if situation.externalResolutions() is not None: + setup = frontend.setup(situation) if setup is None: sys.exit(1) # the user canceled - # apply it - connectorArgs[internalConnector] = setup.getInternalArgs() - connectorArgs[usedExternalConnector] = setup.getExternalArgs(internalConnector) else: # use first resolution of internal connector - connectorArgs[internalConnector] = ["--mode", res2xrandr(connectors[internalConnector][0]), "--primary"] + setup = ScreenSetup(intResolution = situation.internalResolutions()[0], extResolution = None) - # and do it - call = ["xrandr"] - for name in connectorArgs: - call += ["--output", name] + connectorArgs[name] - print("Call that will be made:",call) - subprocess.check_call(call) + # call xrandr + xrandrCall = situation.forXrandr(setup) + print("Call that will be made:",xrandrCall) + subprocess.check_call(xrandrCall) # make sure the internal screen is really, *really* turned on if there is no external screen - if not hasExternal: - try: - backlight = float(subprocess.check_output(["xbacklight", "-get"]).strip()) - if backlight == 0: # it's completely turned off, we better enable it - subprocess.check_call(["xbacklight", "-set", "100"]) - except FileNotFoundError: - print("xbacklight has not been found, unable to turn your laptop backlight on.") - except subprocess.CalledProcessError: - print("xbacklight returned an error while attempting to turn your laptop backlight on.") + if setup.extResolution is None: + turnOnBacklight() except Exception as e: frontend.error(str(e)) raise diff --git a/gui.py b/gui.py index 40cafa5..c1f045d 100644 --- a/gui.py +++ b/gui.py @@ -1,5 +1,5 @@ # DSL - easy Display Setup for Laptops -# Copyright (C) 2012 Ralf Jung +# Copyright (C) 2012-2015 Ralf Jung # # 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 @@ -42,9 +42,9 @@ class QtFrontend: from PyQt4 import QtGui QtGui.QMessageBox.critical(None, 'Fatal error', message) - def setup(self, internalResolutions, externalResolutions, commonRes): + def setup(self, situation): from qt_dialogue import PositionSelection - return PositionSelection(internalResolutions, externalResolutions, commonRes).run() + return PositionSelection(situation).run() @staticmethod def isAvailable(): @@ -61,9 +61,9 @@ class ZenityFrontend: '''Displays a fatal error to the user''' subprocess.check_call(["zenity", "--error", "--text="+message]) - def setup(self, internalResolutions, externalResolutions, commonRes): + def setup(self, situation): from zenity_dialogue import run - return run(internalResolutions, externalResolutions) + return run(situation.internalResolutions(), situation.externalResolutions()) @staticmethod def isAvailable(): diff --git a/qt_dialogue.py b/qt_dialogue.py index c82b8f9..96c04a7 100644 --- a/qt_dialogue.py +++ b/qt_dialogue.py @@ -1,5 +1,5 @@ # DSL - easy Display Setup for Laptops -# Copyright (C) 2012 Ralf Jung +# Copyright (C) 2012-2015 Ralf Jung # # 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 @@ -14,112 +14,98 @@ # 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. -from dsl import RelativeScreenPosition, ScreenSetup, res2user -from PyQt4 import QtCore, QtGui +import os +from screen import RelativeScreenPosition, ScreenSetup +from PyQt4 import QtCore, QtGui, uic + +relPosNames = { + RelativeScreenPosition.LEFT: "left of", + RelativeScreenPosition.RIGHT: "right of", + RelativeScreenPosition.ABOVE: "above", + RelativeScreenPosition.BELOW: "below", + RelativeScreenPosition.MIRROR: "same as", +} -def makeLayout(layout, members): - for m in members: - if isinstance(m, QtGui.QLayout): - layout.addLayout(m) - else: - layout.addWidget(m) - return layout class PositionSelection(QtGui.QDialog): - def __init__(self, internalResolutions, externalResolutions, commonResolutions): + def __init__(self, situation): # set up main window super(PositionSelection, self).__init__() - self.setWindowTitle('DSL - easy Display Setup for Laptops') - - ## position selection - posBox = QtGui.QGroupBox('Position of external screen', self) - self.posLeft = QtGui.QRadioButton('Left of internal screen', posBox) - self.posRight = QtGui.QRadioButton('Right of internal screen', posBox) - self.posRight.setChecked(True) - self.posRight.setFocus() - self.extOnly = QtGui.QRadioButton('Use external screen exclusively', posBox) - self.mirror = QtGui.QRadioButton('Mirror internal screen', posBox) - positions = [self.posLeft, self.posRight, self.extOnly, self.mirror] - posBox.setLayout(makeLayout(QtGui.QVBoxLayout(), positions)) - for pos in positions: - pos.toggled.connect(self.updateForm) - - ## primary screen - self.primBox = QtGui.QGroupBox('Which should be the primary screen?', self) - self.primExt = QtGui.QRadioButton('The external screen', self.primBox) - self.primInt = QtGui.QRadioButton('The internal screen', self.primBox) - self.primInt.setChecked(True) - self.primBox.setLayout(makeLayout(QtGui.QVBoxLayout(), [self.primExt, self.primInt])) + self._situation = situation + uifile = os.path.join(os.path.dirname(__file__), 'qt_dialogue.ui') + uic.loadUi(uifile, self) - ## resolution selection - resBox = QtGui.QGroupBox('Screen resolutions', self) - # external screen - self.extResLabel = QtGui.QLabel('Resolution of external screen:', resBox) - self.extResolutions = externalResolutions - self.extResolutionsBox = QtGui.QComboBox(resBox) - for res in externalResolutions: - self.extResolutionsBox.addItem(res2user(res)) - self.extResolutionsBox.setCurrentIndex(0) # select first resolution - self.extRow = makeLayout(QtGui.QHBoxLayout(), [self.extResLabel, self.extResolutionsBox]) - # internal screen - self.intResLabel = QtGui.QLabel('Resolution of internal screen:', resBox) - self.intResolutions = internalResolutions - self.intResolutionsBox = QtGui.QComboBox(resBox) - for res in internalResolutions: - self.intResolutionsBox.addItem(res2user(res)) - self.intResolutionsBox.setCurrentIndex(0) # select first resolution - self.intRow = makeLayout(QtGui.QHBoxLayout(), [self.intResLabel, self.intResolutionsBox]) - # both screens - self.mirrorResLabel = QtGui.QLabel('Resolution of both screens:', resBox) - self.mirrorResolutions = commonResolutions - self.mirrorResolutionsBox = QtGui.QComboBox(resBox) - for res in commonResolutions: - self.mirrorResolutionsBox.addItem(res2user(res)) - self.mirrorResolutionsBox.setCurrentIndex(0) # select first resolution - self.mirrorRow = makeLayout(QtGui.QHBoxLayout(), [self.mirrorResLabel, self.mirrorResolutionsBox]) - # show them all - resBox.setLayout(makeLayout(QtGui.QVBoxLayout(), [self.extRow, self.intRow, self.mirrorRow])) + # fill relative position box + for pos in RelativeScreenPosition: + self.relPos.addItem(relPosNames[pos], pos) - # last row: buttons - buttons = QtGui.QDialogButtonBox(QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel, QtCore.Qt.Horizontal, self) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - - # add them all to the window - self.setLayout(makeLayout(QtGui.QVBoxLayout(), [posBox, self.primBox, resBox, buttons])) - - # make sure we are consistent - self.updateForm() + # keep resolutions in sync when in mirror mode + def syncIfMirror(source, target): + def _slot(idx): + if self.isMirror: + target.setCurrentIndex(idx) + source.currentIndexChanged.connect(_slot) + syncIfMirror(self.intRes, self.extRes) + syncIfMirror(self.extRes, self.intRes) + + # connect the update function, and make sure we are in a correct state + self.intEnabled.toggled.connect(self.updateEnabledControls) + self.extEnabled.toggled.connect(self.updateEnabledControls) + self.relPos.currentIndexChanged.connect(self.updateEnabledControls) + self.updateEnabledControls() + + def getRelativeScreenPosition(self): + idx = self.relPos.currentIndex() + return self.relPos.itemData(idx) + + def fillResolutionBox(self, box, resolutions): + # if the count did not change, update in-place (this avoids flicker) + if box.count() == len(resolutions): + for idx, res in enumerate(resolutions): + box.setItemText(idx, str(res)) + box.setItemData(idx, res) + else: + # first clear it + while box.count() > 0: + box.removeItem(0) + # then fill it + for res in resolutions: + box.addItem(str(res), res) - def updateForm(self): - self.primBox.setEnabled(self.posLeft.isChecked() or self.posRight.isChecked()) - self.extResolutionsBox.setEnabled(not self.mirror.isChecked()) - self.extResLabel.setEnabled(not self.mirror.isChecked()) - self.intResolutionsBox.setEnabled(self.posLeft.isChecked() or self.posRight.isChecked()) - self.intResLabel.setEnabled(self.posLeft.isChecked() or self.posRight.isChecked()) - self.mirrorResolutionsBox.setEnabled(self.mirror.isChecked()) - self.mirrorResLabel.setEnabled(self.mirror.isChecked()) + def updateEnabledControls(self): + intEnabled = self.intEnabled.isChecked() + extEnabled = self.extEnabled.isChecked() + bothEnabled = intEnabled and extEnabled + self.isMirror = bothEnabled and self.getRelativeScreenPosition() == RelativeScreenPosition.MIRROR # only if both are enabled, we can really mirror + # configure screen controls + self.intRes.setEnabled(intEnabled) + self.intPrimary.setEnabled(intEnabled and not self.isMirror) + self.extRes.setEnabled(extEnabled) + self.extPrimary.setEnabled(extEnabled and not self.isMirror) + if not intEnabled and extEnabled: + self.extPrimary.setChecked(True) + elif not extEnabled and intEnabled: + self.intPrimary.setChecked(True) + # which resolutions do we offer? + if self.isMirror: + commonRes = self._situation.commonResolutions() + self.fillResolutionBox(self.intRes, commonRes) + self.fillResolutionBox(self.extRes, commonRes) + self.intRes.setCurrentIndex(self.extRes.currentIndex()) + else: + self.fillResolutionBox(self.intRes, self._situation.internalResolutions()) + self.fillResolutionBox(self.extRes, self._situation.externalResolutions()) + # configure position control + self.posGroup.setEnabled(bothEnabled) + self.posLabel1.setEnabled(bothEnabled) + self.posLabel2.setEnabled(bothEnabled) + self.relPos.setEnabled(bothEnabled) + # avoid having no screen + self.buttonBox.button(QtGui.QDialogButtonBox.Ok).setEnabled(intEnabled or extEnabled) def run(self): self.exec_() if not self.result(): return None - if self.mirror.isChecked(): - return ScreenSetup(RelativeScreenPosition.MIRROR, - self.mirrorResolutions[self.mirrorResolutionsBox.currentIndex()], - self.mirrorResolutions[self.mirrorResolutionsBox.currentIndex()], - extIsPrimary = True) - else: - return ScreenSetup(self.getRelativeScreenPosition(), - self.intResolutions[self.intResolutionsBox.currentIndex()], - self.extResolutions[self.extResolutionsBox.currentIndex()], - self.primExt.isChecked()) - - def getRelativeScreenPosition(self): - if self.posLeft.isChecked(): - return RelativeScreenPosition.LEFT - elif self.posRight.isChecked(): - return RelativeScreenPosition.RIGHT - elif self.extOnly.isChecked(): - return RelativeScreenPosition.EXTERNAL_ONLY - else: - raise Exception("Nothing is checked?") + intRes = self.intRes.itemData(self.intRes.currentIndex()) if self.intEnabled.isChecked() else None + extRes = self.extRes.itemData(self.extRes.currentIndex()) if self.extEnabled.isChecked() else None + return ScreenSetup(intRes, extRes, self.getRelativeScreenPosition(), self.extPrimary.isChecked()) diff --git a/qt_dialogue.ui b/qt_dialogue.ui new file mode 100644 index 0000000..6b52c88 --- /dev/null +++ b/qt_dialogue.ui @@ -0,0 +1,189 @@ + + + Dialog + + + + 0 + 0 + 332 + 217 + + + + DSL - easy Display Setup for Laptops + + + + + + Screen Configuratioon + + + + + + Enable: + + + + + + + Internal + + + true + + + + + + + External + + + true + + + + + + + Resolution: + + + + + + + + + + + + + Primary: + + + + + + + Internal + + + + + + + External + + + true + + + + + + + + + + Screen Position + + + + + + External screen is + + + + + + + + + + internal screen. + + + + + + + Qt::Horizontal + + + + 1 + 20 + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/screen.py b/screen.py new file mode 100644 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 +# +# 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. 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 , 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 + diff --git a/zenity_dialogue.py b/zenity_dialogue.py index e926b3a..b5e72d0 100644 --- a/zenity_dialogue.py +++ b/zenity_dialogue.py @@ -1,5 +1,6 @@ # DSL - easy Display Setup for Laptops -# Copyright (C) 2012 Ralf Jung +# Copyright (C) 2012 Ralf Jung +# Copyright (C) 2012-2015 Constantin Berhard # # 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 @@ -15,7 +16,7 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -from dsl import RelativeScreenPosition, ScreenSetup, res2user, processOutputIt +from screen import RelativeScreenPosition, ScreenSetup, processOutputIt def userChoose (title, choices, returns, fallback): assert len(choices) == len(returns) @@ -37,16 +38,16 @@ def run (internalResolutions, externalResolutions): extres = externalResolutions[0] extprim = None if relpos != RelativeScreenPosition.EXTERNAL_ONLY: - intres = userChoose ("internal display resolution", list(map(res2user,internalResolutions)), internalResolutions, None) + intres = userChoose ("internal display resolution", list(map(str,internalResolutions)), internalResolutions, None) if intres == None: return None else: extprim = True - extres = userChoose ("external display resolution", list(map(res2user,externalResolutions)), externalResolutions, None) + extres = userChoose ("external display resolution", list(map(str,externalResolutions)), externalResolutions, None) if extres == None: return None if extprim == None: extprim = userChoose ("Which display should be the primary display?", ["internal display", "external display"], [False, True], None) if extprim == None: return None - return ScreenSetup(relpos,intres,extres,extprim) + return ScreenSetup(intres,extres,relpos,extprim) -- 2.30.2