Merge pull request #686 from nayaverdier/main

[xrandr] Add autotoggle behavior to xrandr
This commit is contained in:
tobi-wan-kenobi 2020-07-27 06:55:15 +02:00 committed by GitHub
commit 969378846f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 294 additions and 75 deletions

View file

@ -9,6 +9,8 @@ Parameters:
* xrandr.autoupdate: If set to 'false', does *not* invoke xrandr automatically. Instead, the * 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) 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.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: Requires the following python module:
* (optional) i3 - if present, the need for updating the widget list is auto-detected * (optional) i3 - if present, the need for updating the widget list is auto-detected
@ -35,91 +37,147 @@ except Exception:
pass 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): class Module(core.module.Module):
@core.decorators.every(seconds=5) # takes up to 5s to detect a new screen @core.decorators.every(seconds=5) # takes up to 5s to detect a new screen
def __init__(self, config, theme): def __init__(self, config, theme):
super().__init__(config, theme, []) super().__init__(config, theme, [])
self._exclude = tuple(filter(len, self.parameter("exclude", "").split(","))) self._exclude = tuple(util.format.aslist(self.parameter("exclude")))
self._active_displays = []
self._autoupdate = util.format.asbool(self.parameter("autoupdate", True)) 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: try:
i3.Subscription(self._output_update, "output") i3.Subscription(self._output_update, "output")
except Exception: except Exception:
pass pass
def _output_update(self, event, data, _): def _output_update(self, *_):
self._needs_update = True self.update(force=True)
def update(self): def _query_displays(self):
if not self._autoupdate and not self._needs_update: 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 return
self.clear_widgets() self.clear_widgets()
self._active_displays.clear()
self._needs_update = False for display in self._query_displays():
if display.name.startswith(self._exclude):
for line in util.cli.execute("xrandr -q").split("\n"):
if " connected" not in line:
continue continue
display = line.split(" ", 2)[0] if self._initialized and self._autotoggle:
resolution = re.search(r"\d+x\d+\+(\d+)\+\d+", line) if display.added:
self._enable_display(display.name, self._autotoggle_side)
elif display.removed:
self._disable_display(display.name)
if resolution: if not display.connected:
self._active_displays.append(display)
if display.startswith(self._exclude):
continue continue
widget = self.widget(display) widget = self.add_widget(full_text=display.name, name=display.name)
if not widget: core.input.register(widget, button=1, cmd=self._toggle)
widget = self.add_widget(full_text=display, name=display) core.input.register(widget, button=3, cmd=self._toggle)
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("state", "on" if resolution else "off") widget.set("pos", display.position)
widget.set("pos", int(resolution.group(1)) if resolution else sys.maxsize)
if not self._autoupdate: if not self._autoupdate:
widget = self.add_widget(full_text="") widget = self.add_widget(full_text="")
widget.set("state", "refresh") 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): def state(self, widget):
return widget.get("state", "off") return widget.get("state", "off")
def _refresh(self, event): def _toggle_cmd(self):
self._needs_update = True 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): 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"]) widget = self.widget(widget_id=event["instance"])
if widget.get("state") == "on": if widget.get("state") == "on":
if len(self._active_displays) > 1: self._disable_display(widget.name)
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: else:
if event["button"] == core.input.LEFT_MOUSE: side = "left" if event["button"] == core.input.LEFT_MOUSE else "right"
side, neighbor = "left", self._active_displays[0] self._enable_display(widget.name, side)
else:
side, neighbor = "right", self._active_displays[-1]
util.cli.execute( self.update(force=True)
"{} --output {} --auto --{}-of {}".format(
toggle_cmd, widget.name, side, neighbor,
)
)
self._refresh(event)
# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4 # vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4

View file

@ -21,47 +21,50 @@ def assert_widgets(module, *expected_widgets):
assert widget.get("pos") == pos assert widget.get("pos") == pos
def assert_trigger(mocker, module, widget_index, button, expected_command): def assert_trigger(xrandr_cli, module, widget_index, button, expected_command):
xrandr_cli = mock_xrandr(mocker, "") xrandr_cli.reset_mock()
widget = module.widgets()[widget_index] widget = module.widgets()[widget_index]
trigger({"button": button, "instance": widget.id, "name": module.id}) trigger({"button": button, "instance": widget.id, "name": module.id})
if expected_command is None: if expected_command is None:
xrandr_cli.assert_not_called() xrandr_cli.assert_called_once_with("xrandr -q")
else: 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): 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 = Module(Config([]), theme=None)
module.update() module.update()
xrandr_cli.assert_called_once_with("xrandr -q") xrandr_cli.assert_called_once_with("xrandr -q")
assert_widgets(module, ("eDP-1-1", "on", 0), ("HDMI-1-1", "on", 1920)) 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(xrandr_cli, 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(xrandr_cli, 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(xrandr_cli, 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, 1, RIGHT_MOUSE, "xrandr --output HDMI-1-1 --off")
def test_display_off(mocker): 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 = Module(Config([]), theme=None)
module.update() module.update()
xrandr_cli.assert_called_once_with("xrandr -q") xrandr_cli.assert_called_once_with("xrandr -q")
assert_widgets(module, ("eDP-1-1", "on", 0), ("HDMI-1-1", "off", sys.maxsize)) assert_widgets(module, ("eDP-1-1", "on", 0), ("HDMI-1-1", "off", sys.maxsize))
assert_trigger(mocker, module, 0, LEFT_MOUSE, None) assert_trigger(xrandr_cli, module, 0, LEFT_MOUSE, None)
assert_trigger(mocker, module, 0, RIGHT_MOUSE, None) assert_trigger(xrandr_cli, 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(xrandr_cli, 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, 1, RIGHT_MOUSE, "xrandr --output HDMI-1-1 --auto --right-of eDP-1-1")
def test_no_autoupdate(mocker): 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 = Module(Config(["-p", "xrandr.autoupdate=false"]), theme=None)
module.update() module.update()
xrandr_cli.assert_called_once_with("xrandr -q") xrandr_cli.assert_called_once_with("xrandr -q")
@ -70,39 +73,181 @@ def test_no_autoupdate(mocker):
module, ("eDP-1-1", "on", 0), ("HDMI-1-1", "on", 1920), (None, "refresh", None) 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(xrandr_cli, 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(xrandr_cli, 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(xrandr_cli, 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, 1, RIGHT_MOUSE, "xrandr --output HDMI-1-1 --off")
def test_exclude(mocker): 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 = Module(Config(["-p", "xrandr.exclude=eDP"]), theme=None)
module.update() module.update()
xrandr_cli.assert_called_once_with("xrandr -q") xrandr_cli.assert_called_once_with("xrandr -q")
assert_widgets(module, ("HDMI-1-1", "on", 1920)) assert_widgets(module, ("HDMI-1-1", "on", 1920))
assert_trigger(mocker, module, 0, LEFT_MOUSE, "xrandr --output HDMI-1-1 --off") assert_trigger(xrandr_cli, 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, RIGHT_MOUSE, "xrandr --output HDMI-1-1 --off")
def test_exclude_off(mocker): 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 = Module(Config(["-p", "xrandr.exclude=eDP"]), theme=None)
module.update() module.update()
xrandr_cli.assert_called_once_with("xrandr -q") xrandr_cli.assert_called_once_with("xrandr -q")
assert_widgets(module, ("HDMI-1-1", "off", sys.maxsize)) 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(xrandr_cli, 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, RIGHT_MOUSE, "xrandr --output HDMI-1-1 --auto --right-of eDP-1-1")
def test_no_autotoggle_inactive_connected(mocker):
xrandr_cli = mock_xrandr(mocker, HDMI_DISCONNECTED_INACTIVE)
module = Module(Config([]), 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_no_autotoggle_active_disconnected(mocker):
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))
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_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_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 # 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 eDP-1-1 connected primary 1920x1080+0+0 344mm x 193mm
1920x1080 60.00*+ 59.93 48.00 1920x1080 60.00*+ 59.93 48.00
1680x1050 59.95 59.88 1680x1050 59.95 59.88
@ -150,8 +295,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 1920x1080 60.00*+ 59.93 48.00
HDMI-1-1 connected HDMI-1-1 connected
2560x1440 59.95 + 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
"""