diff --git a/bumblebee_status/modules/core/xrandr.py b/bumblebee_status/modules/core/xrandr.py index 59d37f0..699599f 100644 --- a/bumblebee_status/modules/core/xrandr.py +++ b/bumblebee_status/modules/core/xrandr.py @@ -9,6 +9,8 @@ Parameters: * 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 + * xrandr.autotoggle: Boolean flag to automatically enable new displays (defaults to false) + * xrandr.autotoggle_side: Which side to put autotoggled displays on ('right' or 'left', defaults to 'right') Requires the following python module: * (optional) i3 - if present, the need for updating the widget list is auto-detected @@ -35,91 +37,147 @@ except Exception: pass +RESOLUTION_REGEX = re.compile(r"\d+x\d+\+(\d+)\+\d+") + + +class DisplayInfo: + def __init__(self, name, resolution, connected, added, removed): + self.name = name + self.active = resolution is not None + self.connected = connected + self.added = added + self.removed = removed + + self.position = int(resolution.group(1)) if self.active else sys.maxsize + self.state = "on" if self.active else "off" + + def __str__(self): + return "DisplayInfo(name={}, active={}, connected={}, added={}, removed={}, position={}, state={})".format( + self.name, + self.active, + self.connected, + self.added, + self.removed, + self.position, + self.state, + ) + + def __repr__(self): + return str(self) + + class Module(core.module.Module): @core.decorators.every(seconds=5) # takes up to 5s to detect a new screen def __init__(self, config, theme): super().__init__(config, theme, []) - self._exclude = tuple(filter(len, self.parameter("exclude", "").split(","))) - self._active_displays = [] + self._exclude = tuple(util.format.aslist(self.parameter("exclude"))) self._autoupdate = util.format.asbool(self.parameter("autoupdate", True)) - self._needs_update = True + self._autotoggle = util.format.asbool(self.parameter("autotoggle", False)) + self._autotoggle_side = self.parameter("autotoggle_side", "right") + + self._connected_displays = [] + self._active_displays = [] + self._initialized = False try: i3.Subscription(self._output_update, "output") except Exception: pass - def _output_update(self, event, data, _): - self._needs_update = True + def _output_update(self, *_): + self.update(force=True) - def update(self): - if not self._autoupdate and not self._needs_update: + def _query_displays(self): + displays = [] + + for line in util.cli.execute("xrandr -q").split("\n"): + # disconnected or connected + if "connected" not in line: + continue + + name = line.split(" ", 2)[0] + resolution = RESOLUTION_REGEX.search(line) + active = resolution is not None + + connected = "disconnected" not in line + added = connected and not active and name not in self._connected_displays + removed = not connected and active and name in self._active_displays + + displays.append(DisplayInfo(name, resolution, connected, added, removed)) + + self._connected_displays = [ + display.name for display in displays if display.connected + ] + self._active_displays = [display.name for display in displays if display.active] + + return displays + + def update(self, force=False): + if not (self._autoupdate or force or not self._initialized): return self.clear_widgets() - self._active_displays.clear() - self._needs_update = False - - for line in util.cli.execute("xrandr -q").split("\n"): - if " connected" not in line: + for display in self._query_displays(): + if display.name.startswith(self._exclude): continue - display = line.split(" ", 2)[0] - resolution = re.search(r"\d+x\d+\+(\d+)\+\d+", line) + if self._initialized and self._autotoggle: + if display.added: + self._enable_display(display.name, self._autotoggle_side) + elif display.removed: + self._disable_display(display.name) - if resolution: - self._active_displays.append(display) - - if display.startswith(self._exclude): + if not display.connected: 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 resolution else "off") - widget.set("pos", int(resolution.group(1)) if resolution else sys.maxsize) + widget = self.add_widget(full_text=display.name, name=display.name) + core.input.register(widget, button=1, cmd=self._toggle) + core.input.register(widget, button=3, cmd=self._toggle) + + widget.set("state", display.state) + widget.set("pos", display.position) if not self._autoupdate: widget = self.add_widget(full_text="") widget.set("state", "refresh") - core.input.register(widget, button=1, cmd=self._refresh) + core.input.register(widget, button=1, cmd=self.update) + + self._initialized = True def state(self, widget): return widget.get("state", "off") - def _refresh(self, event): - self._needs_update = True + def _toggle_cmd(self): + if util.format.asbool(self.parameter("overwrite_i3config", False)): + return utility("toggle-display.sh") + else: + return "xrandr" + + def _disable_display(self, name): + if len(self._active_displays) > 1: + util.cli.execute("{} --output {} --off".format(self._toggle_cmd(), name)) + + def _enable_display(self, name, side=None): + # TODO: is there ever a case when there isn't a neighbor? + command = "{} --output {} --auto".format(self._toggle_cmd(), name) + if side and self._active_displays: + neighbor_index = 0 if side == "left" else -1 + command += " --{}-of {}".format(side, self._active_displays[neighbor_index]) + + util.cli.execute(command) def _toggle(self, event): - if util.format.asbool(self.parameter("overwrite_i3config", False)): - toggle_cmd = utility("toggle-display.sh") - else: - toggle_cmd = "xrandr" - widget = self.widget(widget_id=event["instance"]) if widget.get("state") == "on": - 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)) + self._disable_display(widget.name) else: - if event["button"] == core.input.LEFT_MOUSE: - side, neighbor = "left", self._active_displays[0] - else: - side, neighbor = "right", self._active_displays[-1] + side = "left" if event["button"] == core.input.LEFT_MOUSE else "right" + self._enable_display(widget.name, side) - util.cli.execute( - "{} --output {} --auto --{}-of {}".format( - toggle_cmd, widget.name, side, neighbor, - ) - ) - - self._refresh(event) + self.update(force=True) # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 diff --git a/tests/modules/core/test_xrandr.py b/tests/modules/core/test_xrandr.py index b4e6156..c2d5dca 100644 --- a/tests/modules/core/test_xrandr.py +++ b/tests/modules/core/test_xrandr.py @@ -21,47 +21,50 @@ def assert_widgets(module, *expected_widgets): assert widget.get("pos") == pos -def assert_trigger(mocker, module, widget_index, button, expected_command): - xrandr_cli = mock_xrandr(mocker, "") +def assert_trigger(xrandr_cli, module, widget_index, button, expected_command): + xrandr_cli.reset_mock() + widget = module.widgets()[widget_index] trigger({"button": button, "instance": widget.id, "name": module.id}) if expected_command is None: - xrandr_cli.assert_not_called() + xrandr_cli.assert_called_once_with("xrandr -q") else: - xrandr_cli.assert_called_once_with(expected_command) + assert xrandr_cli.call_count == 2 + xrandr_cli.assert_any_call(expected_command) + xrandr_cli.assert_called_with("xrandr -q") def test_autoupdate(mocker): - xrandr_cli = mock_xrandr(mocker, FULL_OUTPUT_TWO_DISPLAYS) + xrandr_cli = mock_xrandr(mocker, HDMI_CONNECTED_ACTIVE) 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") + assert_trigger(xrandr_cli, module, 0, LEFT_MOUSE, "xrandr --output eDP-1-1 --off") + assert_trigger(xrandr_cli, module, 0, RIGHT_MOUSE, "xrandr --output eDP-1-1 --off") + assert_trigger(xrandr_cli, module, 1, LEFT_MOUSE, "xrandr --output HDMI-1-1 --off") + assert_trigger(xrandr_cli, module, 1, RIGHT_MOUSE, "xrandr --output HDMI-1-1 --off") def test_display_off(mocker): - xrandr_cli = mock_xrandr(mocker, TRUNCATED_OUTPUT_DISPLAY_OFF) + xrandr_cli = mock_xrandr(mocker, HDMI_CONNECTED_INACTIVE) 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") + assert_trigger(xrandr_cli, module, 0, LEFT_MOUSE, None) + assert_trigger(xrandr_cli, module, 0, RIGHT_MOUSE, None) + assert_trigger(xrandr_cli, module, 1, LEFT_MOUSE, "xrandr --output HDMI-1-1 --auto --left-of eDP-1-1") + assert_trigger(xrandr_cli, 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) + xrandr_cli = mock_xrandr(mocker, HDMI_CONNECTED_ACTIVE) module = Module(Config(["-p", "xrandr.autoupdate=false"]), theme=None) module.update() xrandr_cli.assert_called_once_with("xrandr -q") @@ -70,39 +73,151 @@ def test_no_autoupdate(mocker): 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") + assert_trigger(xrandr_cli, module, 0, LEFT_MOUSE, "xrandr --output eDP-1-1 --off") + assert_trigger(xrandr_cli, module, 0, RIGHT_MOUSE, "xrandr --output eDP-1-1 --off") + assert_trigger(xrandr_cli, module, 1, LEFT_MOUSE, "xrandr --output HDMI-1-1 --off") + assert_trigger(xrandr_cli, module, 1, RIGHT_MOUSE, "xrandr --output HDMI-1-1 --off") def test_exclude(mocker): - xrandr_cli = mock_xrandr(mocker, FULL_OUTPUT_TWO_DISPLAYS) + xrandr_cli = mock_xrandr(mocker, HDMI_CONNECTED_ACTIVE) 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") + assert_trigger(xrandr_cli, module, 0, LEFT_MOUSE, "xrandr --output HDMI-1-1 --off") + assert_trigger(xrandr_cli, module, 0, RIGHT_MOUSE, "xrandr --output HDMI-1-1 --off") def test_exclude_off(mocker): - xrandr_cli = mock_xrandr(mocker, TRUNCATED_OUTPUT_DISPLAY_OFF) + xrandr_cli = mock_xrandr(mocker, HDMI_CONNECTED_INACTIVE) 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") + assert_trigger(xrandr_cli, module, 0, LEFT_MOUSE, "xrandr --output HDMI-1-1 --auto --left-of eDP-1-1") + assert_trigger(xrandr_cli, module, 0, RIGHT_MOUSE, "xrandr --output HDMI-1-1 --auto --right-of eDP-1-1") + + +def test_autotoggle_excluded_active_disconnected(mocker): + xrandr_cli = mock_xrandr(mocker, HDMI_CONNECTED_ACTIVE) + module = Module(Config(["-p", "xrandr.autotoggle=true", "xrandr.exclude=HDMI"]), theme=None) + module.update() + xrandr_cli.assert_called_once_with("xrandr -q") + + assert_widgets(module, ("eDP-1-1", "on", 0)) + + xrandr_cli.return_value = HDMI_DISCONNECTED_ACTIVE + xrandr_cli.reset_mock() + + module.update() + xrandr_cli.assert_called_once_with("xrandr -q") + + +def test_autotoggle_excluded_inactive_connected(mocker): + xrandr_cli = mock_xrandr(mocker, HDMI_DISCONNECTED_INACTIVE) + module = Module(Config(["-p", "xrandr.autotoggle=true", "xrandr.exclude=HDMI"]), theme=None) + module.update() + xrandr_cli.assert_called_once_with("xrandr -q") + + assert_widgets(module, ("eDP-1-1", "on", 0)) + + xrandr_cli.return_value = HDMI_CONNECTED_INACTIVE + xrandr_cli.reset_mock() + + module.update() + xrandr_cli.assert_called_once_with("xrandr -q") + + +def test_autotoggle_active_disconnected(mocker): + xrandr_cli = mock_xrandr(mocker, HDMI_CONNECTED_ACTIVE) + module = Module(Config(["-p", "xrandr.autotoggle=true"]), 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)) + + xrandr_cli.return_value = HDMI_DISCONNECTED_ACTIVE + xrandr_cli.reset_mock() + + module.update() + assert xrandr_cli.call_count == 2 + xrandr_cli.assert_any_call("xrandr -q") + xrandr_cli.assert_called_with("xrandr --output HDMI-1-1 --off") + + +def test_autotoggle_inactive_disconnected(mocker): + xrandr_cli = mock_xrandr(mocker, HDMI_CONNECTED_INACTIVE) + module = Module(Config(["-p", "xrandr.autotoggle=true"]), 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)) + + xrandr_cli.return_value = HDMI_DISCONNECTED_INACTIVE + xrandr_cli.reset_mock() + + module.update() + xrandr_cli.assert_called_once_with("xrandr -q") + + +def test_autotoggle_active_connected(mocker): + xrandr_cli = mock_xrandr(mocker, HDMI_DISCONNECTED_ACTIVE) + module = Module(Config(["-p", "xrandr.autotoggle=true"]), theme=None) + module.update() + xrandr_cli.assert_called_once_with("xrandr -q") + + assert_widgets(module, ("eDP-1-1", "on", 0)) + + xrandr_cli.return_value = HDMI_CONNECTED_ACTIVE + xrandr_cli.reset_mock() + + module.update() + xrandr_cli.assert_called_once_with("xrandr -q") + + +def test_autotoggle_inactive_connected(mocker): + xrandr_cli = mock_xrandr(mocker, HDMI_DISCONNECTED_INACTIVE) + module = Module(Config(["-p", "xrandr.autotoggle=true"]), theme=None) + module.update() + xrandr_cli.assert_called_once_with("xrandr -q") + + assert_widgets(module, ("eDP-1-1", "on", 0)) + + xrandr_cli.return_value = HDMI_CONNECTED_INACTIVE + xrandr_cli.reset_mock() + + module.update() + assert xrandr_cli.call_count == 2 + xrandr_cli.assert_any_call("xrandr -q") + xrandr_cli.assert_called_with("xrandr --output HDMI-1-1 --auto --right-of eDP-1-1") + + +def test_autotoggle_left(mocker): + xrandr_cli = mock_xrandr(mocker, HDMI_DISCONNECTED_INACTIVE) + module = Module(Config(["-p", "xrandr.autotoggle=true", "xrandr.autotoggle_side=left"]), theme=None) + module.update() + xrandr_cli.assert_called_once_with("xrandr -q") + + assert_widgets(module, ("eDP-1-1", "on", 0)) + + xrandr_cli.return_value = HDMI_CONNECTED_INACTIVE + xrandr_cli.reset_mock() + + module.update() + assert xrandr_cli.call_count == 2 + xrandr_cli.assert_any_call("xrandr -q") + xrandr_cli.assert_called_with("xrandr --output HDMI-1-1 --auto --left-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 +HDMI_CONNECTED_ACTIVE = """ +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 @@ -150,8 +265,24 @@ HDMI-1-1 connected 2560x1440+1920+0 596mm x 335mm """ -TRUNCATED_OUTPUT_DISPLAY_OFF = """eDP-1-1 connected primary 1920x1080+0+0 344mm x 193mm +HDMI_CONNECTED_INACTIVE = """ +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 + """ + +HDMI_DISCONNECTED_ACTIVE = """ +eDP-1-1 connected primary 1920x1080+0+0 344mm x 193mm + 1920x1080 60.00*+ 59.93 48.00 +HDMI-1-1 disconnected 2560x1440+1920+0 0mm x 0mm + 2560x1440 (0x6b) 241.500MHz +HSync +VSync + h: width 2560 start 2608 end 2640 total 2720 skew 0 clock 88.79KHz + v: height 1440 start 1442 end 1447 total 1481 clock 59.95Hz +""" + +HDMI_DISCONNECTED_INACTIVE = """ +eDP-1-1 connected primary 1920x1080+0+0 344mm x 193mm + 1920x1080 60.00*+ 59.93 48.00 +HDMI-1-1 disconnected +"""