nordvpntray/venv/lib/python3.12/site-packages/pystray/_base.py
2024-12-23 11:03:07 +01:00

683 lines
21 KiB
Python

# coding=utf-8
# pystray
# Copyright (C) 2016-2022 Moses Palmér
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import functools
import inspect
import itertools
import logging
import threading
from six.moves import queue
class Icon(object):
"""A representation of a system tray icon.
The icon is initially hidden. Set :attr:`visible` to ``True`` to show it.
:param str name: The name of the icon. This is used by the system to
identify the icon.
:param icon: The icon to use. If this is specified, it must be a
:class:`PIL.Image.Image` instance.
:param str title: A short title for the icon.
:param menu: A menu to use as popup menu. This can be either an instance of
:class:`Menu` or an iterable, which will be interpreted as arguments to
the :class:`Menu` constructor, or ``None``, which disables the menu.
The behaviour of the menu depends on the platform. Only one action is
guaranteed to be invokable: the first menu item whose
:attr:`~pystray.MenuItem.default` attribute is set.
Some platforms allow both menu interaction and a special way of
activating the default action, some platform allow only either an
invisible menu with a default entry as special action or a full menu
with no special way to activate the default item, and some platforms do
not support a menu at all.
:param kwargs: Any non-standard platform dependent options. These should be
prefixed with the platform name thus: ``appindicator_``, ``darwin_``,
``gtk_``, ``win32_`` or ``xorg_``.
Supported values are:
``darwin_nsapplication``
An ``NSApplication`` instance used to run the event loop. If this
is not specified, the shared application will be used.
"""
#: Whether this particular implementation has a default action that can be
#: invoked in a special way, such as clicking on the icon.
HAS_DEFAULT_ACTION = True
#: Whether this particular implementation supports menus.
HAS_MENU = True
#: Whether this particular implementation supports displaying mutually
#: exclusive menu items using the :attr:`MenuItem.radio` attribute.
HAS_MENU_RADIO = True
#: Whether this particular implementation supports displaying a
#: notification.
HAS_NOTIFICATION = True
#: The timeout, in secods, before giving up on waiting for the setup thread
#: when stopping the icon.
SETUP_THREAD_TIMEOUT = 5.0
def __init__(
self, name, icon=None, title=None, menu=None, **kwargs):
self._name = name
self._icon = icon or None
self._title = title or ''
self._menu = menu if isinstance(menu, Menu) \
else Menu(*menu) if menu is not None \
else None
self._visible = False
self._icon_valid = False
self._log = logging.getLogger(__name__)
self._running = False
self.__queue = queue.Queue()
prefix = self.__class__.__module__.rsplit('.', 1)[-1][1:] + '_'
self._options = {
key[len(prefix):]: value
for key, value in kwargs.items()
if key.startswith(prefix)}
def __del__(self):
if self.visible:
self._hide()
def __call__(self):
if self._menu is not None:
self._menu(self)
self.update_menu()
@property
def name(self):
"""The name passed to the constructor.
"""
return self._name
@property
def icon(self):
"""The current icon.
Setting this to a falsy value will hide the icon. Setting this to an
image while the icon is hidden has no effect until the icon is shown.
"""
return self._icon
@icon.setter
def icon(self, value):
self._icon = value
self._icon_valid = False
if value:
if self.visible:
self._update_icon()
else:
if self.visible:
self.visible = False
@property
def title(self):
"""The current icon title.
"""
return self._title
@title.setter
def title(self, value):
if value != self._title:
self._title = value
if self.visible:
self._update_title()
@property
def menu(self):
"""The menu.
Setting this to a falsy value will disable the menu.
"""
return self._menu
@menu.setter
def menu(self, value):
self._menu = value
self.update_menu()
@property
def visible(self):
"""Whether the icon is currently visible.
:raises ValueError: if set to ``True`` and no icon image has been set
"""
return self._visible
@visible.setter
def visible(self, value):
if self._visible == value:
return
if value:
if not self._icon:
raise ValueError('cannot show icon without icon data')
if not self._icon_valid:
self._update_icon()
self._show()
self._visible = True
else:
self._hide()
self._visible = False
def run(self, setup=None):
"""Enters the loop handling events for the icon.
This method is blocking until :meth:`stop` is called. It *must* be
called from the main thread.
:param callable setup: An optional callback to execute in a separate
thread once the loop has started. It is passed the icon as its sole
argument.
Please note that this function is started in a thread, and when the
icon is stopped, an attempt to join this thread is made, so
stopping the icon may be blocking for up to
``SETUP_THREAD_TIMEOUT`` seconds if the function is not
well-behaved.
If not specified, a simple setup function setting :attr:`visible`
to ``True`` is used. If you specify a custom setup function, you
must explicitly set this attribute.
"""
self._start_setup(setup)
self._run()
def run_detached(self, setup=None):
"""Prepares for running the loop handling events detached.
This allows integrating *pystray* with other libraries requiring a
mainloop. Call this method before entering the mainloop of the other
library.
Depending on the backend used, calling this method may require special
preparations:
macOS
Pass an instance of ``NSApplication`` retrieved from the library
with which you are integrating as the argument
``darwin_nsapplication``. This will allow this library to integrate
with the main loop.
:param callable setup: An optional callback to execute in a separate
thread once the loop has started. It is passed the icon as its sole
argument.
If not specified, a simple setup function setting :attr:`visible`
to ``True`` is used. If you specify a custom setup function, you
must explicitly set this attribute.
:raises NotImplementedError: if this is not implemented for the
preparations taken
"""
self._start_setup(setup)
self._run_detached()
def stop(self):
"""Stops the loop handling events for the icon.
If the icon is not running, calling this method has no effect.
"""
if self._running:
self._stop()
if self._setup_thread.ident != threading.current_thread().ident:
self._setup_thread.join(timeout=self.SETUP_THREAD_TIMEOUT)
if self._setup_thread.is_alive():
self._log.warning(
'The function passed as setup to the icon did not '
'finish within {} seconds after icon was '
'stopped'.format(
self.SETUP_THREAD_TIMEOUT))
self._running = False
def update_menu(self):
"""Updates the menu.
If the properties of the menu descriptor are dynamic, that is, any are
defined by callables and not constants, and the return values of these
callables change by actions other than the menu item activation
callbacks, calling this function is required to keep the menu in sync.
This is required since not all supported platforms allow the menu to be
generated when shown.
For simple use cases where menu changes are triggered by interaction
with the menu, this method is not necessary.
"""
self._update_menu()
def notify(self, message, title=None):
"""Displays a notification.
The notification will generally be visible until
:meth:`remove_notification` is called.
The class field :attr:`HAS_NOTIFICATION` indicates whether this feature
is supported on the current platform.
:param str message: The message of the notification.
:param str title: The title of the notification. This will be replaced
with :attr:`title` if ``None``.
"""
self._notify(message, title)
def remove_notification(self):
"""Remove a notification.
"""
self._remove_notification()
def _mark_ready(self):
"""Marks the icon as ready.
The setup callback passed to :meth:`run` will not be called until this
method has been invoked.
Before the setup method is scheduled to be called, :meth:`update_menu`
is called.
"""
self._running = True
try:
self.update_menu()
finally:
self.__queue.put(True)
def _handler(self, callback):
"""Generates a callback handler.
This method is used in platform implementations to create callback
handlers. It will return a function taking any parameters, which will
call ``callback`` with ``self`` and then call :meth:`update_menu`.
:param callable callback: The callback to wrap.
:return: a wrapped callback
"""
@functools.wraps(callback)
def inner(*args, **kwargs):
try:
callback(self)
finally:
self.update_menu()
return inner
def _show(self):
"""The implementation of the :meth:`show` method.
This is a platform dependent implementation.
"""
raise NotImplementedError()
def _hide(self):
"""The implementation of the :meth:`hide` method.
This is a platform dependent implementation.
"""
raise NotImplementedError()
def _update_icon(self):
"""Updates the image for an already shown icon.
This method should self :attr:`_icon_valid` to ``True`` if the icon is
successfully updated.
This is a platform dependent implementation.
"""
raise NotImplementedError()
def _update_title(self):
"""Updates the title for an already shown icon.
This is a platform dependent implementation.
"""
raise NotImplementedError()
def _update_menu(self):
"""Updates the native menu state to mimic :attr:`menu`.
This is a platform dependent implementation.
"""
raise NotImplementedError()
def _run(self):
"""Runs the event loop.
This method must call :meth:`_mark_ready` once the loop is ready.
This is a platform dependent implementation.
"""
raise NotImplementedError()
def _run_detached(self):
"""Runs detached.
This method must call :meth:`_mark_ready` once ready.
This is a platform dependent implementation.
"""
raise NotImplementedError()
def _start_setup(self, setup):
"""Starts the setup thread.
:param callable setup: The thread handler.
"""
def setup_handler():
self.__queue.get()
if setup:
setup(self)
else:
self.visible = True
self._setup_thread = threading.Thread(target=setup_handler)
self._setup_thread.start()
def _stop(self):
"""Stops the event loop.
This is a platform dependent implementation.
"""
raise NotImplementedError()
def _notify(self, message, title=None):
"""Show a notification.
This is a platform dependent implementation.
"""
raise NotImplementedError()
def _remove_notification(self):
"""Remove a notification.
This is a platform dependent implementation.
"""
raise NotImplementedError()
class MenuItem(object):
"""A single menu item.
A menu item is immutable.
It has a text and an action. The action is either a callable of a menu. It
is callable; when called, the activation callback is called.
The :attr:`visible` attribute is provided to make menu creation easier; all
menu items with this value set to ``False`` will be discarded when a
:class:`Menu` is constructed.
"""
def __init__(
self, text, action, checked=None, radio=False, default=False,
visible=True, enabled=True):
self.__name__ = str(text)
self._text = self._wrap(text or '')
self._action = self._assert_action(action)
self._checked = self._assert_callable(checked, lambda _: None)
self._radio = self._wrap(radio)
self._default = self._wrap(default)
self._visible = self._wrap(visible)
self._enabled = self._wrap(enabled)
def __call__(self, icon):
if not isinstance(self._action, Menu):
return self._action(icon, self)
def __str__(self):
if isinstance(self._action, Menu):
return '%s =>\n%s' % (self.text, str(self._action))
else:
return self.text
@property
def text(self):
"""The menu item text.
"""
return self._text(self)
@property
def checked(self):
"""Whether this item is checked.
This can be either ``True``, which implies that the item is checkable
and checked, ``False``, which implies that the item is checkable but
not checked, and ``None`` for uncheckable items.
Depending on platform, uncheckable items may be rendered differently
from unchecked items.
"""
return self._checked(self)
@property
def radio(self):
"""Whether this item is a radio button.
This is only used for checkable items. It is always set to ``False``
for uncheckable items.
"""
if self.checked is not None:
return self._radio(self)
else:
return False
@property
def default(self):
"""Whether this is the default menu item.
"""
return self._default(self)
@property
def visible(self):
"""Whether this menu item is visible.
If the action for this menu item is a menu, that also has to be visible
for this property to be ``True``.
"""
if isinstance(self._action, Menu):
return self._visible(self) and self._action.visible
else:
return self._visible(self)
@property
def enabled(self):
"""Whether this menu item is enabled.
"""
return self._enabled(self)
@property
def submenu(self):
"""The submenu used by this menu item, or ``None``.
"""
return self._action if isinstance(self._action, Menu) else None
def _assert_action(self, action):
"""Ensures that a callable can be called with the expected number of
arguments.
:param callable action: The action to modify. If this callable takes
less than the expected number of arguments, a wrapper will be
returned.
:raises ValueError: if ``action`` requires more than the expected
number of arguments
:return: a callable
"""
if action is None:
return lambda *_: None
elif not hasattr(action, '__code__'):
return action
else:
argcount = action.__code__.co_argcount - (
1 if inspect.ismethod(action) else 0)
if argcount == 0:
@functools.wraps(action)
def wrapper0(*args):
return action()
return wrapper0
elif argcount == 1:
@functools.wraps(action)
def wrapper1(icon, *args):
return action(icon)
return wrapper1
elif argcount == 2:
return action
else:
raise ValueError(action)
def _assert_callable(self, value, default):
"""Asserts that a value is callable.
If the value is a callable, it will be returned. If the value is
``None``, ``default`` will be returned, otherwise a :class:`ValueError`
will be raised.
:param value: The callable to check.
:param callable default: The default value to return if ``value`` is
``None``
:return: a callable
"""
if value is None:
return default
elif callable(value):
return value
else:
raise ValueError(value)
def _wrap(self, value):
"""Wraps a value in a callable.
If the value already is a callable, it is returned unmodified
:param value: The value or callable to wrap.
"""
return value if callable(value) else lambda _: value
class Menu(object):
"""A description of a menu.
A menu description is immutable.
It is created with a sequence of :class:`Menu.Item` instances, or a single
callable which must return a generator for the menu items.
First, non-visible menu items are removed from the list, then any instances
of :attr:`SEPARATOR` occurring at the head or tail of the item list are
removed, and any consecutive separators are reduced to one.
"""
#: A representation of a simple separator
SEPARATOR = MenuItem('- - - -', None)
def __init__(self, *items):
self._items = tuple(items)
@property
def items(self):
"""All menu items.
"""
if (True
and len(self._items) == 1
and not isinstance(self._items[0], MenuItem)
and callable(self._items[0])):
return self._items[0]()
else:
return self._items
@property
def visible(self):
"""Whether this menu is visible.
"""
return bool(self)
def __call__(self, icon):
try:
return next(
menuitem
for menuitem in self.items
if menuitem.default)(icon)
except StopIteration:
pass
def __iter__(self):
return iter(self._visible_items())
def __bool__(self):
return len(self._visible_items()) > 0
__nonzero__ = __bool__
def __str__(self):
return '\n'.join(
'\n'.join(
' %s' % l
for l in str(i).splitlines())
for i in self)
def _visible_items(self):
"""Returns all visible menu items.
This method also filters redundant separators as is described in the
class documentation.
:return: a tuple containing all currently visible items
"""
def cleaned(items):
was_separator = False
for i in items:
if not i.visible:
continue
if i is self.SEPARATOR:
if was_separator:
continue
was_separator = True
else:
was_separator = False
yield i
def strip_head(items):
return itertools.dropwhile(lambda i: i is self.SEPARATOR, items)
def strip_tail(items):
return reversed(list(strip_head(reversed(list(items)))))
return tuple(strip_tail(strip_head(cleaned(self.items))))