From 436dc315ae39dfac14edd1c208c26c7419e9f58e Mon Sep 17 00:00:00 2001 From: Constantin Berhard Date: Tue, 17 Nov 2015 15:31:06 +0100 Subject: [PATCH 01/16] fix preferred Resolution and new config file location --- lilass | 16 +++++++++++----- screen.py | 8 ++++++++ util.py | 23 +++++++++++++++++++++++ 3 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 util.py diff --git a/lilass b/lilass index 7b94e18..f532b01 100755 --- a/lilass +++ b/lilass @@ -16,9 +16,9 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -import argparse, sys, os, re, subprocess +import argparse, sys, os, os.path, shutil, re, subprocess from enum import Enum -import gui, screen +import gui, screen, util frontend = gui.getFrontend("cli") # the fallback, until we got a proper frontend. This is guaranteed to be available. @@ -30,7 +30,6 @@ def commonInternalConnectorNames(): for suffix in commonInternalConnectorSuffices: yield prefix+suffix - # Load a section-less config file: maps parameter names to space-separated lists of strings (with shell quotation) def loadConfigFile(filename): import shlex @@ -106,7 +105,14 @@ if __name__ == "__main__": frontend = gui.getFrontend(cmdArgs.frontend) # load configuration - config = loadConfigFile(os.getenv('HOME') + '/.lilass.conf') + legacyConfigFilePath = os.getenv('HOME') + '/.lilass.conf' + configDirectory = util.getConfigDirectory() + configFilePath = os.path.join(configDirectory, "lilass.conf") + if os.path.isfile(legacyConfigFilePath) and not os.path.isfile(configFilePath): + # looks like we just upgraded to a new version of lilass + util.mkdirP(configDirectory) + shutil.move(legacyConfigFilePath, configFilePath) + config = loadConfigFile(configFilePath) # see what situation we are in situation = situationByConfig(config) @@ -135,7 +141,7 @@ if __name__ == "__main__": if setup is None: sys.exit(1) # the user canceled else: # use first resolution of internal connector - setup = screen.ScreenSetup(intResolution = situation.internalResolutions()[0], extResolution = None) + setup = screen.ScreenSetup(intResolution = situation.internalConnector.getPreferredResolution(), extResolution = None) # call xrandr xrandrCall = situation.forXrandr(setup) diff --git a/screen.py b/screen.py index 9942e98..b096fb1 100644 --- a/screen.py +++ b/screen.py @@ -129,6 +129,7 @@ class Connector: self.name = name # connector name, e.g. "HDMI1" self.edid = None # EDID string for the connector, or None if disconnected self.resolutions = set() # list of Resolution objects, empty if disconnected + self.preferredResolution = None def __str__(self): return str(self.name) @@ -149,6 +150,11 @@ class Connector: self.edid = s else: self.edid += s + + def getPreferredResolution(self): + if self.preferredResolution: + return self.preferredResolution + return max(self.resolutions, key=lambda r: r.pixelCount()) class ScreenSituation: connectors = [] # contains all the Connector objects @@ -209,6 +215,8 @@ class ScreenSituation: resolution = Resolution(int(m.group(1)), int(m.group(2))) assert connector is not None connector.addResolution(resolution) + if '+preferred' in line: + connector.preferredResolution = resolution continue # EDID? m = re.search(r'^\s*EDID:\s*$', line) diff --git a/util.py b/util.py new file mode 100644 index 0000000..00c80a1 --- /dev/null +++ b/util.py @@ -0,0 +1,23 @@ +import os, os.path + +def getConfigDirectory(): + d = os.environ.get("XDG_CONFIG_HOME") + if d: + return os.path.join(d, "lilass") + d = os.path.expanduser("~") + if d: + return os.path.join(d, ".config", "lilass") + raise Exception("Couldn't find config directory") + +def getDataDirectory(): + d = os.environ.get("XDG_DATA_HOME") + if d: + return os.path.join(d, "lilass") + d = os.path.expanduser("~") + if d: + return os.path.join(d, ".local", "share", "lilass") + raise Exception("Couldn't find data directory.") + +def mkdirP(path, mode=0o700): + os.makedirs(path, mode=mode, exist_ok=True) + -- 2.30.2 From 09b026a7a1fc1a072c7bc7dc6d491ad71180617f Mon Sep 17 00:00:00 2001 From: Constantin Berhard Date: Fri, 20 Nov 2015 17:12:59 +0100 Subject: [PATCH 02/16] useful sorting of resolutions --- lilass | 2 +- screen.py | 22 ++++++++++------------ 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/lilass b/lilass index f532b01..85ccc99 100755 --- a/lilass +++ b/lilass @@ -141,7 +141,7 @@ if __name__ == "__main__": if setup is None: sys.exit(1) # the user canceled else: # use first resolution of internal connector - setup = screen.ScreenSetup(intResolution = situation.internalConnector.getPreferredResolution(), extResolution = None) + setup = screen.ScreenSetup(intResolution = situation.internalConnector.getResolutionList()[0], extResolution = None) # call xrandr xrandrCall = situation.forXrandr(setup) diff --git a/screen.py b/screen.py index b096fb1..f9bef55 100644 --- a/screen.py +++ b/screen.py @@ -126,24 +126,24 @@ class ScreenSetup: class Connector: def __init__(self, name=None): - self.name = name # connector name, e.g. "HDMI1" - self.edid = None # EDID string for the connector, or None if disconnected - self.resolutions = set() # list of Resolution objects, empty if disconnected + self.name = name # connector name, e.g. "HDMI1" + self.edid = None # EDID string for the connector, or None if disconnected + self._resolutions = set() # list of Resolution objects, empty if disconnected self.preferredResolution = None def __str__(self): return str(self.name) def __repr__(self): - return """""" % (str(self.name), str(self.edid), ", ".join(str(r) for r in self.resolutions)) + return """""" % (str(self.name), str(self.edid), ", ".join(str(r) for r in self.getResolutionList())) def isConnected(self): - assert (self.edid is None) == (len(self.resolutions)==0) + assert (self.edid is None) == (len(self._resolutions)==0) return self.edid is not None def addResolution(self, resolution): assert isinstance(resolution, Resolution) - self.resolutions.add(resolution) + self._resolutions.add(resolution) def appendToEdid(self, s): if self.edid is None: @@ -151,10 +151,8 @@ class Connector: else: self.edid += s - def getPreferredResolution(self): - if self.preferredResolution: - return self.preferredResolution - return max(self.resolutions, key=lambda r: r.pixelCount()) + def getResolutionList(self): + return sorted(self._resolutions, key=lambda r: (0 if r==self.preferredResolution else 1, -r.pixelCount())) class ScreenSituation: connectors = [] # contains all the Connector objects @@ -235,13 +233,13 @@ class ScreenSituation: # return available internal resolutions def internalResolutions(self): - return self.internalConnector.resolutions + return self.internalConnector.getResolutionList() # 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.externalConnector.resolutions + return self.externalConnector.getResolutionList() # return resolutions available for both internal and external screen def commonResolutions(self): -- 2.30.2 From 53c8405139ffdc848753335f50b6b746a7bafcb5 Mon Sep 17 00:00:00 2001 From: Constantin Berhard Date: Fri, 20 Nov 2015 17:51:21 +0100 Subject: [PATCH 03/16] made aspect ratio accurate --- screen.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/screen.py b/screen.py index f9bef55..b0c6577 100644 --- a/screen.py +++ b/screen.py @@ -18,6 +18,7 @@ import re, subprocess from enum import Enum +from fractions import Fraction ## utility functions @@ -66,13 +67,8 @@ class Resolution: 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 + ratio = Fraction(self.width, self.height) # automatically divides by the gcd + strRatio = "%d:%d" % (ratio.numerator, ratio.denominator) return '%dx%d (%s)' %(self.width, self.height, strRatio) def __repr__(self): -- 2.30.2 From 381dd16b7011170fac8413d93f0d175ef2f22c7a Mon Sep 17 00:00:00 2001 From: Constantin Berhard Date: Tue, 24 Nov 2015 01:33:34 +0100 Subject: [PATCH 04/16] saving to and loading from db works :-) --- database.py | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++ lilass | 33 ++++++++++++++++++++++-- screen.py | 58 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 156 insertions(+), 7 deletions(-) create mode 100644 database.py diff --git a/database.py b/database.py new file mode 100644 index 0000000..c65b4b9 --- /dev/null +++ b/database.py @@ -0,0 +1,72 @@ +import sqlite3 +import os.path +from binascii import hexlify, unhexlify +from screen import ScreenSetup, Resolution, RelativeScreenPosition + +class InvalidDBFile(Exception): + pass + +class Database: + def __init__(self, dbfilename): + self._create = False + assert(os.path.isdir(os.path.dirname(dbfilename))) + if not os.path.isfile(dbfilename): + if os.path.lexists(dbfilename): + raise Exception("Database must be a file: '%s'" % dbfilename) + # database will be created on __enter__ because we need a dbconnection for it + self._create = True + self._dbfilename = dbfilename + self._connection = None + def __enter__(self): + self._connection = sqlite3.connect(self._dbfilename) + c = self._c() + if self._create: + c.execute("""CREATE TABLE meta (key text, value text, PRIMARY KEY(key))""") + c.execute("""INSERT INTO meta VALUES ('version', '1')""") + c.execute("""CREATE TABLE known_configs (edid blob, resinternal text, resexternal text, mode text, ext_is_primary integer, PRIMARY KEY(edid))""") + # edid in binary format + # resindernal, resexternal = "1024x768" or NULL if display is off + # mode: the enum text of screen.RelativeScreenPosition or NULL if one display is off + else: # check compatibility + dbversion = int(self._getMeta("version")) + if dbversion > 1: + raise InvalidDBFile("Database is too new: Version %d. Please update lilass." % dbversion) + return self + def _getMeta(self, key): + c = self._c() + c.execute("""SELECT value FROM meta WHERE key=?""", (key,)) + got = c.fetchone() + if got is None: # to differentiate between the value being NULL and the row being not there + raise KeyError("""Key "%s" is not in the meta table.""" % key) + assert c.fetchone() is None # uniqueness + assert len(got) == 1 + return got[0] + def putConfig(self, extconn_edid, conf): + c = self._c() + b_edid = unhexlify(extconn_edid) + intres = conf.intResolution.forDatabase() if conf.intResolution else None + extres = conf.extResolution.forDatabase() if conf.extResolution else None + mode = conf.relPosition.text if conf.relPosition else None + extprim = int(conf.extIsPrimary) # False => 0, True => 1 + c.execute("""INSERT OR REPLACE INTO known_configs VALUES (?,?,?,?,?)""", (b_edid, intres, extres, mode, extprim)) + def getConfig(self, extconn_edid): + c = self._c() + b_edid = unhexlify(extconn_edid) + c.execute("""SELECT * FROM known_configs WHERE edid=?""", (b_edid,)) + result = c.fetchone() + if result is None: + return None + assert c.fetchone() is None # uniqueness + _, intres, extres, mode, extprim = result + intres = Resolution.fromDatabase(intres) # this method is safe for NULLs + extres = Resolution.fromDatabase(extres) + mode = RelativeScreenPosition(mode) + extprim = bool(extprim) # 0 => False, 1 => True + return ScreenSetup(intres, extres, mode, extprim) + def __exit__(self, type, value, tb): + if self._connection: + self._connection.commit() + self._connection.close() + def _c(self): + assert(self._connection) + return self._connection.cursor() diff --git a/lilass b/lilass index 85ccc99..42c15c6 100755 --- a/lilass +++ b/lilass @@ -18,7 +18,7 @@ import argparse, sys, os, os.path, shutil, re, subprocess from enum import Enum -import gui, screen, util +import gui, screen, util, database frontend = gui.getFrontend("cli") # the fallback, until we got a proper frontend. This is guaranteed to be available. @@ -78,6 +78,18 @@ def situationByConfig(config): # run! return screen.ScreenSituation(internalConnectors, config.get('externalConnectors')) +class ShowLevels(Enum): + ONEXTERNAL = ("on-external") + ONNEW = ("on-new") + ONERROR = ("on-error") + def __init__(self, text): + # auto numbering + cls = self.__class__ + self._value_ = len(cls.__members__) + 1 + self.text = text + @classmethod + def getNames(cls): + return list(x.text for x in cls) # if we run top-level if __name__ == "__main__": @@ -99,12 +111,16 @@ if __name__ == "__main__": parser.add_argument("-i", "--internal-only", dest="internal_only", action='store_true', help="Enable internal screen, disable all the others.") + parser.add_argument("-s", "--show", + dest="show", choices=ShowLevels.getNames(), + help="In which situations should the UI be displayed?") cmdArgs = parser.parse_args() # load frontend early (for error mssages) frontend = gui.getFrontend(cmdArgs.frontend) - # load configuration + # find files + ## find config file legacyConfigFilePath = os.getenv('HOME') + '/.lilass.conf' configDirectory = util.getConfigDirectory() configFilePath = os.path.join(configDirectory, "lilass.conf") @@ -112,11 +128,22 @@ if __name__ == "__main__": # looks like we just upgraded to a new version of lilass util.mkdirP(configDirectory) shutil.move(legacyConfigFilePath, configFilePath) + ## find database + dataDirectory = util.getDataDirectory() + util.mkdirP(dataDirectory) + databaseFilePath = os.path.join(dataDirectory, "collected_data.sqlite") + + # load configuration config = loadConfigFile(configFilePath) # see what situation we are in situation = situationByConfig(config) + # fetch info from the database + # if it is too slow to open the DB twice (reading and saving), we can keep it open all the time + with database.Database(databaseFilePath) as db: + situation.fetchDBInfo(db) + # construct the ScreenSetup setup = None if not cmdArgs.internal_only and situation.externalResolutions() is not None: @@ -139,6 +166,8 @@ if __name__ == "__main__": # ask the user setup = frontend.setup(situation) if setup is None: sys.exit(1) # the user canceled + with database.Database(databaseFilePath) as db: + situation.putDBInfo(db, setup) else: # use first resolution of internal connector setup = screen.ScreenSetup(intResolution = situation.internalConnector.getResolutionList()[0], extResolution = None) diff --git a/screen.py b/screen.py index b0c6577..39c9367 100644 --- a/screen.py +++ b/screen.py @@ -45,7 +45,7 @@ class RelativeScreenPosition(Enum): def __init__(self, text): # auto numbering cls = self.__class__ - self._value_ = len(cls.__members__) + self._value_ = len(cls.__members__) + 1 self.text = text class Resolution: @@ -54,6 +54,24 @@ class Resolution: self.width = width self.height = height + @classmethod + def fromDatabase(cls, dbstr): + if dbstr is None: + return None + parts = dbstr.split("x") + if len(parts) != 2: + raise ValueError(xrandrstr) + return Resolution(*map(int,parts)) + + def forDatabase(self): + return str(self.width)+'x'+str(self.height) + + def forXrandr(self): + return self.forDatabase() + + def toTuple(self): + return (self.width, self.height) + def __eq__(self, other): if not isinstance(other, Resolution): return False @@ -76,9 +94,6 @@ class Resolution: def pixelCount(self): return self.width * self.height - - def forXrandr(self): - return str(self.width)+'x'+str(self.height) class ScreenSetup: @@ -126,6 +141,8 @@ class Connector: self.edid = None # EDID string for the connector, or None if disconnected self._resolutions = set() # list of Resolution objects, empty if disconnected self.preferredResolution = None + self.__lastResolution = None + self.hasLastResolution = False def __str__(self): return str(self.name) @@ -133,6 +150,20 @@ class Connector: def __repr__(self): return """""" % (str(self.name), str(self.edid), ", ".join(str(r) for r in self.getResolutionList())) + def __setLastRes(self, res): + # res == None means this display was last switched off + if res is not None and not res in self._resolutions: + raise ValueError("Resolution "+res+" not available for "+self.name+".") + self.__lastResolution = res + self.hasLastResolution = True + + def __getLastRes(self): + if not self.hasLastResolution: + raise ValueError("Connector %s has no last known resolution." % self.name) + return self.__lastResolution + + lastResolution = property(__getLastRes, __setLastRes) + def isConnected(self): assert (self.edid is None) == (len(self._resolutions)==0) return self.edid is not None @@ -148,7 +179,7 @@ class Connector: self.edid += s def getResolutionList(self): - return sorted(self._resolutions, key=lambda r: (0 if r==self.preferredResolution else 1, -r.pixelCount())) + return sorted(self._resolutions, key=lambda r: (0 if self.hasLastResolution and r==self.lastResolution else 1, 0 if r==self.preferredResolution else 1, -r.pixelCount())) class ScreenSituation: connectors = [] # contains all the Connector objects @@ -177,6 +208,7 @@ class ScreenSituation: if self.internalConnector == self.externalConnector: raise Exception("Internal and external connector are the same. This must not happen. Please fix ~/.dsl.conf."); print("Detected external connector:",self.externalConnector) + # self.preferredSetup is left uninitialized so you can't access it before trying a lookup in the database # Run xrandr and fill the dict of connector names mapped to lists of available resolutions. def _getXrandrInformation(self): @@ -262,3 +294,19 @@ class ScreenSituation: call += ["--output", name] + connectorArgs[name] return call + def fetchDBInfo(self, db): + if self.externalConnector and self.externalConnector.edid: + self.preferredSetup = db.getConfig(self.externalConnector.edid) # may also return None + else: + self.preferredSetup = None + if self.preferredSetup: + print("SETUP FOUND", self.preferredSetup) + self.externalConnector.lastResolution = self.preferredSetup.extResolution + self.internalConnector.lastResolution = self.preferredSetup.intResolution + else: + print("NO SETUP FOUND") + + def putDBInfo(self, db, setup): + if not self.externalConnector or not self.externalConnector.edid: + return + db.putConfig(self.externalConnector.edid, setup) -- 2.30.2 From dae20412c707b571e8c2dd28d067e914052b6279 Mon Sep 17 00:00:00 2001 From: Constantin Berhard Date: Tue, 24 Nov 2015 11:52:03 +0100 Subject: [PATCH 05/16] added command line argument --show --- lilass | 22 ++++++++++++++++------ screen.py | 14 +++++++------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/lilass b/lilass index 42c15c6..5dfd19a 100755 --- a/lilass +++ b/lilass @@ -112,7 +112,7 @@ if __name__ == "__main__": dest="internal_only", action='store_true', help="Enable internal screen, disable all the others.") parser.add_argument("-s", "--show", - dest="show", choices=ShowLevels.getNames(), + dest="show", choices=ShowLevels.getNames(), default=ShowLevels.ONEXTERNAL.text, help="In which situations should the UI be displayed?") cmdArgs = parser.parse_args() @@ -163,11 +163,21 @@ if __name__ == "__main__": else: setup = screen.ScreenSetup(intResolution = situation.internalResolutions()[0], extResolution = situation.externalResolutions()[0], relPosition = relPos) else: - # ask the user - setup = frontend.setup(situation) - if setup is None: sys.exit(1) # the user canceled - with database.Database(databaseFilePath) as db: - situation.putDBInfo(db, setup) + showlvl = ShowLevels(cmdArgs.show) + if showlvl != ShowLevels.ONEXTERNAL and situation.lastSetup: + # use last config + setup = situation.lastSetup + elif showlvl == ShowLevels.ONERROR: + # guess config + setup = screen.ScreenSetup(situation.internalResolutions()[0], situation.externalResolutions()[0], screen.RelativeScreenPosition.RIGHT) + # TODO make default relative position configurable in the config file + # TODO this has a bit of code duplication with the cmdArgs method above + else: + # ask the user + setup = frontend.setup(situation) + if setup is None: sys.exit(1) # the user canceled + with database.Database(databaseFilePath) as db: + situation.putDBInfo(db, setup) else: # use first resolution of internal connector setup = screen.ScreenSetup(intResolution = situation.internalConnector.getResolutionList()[0], extResolution = None) diff --git a/screen.py b/screen.py index 39c9367..456c0bd 100644 --- a/screen.py +++ b/screen.py @@ -208,7 +208,7 @@ class ScreenSituation: if self.internalConnector == self.externalConnector: raise Exception("Internal and external connector are the same. This must not happen. Please fix ~/.dsl.conf."); print("Detected external connector:",self.externalConnector) - # self.preferredSetup is left uninitialized so you can't access it before trying a lookup in the database + # self.lastSetup is left uninitialized so you can't access it before trying a lookup in the database # Run xrandr and fill the dict of connector names mapped to lists of available resolutions. def _getXrandrInformation(self): @@ -296,13 +296,13 @@ class ScreenSituation: def fetchDBInfo(self, db): if self.externalConnector and self.externalConnector.edid: - self.preferredSetup = db.getConfig(self.externalConnector.edid) # may also return None + self.lastSetup = db.getConfig(self.externalConnector.edid) # may also return None else: - self.preferredSetup = None - if self.preferredSetup: - print("SETUP FOUND", self.preferredSetup) - self.externalConnector.lastResolution = self.preferredSetup.extResolution - self.internalConnector.lastResolution = self.preferredSetup.intResolution + self.lastSetup = None + if self.lastSetup: + print("SETUP FOUND", self.lastSetup) + self.externalConnector.lastResolution = self.lastSetup.extResolution + self.internalConnector.lastResolution = self.lastSetup.intResolution else: print("NO SETUP FOUND") -- 2.30.2 From 6adc8bd3434dec9bc70c4f8f5adc0e54f990339b Mon Sep 17 00:00:00 2001 From: Constantin Berhard Date: Tue, 24 Nov 2015 13:23:27 +0100 Subject: [PATCH 06/16] pre select last used setup in qt gui --- qt_frontend.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/qt_frontend.py b/qt_frontend.py index 6e41d55..05c33ae 100644 --- a/qt_frontend.py +++ b/qt_frontend.py @@ -42,12 +42,23 @@ try: syncIfMirror(self.intRes, self.extRes) syncIfMirror(self.extRes, self.intRes) - # connect the update function, and make sure we are in a correct state + # connect the update function self.intEnabled.toggled.connect(self.updateEnabledControls) self.extEnabled.toggled.connect(self.updateEnabledControls) self.relPos.currentIndexChanged.connect(self.updateEnabledControls) + + # if situation has a lastSetup, use its values as initial state + if situation.lastSetup: + last = situation.lastSetup + self.intEnabled.setChecked(last.intResolution is not None) + self.extEnabled.setChecked(last.extResolution is not None) + if last.relPosition: + print("YO:",last.relPosition.value-1) + self.relPos.setCurrentIndex(last.relPosition.value-1) + + # make sure we are in a correct state self.updateEnabledControls() - + def getRelativeScreenPosition(self): idx = self.relPos.currentIndex() return self.relPos.itemData(idx) -- 2.30.2 From 158f0efbe4109a5d3df516404caa8567ddf5e03c Mon Sep 17 00:00:00 2001 From: Constantin Berhard Date: Tue, 24 Nov 2015 13:41:33 +0100 Subject: [PATCH 07/16] lastSetup support in question frontends --- database.py | 2 +- qt_frontend.py | 1 - question_frontend.py | 7 +++++++ screen.py | 9 +++++++++ zenity_frontend.py | 2 +- 5 files changed, 18 insertions(+), 3 deletions(-) diff --git a/database.py b/database.py index c65b4b9..e4ec011 100644 --- a/database.py +++ b/database.py @@ -60,7 +60,7 @@ class Database: _, intres, extres, mode, extprim = result intres = Resolution.fromDatabase(intres) # this method is safe for NULLs extres = Resolution.fromDatabase(extres) - mode = RelativeScreenPosition(mode) + mode = RelativeScreenPosition(mode) if mode else None extprim = bool(extprim) # 0 => False, 1 => True return ScreenSetup(intres, extres, mode, extprim) def __exit__(self, type, value, tb): diff --git a/qt_frontend.py b/qt_frontend.py index 05c33ae..b3ee61e 100644 --- a/qt_frontend.py +++ b/qt_frontend.py @@ -53,7 +53,6 @@ try: self.intEnabled.setChecked(last.intResolution is not None) self.extEnabled.setChecked(last.extResolution is not None) if last.relPosition: - print("YO:",last.relPosition.value-1) self.relPos.setCurrentIndex(last.relPosition.value-1) # make sure we are in a correct state diff --git a/question_frontend.py b/question_frontend.py index a17b7ca..fc9e8c6 100644 --- a/question_frontend.py +++ b/question_frontend.py @@ -40,6 +40,13 @@ class QuestionFrontend: return self.userChoose("Select resolution for %s"%displayname, modedescs, availablemodes, None) def setup (self, situation): + if situation.lastSetup: + applyLast = self.userChoose("This display is known. The last setup for it was like this:\n%s.\nApply the last used configuration?" % str(situation.lastSetup), ("Apply last setup", "Enter different setup"), (True,False), None) + if applyLast is None: + return None + if applyLast is True: + return situation.lastSetup + assert applyLast is False operationmodes = list(OperationMode) operationmodedescs = list(map(lambda x: x.text, operationmodes)) operationmode = self.userChoose ("Display setup", operationmodedescs, operationmodes, None) diff --git a/screen.py b/screen.py index 456c0bd..21774df 100644 --- a/screen.py +++ b/screen.py @@ -47,6 +47,8 @@ class RelativeScreenPosition(Enum): cls = self.__class__ self._value_ = len(cls.__members__) + 1 self.text = text + def __str__(self): + return self.text class Resolution: '''Represents a resolution of a screen''' @@ -134,6 +136,13 @@ class ScreenSetup: RelativeScreenPosition.MIRROR: '--same-as', }[self.relPosition], intName] return args + + def __str__(self): + if self.intResolution is None: + return "External display only, at "+str(self.extResolution) + if self.extResolution is None: + return "Internal display only, at "+str(self.intResolution) + return "External display %s at %s %s internal display %s at %s" % ("(primary)" if self.extIsPrimary else "", str(self.extResolution), str(self.relPosition), "" if self.extIsPrimary else "(primary)", str(self.intResolution)) class Connector: def __init__(self, name=None): diff --git a/zenity_frontend.py b/zenity_frontend.py index 7dd4d82..f6834b5 100644 --- a/zenity_frontend.py +++ b/zenity_frontend.py @@ -29,7 +29,7 @@ class ZenityFrontend(QuestionFrontend): def userChoose (self, title, choices, returns, fallback): assert len(choices) == len(returns) - args = ["zenity", "--list", "--text="+title, "--column="]+choices + args = ["zenity", "--list", "--text="+title, "--column="]+list(choices) switch = dict (list(zip (choices,returns))) try: for line in processOutputIt(*args): -- 2.30.2 From 06344a9d0c2cbd40023d1db4ab966c0577234049 Mon Sep 17 00:00:00 2001 From: Ralf Jung Date: Tue, 22 Mar 2016 22:35:11 +0100 Subject: [PATCH 08/16] Revert "made aspect ratio accurate" This reverts commit 53c8405139ffdc848753335f50b6b746a7bafcb5. The aspect ratio code had some heuristics to make sure aspect ratios are shown as expected by the users. --- screen.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/screen.py b/screen.py index 21774df..6ba9418 100644 --- a/screen.py +++ b/screen.py @@ -18,7 +18,6 @@ import re, subprocess from enum import Enum -from fractions import Fraction ## utility functions @@ -87,8 +86,13 @@ class Resolution: def __str__(self): # get ratio - ratio = Fraction(self.width, self.height) # automatically divides by the gcd - strRatio = "%d:%d" % (ratio.numerator, ratio.denominator) + 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): -- 2.30.2 From 347af27784cca6310f62ad7dc65a070405a75878 Mon Sep 17 00:00:00 2001 From: Ralf Jung Date: Tue, 22 Mar 2016 23:01:55 +0100 Subject: [PATCH 09/16] rework the new screen DB stuff: - The tri-state --show actually did not make that much sense, which was particualrily obvious because of questions like "What should the default mode be?". We have -r/-e/-i for that. So replace --show by --silent, such that e.g., `lilass -s -r mirror` will use the saved data and fall back to mirror mode, never showing a UI under normal circumstances. On the other hand, `lilass -s` *will* show a UI for unknown screens only. - Rename last* -> previous* - Sort resolutions by size only (not depending on preferred/previous resolution) - Qt UI: pre-select previous/preferred resolution - Remove some Debug output --- lilass | 74 +++++++++++++++++++++-------------------- qt_frontend.py | 42 +++++++++++++++-------- question_frontend.py | 12 +++---- screen.py | 79 +++++++++++++++++--------------------------- 4 files changed, 104 insertions(+), 103 deletions(-) diff --git a/lilass b/lilass index 5dfd19a..b9283eb 100755 --- a/lilass +++ b/lilass @@ -20,7 +20,7 @@ import argparse, sys, os, os.path, shutil, re, subprocess from enum import Enum import gui, screen, util, database frontend = gui.getFrontend("cli") # the fallback, until we got a proper frontend. This is guaranteed to be available. - +cmdArgs = None # for auto-config: common names of internal connectors commonInternalConnectorPrefixes = ['LVDS', 'eDP'] @@ -104,16 +104,19 @@ if __name__ == "__main__": help="The frontend to be used for user interaction") parser.add_argument("-r", "--relative-position", dest="rel_position", choices=list(map(relPosFilter, screen.RelativeScreenPosition.__members__.keys())), - help="Set the position of external screen relative to internal one.") + help="Set the position of external screen relative to internal one, in case it is not found in the DB.") parser.add_argument("-e", "--external-only", dest="external_only", action='store_true', help="If an external screen is connected, disable all the others.") parser.add_argument("-i", "--internal-only", dest="internal_only", action='store_true', help="Enable internal screen, disable all the others.") - parser.add_argument("-s", "--show", - dest="show", choices=ShowLevels.getNames(), default=ShowLevels.ONEXTERNAL.text, - help="In which situations should the UI be displayed?") + parser.add_argument("-s", "--silent", + dest="silent", action='store_true', + help="Prefer to be silent: Opens a UI only if the external screen is not known *and* no default configuration (-r/-e/-i) is given.") + parser.add_argument("-v", "--verbose", + dest="verbose", action='store_true', + help="More verbose output on stderr.") cmdArgs = parser.parse_args() # load frontend early (for error mssages) @@ -139,17 +142,29 @@ if __name__ == "__main__": # see what situation we are in situation = situationByConfig(config) - # fetch info from the database - # if it is too slow to open the DB twice (reading and saving), we can keep it open all the time - with database.Database(databaseFilePath) as db: - situation.fetchDBInfo(db) - # construct the ScreenSetup setup = None - if not cmdArgs.internal_only and situation.externalResolutions() is not None: - # there's an external screen connected that we may want to use - if cmdArgs.external_only: - setup = screen.ScreenSetup(intResolution = None, extResolution = situation.externalResolutions()[0]) + if situation.externalConnector is not None: + # There's an external screen connected that we may want to use. + # Fetch info about this screen from the database. + # NOTE: If it is too slow to open the DB twice (reading and saving), we can keep it open all the time + with database.Database(databaseFilePath) as db: + situation.fetchDBInfo(db) + # what to we do? + have_default_conf = bool(cmdArgs.external_only or cmdArgs.internal_only or cmdArgs.rel_position) + no_ui = bool(have_default_conf or (situation.previousSetup and cmdArgs.silent)) + if not no_ui: + # ask the user what to do + setup = frontend.setup(situation) + if setup is None: sys.exit(1) # the user canceled + with database.Database(databaseFilePath) as db: + situation.putDBInfo(db, setup) + elif situation.previousSetup: + # apply the old setup again + setup = situation.previousSetup + # use default config from CLI + elif cmdArgs.external_only: + setup = screen.ScreenSetup(intResolution = None, extResolution = situation.externalConnector.getPreferredResolution()) elif cmdArgs.rel_position is not None: # construct automatically, based on CLI arguments # first, figure out the desired RelativeScreenPosition... waht a bad hack... @@ -161,26 +176,14 @@ if __name__ == "__main__": res = situation.commonResolutions()[0] setup = screen.ScreenSetup(res, res, relPos) else: - setup = screen.ScreenSetup(intResolution = situation.internalResolutions()[0], extResolution = situation.externalResolutions()[0], relPosition = relPos) - else: - showlvl = ShowLevels(cmdArgs.show) - if showlvl != ShowLevels.ONEXTERNAL and situation.lastSetup: - # use last config - setup = situation.lastSetup - elif showlvl == ShowLevels.ONERROR: - # guess config - setup = screen.ScreenSetup(situation.internalResolutions()[0], situation.externalResolutions()[0], screen.RelativeScreenPosition.RIGHT) - # TODO make default relative position configurable in the config file - # TODO this has a bit of code duplication with the cmdArgs method above - else: - # ask the user - setup = frontend.setup(situation) - if setup is None: sys.exit(1) # the user canceled - with database.Database(databaseFilePath) as db: - situation.putDBInfo(db, setup) - else: - # use first resolution of internal connector - setup = screen.ScreenSetup(intResolution = situation.internalConnector.getResolutionList()[0], extResolution = None) + setup = screen.ScreenSetup(intResolution = situation.internalConnector.getPreferredResolution(), + extResolution = situation.externalConnector.getPreferredResolution(), + relPosition = relPos) + # cmdArgs.internal_only: fall-through + if setup is None: + assert cmdArgs.internal_only or situation.externalConnector is None + # Nothing chosen yet? Use first resolution of internal connector. + setup = screen.ScreenSetup(intResolution = situation.internalConnector.getPreferredResolution(), extResolution = None) # call xrandr xrandrCall = situation.forXrandr(setup) @@ -191,5 +194,6 @@ if __name__ == "__main__": if setup.extResolution is None: turnOnBacklight() except Exception as e: - raise e frontend.error(str(e)) + if cmdArgs is None or cmdArgs.verbose: + raise(e) diff --git a/qt_frontend.py b/qt_frontend.py index b3ee61e..0c86555 100644 --- a/qt_frontend.py +++ b/qt_frontend.py @@ -42,19 +42,31 @@ try: syncIfMirror(self.intRes, self.extRes) syncIfMirror(self.extRes, self.intRes) + # if situation has a previousSetup, use its values as initial state + if situation.previousSetup: + p = situation.previousSetup + self.intEnabled.setChecked(p.intResolution is not None) + self.extEnabled.setChecked(p.extResolution is not None) + if p.relPosition: + self.relPos.setCurrentIndex(p.relPosition.value - 1) + if p.extIsPrimary: + self.extPrimary.setChecked(True) + else: + self.intPrimary.setChecked(True) + # Pre-select the previous resolution + self._intDefaultRes = p.intResolution + self._extDefaultRes = p.extResolution + self._mirrorDefaultRes = p.intResolution if p.relPosition == RelativeScreenPosition.MIRROR else None # in case of a mirror, they would be the same anyway + else: + self._intDefaultRes = situation.internalConnector.getPreferredResolution() + self._extDefaultRes = situation.externalConnector.getPreferredResolution() + self._mirrorDefaultRes = None + # connect the update function self.intEnabled.toggled.connect(self.updateEnabledControls) self.extEnabled.toggled.connect(self.updateEnabledControls) self.relPos.currentIndexChanged.connect(self.updateEnabledControls) - # if situation has a lastSetup, use its values as initial state - if situation.lastSetup: - last = situation.lastSetup - self.intEnabled.setChecked(last.intResolution is not None) - self.extEnabled.setChecked(last.extResolution is not None) - if last.relPosition: - self.relPos.setCurrentIndex(last.relPosition.value-1) - # make sure we are in a correct state self.updateEnabledControls() @@ -62,12 +74,14 @@ try: idx = self.relPos.currentIndex() return self.relPos.itemData(idx) - def fillResolutionBox(self, box, resolutions): + def fillResolutionBox(self, box, resolutions, select = None): # 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) + if res == select: + box.setCurrentIndex(idx) else: # first clear it while box.count() > 0: @@ -75,6 +89,8 @@ try: # then fill it for res in resolutions: box.addItem(str(res), res) + if res == select: + box.setCurrentIndex(box.count() - 1) # select the most recently added one def updateEnabledControls(self): intEnabled = self.intEnabled.isChecked() @@ -93,12 +109,12 @@ try: # which resolutions do we offer? if self.isMirror: commonRes = self._situation.commonResolutions() - self.fillResolutionBox(self.intRes, commonRes) - self.fillResolutionBox(self.extRes, commonRes) + self.fillResolutionBox(self.intRes, commonRes, select = self._mirrorDefaultRes) + self.fillResolutionBox(self.extRes, commonRes, select = self._mirrorDefaultRes) self.intRes.setCurrentIndex(self.extRes.currentIndex()) else: - self.fillResolutionBox(self.intRes, self._situation.internalResolutions()) - self.fillResolutionBox(self.extRes, self._situation.externalResolutions()) + self.fillResolutionBox(self.intRes, self._situation.internalConnector.getResolutionList(), select = self._intDefaultRes) + self.fillResolutionBox(self.extRes, self._situation.externalConnector.getResolutionList(), select = self._extDefaultRes) # configure position control self.posGroup.setEnabled(bothEnabled) self.posLabel1.setEnabled(bothEnabled) diff --git a/question_frontend.py b/question_frontend.py index fc9e8c6..4244ba8 100644 --- a/question_frontend.py +++ b/question_frontend.py @@ -40,13 +40,13 @@ class QuestionFrontend: return self.userChoose("Select resolution for %s"%displayname, modedescs, availablemodes, None) def setup (self, situation): - if situation.lastSetup: - applyLast = self.userChoose("This display is known. The last setup for it was like this:\n%s.\nApply the last used configuration?" % str(situation.lastSetup), ("Apply last setup", "Enter different setup"), (True,False), None) - if applyLast is None: + if situation.previousSetup: + applyPrevious = self.userChoose("This display is known. The last setup for it was like this:\n%s.\nApply the last used configuration?" % str(situation.previousSetup), ("Apply last setup", "Enter different setup"), (True,False), None) + if applyPrevious is None: return None - if applyLast is True: - return situation.lastSetup - assert applyLast is False + if applyPrevious is True: + return situation.previousSetup + assert applyPrevious is False operationmodes = list(OperationMode) operationmodedescs = list(map(lambda x: x.text, operationmodes)) operationmode = self.userChoose ("Display setup", operationmodedescs, operationmodes, None) diff --git a/screen.py b/screen.py index 6ba9418..a95d45d 100644 --- a/screen.py +++ b/screen.py @@ -152,9 +152,9 @@ class Connector: def __init__(self, name=None): self.name = name # connector name, e.g. "HDMI1" self.edid = None # EDID string for the connector, or None if disconnected - self._resolutions = set() # list of Resolution objects, empty if disconnected - self.preferredResolution = None - self.__lastResolution = None + self._resolutions = set() # set of Resolution objects, empty if disconnected + self._preferredResolution = None + self.previousResolution = None self.hasLastResolution = False def __str__(self): @@ -163,22 +163,8 @@ class Connector: def __repr__(self): return """""" % (str(self.name), str(self.edid), ", ".join(str(r) for r in self.getResolutionList())) - def __setLastRes(self, res): - # res == None means this display was last switched off - if res is not None and not res in self._resolutions: - raise ValueError("Resolution "+res+" not available for "+self.name+".") - self.__lastResolution = res - self.hasLastResolution = True - - def __getLastRes(self): - if not self.hasLastResolution: - raise ValueError("Connector %s has no last known resolution." % self.name) - return self.__lastResolution - - lastResolution = property(__getLastRes, __setLastRes) - def isConnected(self): - assert (self.edid is None) == (len(self._resolutions)==0) + assert (self.edid is None) == (len(self._resolutions)==0), "Resolution-EDID mismatch; #resolutions: {}".format(len(self._resolutions)) return self.edid is not None def addResolution(self, resolution): @@ -191,13 +177,23 @@ class Connector: else: self.edid += s + def setPreferredResolution(self, resolution): + assert isinstance(resolution, Resolution) and resolution in self._resolutions + self._preferredResolution = resolution + + def getPreferredResolution(self): + if self._preferredResolution is not None: + return self._preferredResolution + return self.getResolutionList()[0] # prefer the largest resolution + def getResolutionList(self): - return sorted(self._resolutions, key=lambda r: (0 if self.hasLastResolution and r==self.lastResolution else 1, 0 if r==self.preferredResolution else 1, -r.pixelCount())) + return sorted(self._resolutions, key=lambda r: -r.pixelCount()) class ScreenSituation: connectors = [] # contains all the Connector objects internalConnector = None # the internal Connector object (will be an enabled one) externalConnector = None # the used external Connector object (an enabled one), or None + previousSetup = None # None or the ScreenSetup used the last time this external screen was connected '''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): @@ -205,9 +201,6 @@ class ScreenSituation: just choose any remaining connector.''' # which connectors are there? self._getXrandrInformation() - for c in self.connectors: - print(repr(c)) - print() # figure out which is the internal connector self.internalConnector = self._findAvailableConnector(internalConnectorNames) if self.internalConnector is None: @@ -221,7 +214,6 @@ class ScreenSituation: if self.internalConnector == self.externalConnector: raise Exception("Internal and external connector are the same. This must not happen. Please fix ~/.dsl.conf."); print("Detected external connector:",self.externalConnector) - # self.lastSetup is left uninitialized so you can't access it before trying a lookup in the database # Run xrandr and fill the dict of connector names mapped to lists of available resolutions. def _getXrandrInformation(self): @@ -246,7 +238,9 @@ class ScreenSituation: if m is not None: connector = Connector(m.group(1)) assert not any(c.name == connector.name for c in self.connectors) - self.connectors.append(connector) + if not connector.name.startswith("VIRTUAL"): + # skip "VIRTUAL" connectors + self.connectors.append(connector) continue # new resolution? m = re.search(r'^\s*([\d]+)x([\d]+)', line) @@ -254,8 +248,8 @@ class ScreenSituation: resolution = Resolution(int(m.group(1)), int(m.group(2))) assert connector is not None connector.addResolution(resolution) - if '+preferred' in line: - connector.preferredResolution = resolution + if re.search(r' [+]preferred\b', line): + connector.setPreferredResolution(resolution) continue # EDID? m = re.search(r'^\s*EDID:\s*$', line) @@ -263,8 +257,7 @@ class ScreenSituation: readingEdid = True 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) + # not fatal, e.g. xrandr shows strange stuff when a display is enabled, but not connected; --verbose adds a whole lot of other weird stuff # return the first available connector from those listed in , skipping disabled connectors def _findAvailableConnector(self, tryConnectorNames): @@ -272,21 +265,11 @@ class ScreenSituation: return c return None - # return available internal resolutions - def internalResolutions(self): - return self.internalConnector.getResolutionList() - - # 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.externalConnector.getResolutionList() - # return resolutions available for both internal and external screen def commonResolutions(self): - internalRes = self.internalResolutions() - externalRes = self.externalResolutions() - assert externalRes is not None + assert self.externalConnector is not None, "Common resolutions may only be queried if there is an external screen connected." + internalRes = self.internalConnector.getResolutionList() + externalRes = self.externalConnector.getResolutionList() return sorted(set(externalRes).intersection(internalRes), key=lambda r: -r.pixelCount()) # compute the xrandr call @@ -309,15 +292,13 @@ class ScreenSituation: def fetchDBInfo(self, db): if self.externalConnector and self.externalConnector.edid: - self.lastSetup = db.getConfig(self.externalConnector.edid) # may also return None - else: - self.lastSetup = None - if self.lastSetup: - print("SETUP FOUND", self.lastSetup) - self.externalConnector.lastResolution = self.lastSetup.extResolution - self.internalConnector.lastResolution = self.lastSetup.intResolution + self.previousSetup = db.getConfig(self.externalConnector.edid) # may also return None else: - print("NO SETUP FOUND") + self.previousSetup = None + if self.previousSetup: + print("Known screen, previous setup:", self.previousSetup) + self.externalConnector.previousResolution = self.previousSetup.extResolution + self.internalConnector.previousResolution = self.previousSetup.intResolution def putDBInfo(self, db, setup): if not self.externalConnector or not self.externalConnector.edid: -- 2.30.2 From fcdeaedef970f8cd47f66f97d755756a0fe0e40a Mon Sep 17 00:00:00 2001 From: Ralf Jung Date: Wed, 23 Mar 2016 08:55:15 +0100 Subject: [PATCH 10/16] add a switch to NOT use the DB --- lilass | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lilass b/lilass index b9283eb..2352e32 100755 --- a/lilass +++ b/lilass @@ -114,6 +114,9 @@ if __name__ == "__main__": parser.add_argument("-s", "--silent", dest="silent", action='store_true', help="Prefer to be silent: Opens a UI only if the external screen is not known *and* no default configuration (-r/-e/-i) is given.") + parser.add_argument("--no-db", + dest="use_db", action='store_false', + help="Do not use the database of known screens.") parser.add_argument("-v", "--verbose", dest="verbose", action='store_true', help="More verbose output on stderr.") @@ -148,8 +151,9 @@ if __name__ == "__main__": # There's an external screen connected that we may want to use. # Fetch info about this screen from the database. # NOTE: If it is too slow to open the DB twice (reading and saving), we can keep it open all the time - with database.Database(databaseFilePath) as db: - situation.fetchDBInfo(db) + if cmdArgs.use_db: + with database.Database(databaseFilePath) as db: + situation.fetchDBInfo(db) # what to we do? have_default_conf = bool(cmdArgs.external_only or cmdArgs.internal_only or cmdArgs.rel_position) no_ui = bool(have_default_conf or (situation.previousSetup and cmdArgs.silent)) @@ -157,8 +161,10 @@ if __name__ == "__main__": # ask the user what to do setup = frontend.setup(situation) if setup is None: sys.exit(1) # the user canceled - with database.Database(databaseFilePath) as db: - situation.putDBInfo(db, setup) + if cmdArgs.use_db: + # persists this to disk + with database.Database(databaseFilePath) as db: + situation.putDBInfo(db, setup) elif situation.previousSetup: # apply the old setup again setup = situation.previousSetup -- 2.30.2 From 953d73eb56c0bdf86b9b168a657b8f9fa04d80bf Mon Sep 17 00:00:00 2001 From: Ralf Jung Date: Wed, 23 Mar 2016 09:04:04 +0100 Subject: [PATCH 11/16] remove unused enum --- lilass | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/lilass b/lilass index 2352e32..6780879 100755 --- a/lilass +++ b/lilass @@ -78,19 +78,6 @@ def situationByConfig(config): # run! return screen.ScreenSituation(internalConnectors, config.get('externalConnectors')) -class ShowLevels(Enum): - ONEXTERNAL = ("on-external") - ONNEW = ("on-new") - ONERROR = ("on-error") - def __init__(self, text): - # auto numbering - cls = self.__class__ - self._value_ = len(cls.__members__) + 1 - self.text = text - @classmethod - def getNames(cls): - return list(x.text for x in cls) - # if we run top-level if __name__ == "__main__": try: -- 2.30.2 From 719b40f1d4ff22d56a17de284ae1f0ac1a82a2d4 Mon Sep 17 00:00:00 2001 From: Ralf Jung Date: Wed, 23 Mar 2016 09:12:33 +0100 Subject: [PATCH 12/16] add a testsuite checking the aspect ration computation --- tests.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100755 tests.py diff --git a/tests.py b/tests.py new file mode 100755 index 0000000..5a48857 --- /dev/null +++ b/tests.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +import unittest +import screen + +class TestResolutions(unittest.TestCase): + + def test_ratio(self): + # check whether a few aspect ratios are printed as expected + self.assertEqual(str(screen.Resolution(1024, 768)), '1024x768 (4:3)') + self.assertTrue(str(screen.Resolution(1280, 1024)) in ('1280x1024 (5:4)', '1280x1024 (4:3)')) + self.assertEqual(str(screen.Resolution(1366, 768)), '1366x768 (16:9)') + self.assertEqual(str(screen.Resolution(1920, 1080)), '1920x1080 (16:9)') + self.assertEqual(str(screen.Resolution(1920, 1200)), '1920x1200 (16:10)') + +if __name__ == '__main__': + unittest.main() -- 2.30.2 From 9dcdd8e65683504437a6a2f93e20edbdfd50c9c0 Mon Sep 17 00:00:00 2001 From: Ralf Jung Date: Wed, 23 Mar 2016 09:48:03 +0100 Subject: [PATCH 13/16] fix aspect ratio shown for 720x480 --- screen.py | 4 +++- tests.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/screen.py b/screen.py index a95d45d..160efec 100644 --- a/screen.py +++ b/screen.py @@ -87,7 +87,9 @@ class Resolution: def __str__(self): # get ratio ratio = int(round(16.0*self.height/self.width)) - if ratio == 12: # 16:12 = 4:3 + if ratio == 11: # 16:10.66 = 3:2 + strRatio = "3:2" + elif ratio == 12: # 16:12 = 4:3 strRatio = '4:3' elif ratio == 13: # 16:12.8 = 5:4 strRatio = '5:4' diff --git a/tests.py b/tests.py index 5a48857..9f3fb46 100755 --- a/tests.py +++ b/tests.py @@ -11,6 +11,7 @@ class TestResolutions(unittest.TestCase): self.assertEqual(str(screen.Resolution(1366, 768)), '1366x768 (16:9)') self.assertEqual(str(screen.Resolution(1920, 1080)), '1920x1080 (16:9)') self.assertEqual(str(screen.Resolution(1920, 1200)), '1920x1200 (16:10)') + self.assertEqual(str(screen.Resolution(720, 480)), '720x480 (3:2)') if __name__ == '__main__': unittest.main() -- 2.30.2 From 4cfe2612d7a4a18495e0cec70a9f94270a19dd29 Mon Sep 17 00:00:00 2001 From: Constantin Berhard Date: Tue, 5 Apr 2016 11:54:54 +0200 Subject: [PATCH 14/16] repair question frontend --- question_frontend.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/question_frontend.py b/question_frontend.py index 4244ba8..8fe4a7a 100644 --- a/question_frontend.py +++ b/question_frontend.py @@ -53,13 +53,13 @@ class QuestionFrontend: if operationmode is None: return None elif operationmode is OperationMode.INTERNAL_ONLY: - intres = self.selectResolution("the internal screen", situation.internalResolutions()) + intres = self.selectResolution("the internal screen", situation.internalConnector.getResolutionList()) if intres is None: return None else: return ScreenSetup(intres, None, None, False) elif operationmode is OperationMode.EXTERNAL_ONLY: - extres = self.selectResolution("the external screen", situation.externalResolutions()) + extres = self.selectResolution("the external screen", situation.externalConnector.getResolutionList()) if extres is None: return None else: @@ -78,10 +78,10 @@ class QuestionFrontend: return None return ScreenSetup(commonres,commonres,relpos,False) # select resolutions independently - intres = self.selectResolution("the internal screen", situation.internalResolutions()) + intres = self.selectResolution("the internal screen", situation.internalConnector.getResolutionList()) if intres is None: return None - extres = self.selectResolution("the external screen", situation.externalResolutions()) + extres = self.selectResolution("the external screen", situation.externalConnector.getResolutionList()) if extres is None: return None extprim = self.userChoose("Select primary screen", ["Internal screen is primary","External screen is primary"], [False,True], None) -- 2.30.2 From 14cedf646361c0aee69fb33d44d1063bc80e1ee5 Mon Sep 17 00:00:00 2001 From: Ralf Jung Date: Sun, 10 Apr 2016 16:13:37 +0200 Subject: [PATCH 15/16] work on README --- README.md | 98 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 55 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 2512ae5..01ca101 100644 --- a/README.md +++ b/README.md @@ -5,75 +5,87 @@ This is the documentation of [LiLaSS]( https://www.ralfj.de/projects/lilass), a tool to setup screens on a Linux-powered Laptop. -LiLaSS is targeted for a specific use-case: The laptop is used both with the -internal screen only, and in combination with a single external screen. -[xrandr](http://www.x.org/wiki/Projects/XRandR) is used to detect whether an -external screen is plugged in, and to change the configuration according to the -user's specification. +LiLaSS is targeted for a specific use-case: The laptop is used both with the +internal screen only, and in combination with a single external screen. +[xrandr](http://www.x.org/wiki/Projects/XRandR) is used to detect whether an +external screen is plugged in, and to change the configuration according to the +user's specification. Furthermore, LiLaSS remembers the configuration used for +any particular screen, so that it can offer the same configuration next time. +You can even make it apply that configuration automatically. ## Usage -LiLaSS features an interactive and a batched mode of use. -Either way, if LiLaSS is started while no external screen is connected, it -enables the internal screen. +LiLaSS features an interactive and a batched mode of use. Either way, if LiLaSS +is started while no external screen is connected, it enables the internal +screen. It is in the case that an external screen is plugged in that the two modes differ. -Simply run `lilass` to start the interactive mode. A window will pop up, -allowing you to select which screens are enabled, their resolution, and how they -are positioned relatively to each other. The option `--frontend` (or `-f`) -can be used to choose the frontend which opens the window. Currently, the -frontends `qt` (using Qt5) and `zenity` are available. LiLaSS attempts to -choose an adequate frontend automatically. - -The option `--relative-position` (`-r`) suppresses the interactive -configuration. Instead, the given given option (`left`, `right`, `above`, -`below` or `mirror`) is applied with the default resolution of the external -screen. - -Finally, the flags `--internal-only` (`-i`) and `--external-only` (`-e`) -tells LiLaSS to use only one of the two screens. +Simply run `lilass` to start the interactive mode. A window will pop up, +allowing you to select which screens are enabled, their resolution, and how they +are positioned relatively to each other. The option `--frontend` (or `-f`) can +be used to choose the frontend which opens the window. Currently, the frontends +`qt` (using Qt5), `zenity` and `cli` are available. LiLaSS attempts to choose +an adequate frontend automatically. + +If a screen is connected that was already configured with LiLaSS before, the +previously selected configuration will be offered per default. You can pass +`--silent` (`-s`) to instead suppress the UI altogether, and just apply the +previous configuration. You can disable the use of the stored screen +configurations by passing `--no-db`. + +Furthermore, you can also suppress the UI in case LiLaSS sees a new screen by +telling LiLaSS directly what to do with that screen: With the flags +`--internal-only` (`-i`) and `--external-only` (`-e`), one of the two screens is +picked and the other one disabled. With `--relative-position` (`-r`), the +relative position of the two screens can be set (`left`, `right`, `above`, +`below` or `mirror`). In either case, the preferred possible resolution(s) of +the screen(s) will be picked if applicable. (In `mirror` mode, LiLaSS instead +picks the largest resolution that both screens have in common.) If the internal screen ends up being the only one that is used, LiLaSS attempts to turn on your backlight if it was disabled. ## Automatic Configuration -In combination with [x-on-resize](http://keithp.com/blogs/x-on-resize/) by Keith -Peckard, LiLaSS can automatically pop-up when a screen is plugged in, and -automatically re-enable the internal screen the external one is plugged off. +In combination with [x-on-resize](http://keithp.com/blogs/x-on-resize/) by Keith +Peckard, LiLaSS can be run automatically when a screen is plugged in, and +automatically re-enable the internal screen the external one is plugged off. As +LiLaSS remembers the screen configuration that was used last time, this +automatic mode will use the previous configuration if the same screen is +connected again. -Besides, you may want to apply some configuration without pop-up if an -external screen is plugged in when you log in to your desktop environment. +All this is achieved by running the following on log-in: -All this is achieved by running the following shell script on log-in: + x-on-resize --config "lilass -s -r mirror" --start - LILASS=/path/to/lilass - x-on-resize -c $LILASS - $LILASS --external-only +Of course, instead of `-r mirror`, you can pick a different default +configuration applied to screens that have not been seen previously. By dropping +this option altogether, LiLaSS will instead pop up and ask what to do when a new +screen is connected. ## Configuration File -You can use `~/.lilass.conf` to tell LiLaSS which are the names of your -internal and external connectors. These are the names as used by `xrandr`. The -option `internalConnector` gives the name of the xrandr connector -corresponding to your internal laptop screen. All the others will be considered -external screens, unless you use the option `externalConnectors` to provide a -(space-separated) list of connectors to be considered external by LiLaSS. Any +You can use `~/.config/lilass.conf` to tell LiLaSS which are the names of your +internal and external connectors. These are the names as used by `xrandr`. The +option `internalConnector` gives the name of the xrandr connector corresponding +to your internal laptop screen. All the others will be considered external +screens, unless you use the option `externalConnectors` to provide a +(space-separated) list of connectors to be considered external by LiLaSS. Any connector not mentioned in either option will be completely ignored. ## Source, License -You can find the sources in the [git -repository](http://www.ralfj.de/git/lilass.git) (also available [on -GitHub](https://github.com/RalfJung/lilass)). They are provided under the -[GPLv2](https://www.gnu.org/licenses/old-licenses/gpl-2.0.html) or (at your -option) any later version of the GPL. See the file `LICENSE-GPL2` for more +You can find the sources in the +[git repository](http://www.ralfj.de/git/lilass.git) (also available +[on GitHub](https://github.com/RalfJung/lilass)). They are provided under the +[GPLv2](https://www.gnu.org/licenses/old-licenses/gpl-2.0.html) or (at your +option) any later version of the GPL. See the file `LICENSE-GPL2` for more details. ## Contact If you found a bug, or want to leave a comment, please -[send me a mail](mailto:post-AT-ralfj-DOT-de). I'm also happy about pull +[send me a mail](mailto:post-AT-ralfj-DOT-de). I'm also happy about pull requests :) -- 2.30.2 From 7e135db0cf1248b4c5f862cedcd6e89d1aef58c5 Mon Sep 17 00:00:00 2001 From: Ralf Jung Date: Mon, 11 Apr 2016 09:07:52 +0200 Subject: [PATCH 16/16] github issue template --- .github/ISSUE_TEMPLATE.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE.md diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..6866369 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,16 @@ +## Steps to reproduce + +(Describe, step-by-step, how the bug can be reproduced. if the bug is not +reproducible, describe at least what you did when you saw the bug.) + +## Actual behavior + +(Descrbe what you see happening.) + +## Expected behavior + +(Describe waht you expected to see happening instead.) + +## Further information + +(Provide at least the versions of your OS and the faulty application.) -- 2.30.2