diff --git a/bumblebee_status/modules/core/vault.py b/bumblebee_status/modules/core/vault.py index 28f37f4..7f3fb75 100644 --- a/bumblebee_status/modules/core/vault.py +++ b/bumblebee_status/modules/core/vault.py @@ -12,6 +12,7 @@ Parameters: * vault.location: Location of the password store (defaults to ~/.password-store) * vault.offx: x-axis offset of popup menu (defaults to 0) * vault.offy: y-axis offset of popup menu (defaults to 0) + * vault.text: Text to display on the widget (defaults to ) Many thanks to `bbernhard `_ for the idea! """ diff --git a/bumblebee_status/modules/core/xrandr.py b/bumblebee_status/modules/core/xrandr.py index e1717e8..59d37f0 100644 --- a/bumblebee_status/modules/core/xrandr.py +++ b/bumblebee_status/modules/core/xrandr.py @@ -8,6 +8,7 @@ Parameters: and appending a file '~/.config/i3/config.' for every screen. * xrandr.autoupdate: If set to 'false', does *not* invoke xrandr automatically. Instead, the module will only refresh when displays are enabled or disabled (defaults to true) + * xrandr.exclude: Comma-separated list of display name prefixes to exclude Requires the following python module: * (optional) i3 - if present, the need for updating the widget list is auto-detected @@ -16,7 +17,6 @@ Requires the following executable: * xrandr """ -import os import re import sys @@ -31,7 +31,7 @@ import util.format try: import i3 -except: +except Exception: pass @@ -40,40 +40,50 @@ class Module(core.module.Module): def __init__(self, config, theme): super().__init__(config, theme, []) + self._exclude = tuple(filter(len, self.parameter("exclude", "").split(","))) + self._active_displays = [] self._autoupdate = util.format.asbool(self.parameter("autoupdate", True)) self._needs_update = True try: i3.Subscription(self._output_update, "output") - except: + except Exception: pass def _output_update(self, event, data, _): self._needs_update = True def update(self): - self.clear_widgets() - - if self._autoupdate == False and self._needs_update == False: + if not self._autoupdate and not self._needs_update: return + self.clear_widgets() + self._active_displays.clear() + self._needs_update = False for line in util.cli.execute("xrandr -q").split("\n"): - if not " connected" in line: + if " connected" not in line: continue + display = line.split(" ", 2)[0] - m = re.search(r"\d+x\d+\+(\d+)\+\d+", line) + resolution = re.search(r"\d+x\d+\+(\d+)\+\d+", line) + + if resolution: + self._active_displays.append(display) + + if display.startswith(self._exclude): + continue widget = self.widget(display) if not widget: widget = self.add_widget(full_text=display, name=display) core.input.register(widget, button=1, cmd=self._toggle) core.input.register(widget, button=3, cmd=self._toggle) - widget.set("state", "on" if m else "off") - widget.set("pos", int(m.group(1)) if m else sys.maxsize) + widget.set("state", "on" if resolution else "off") + widget.set("pos", int(resolution.group(1)) if resolution else sys.maxsize) - if self._autoupdate == False: + if not self._autoupdate: widget = self.add_widget(full_text="") widget.set("state", "refresh") core.input.register(widget, button=1, cmd=self._refresh) @@ -85,9 +95,7 @@ class Module(core.module.Module): self._needs_update = True def _toggle(self, event): - self._refresh(event) - - if util.format.asbool(self.parameter("overwrite_i3config", False)) == True: + if util.format.asbool(self.parameter("overwrite_i3config", False)): toggle_cmd = utility("toggle-display.sh") else: toggle_cmd = "xrandr" @@ -95,42 +103,23 @@ class Module(core.module.Module): widget = self.widget(widget_id=event["instance"]) if widget.get("state") == "on": - util.cli.execute("{} --output {} --off".format(toggle_cmd, widget.name)) + if len(self._active_displays) > 1: + util.cli.execute("{} --output {} --off".format(toggle_cmd, widget.name)) + elif not self._active_displays: + util.cli.execute("{} --output {} --auto".format(toggle_cmd, widget.name)) else: - first_neighbor = next( - (widget for widget in self.widgets() if widget.get("state") == "on"), - None, - ) - last_neighbor = next( - ( - widget - for widget in reversed(self.widgets()) - if widget.get("state") == "on" - ), - None, - ) - - neighbor = ( - first_neighbor - if event["button"] == core.input.LEFT_MOUSE - else last_neighbor - ) - - if neighbor is None: - util.cli.execute( - "{} --output {} --auto".format(toggle_cmd, widget.name) - ) + if event["button"] == core.input.LEFT_MOUSE: + side, neighbor = "left", self._active_displays[0] else: - util.cli.execute( - "{} --output {} --auto --{}-of {}".format( - toggle_cmd, - widget.name, - "left" - if event.get("button") == core.input.LEFT_MOUSE - else "right", - neighbor.name, - ) + side, neighbor = "right", self._active_displays[-1] + + util.cli.execute( + "{} --output {} --auto --{}-of {}".format( + toggle_cmd, widget.name, side, neighbor, ) + ) + + self._refresh(event) # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/bumblebee_status/util/popup.py b/bumblebee_status/util/popup.py index f162846..bbabe66 100644 --- a/bumblebee_status/util/popup.py +++ b/bumblebee_status/util/popup.py @@ -16,23 +16,19 @@ class menu(object): def __init__(self, parent=None, leave=True): self.running = True - self.parent = None - if not parent: - self._root = tk.Tk() - self._root.withdraw() - self._menu = tk.Menu(self._root, tearoff=0) - self._menu.bind("", self.__on_focus_out) - self.add_menuitem("close", self.__on_focus_out) - self.add_separator() - else: - self._root = parent.root() - self._root.withdraw() - self._menu = tk.Menu(self._root, tearoff=0) - self._menu.bind("", self.__on_focus_out) - self.parent = parent + self.parent = parent + + self._root = parent.root() if parent else tk.Tk() + self._root.withdraw() + self._menu = tk.Menu(self._root, tearoff=0) + self._menu.bind("", self.__on_focus_out) + if leave: self._menu.bind("", self.__on_focus_out) + elif not parent: + self.add_menuitem("close", self.__on_focus_out) + self.add_separator() self._menu.bind("", self.release) diff --git a/tests/modules/core/test_xrandr.py b/tests/modules/core/test_xrandr.py index 720d96a..b4e6156 100644 --- a/tests/modules/core/test_xrandr.py +++ b/tests/modules/core/test_xrandr.py @@ -1,7 +1,157 @@ -import pytest +import sys -pytest.importorskip("i3") +from core.input import trigger, LEFT_MOUSE, RIGHT_MOUSE +from core.config import Config +from modules.core.xrandr import Module -def test_load_module(): - __import__("modules.core.xrandr") +MOCK_EXECUTE = "modules.core.xrandr.util.cli.execute" + + +def mock_xrandr(mocker, xrandr_output): + return mocker.patch(MOCK_EXECUTE, return_value=xrandr_output) + + +def assert_widgets(module, *expected_widgets): + assert len(module.widgets()) == len(expected_widgets) + + for widget, (name, state, pos) in zip(module.widgets(), expected_widgets): + assert widget.name == name + assert widget.get("state") == state + assert widget.get("pos") == pos + + +def assert_trigger(mocker, module, widget_index, button, expected_command): + xrandr_cli = mock_xrandr(mocker, "") + widget = module.widgets()[widget_index] + trigger({"button": button, "instance": widget.id, "name": module.id}) + + if expected_command is None: + xrandr_cli.assert_not_called() + else: + xrandr_cli.assert_called_once_with(expected_command) + + +def test_autoupdate(mocker): + xrandr_cli = mock_xrandr(mocker, FULL_OUTPUT_TWO_DISPLAYS) + module = Module(Config([]), theme=None) + module.update() + xrandr_cli.assert_called_once_with("xrandr -q") + + assert_widgets(module, ("eDP-1-1", "on", 0), ("HDMI-1-1", "on", 1920)) + + assert_trigger(mocker, module, 0, LEFT_MOUSE, "xrandr --output eDP-1-1 --off") + assert_trigger(mocker, module, 0, RIGHT_MOUSE, "xrandr --output eDP-1-1 --off") + assert_trigger(mocker, module, 1, LEFT_MOUSE, "xrandr --output HDMI-1-1 --off") + assert_trigger(mocker, module, 1, RIGHT_MOUSE, "xrandr --output HDMI-1-1 --off") + + +def test_display_off(mocker): + xrandr_cli = mock_xrandr(mocker, TRUNCATED_OUTPUT_DISPLAY_OFF) + module = Module(Config([]), theme=None) + module.update() + xrandr_cli.assert_called_once_with("xrandr -q") + + assert_widgets(module, ("eDP-1-1", "on", 0), ("HDMI-1-1", "off", sys.maxsize)) + + assert_trigger(mocker, module, 0, LEFT_MOUSE, None) + assert_trigger(mocker, module, 0, RIGHT_MOUSE, None) + assert_trigger(mocker, module, 1, LEFT_MOUSE, "xrandr --output HDMI-1-1 --auto --left-of eDP-1-1") + assert_trigger(mocker, module, 1, RIGHT_MOUSE, "xrandr --output HDMI-1-1 --auto --right-of eDP-1-1") + + +def test_no_autoupdate(mocker): + xrandr_cli = mock_xrandr(mocker, FULL_OUTPUT_TWO_DISPLAYS) + module = Module(Config(["-p", "xrandr.autoupdate=false"]), theme=None) + module.update() + xrandr_cli.assert_called_once_with("xrandr -q") + + assert_widgets( + module, ("eDP-1-1", "on", 0), ("HDMI-1-1", "on", 1920), (None, "refresh", None) + ) + + assert_trigger(mocker, module, 0, LEFT_MOUSE, "xrandr --output eDP-1-1 --off") + assert_trigger(mocker, module, 0, RIGHT_MOUSE, "xrandr --output eDP-1-1 --off") + assert_trigger(mocker, module, 1, LEFT_MOUSE, "xrandr --output HDMI-1-1 --off") + assert_trigger(mocker, module, 1, RIGHT_MOUSE, "xrandr --output HDMI-1-1 --off") + + +def test_exclude(mocker): + xrandr_cli = mock_xrandr(mocker, FULL_OUTPUT_TWO_DISPLAYS) + module = Module(Config(["-p", "xrandr.exclude=eDP"]), theme=None) + module.update() + xrandr_cli.assert_called_once_with("xrandr -q") + + assert_widgets(module, ("HDMI-1-1", "on", 1920)) + + assert_trigger(mocker, module, 0, LEFT_MOUSE, "xrandr --output HDMI-1-1 --off") + assert_trigger(mocker, module, 0, RIGHT_MOUSE, "xrandr --output HDMI-1-1 --off") + + +def test_exclude_off(mocker): + xrandr_cli = mock_xrandr(mocker, TRUNCATED_OUTPUT_DISPLAY_OFF) + module = Module(Config(["-p", "xrandr.exclude=eDP"]), theme=None) + module.update() + xrandr_cli.assert_called_once_with("xrandr -q") + + assert_widgets(module, ("HDMI-1-1", "off", sys.maxsize)) + + assert_trigger(mocker, module, 0, LEFT_MOUSE, "xrandr --output HDMI-1-1 --auto --left-of eDP-1-1") + assert_trigger(mocker, module, 0, RIGHT_MOUSE, "xrandr --output HDMI-1-1 --auto --right-of eDP-1-1") + + +# xrandr sample data + +FULL_OUTPUT_TWO_DISPLAYS = """Screen 0: minimum 8 x 8, current 4480 x 1440, maximum 32767 x 32767 +eDP-1-1 connected primary 1920x1080+0+0 344mm x 193mm + 1920x1080 60.00*+ 59.93 48.00 + 1680x1050 59.95 59.88 + 1600x1024 60.17 + 1400x1050 59.98 + 1280x1024 60.02 + 1440x900 59.89 + 1280x960 60.00 + 1360x768 59.80 59.96 + 1152x864 60.00 + 1024x768 60.04 60.00 + 960x720 60.00 + 928x696 60.05 + 896x672 60.01 + 960x600 60.00 + 960x540 59.99 + 800x600 60.00 60.32 56.25 + 840x525 60.01 59.88 + 800x512 60.17 + 700x525 59.98 + 640x512 60.02 + 720x450 59.89 + 640x480 60.00 59.94 + 680x384 59.80 59.96 + 576x432 60.06 + 512x384 60.00 + 400x300 60.32 56.34 + 320x240 60.05 +HDMI-1-1 connected 2560x1440+1920+0 596mm x 335mm + 2560x1440 59.95*+ + 1920x1080 60.00 50.00 59.94 + 1920x1080i 60.00 50.00 59.94 + 1680x1050 59.88 + 1600x900 60.00 + 1280x1024 60.02 + 1280x800 59.91 + 1280x720 60.00 50.00 59.94 + 1024x768 60.00 + 800x600 60.32 + 720x576 50.00 + 720x576i 50.00 + 720x480 60.00 59.94 + 720x480i 60.00 59.94 + 640x480 60.00 59.94 +""" + + +TRUNCATED_OUTPUT_DISPLAY_OFF = """eDP-1-1 connected primary 1920x1080+0+0 344mm x 193mm + 1920x1080 60.00*+ 59.93 48.00 +HDMI-1-1 connected + 2560x1440 59.95 + +"""