# coding=utf-8
# pystray
# Copyright (C) 2016 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 itertools
import logging
import threading
from six.moves import queue
[docs]class Icon(object):
"""A representation of a system tray icon.
The icon is initially hidden. Call :meth:`show` 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 a tuple, which will be interpreted as arguments to the
:class:`Menu` constructor.
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.
"""
#: 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
def __init__(
self, name, icon=None, title=None, menu=None):
self._name = name
self._icon = icon or None
self._title = title or ''
self._menu = menu
self._visible = False
self._log = logging.getLogger(__name__)
self._running = False
self.__queue = queue.Queue()
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
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')
self._show()
self._visible = True
else:
self._hide()
self._visible = False
[docs] 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.
"""
def setup_handler():
self.__queue.get()
if setup:
setup(self)
self._setup_thread = threading.Thread(target=setup_handler)
self._setup_thread.start()
self._run()
self._running = True
[docs] def stop(self):
"""Stops the loop handling events for the icon.
"""
self._stop()
if self._setup_thread.ident != threading.current_thread().ident:
self._setup_thread.join()
self._running = False
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.update_menu()
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 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 _create_menu_handle(self):
"""Creates an opaque menu handle from :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 _stop(self):
"""Stops the event loop.
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, default=False,
visible=True):
self.__name__ = str(text)
self._text = self._wrap(text or '')
# Check for None to allow instantiation from Menu class body
self._action = action \
if action is not None and isinstance(action, Menu) \
else self._wrap(action)
self._checked = self._assert_callable(checked, lambda _: None)
self._default = self._wrap(default)
self._visible = self._wrap(visible)
def __call__(self, icon):
if not isinstance(self._action, Menu):
return self._action(icon)
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.
"""
return self._checked(self)
@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 submenu(self):
"""The submenu used by this menu item, or ``None``.
"""
return self._action if isinstance(self._action, Menu) else None
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))))