#!/usr/bin/env python3 # # Copyright (c) 2016 James Murphy # Licensed under the GPL version 2 only # # monitor_manager is an i3blocks blocklet script to quickly manage your # connected output devices from tkinter import * from tkinter import messagebox from shutil import which import tkinter.font as font from subprocess import call, check_output, CalledProcessError import re import os DESKTOP_SYMBOL = "\uf108" UP_ARROW = "\uf062" DOWN_ARROW = "\uf063" UNBLANKED_SYMBOL = "\uf06e" BLANKED_SYMBOL = "\uf070" NOT_CLONED_SYMBOL = "\uf096" PRIMARY_SYMBOL = "\uf005" SECONDARY_SYMBOL = "\uf006" CLONED_SYMBOL = "\uf24d" ROTATION_NORMAL = "\uf151" ROTATION_LEFT = "\uf191" ROTATION_RIGHT = "\uf152" ROTATION_INVERTED = "\uf150" REFLECTION_NORMAL = "\uf176" REFLECTION_X = "\uf07e" REFLECTION_Y = "\uf07d" REFLECTION_XY = "\uf047" TOGGLE_ON = "\uf205" TOGGLE_OFF = "\uf204" APPLY_SYMBOL = "\uf00c" CANCEL_SYMBOL = "\uf00d" ARANDR_SYMBOL = "\uf085" REFRESH_SYMBOL = "\uf021" strbool = lambda s: s.lower() in ['t', 'true', '1'] def _default(name, default='', arg_type=strbool): val = default if name in os.environ: val = os.environ[name] return arg_type(val) SHOW_ON_OFF = _default("SHOW_ON_OFF","1") SHOW_NAMES = _default("SHOW_NAMES", "1") SHOW_PRIMARY = _default("SHOW_PRIMARY", "1") SHOW_MODE = _default("SHOW_MODE", "1") SHOW_BLANKED = _default("SHOW_BLANKED", "1") SHOW_DUPLICATE = _default("SHOW_DUPLICATE", "1") SHOW_ROTATION = _default("SHOW_ROTATION", "1") SHOW_REFLECTION = _default("SHOW_REFLECTION", "1") SHOW_BRIGHTNESS = _default("SHOW_BRIGHTNESS", "1") SHOW_BRIGHTNESS_VALUE = _default("SHOW_BRIGHTNESS_VALUE", "0") SHOW_UP_DOWN = _default("SHOW_UP_DOWN", "1") FONTAWESOME_FONT_FAMILY = "FontAwesome" FONTAWESOME_FONT_SIZE = 11 FONTAWESOME_FONT = (FONTAWESOME_FONT_FAMILY, FONTAWESOME_FONT_SIZE) DEFAULT_FONT_FAMILY = _default("FONT_FAMILY","DejaVu Sans Mono", str) DEFAULT_FONT_SIZE = _default("FONT_SIZE", 11, int) DEFAULT_FONT = (DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE) BRIGHTNESS_SLIDER_HANDLE_LENGTH = 20 BRIGHTNESS_SLIDER_WIDTH = 15 BRIGHTNESS_SLIDER_LENGTH = 50 WINDOW_CLOSE_TO_BOUNDARY_BUFFER = _default("CLOSE_TO_BOUNDARY_BUFFER", 20, int) class Output: def __init__(self, name=None, w=None, h=None, x=None, y=None, rate=None, active=False, primary=False, sameAs=None, blanked=False, rotation="normal", reflection="normal", brightness=1.0): self.name = name self.w = w self.h = h self.x = x self.y = y self.rate = rate self.active = active self.primary = primary self.sameAs = sameAs self.blanked = blanked self.modes = [] self.currentModeIndex = None self.preferredModeIndex = None self.row = None self.rotation = rotation self.reflection = reflection self.brightness = brightness def setPreferredMode(self): if self.preferredModeIndex != None: self.setMode(self.preferredModeIndex) elif self.modes != None: self.setMode(0) def setMode(self, index): self.w, self.h, self.rate = self.modes[index] self.currentModeIndex = index def realOutputs(): outputs = [] xrandrText = check_output(["xrandr","--verbose"],universal_newlines=True) outputBlocks = re.split(r'\n(?=\S)', xrandrText, re.MULTILINE) infoPattern = re.compile( r'^(\S+)' # output name ' connected ' # must be connected '(primary )?' # check if primary output '((\d+)x(\d+)\+(\d+)\+(\d+) )?' # width x height + xoffset + yoffset '(\(\S+\) )?' # mode code (0x4a) '(normal|left|inverted|right)? ?' # rotation '(X axis|Y axis|X and Y axis)?') # reflection brightnessPattern = re.compile(r'^\tBrightness: ([\d.]+)', re.MULTILINE) modePattern = re.compile(r'^ (\d+)x(\d+)[^\n]*?\n +h:[^\n]*?\n +v:[^\n]*?([\d.]+)Hz$', re.MULTILINE) for outputBlock in outputBlocks: output = Output() infoMatch = infoPattern.match(outputBlock) if infoMatch: output.name = infoMatch.group(1) if infoMatch.group(2): output.primary = True if infoMatch.group(3): output.active = True output.w, output.h, output.x, output.y = map(int,infoMatch.group(4, 5, 6, 7)) if infoMatch.group(9): output.rotation = infoMatch.group(9) if output.rotation in ["left", "right"]: output.w, output.h = output.h, output.w if infoMatch.group(10): if infoMatch.group(10) == "X axis": output.reflection = "x" elif infoMatch.group(10) == "Y axis": output.reflection = "y" elif infoMatch.group(10) == "X and Y axis": output.reflection = "xy" else: output.reflection = "normal" else: output.reflection = "normal" brightnessMatch = brightnessPattern.search(outputBlock) if brightnessMatch: try: brightness = float(brightnessMatch.group(1)) output.brightness = brightness if abs(brightness) < 1e-09: output.blanked = True except ValueError: pass modeMatches = modePattern.finditer(outputBlock) for i, modeMatch in enumerate(modeMatches): if "*current" in modeMatch.group(0): output.currentModeIndex = i output.rate = modeMatch.group(3) if "+preferred" in modeMatch.group(0): output.preferredModeIndex = i output.modes.append(modeMatch.group(1,2,3)) outputs.append(output) outputs.sort(key=lambda m: m.x if m.x != None else -1) prev = None for output in outputs: if prev != None and output.active and prev.active and output.x == prev.x: output.sameAs = prev.name else: prev = output return outputs def modestr(mode): return "{}x{}@{}".format(*mode) def status(self): if self.active: if self.sameAs == None or self.sameAs == self.name: if self.w and self.h and self.rate: return "{}x{}@{}".format(self.w, self.h, self.rate) else: return "auto" else: return "duplicate {}".format(self.sameAs) else: return "Inactive" def __str__(self): return "{} {}x{}+{}+{} active:{}, primary:{}\nmodes:{}\ncurrentIndex:{} preferredIndex:{}".format( self.name, self.w, self.h, self.x, self.y, self.active, self.primary, self.modes, self.currentModeIndex, self.preferredModeIndex) class MonitorManager(): def __init__(self, root): self.root = root self.root.withdraw() self.root.resizable(0,0) self.root.wm_title("Monitor Manager") self.frame = None self.outputs = [] self.hardRefreshList() style = {'relief':FLAT, 'padx':1, 'pady':1, 'anchor':'w', 'font':FONTAWESOME_FONT} self.infoLabel = Label(self.root, text="", **style) self.infoLabel.config(font=DEFAULT_FONT) self.bottomRow = [] self.applyButton = Button(self.root, text=APPLY_SYMBOL, **style) self.bottomRow.append(self.applyButton) self.refreshButton = Button(self.root, text=REFRESH_SYMBOL, **style) self.bottomRow.append(self.refreshButton) if which("arandr"): self.arandrButton = Button(self.root, text=ARANDR_SYMBOL, **style) self.bottomRow.append(self.arandrButton) else: self.arandrButton = None self.cancelButton = Button(self.root, text=CANCEL_SYMBOL, **style) self.bottomRow.append(self.cancelButton) self.infoLabel.grid(row=1,column=0, columnspan=len(self.bottomRow)) self.gridRow(2, self.bottomRow) self.moveToMouse() self.root.deiconify() def registerBindings(self): self.root.bind("", self.handleApply) self.root.bind("", self.handleCancel) self.applyButton.bind("", self.handleApply) self.setInfo(self.applyButton, "Apply changes") self.refreshButton.bind("", self.hardRefreshList) self.setInfo(self.refreshButton, "Refresh list") if self.arandrButton: self.arandrButton.bind("", self.handleArandr) self.setInfo(self.arandrButton, "Launch aRandR") self.cancelButton.bind("", self.handleCancel) self.setInfo(self.cancelButton, "Cancel") for toggleButton in self.toggleButtons: toggleButton.bind("", self.toggleActive) toggleButton.bind("", self.handleUp) toggleButton.bind("", self.handleDown) self.setInfo(toggleButton, "Turn output on/off") for primaryButton in self.primaryButtons: primaryButton.bind("", self.setPrimary) self.setInfo(primaryButton, "Set primary output") for blankedButton in self.blankedButtons: blankedButton.bind("", self.toggleBlanked) self.setInfo(blankedButton, "Show/hide output") for duplicateButton in self.duplicateButtons: duplicateButton.bind("", self.toggleDuplicate) self.setInfo(duplicateButton, "Duplicate another output") for rotateButton in self.rotateButtons: rotateButton.bind("", self.cycleRotation) self.setInfo(rotateButton, "Rotate output") for reflectButton in self.reflectButtons: reflectButton.bind("", self.cycleReflection) self.setInfo(reflectButton, "Reflect output") for brightnessSlider in self.brightnessSliders: brightnessSlider.bind("", self.updateBrightness) self.setInfo(brightnessSlider, "Adjust brightness") for upButton in self.upButtons: upButton.bind("", self.handleUp) upButton.bind("", self.handleUp) upButton.bind("", self.handleDown) self.setInfo(upButton, "Move up") for downButton in self.downButtons: downButton.bind("", self.handleDown) downButton.bind("", self.handleUp) downButton.bind("", self.handleDown) self.setInfo(downButton, "Move down") def gridRow(self, row, widgets): column = 0 for w in widgets: w.grid(row=row, column=column) column += 1 def moveToMouse(self): root = self.root root.update_idletasks() width = root.winfo_reqwidth() height = root.winfo_reqheight() x = root.winfo_pointerx() - width//2 y = root.winfo_pointery() - height//2 screen_width = root.winfo_screenwidth() screen_height = root.winfo_screenheight() if x+width > screen_width - WINDOW_CLOSE_TO_BOUNDARY_BUFFER: x = screen_width - WINDOW_CLOSE_TO_BOUNDARY_BUFFER - width elif x < WINDOW_CLOSE_TO_BOUNDARY_BUFFER: x = WINDOW_CLOSE_TO_BOUNDARY_BUFFER if y+height > screen_height - WINDOW_CLOSE_TO_BOUNDARY_BUFFER: y = screen_height - WINDOW_CLOSE_TO_BOUNDARY_BUFFER - height elif y < WINDOW_CLOSE_TO_BOUNDARY_BUFFER: y = WINDOW_CLOSE_TO_BOUNDARY_BUFFER root.geometry('+{}+{}'.format(x, y)) def setInfo(self, widget, info): widget.bind("", lambda e: self.infoLabel.config(text=info, fg="black")) widget.bind("", lambda e: self.infoLabel.config(text="")) def handleApply(self, e=None): self.root.after_idle(self.doHandleApply) def doHandleApply(self): if not self.getUserConfirmationIfDangerousConfiguration(): return command = ["xrandr"] if not self.existsPrimary(): command += ["--noprimary"] partition = self.sameAsPartition() prevFirstActive = None for p in partition: firstActive = None for output in p: command += ["--output", output.name] if output.active: if firstActive == None: firstActive = output else: command += ["--same-as", firstActive.name] if output.primary: command += ["--primary"] if prevFirstActive != None: command += ["--right-of", prevFirstActive.name] if output.w != None and output.h != None and output.rate != None: command += ["--mode", "{}x{}".format(output.w,output.h)] command += ["--rate", output.rate ] else: command += ["--auto"] command += ["--brightness", str(output.brightness)] command += ["--rotate", output.rotation] command += ["--reflect", output.reflection] else: command += ["--off"] if firstActive: prevFirstActive = firstActive self.root.after_idle(lambda: self.executeXrandrCommand(command)) def executeXrandrCommand(self, command): try: check_output(command, universal_newlines=True) except CalledProcessError as err: self.infoLabel.config(text="xrandr returned nonzero exit status {}".format(err.returncode), fg="red") def getUserConfirmationIfDangerousConfiguration(self): result = "yes" if all(map(lambda o: o.blanked or not o.active, self.outputs)): result = messagebox.askquestion("All blanked or off", "All ouputs are set to be turned off or blanked, continue?", icon="warning") return result == "yes" def sameAsPartition(self): partition = [] for output in self.outputs: place = None for p in partition: if place != None: break; for o in p: if place == None and (output.sameAs == o.name or o.sameAs == output.name): place = p break; if place: place.append(output) else: partition.append([output]) return partition def handleCancel(self, e=None): self.root.destroy() def handleArandr(self, e=None): call(["i3-msg", "-q", "exec", "arandr"]) self.root.destroy() def handleUp(self, e): row = e.widget.output.row if row > 0: self.swapOutputRows(row-1, row) self.softRefreshList() def handleDown(self, e): row = e.widget.output.row n = len(self.outputs) if row + 1 < n: self.swapOutputRows(row, row+1) self.softRefreshList() def swapOutputRows(self, row1, row2): outputs = self.outputs outputs[row1],outputs[row2] = outputs[row2],outputs[row1] outputs[row1].row = row1 outputs[row2].row = row2 for widget in self.frame.grid_slaves(row=row2): widget.output = outputs[row2] for widget in self.frame.grid_slaves(row=row1): widget.output = outputs[row1] def setPrimary(self, e): output = e.widget.output output.primary = not output.primary for otherOutput in self.outputs: if otherOutput != output: otherOutput.primary = False self.softRefreshList() def existsPrimary(self): for output in self.outputs: if output.primary: return True return False def toggleActive(self, e): output = e.widget.output output.active = not output.active if output.active: output.setPreferredMode() else: for otherOutput in self.outputs: if otherOutput.sameAs == output.name: otherOutput.sameAs = None self.softRefreshList() def toggleBlanked(self, e): output = e.widget.output if output.blanked: output.blanked = False output.brightness = 1.0 else: output.blanked = True output.brightness = 0.0 self.softRefreshList() def updateBrightness(self, e): output = e.widget.output output.brightness = .01 * e.widget.get() output.blanked = False if abs(output.brightness) < 1e-09: output.blanked = True self.softRefreshList() def cycleRotation(self, e): output = e.widget.output if output.rotation == "normal": output.rotation = "right" elif output.rotation == "right": output.rotation = "inverted" elif output.rotation == "inverted": output.rotation = "left" else: output.rotation = "normal" self.softRefreshList() def rotationSymbol(self, rotation): return { "normal": ROTATION_NORMAL, "left": ROTATION_LEFT, "right": ROTATION_RIGHT, "inverted": ROTATION_INVERTED, }[rotation] def cycleReflection(self, e): output = e.widget.output if output.reflection == "normal": output.reflection = "x" elif output.reflection == "x": output.reflection = "y" elif output.reflection == "y": output.reflection = "xy" else: output.reflection = "normal" self.softRefreshList() def reflectionSymbol(self, reflection): return { "normal": REFLECTION_NORMAL, "x": REFLECTION_X, "y": REFLECTION_Y, "xy": REFLECTION_XY, }[reflection] def toggleDuplicate(self, e): duplicateButton = e.widget optionMenu = duplicateButton.statusOptionMenu output = optionMenu.output if output.sameAs != None: output.sameAs = None self.setMenuToOutput(optionMenu, output) else: self.setMenuToDuplicate(optionMenu) self.softRefreshList() def getDuplicableOutputsFor(self, output): return [o for o in self.outputs if o != output and o.sameAs == None] def softRefreshList(self, e=None): for widget in set().union(self.nameLabels, self.primaryButtons, self.statusOptionMenus, self.blankedButtons, self.duplicateButtons, self.rotateButtons, self.reflectButtons, self.brightnessSliders, self.upButtons, self.downButtons): widget.config(fg="gray" if not widget.output.active else "black") for widget in self.toggleButtons: widget.config(text=TOGGLE_ON if widget.output.active else TOGGLE_OFF) for widget in self.nameLabels: widget.config(text=widget.output.name) for widget in self.primaryButtons: widget.config(text=PRIMARY_SYMBOL if widget.output.primary else SECONDARY_SYMBOL) if not widget.output.primary: widget.config(fg="gray") for widget in self.statusOptionMenus: widget.config(text=widget.output.status()) if widget.output.sameAs != None: self.setMenuToDuplicate(widget) self.setInfo(widget, "Select output to duplicate") else: self.setMenuToOutput(widget, widget.output) self.setInfo(widget, "Select output mode") for widget in self.blankedButtons: widget.config(text=BLANKED_SYMBOL if widget.output.blanked else UNBLANKED_SYMBOL) for widget in self.duplicateButtons: widget.config(text=CLONED_SYMBOL if widget.output.sameAs else NOT_CLONED_SYMBOL) for widget in self.rotateButtons: widget.config(text=self.rotationSymbol(widget.output.rotation)) for widget in self.reflectButtons: widget.config(text=self.reflectionSymbol(widget.output.reflection)) for widget in self.brightnessSliders: widget.set(int(100*widget.output.brightness)) def hardRefreshList(self, e=None): self.outputs = Output.realOutputs() self.root.after_idle(self.populateGrid) def populateGrid(self): oldFrame = self.frame self.frame = Frame(self.root) self.frame.grid(row=0, column=0, columnspan=len(self.bottomRow)) self.toggleButtons = [] self.nameLabels = [] self.primaryButtons = [] self.statusOptionMenus = [] self.blankedButtons = [] self.duplicateButtons = [] self.rotateButtons = [] self.reflectButtons = [] self.brightnessSliders = [] self.upButtons = [] self.downButtons = [] for row, output in enumerate(self.outputs): self.makeLabelRow(output, row) self.registerBindings() if oldFrame: oldFrame.destroy() def makeLabelRow(self, output, row): output.row = row style = {'relief':FLAT, 'padx':1, 'pady':1, 'anchor':'w'} widgets = [] toggleButton = Button(self.frame, font=FONTAWESOME_FONT, **style) toggleButton.output = output self.toggleButtons.append(toggleButton) if SHOW_ON_OFF: widgets.append(toggleButton) nameLabel = Label(self.frame, font=DEFAULT_FONT) nameLabel.output = output self.nameLabels.append(nameLabel) if SHOW_NAMES: widgets.append(nameLabel) primaryButton = Button(self.frame, font=FONTAWESOME_FONT, **style) primaryButton.output = output self.primaryButtons.append(primaryButton) if not output.primary: primaryButton.config(fg="gray") if SHOW_PRIMARY: widgets.append(primaryButton) var = StringVar(self.frame) statusOptionMenu = OptionMenu(self.frame, var, None) statusOptionMenu.output = output statusOptionMenu.var = var statusOptionMenu.config(relief=FLAT) self.statusOptionMenus.append(statusOptionMenu) if SHOW_MODE or SHOW_DUPLICATE: widgets.append(statusOptionMenu) blankedButton = Button(self.frame, font=FONTAWESOME_FONT, **style) blankedButton.output = output self.blankedButtons.append(blankedButton) if SHOW_BLANKED: widgets.append(blankedButton) duplicateButton = Button(self.frame, font=FONTAWESOME_FONT, **style) duplicateButton.statusOptionMenu = statusOptionMenu duplicateButton.output = output self.duplicateButtons.append(duplicateButton) if SHOW_DUPLICATE: widgets.append(duplicateButton) rotateButton = Button(self.frame, font=FONTAWESOME_FONT, **style) rotateButton.output = output self.rotateButtons.append(rotateButton) if SHOW_ROTATION: widgets.append(rotateButton) reflectButton = Button(self.frame, font=FONTAWESOME_FONT, **style) reflectButton.output = output self.reflectButtons.append(reflectButton) if SHOW_REFLECTION: widgets.append(reflectButton) brightnessSlider = Scale(self.frame, orient="horizontal", from_=0, to=100, length=BRIGHTNESS_SLIDER_LENGTH, showvalue=SHOW_BRIGHTNESS_VALUE, sliderlength=BRIGHTNESS_SLIDER_HANDLE_LENGTH, width=BRIGHTNESS_SLIDER_WIDTH, font=FONTAWESOME_FONT) brightnessSlider.output = output self.brightnessSliders.append(brightnessSlider) if SHOW_BRIGHTNESS: widgets.append(brightnessSlider) upButton = Button(self.frame, text=UP_ARROW, font=FONTAWESOME_FONT, **style) upButton.output = output self.upButtons.append(upButton) if SHOW_UP_DOWN: widgets.append(upButton) downButton = Button(self.frame, text=DOWN_ARROW, font=FONTAWESOME_FONT, **style) downButton.output = output self.downButtons.append(downButton) if SHOW_UP_DOWN: widgets.append(downButton) for widget in widgets: widget.output = output self.gridRow(row, widgets) self.softRefreshList() def setMenuToOutput(self, optionMenu, output): menu = optionMenu["menu"] var = optionMenu.var modes = output.modes menu.delete(0, END) for i, mode in enumerate(modes): label = Output.modestr(mode) menu.add_command(label=label, command=setLabelAndOutputModeFunc(var,label,output,i)) if output.currentModeIndex != None: var.set(Output.modestr(modes[output.currentModeIndex])) elif output.preferredModeIndex != None: var.set(Output.modestr(modes[output.preferredModeIndex])) elif len(modes) > 0: var.set(Output.modestr(modes[0])) def setMenuToDuplicate(self, optionMenu): menu = optionMenu["menu"] var = optionMenu.var output = optionMenu.output menu.delete(0, END) duplicables = self.getDuplicableOutputsFor(output) defaultIndex = 0 for i,otherOutput in enumerate(duplicables): label = otherOutput.name menu.add_command(label=label, command=setLabelAndSameAsFunc(var,label,output)) if label == output.sameAs: defaultIndex = i if len(duplicables) > 0: var.set(menu.entrycget(defaultIndex, "label")) output.sameAs = menu.entrycget(defaultIndex, "label") else: var.set("None") def handleFocusOut(self, event): self.root.destroy() def setLabelAndOutputModeFunc(var, label, output, i): def func(): var.set(label) output.setMode(i) return func def setLabelAndSameAsFunc(var, sameAs, output): def func(): var.set(sameAs) output.sameAs = sameAs return func if os.environ.get('BLOCK_BUTTON') == "1": if os.fork() != 0: root = Tk() if DEFAULT_FONT_FAMILY and DEFAULT_FONT_SIZE: font.nametofont("TkDefaultFont").config(family=DEFAULT_FONT_FAMILY, size=DEFAULT_FONT_SIZE) manager = MonitorManager(root) root.mainloop() else: print(DESKTOP_SYMBOL) else: print(DESKTOP_SYMBOL)