Skip to content

Commit

Permalink
Merge branch 'feature-run-detached'
Browse files Browse the repository at this point in the history
  • Loading branch information
moses-palmer committed Oct 20, 2021
2 parents 5376f77 + ccaf7a9 commit 21d0e5d
Show file tree
Hide file tree
Showing 4 changed files with 123 additions and 41 deletions.
16 changes: 14 additions & 2 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,7 @@ it also requires the application runloop to be running. ``pystray.Icon.run()``
will start the runloop.

If you only target *Windows*, calling ``run()`` from a thread other than the
main thread is safe, provided that you also create the ``Icon`` instance in the
same thread.
main thread is safe.

The ``run()`` method accepts an optional argument: ``setup``, a callable.

Expand Down Expand Up @@ -236,6 +235,19 @@ To display a system notification, use :meth:`pystray.Icon.notify`::
lambda icon, item: icon.remove_notification()))))).run()


Integrating with other frameworks
---------------------------------

The *pystray* ``run`` method is blocking, and must be called from the main
thread to maintain platform independence. This is troublesome when attempting
to use frameworks with an event loop, since they may also require running in
the main thread.

For this case you can use ``run_detached``. This allows you to setup the icon
and then pass control to the framework. Please see the documentation for more
information.


Selecting a backend
-------------------

Expand Down
84 changes: 74 additions & 10 deletions lib/pystray/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ class Icon(object):
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.
This must be specified when calling :meth:`run_detached`.
"""
#: Whether this particular implementation has a default action that can be
#: invoked in a special way, such as clicking on the icon.
Expand All @@ -67,7 +79,7 @@ class Icon(object):
HAS_NOTIFICATION = True

def __init__(
self, name, icon=None, title=None, menu=None):
self, name, icon=None, title=None, menu=None, **kwargs):
self._name = name
self._icon = icon or None
self._title = title or ''
Expand All @@ -79,6 +91,12 @@ def __init__(
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()
Expand Down Expand Up @@ -180,17 +198,38 @@ def run(self, setup=None):
to ``True`` is used. If you specify a custom setup function, you
must explicitly set this attribute.
"""
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()
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
You must pass the argument ``darwin_nsapplication`` to the
constructor. This is to ensure that you actually have a reference
to the application instance used to drive the icon.
: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.
"""
Expand Down Expand Up @@ -319,6 +358,31 @@ def _run(self):
"""
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 _run_detached(self):
"""Runs detached.
This method must call :meth:`_mark_ready` once ready.
This is a platform dependent implementation.
"""
# By default, we assume that we can simply delegate to a thread
threading.Thread(target=lambda: self.run(setup)).start()

def _stop(self):
"""Stops the event loop.
Expand Down
42 changes: 24 additions & 18 deletions lib/pystray/_darwin.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,26 @@ class Icon(_base.Icon):
def __init__(self, *args, **kwargs):
super(Icon, self).__init__(*args, **kwargs)

#: The icon delegate
self._delegate = None

#: The NSImage version of the icon
self._icon_image = None

#: The NSApplication managing this icon
self._app = self._options['nsapplication'] \
if 'nsapplication' in self._options \
else AppKit.NSApplication.sharedApplication()
self._detachable = 'nsapplication' in self._options

#: The icon delegate
self._delegate = IconDelegate.alloc().init()
self._delegate.icon = self

self._status_bar = AppKit.NSStatusBar.systemStatusBar()
self._status_item = self._status_bar.statusItemWithLength_(
AppKit.NSVariableStatusItemLength)

self._status_item.button().setTarget_(self._delegate)
self._status_item.button().setAction_(self._ACTION_SELECTOR)

def _show(self):
self._assert_image()
self._update_title()
Expand Down Expand Up @@ -82,20 +96,6 @@ def _update_menu(self):
self._menu_handle = None

def _run(self):
# Make sure there is an NSApplication instance
self._app = AppKit.NSApplication.sharedApplication()

# Make sure we have a delegate to handle the action events
self._delegate = IconDelegate.alloc().init()
self._delegate.icon = self

self._status_bar = AppKit.NSStatusBar.systemStatusBar()
self._status_item = self._status_bar.statusItemWithLength_(
AppKit.NSVariableStatusItemLength)

self._status_item.button().setTarget_(self._delegate)
self._status_item.button().setAction_(self._ACTION_SELECTOR)

# Notify the setup callback
self._mark_ready()

Expand All @@ -117,6 +117,12 @@ def sigint(*args):
PyObjCTools.MachSignals.signal(signal.SIGINT, previous_sigint)
self._status_bar.removeStatusItem_(self._status_item)

def _run_detached(self):
if self._detachable:
self._mark_ready()
else:
raise NotImplementedError()

def _stop(self):
self._app.stop_(self._app)

Expand Down Expand Up @@ -185,7 +191,7 @@ def _create_menu(self, descriptors, callbacks):
:return: a menu
"""
if not descriptors or self._delegate is None:
if not descriptors:
return None

else:
Expand Down
22 changes: 11 additions & 11 deletions lib/pystray/_win32.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class Icon(_base.Icon):
def __init__(self, *args, **kwargs):
super(Icon, self).__init__(*args, **kwargs)

self._atom = self._register_class()
self._icon_handle = None
self._hwnd = None
self._menu_hwnd = None
Expand All @@ -45,17 +46,6 @@ def __init__(self, *args, **kwargs):

self._queue = queue.Queue()

# Create the message loop
msg = wintypes.MSG()
lpmsg = ctypes.byref(msg)
win32.PeekMessage(
lpmsg, None, win32.WM_USER, win32.WM_USER, win32.PM_NOREMOVE)

self._atom = self._register_class()
self._hwnd = self._create_window(self._atom)
self._menu_hwnd = self._create_window(self._atom)
self._HWND_TO_ICON[self._hwnd] = self

def __del__(self):
if self._running:
self._stop()
Expand Down Expand Up @@ -120,6 +110,16 @@ def _update_menu(self):
self._menu_handle = None

def _run(self):
# Create the message loop
msg = wintypes.MSG()
lpmsg = ctypes.byref(msg)
win32.PeekMessage(
lpmsg, None, win32.WM_USER, win32.WM_USER, win32.PM_NOREMOVE)

self._hwnd = self._create_window(self._atom)
self._menu_hwnd = self._create_window(self._atom)
self._HWND_TO_ICON[self._hwnd] = self

self._mark_ready()

# Run the event loop
Expand Down

0 comments on commit 21d0e5d

Please sign in to comment.