diff --git a/CHANGELOG b/CHANGELOG index 96b1b93c..dc2cc817 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +2023-11-10: [FEATURE] Add custom hooks to certain widgets (refer to docs for more) 2023-10-16: [FEATURE] Updated `Bluetooth` widget to add context menu 2023-10-08: [BUGFIX] Fix `BrightnessControl` text display issue in `Bar` mode 2023-10-07: [BUGFIX] Tooltip position on multiple screens diff --git a/docs/index.rst b/docs/index.rst index 863fb22a..a4daa4cb 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -53,6 +53,7 @@ without needing to recrate the icons themselves. You can see the class :ref:`her :hidden: manual/ref/widgets + manual/ref/hooks manual/ref/popup manual/ref/decorations manual/ref/imgmask diff --git a/docs/manual/ref/hooks.rst b/docs/manual/ref/hooks.rst new file mode 100644 index 00000000..3ac33f70 --- /dev/null +++ b/docs/manual/ref/hooks.rst @@ -0,0 +1,5 @@ +===== +Hooks +===== + +.. qte_hooks:: qtile_extras.hook.subscribe diff --git a/docs/sphinx_qtile_extras.py b/docs/sphinx_qtile_extras.py index b01dac62..52b2e9c1 100644 --- a/docs/sphinx_qtile_extras.py +++ b/docs/sphinx_qtile_extras.py @@ -124,6 +124,13 @@ {{ caption }} {% endfor %} + {% endif %} + {% if hooks %} + Available hooks: + {% for name in hooks %} + - `{{ name}} `_ + {% endfor %} + {% endif %} {% if defaults %} .. raw:: html @@ -154,7 +161,7 @@ qtile_hooks_template = Template( """ -.. automethod:: libqtile.hook.subscribe.{{ method }} +.. automethod:: qtile_extras.hook.subscribe.{{ method }} """ ) @@ -227,10 +234,11 @@ def make_rst(self): "commandable": is_commandable and issubclass(obj, command.base.CommandObject), "is_widget": issubclass(obj, widget.base._Widget), "experimental": getattr(obj, "_experimental", False), + "hooks": getattr(obj, "_hooks", list()), "inactive": getattr(obj, "_inactive", False), "screenshots": getattr(obj, "_screenshots", list()), "dependencies": dependencies, - "compatibility": getattr(obj, "_qte_compatibility", False) + "compatibility": getattr(obj, "_qte_compatibility", False), } if context["commandable"]: context["commands"] = [ @@ -342,8 +350,19 @@ def make_rst(self): yield line +class QtileHooks(SimpleDirectiveMixin, Directive): + def make_rst(self): + module, class_name = self.arguments[0].rsplit(".", 1) + obj = import_class(module, class_name) + for method in sorted(obj.hooks): + rst = qtile_hooks_template.render(method=method) + for line in rst.splitlines(): + yield line + + def setup(app): app.add_directive("qtile_class", QtileClass) app.add_directive("qtile_module", QtileModule) app.add_directive("list_objects", ListObjects) app.add_directive("qte_wallpapers", ListWallpapers) + app.add_directive("qte_hooks", QtileHooks) diff --git a/qtile_extras/hook.py b/qtile_extras/hook.py new file mode 100644 index 00000000..79edd18a --- /dev/null +++ b/qtile_extras/hook.py @@ -0,0 +1,384 @@ +# Copyright (c) 2023, elParaguayo. All rights reserved. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +from libqtile.hook import Hook, Registry + +hooks: list[Hook] = [] + +# Live Football Scores +footballscores_hooks = [ + Hook( + "lfs_goal_scored", + """ + LiveFootballScores widget. + + Fired when the score in a match changes. + + Hooked function should receive one argument which is the + ``FootballMatch`` object for the relevant match. + + Note: as the widget polls all matches at the same time, you + may find that the hook is fired multiple times in quick succession. + Handling multiple hooks is left to the user to manage. + + .. code:: python + + from libqtile import qtile + + import qtile_extras.hook + + @qtile_extras.hook.subscribe.lfs_goal_scored + def goal(match): + if "Arsenal" in (match.home_team, match.away_team): + qtile.spawn("ffplay goal.wav") + + """, + ), + Hook( + "lfs_status_change", + """ + LiveFootballScores widget. + + Fired when the match status changes (i.e. kick-off, half time etc.). + + Hooked function should receive one argument which is the + ``FootballMatch`` object for the relevant match. + + Note: as the widget polls all matches at the same time, you + may find that the hook is fired multiple times in quick succession. + Handling multiple hooks is left to the user to manage. + + .. code:: python + + from libqtile import qtile + + import qtile_extras.hook + + @qtile_extras.hook.subscribe.lfs_status_change + def status(match): + if match.is_finished and "Arsenal" in (match.home_team, match.away_team): + qtile.spawn("ffplay whistle.wav") + + """, + ), + Hook( + "lfs_red_card", + """ + LiveFootballScores widget. + + Fired when a red card is issued in a match. + + Hooked function should receive one argument which is the + ``FootballMatch`` object for the relevant match. + + .. code:: python + + from libqtile import qtile + + import qtile_extras.hook + + @qtile_extras.hook.subscribe.lfs_red_card + def red_card(match): + if "Arsenal" in (match.home_team, match.away_team): + qtile.spawn("ffplay off.wav") + + """, + ), +] + +hooks.extend(footballscores_hooks) + +# TVHeadend +tvh_hooks = [ + Hook( + "tvh_recording_started", + """ + TVHeadend widget. + + Fired when a recording starts. + + Hooked function should receive one argument which is the + name of the program being recorded. + + .. code:: python + + from libqtile.utils import send_notification + + import qtile_extras.hook + + @qtile_extras.hook.subscribe.tvh_recording_started + def start_recording(prog): + send_notification("Recording Started", prog) + + """, + ), + Hook( + "tvh_recording_ended", + """ + TVHeadend widget. + + Fired when a recording ends. + + Hooked function should receive one argument which is the + name of the program that was recorded. + + .. code:: python + + from libqtile.utils import send_notification + + import qtile_extras.hook + + @qtile_extras.hook.subscribe.tvh_recording_ended + def stop_recording(prog): + send_notification("Recording Ended", prog) + + """, + ), +] + +hooks.extend(tvh_hooks) + +# Githubnotifications +githubnotifications_hooks = [ + Hook( + "ghn_new_notification", + """ + GithubNotifications widget. + + Fired when there is a new notification. + + Note: the hook will only be fired whenever the widget + polls. + + .. code:: python + + from libqtile import qtile + from libqtile.utils import send_notification + + import qtile_extras.hook + + @qtile_extras.hook.subscribe.ghn_new_notification + def ghn_notification(): + qtile.spawn("ffplay ding.wav") + + """, + ) +] + +hooks.extend(githubnotifications_hooks) + +# Upower +upower_hooks = [ + Hook( + "up_power_connected", + """ + UPowerWidget. + + Fired when a power supply is connected. + + .. code:: python + + from libqtile import qtile + + import qtile_extras.hook + + @qtile_extras.hook.subscribe.up_power_connected + def plugged_in(): + qtile.spawn("ffplay power_on.wav") + + """, + ), + Hook( + "up_power_disconnected", + """ + UPowerWidget. + + Fired when a power supply is disconnected. + + .. code:: python + + from libqtile import qtile + + import qtile_extras.hook + + @qtile_extras.hook.subscribe.up_power_disconnected + def unplugged(): + qtile.spawn("ffplay power_off.wav") + + """, + ), + Hook( + "up_battery_full", + """ + UPowerWidget. + + Fired when a battery is fully charged. + + .. code:: python + + from libqtile.utils import send_notification + + import qtile_extras.hook + + @qtile_extras.hook.subscribe.up_battery_full + def battery_full(battery_name): + send_notification(battery_name, "Battery is fully charged.") + + """, + ), + Hook( + "up_battery_low", + """ + UPowerWidget. + + Fired when a battery reaches low threshold. + + .. code:: python + + from libqtile.utils import send_notification + + import qtile_extras.hook + + @qtile_extras.hook.subscribe.up_battery_low + def battery_low(battery_name): + send_notification(battery_name, "Battery is running low.") + + """, + ), + Hook( + "up_battery_critical", + """ + UPowerWidget. + + Fired when a battery is critically low. + + .. code:: python + + from libqtile.utils import send_notification + + import qtile_extras.hook + + @qtile_extras.hook.subscribe.up_battery_critical + def battery_critical(battery_name): + send_notification(battery_name, "Battery is critically low. Plug in power supply.") + + """, + ), +] + +hooks.extend(upower_hooks) + +# Syncthing hooks +syncthing_hooks = [ + Hook( + "st_sync_started", + """ + Syncthing widget. + + Fired when a sync starts. + + .. code:: python + + from libqtile import qtile + + import qtile_extras.hook + + @qtile_extras.hook.subscribe.st_sync_started + def sync_start(): + qtile.spawn("ffplay start.wav") + + """, + ), + Hook( + "st_sync_stopped", + """ + Syncthing widget. + + Fired when a sync stops. + + .. code:: python + + from libqtile import qtile + + import qtile_extras.hook + + @qtile_extras.hook.subscribe.st_sync_stopped + def sync_stop(): + qtile.spawn("ffplay complete.wav") + + """, + ), +] + +hooks.extend(syncthing_hooks) + +# MPRIS2 widget +mpris_hooks = [ + Hook( + "mpris_new_track", + """ + Mpris2 widget. + + Fired when a track changes. Receives a dict of the new metadata. + + .. code:: python + + from libqtile import qtile + + import qtile_extras.hook + + @qtile_extras.hook.subscribe.mpris_new_track + def new_track(metadata): + if metadata["xesam:title"] == "Never Gonna Give You Up": + qtile.spawn("max_volume.sh") + + """, + ), + Hook( + "mpris_status_change", + """ + Mpris2 widget. + + Fired when the playback status changes. Receives a string containing the new status. + + .. code:: python + + from libqtile import qtile + + import qtile_extras.hook + + @qtile_extras.hook.subscribe.mpris_status_change + def new_track(status): + if status == "Stopped": + qtile.spawn("mute.sh") + else: + qtile.spawn("unmute.sh") + + """, + ), +] + +hooks.extend(mpris_hooks) + +# Build the registry and expose helpful entrypoints +qte = Registry("qtile-extras", hooks) + +subscribe = qte.subscribe +unsubscribe = qte.unsubscribe +fire = qte.fire diff --git a/qtile_extras/widget/githubnotifications.py b/qtile_extras/widget/githubnotifications.py index 281d5ffd..acd07659 100644 --- a/qtile_extras/widget/githubnotifications.py +++ b/qtile_extras/widget/githubnotifications.py @@ -25,6 +25,7 @@ from libqtile.log_utils import logger from libqtile.widget import base +from qtile_extras import hook from qtile_extras.images import ImgMask GITHUB_ICON = Path(__file__).parent / ".." / "resources" / "github-icons" / "github.svg" @@ -68,6 +69,8 @@ class GithubNotifications(base._Widget): _dependencies = ["requests"] + _hooks = [h.name for h in hook.githubnotifications_hooks] + def __init__(self, **config): base._Widget.__init__(self, bar.CALCULATED, **config) self.add_defaults(GithubNotifications.defaults) @@ -76,6 +79,7 @@ def __init__(self, **config): self.error = False self._timer = None self._polling = False + self._new_notification = False def _configure(self, qtile, bar): base._Widget._configure(self, qtile, bar) @@ -141,6 +145,9 @@ def _read_data(self, reply): self.error = False self.has_notifications = bool(r.json()) + if self.has_notifications and not self._new_notification: + hook.fire("ghn_new_notification") + self._new_notification = self.has_notifications self._polling = False self._timer = self.timeout_add(self.update_interval, self.update) diff --git a/qtile_extras/widget/livefootballscores.py b/qtile_extras/widget/livefootballscores.py index c820ce13..e48d914f 100644 --- a/qtile_extras/widget/livefootballscores.py +++ b/qtile_extras/widget/livefootballscores.py @@ -24,6 +24,7 @@ from libqtile.log_utils import logger from libqtile.widget import base +from qtile_extras import hook from qtile_extras.popup.menu import PopupMenuItem, PopupMenuSeparator from qtile_extras.popup.toolkit import PopupRelativeLayout, PopupText from qtile_extras.resources.footballscores import FootballMatch, FSConnectionError, League @@ -224,6 +225,7 @@ class LiveFootballScores(base._Widget, base.MarginMixin, ExtendedPopupMixin, Men _dependencies = ["requests"] _queue_time = 1 + _hooks = [h.name for h in hook.footballscores_hooks] def __init__(self, **config): base._Widget.__init__(self, bar.CALCULATED, **config) @@ -436,13 +438,16 @@ def match_event(self, event): if event.is_goal: flags.homegoal = event.home flags.awaygoal = not event.home + hook.fire("lfs_goal_scored", event.match) elif event.is_red: flags.homered = event.home flags.awayred = not event.home + hook.fire("lfs_red_card", event.match) elif event.is_status_change: flags.statuschange = True + hook.fire("lfs_status_change", event.match) if flags.changes: self.queue_update() diff --git a/qtile_extras/widget/mpris2widget.py b/qtile_extras/widget/mpris2widget.py index bf0dfbe3..e2047a9f 100644 --- a/qtile_extras/widget/mpris2widget.py +++ b/qtile_extras/widget/mpris2widget.py @@ -24,6 +24,7 @@ from libqtile.command.base import expose_command from libqtile.utils import _send_dbus_message +from qtile_extras import hook from qtile_extras.popup.templates.mpris2 import DEFAULT_IMAGE, DEFAULT_LAYOUT from qtile_extras.widget.mixins import ExtendedPopupMixin @@ -118,6 +119,8 @@ class Mpris2(widget.Mpris2, ExtendedPopupMixin): ("popup_show_args", {"relative_to": 2, "relative_to_bar": True}, "Where to place popup"), ] + _hooks = [h.name for h in hook.mpris_hooks] + def __init__(self, **config): widget.Mpris2.__init__(self, **config) ExtendedPopupMixin.__init__(self, **config) @@ -125,6 +128,19 @@ def __init__(self, **config): self.add_defaults(Mpris2.defaults) self._popup_values = {} + def get_track_info(self, metadata): + result = widget.Mpris2.get_track_info(self, metadata) + hook.fire("mpris_new_track", self.metadata) + return result + + def parse_message(self, _interface_name, changed_properties, _invalidated_properties): + update_status = "PlaybackStatus" in changed_properties + widget.Mpris2.parse_message( + self, _interface_name, changed_properties, _invalidated_properties + ) + if update_status: + hook.fire("mpris_status_change", changed_properties["PlaybackStatus"].value) + def bind_callbacks(self): self.extended_popup.bind_callbacks( play_pause={"Button1": self.play_pause}, diff --git a/qtile_extras/widget/syncthing.py b/qtile_extras/widget/syncthing.py index 40acb564..07c0c196 100644 --- a/qtile_extras/widget/syncthing.py +++ b/qtile_extras/widget/syncthing.py @@ -25,6 +25,7 @@ from libqtile.log_utils import logger from libqtile.widget import base +from qtile_extras import hook from qtile_extras.images import ImgMask from qtile_extras.widget.mixins import ProgressBarMixin @@ -79,6 +80,8 @@ class Syncthing(base._Widget, ProgressBarMixin): _dependencies = ["requests"] + _hooks = [h.name for h in hook.syncthing_hooks] + def __init__(self, length=bar.CALCULATED, **config): base._Widget.__init__(self, length, **config) ProgressBarMixin.__init__(self, **config) @@ -157,6 +160,12 @@ def update(self): self.update_interval_syncing if self.is_syncing else self.update_interval, self.update ) + if old_sync != self.is_syncing: + if self.is_syncing: + hook.fire("st_sync_started") + else: + hook.fire("st_sync_stopped") + if old_sync != self.is_syncing and (self.hide_on_idle or self.show_bar): self.bar.draw() else: diff --git a/qtile_extras/widget/tvheadend.py b/qtile_extras/widget/tvheadend.py index c6fe80f1..0d549ba7 100644 --- a/qtile_extras/widget/tvheadend.py +++ b/qtile_extras/widget/tvheadend.py @@ -29,6 +29,8 @@ from libqtile.widget import base from requests.auth import HTTPBasicAuth, HTTPDigestAuth +from qtile_extras import hook + def icon_path(): """Get the path to tv icon""" @@ -42,6 +44,7 @@ def __init__(self, host=None, auth=None, timeout=5): self.host = host self.auth = auth self.timeout = timeout + self.recs = set() def _send_api_request(self, path, args=None): url = self.host + path @@ -75,10 +78,32 @@ def _tidy_prog(self, prog, uuid=None): "duplicate": x.get("duplicate", 0) > 0, } + def _check_recording(self, progs): + dtnow = datetime.now() + live_recs = set() + for prog in progs: + recording = prog["start"] <= dtnow <= prog["stop"] + prog["recording"] = recording + if recording: + live_recs.add((prog["uuid"], prog["title"])) + + # Fire hooks for new recordings + for _, title in live_recs - self.recs: + hook.fire("tvh_recording_started", title) + + # Fire jooks for finished recordings + for _, title in self.recs - live_recs: + hook.fire("tvh_recording_ended", title) + + self.recs = live_recs + + return progs + def get_upcoming(self, path, hide_duplicates=True): programmes = self._send_api_request(path) if programmes: programmes = [self._tidy_prog(x) for x in programmes["entries"]] + programmes = self._check_recording(programmes) programmes = sorted(programmes, key=lambda x: x["start_epoch"]) if hide_duplicates: programmes = [p for p in programmes if not p["duplicate"]] @@ -139,6 +164,8 @@ class TVHWidget(base._Widget, base.MarginMixin): _dependencies = ["requests"] + _hooks = [h.name for h in hook.tvh_hooks] + def __init__(self, **config): base._Widget.__init__(self, bar.CALCULATED, **config) self.add_defaults(TVHWidget.defaults) @@ -148,6 +175,7 @@ def __init__(self, **config): self.iconsize = 0 self.popup = None self.add_callbacks({"Button1": self.toggle_info}) + self._rec = None def _configure(self, qtile, bar): base._Widget._configure(self, qtile, bar) @@ -225,13 +253,7 @@ def is_recording(self): if not self.data: return False - dtnow = datetime.now() - - for prog in self.data: - if prog["start"] <= dtnow <= prog["stop"]: - return True - - return False + return any(p["recording"] for p in self.data) def toggle_info(self): if self.popup: diff --git a/qtile_extras/widget/upower.py b/qtile_extras/widget/upower.py index c8b05bad..6ff9a3c0 100644 --- a/qtile_extras/widget/upower.py +++ b/qtile_extras/widget/upower.py @@ -19,6 +19,7 @@ # SOFTWARE. import asyncio +from enum import Enum, auto from dbus_next.aio import MessageBus from dbus_next.constants import BusType @@ -26,6 +27,8 @@ from libqtile.log_utils import logger from libqtile.widget import base +from qtile_extras import hook + PROPS_IFACE = "org.freedesktop.DBus.Properties" UPOWER_SERVICE = "org.freedesktop.UPower" UPOWER_INTERFACE = "org.freedesktop.UPower" @@ -34,6 +37,13 @@ UPOWER_BUS = BusType.SYSTEM +class BatteryState(Enum): + NONE = auto() + FULL = auto() + LOW = auto() + CRITICAL = auto() + + class UPowerWidget(base._Widget): """ A graphical widget to display laptop battery level. @@ -93,6 +103,8 @@ class UPowerWidget(base._Widget): _dependencies = ["dbus-next"] + _hooks = [h.name for h in hook.upower_hooks] + def __init__(self, **config): base._Widget.__init__(self, bar.CALCULATED, **config) self.add_defaults(UPowerWidget.defaults) @@ -216,6 +228,7 @@ async def find_batteries(self): bat["device"] = battery_dev bat["props"] = props bat["name"] = await battery_dev.get_native_path() + bat["flags"] = BatteryState.NONE battery_devices.append(bat) @@ -241,7 +254,13 @@ def upower_change(self, interface, changed, invalidated): asyncio.create_task(self._upower_change()) async def _upower_change(self): - self.charging = not await self.upower.get_on_battery() + charging = not await self.upower.get_on_battery() + if charging != self.charging: + if charging: + hook.fire("up_power_connected") + else: + hook.fire("up_power_disconnected") + self.charging = charging asyncio.create_task(self._update_battery_info()) def battery_change(self, interface, changed, invalidated): @@ -256,14 +275,31 @@ async def _update_battery_info(self, draw=True): battery["fraction"] = percentage / 100.0 battery["percentage"] = percentage if self.charging: + if battery["flags"] in (BatteryState.LOW, BatteryState.CRITICAL): + battery["flags"] = BatteryState.NONE ttf = await dev.get_time_to_full() + if ttf == 0 and battery["flags"] != BatteryState.FULL: + hook.fire("up_battery_full", battery["name"]) + battery["flags"] = BatteryState.FULL battery["ttf"] = self.secs_to_hm(ttf) battery["tte"] = "" else: + if battery["flags"] == BatteryState.FULL: + battery["flags"] = BatteryState.NONE tte = await dev.get_time_to_empty() battery["tte"] = self.secs_to_hm(tte) battery["ttf"] = "" - battery["status"] = next(x[1] for x in self.status if battery["fraction"] <= x[0]) + status = next(x[1] for x in self.status if battery["fraction"] <= x[0]) + if status == "Low": + if battery["flags"] != BatteryState.LOW and not self.charging: + hook.fire("up_battery_low", battery["name"]) + battery["flags"] = BatteryState.LOW + elif status == "Critical": + if battery["flags"] != BatteryState.CRITICAL and not self.charging: + hook.fire("up_battery_critical", battery["name"]) + battery["flags"] = BatteryState.CRITICAL + + battery["status"] = status if draw: self.qtile.call_soon(self.bar.draw) @@ -381,7 +417,8 @@ def hide(self): def info(self): info = base._Widget.info(self) info["batteries"] = [ - {k: v for k, v in x.items() if k not in ["device", "props"]} for x in self.batteries + {k: v for k, v in x.items() if k not in ["device", "props", "flags"]} + for x in self.batteries ] info["charging"] = self.charging info["levels"] = self.status