gh-153259: Add tkinter.systray -- system tray icon and notifications (GH-153260)

Add the tkinter.systray module with the SysTrayIcon class wrapping the
"tk systray" command and the notify() function wrapping "tk sysnotify"
(Tk 8.7/9.0 and newer).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Serhiy StorchakaandClaude Opus 4.8 authored and GitHub committed 2026-07-11 08:34:13 +03:00
1 parent 45fa07b025
commit 342e5a4218
7 files changed
+350

No files matched your search

+1
View File
@@ -36,6 +36,7 @@ alternative `GUI frameworks and tools <https://wiki.python.org/moin/GuiProgrammi
dialog.rst
tkinter.messagebox.rst
tkinter.scrolledtext.rst
tkinter.systray.rst
tkinter.dnd.rst
tkinter.ttk.rst
idle.rst
+3
View File
@@ -155,6 +155,9 @@ The modules that provide Tk support include:
:mod:`tkinter.simpledialog`
Basic dialogs and convenience functions.
:mod:`tkinter.systray`
System tray icon and desktop notifications.
:mod:`tkinter.ttk`
Themed widget set introduced in Tk 8.5, providing modern alternatives
for many of the classic widgets in the main :mod:`!tkinter` module.
+72
View File
@@ -0,0 +1,72 @@
:mod:`!tkinter.systray` --- System tray icon and notifications
==============================================================
.. module:: tkinter.systray
:synopsis: System tray icon and desktop notifications
**Source code:** :source:`Lib/tkinter/systray.py`
.. versionadded:: next
--------------
The :mod:`!tkinter.systray` module provides the :class:`SysTrayIcon` class
as an interface to the system tray (or taskbar) icon,
and the :func:`notify` function which sends a desktop notification.
They require Tk 8.7/9.0 or newer.
Only one system tray icon is supported per Tcl interpreter.
.. class:: SysTrayIcon(master=None, *, exists=False, **options)
The class implementing the system tray icon.
With *exists* false (the default), a new icon is created;
creating a second one raises :exc:`~tkinter.TclError`.
With *exists* true, the instance refers to the already-existing icon
instead of creating one, reconfiguring it with any given options.
The supported configuration options are:
* *image* --- the image displayed in the system tray
(required when creating an icon).
On Windows, it must be a :class:`!PhotoImage`.
* *text* --- the text displayed in the tooltip of the icon.
* *button1* --- a callback that is called without arguments
when the icon is clicked with the left mouse button.
* *button3* --- a callback that is called without arguments
when the icon is clicked with the right mouse button.
.. method:: configure(**options)
config(**options)
Query or modify the options of the system tray icon.
With no arguments, return a dict of all option values.
With a string argument, return the value of that option.
Otherwise, set the given options.
.. method:: cget(option)
Return the value of the given option of the system tray icon.
.. method:: exists()
Return whether the system tray icon exists.
.. method:: destroy()
Destroy the system tray icon.
A new icon can be created afterwards.
.. method:: notify(title, message)
Send a desktop notification with the given title and message.
.. function:: notify(title, message, *, master=None)
Send a desktop notification with the given title and message
without creating a system tray icon first.
On Windows, sending a notification requires an existing system
tray icon, which is also displayed in the notification;
use the :meth:`SysTrayIcon.notify` method instead.
+6
View File
@@ -461,6 +461,12 @@ tkinter
them.
(Contributed by Serhiy Storchaka in :gh:`59396`.)
* Added the :mod:`tkinter.systray` module which provides the
:class:`~tkinter.systray.SysTrayIcon` class as an interface to the system
tray icon and the :func:`~tkinter.systray.notify` function which sends a
desktop notification. They require Tk 8.7/9.0 or newer.
(Contributed by Serhiy Storchaka in :gh:`153259`.)
* :class:`tkinter.scrolledtext.ScrolledText` gained a *use_ttk* parameter to use
the themed :mod:`tkinter.ttk` frame and scroll bar instead of the classic
:mod:`tkinter` widgets.
+134
View File
@@ -0,0 +1,134 @@
import unittest
import tkinter
from tkinter.systray import SysTrayIcon, notify
from test.support import requires
from test.test_tkinter.support import (AbstractTkTest,
AbstractDefaultRootTest,
requires_tk,
setUpModule) # noqa: F401
requires('gui')
class SysTrayIconTest(AbstractTkTest, unittest.TestCase):
def setUp(self):
super().setUp()
self.image = tkinter.PhotoImage(master=self.root,
width=16, height=16)
def create(self, **kwargs):
try:
icon = SysTrayIcon(self.root, image=self.image, **kwargs)
except tkinter.TclError as e:
self.skipTest(f'cannot create a system tray icon: {e}')
self.addCleanup(self._destroy, icon)
return icon
def _destroy(self, icon):
try:
icon.destroy()
except tkinter.TclError:
pass
@requires_tk(8, 7)
def test_create(self):
icon = self.create(text='tooltip')
self.assertTrue(icon.exists())
self.assertEqual(str(icon.cget('image')), str(self.image))
self.assertEqual(icon.cget('text'), 'tooltip')
@requires_tk(8, 7)
def test_create_requires_image(self):
with self.assertRaises(TypeError):
SysTrayIcon(self.root)
@requires_tk(8, 7)
def test_exists_argument(self):
icon = self.create(text='tooltip')
# exists=True refers to the already-created icon without creating
# a new one (which would raise the singleton error).
icon2 = SysTrayIcon(self.root, exists=True)
self.assertTrue(icon2.exists())
self.assertEqual(icon2.cget('text'), 'tooltip')
# It can reconfigure the existing icon.
SysTrayIcon(self.root, exists=True, text='new')
self.assertEqual(icon.cget('text'), 'new')
@requires_tk(8, 7)
def test_singleton(self):
self.create()
with self.assertRaisesRegex(tkinter.TclError,
'only one system tray icon'):
SysTrayIcon(self.root, image=self.image)
@requires_tk(8, 7)
def test_configure(self):
icon = self.create(text='old')
icon.configure(text='new')
self.assertEqual(icon.cget('text'), 'new')
options = icon.configure()
self.assertIsInstance(options, dict)
self.assertLessEqual({'image', 'text', 'button1', 'button3'},
options.keys())
@requires_tk(8, 7)
def test_callbacks(self):
clicks = []
icon = self.create(button1=lambda: clicks.append(1))
name = icon._command_names['button1']
# The registered callback is called without arguments.
self.root.tk.call(name)
self.assertEqual(clicks, [1])
# Replacing the callback deletes the old Tcl command.
icon.configure(button1=lambda: clicks.append(2))
self.assertFalse(self.root.tk.call('info', 'commands', name))
self.root.tk.call(icon._command_names['button1'])
self.assertEqual(clicks, [1, 2])
# Removing the callback deletes the Tcl command.
name = icon._command_names['button1']
icon.configure(button1=None)
self.assertNotIn('button1', icon._command_names)
self.assertFalse(self.root.tk.call('info', 'commands', name))
@requires_tk(8, 7)
def test_destroy(self):
icon = self.create(button1=lambda: None)
name = icon._command_names['button1']
icon.destroy()
self.assertFalse(icon.exists())
self.assertFalse(self.root.tk.call('info', 'commands', name))
# A new icon can be created after the old one was destroyed.
icon2 = self.create()
self.assertTrue(icon2.exists())
@requires_tk(8, 7)
def test_notify(self):
if self.root._windowingsystem != 'x11':
self.skipTest('cannot safely send a native notification')
icon = self.create()
# Sends a real desktop notification.
icon.notify('Python test', 'tkinter.systray test notification')
class DefaultRootTest(AbstractDefaultRootTest, unittest.TestCase):
@requires_tk(8, 7)
def test_systray(self):
root = tkinter.Tk()
image = tkinter.PhotoImage(master=root, width=16, height=16)
try:
icon = SysTrayIcon(image=image)
except tkinter.TclError as e:
root.destroy()
self.skipTest(f'cannot create a system tray icon: {e}')
self.assertIs(icon.master, root)
icon.destroy()
root.destroy()
tkinter.NoDefaultRoot()
self.assertRaises(RuntimeError, SysTrayIcon, image='none')
self.assertRaises(RuntimeError, notify, 'title', 'message')
if __name__ == "__main__":
unittest.main()
+130
View File
@@ -0,0 +1,130 @@
"""Interface to the system tray icon and desktop notifications.
The SysTrayIcon class gives access to the "tk systray" command and
the notify() function gives access to the "tk sysnotify" command.
They require Tk 8.7/9.0 or newer.
Only one system tray icon is supported per Tcl interpreter.
On Windows, sending a notification requires that the system tray icon
has been created first; the icon is also displayed in the
notification.
"""
import tkinter
__all__ = ["SysTrayIcon", "notify"]
class SysTrayIcon:
"""The system tray icon.
Only one system tray icon is supported per Tcl interpreter.
With exists false (the default) a new icon is created, and creating
a second one raises TclError. With exists true this refers to the
already-existing icon instead of creating one.
Supported configuration options are:
image: the image displayed in the system tray (required when
creating an icon; it must be a photo image on Windows)
text: the text displayed in the tooltip of the icon
button1: a callback that is called when the icon is clicked
with the left mouse button
button3: a callback that is called when the icon is clicked
with the right mouse button
"""
def __init__(self, master=None, *, exists=False, **options):
if master is None:
master = tkinter._get_default_root('use the system tray icon')
self.master = master
self._command_names = {}
if exists:
# Refer to the already-existing icon, reconfiguring it if
# any options are given.
if options:
self._call('configure', options)
else:
if options.get('image') is None:
raise TypeError(
"the 'image' argument is required to create an icon")
self._call('create', options)
def _call(self, subcommand, cnf):
# Call "tk systray" with the given options, registering and
# unregistering the callback commands as needed.
master = self.master
cnf = dict(cnf)
new_names = {}
for key in ('button1', 'button3'):
if key in cnf:
command = cnf[key]
if command is None:
cnf[key] = ''
elif callable(command):
new_names[key] = cnf[key] = master._register(command)
try:
master.tk.call('tk', 'systray', subcommand,
*master._options(cnf))
except tkinter.TclError:
for name in new_names.values():
master.deletecommand(name)
raise
for key in ('button1', 'button3'):
if key in cnf:
old_name = self._command_names.pop(key, None)
if old_name is not None:
master.deletecommand(old_name)
if key in new_names:
self._command_names[key] = new_names[key]
def configure(self, cnf=None, **kw):
"""Query or modify the options of the system tray icon.
With no arguments, return a dict of all option values. With a
string argument, return the value of that option. Otherwise,
set the given options.
"""
if kw:
cnf = tkinter._cnfmerge((cnf, kw))
elif cnf:
cnf = tkinter._cnfmerge(cnf)
tk = self.master.tk
if cnf is None:
items = tk.splitlist(tk.call('tk', 'systray', 'configure'))
return {items[i][1:]: items[i+1] for i in range(0, len(items), 2)}
if isinstance(cnf, str):
return tk.call('tk', 'systray', 'configure', '-' + cnf)
self._call('configure', cnf)
config = configure
def cget(self, option):
"""Return the value of the given option of the system tray icon."""
return self.master.tk.call('tk', 'systray', 'configure', '-' + option)
def exists(self):
"""Return whether the system tray icon exists."""
tk = self.master.tk
return tk.getboolean(tk.call('tk', 'systray', 'exists'))
def destroy(self):
"""Destroy the system tray icon."""
self.master.tk.call('tk', 'systray', 'destroy')
for name in self._command_names.values():
self.master.deletecommand(name)
self._command_names.clear()
def notify(self, title, message):
"""Send a desktop notification with the given title and message."""
self.master.tk.call('tk', 'sysnotify', title, message)
def notify(title, message, *, master=None):
"""Send a desktop notification with the given title and message.
On Windows, the system tray icon must have been created first.
"""
if master is None:
master = tkinter._get_default_root('send a notification')
master.tk.call('tk', 'sysnotify', title, message)
@@ -0,0 +1,4 @@
Added the :mod:`tkinter.systray` module which provides the
:class:`~tkinter.systray.SysTrayIcon` class as an interface to the system
tray icon and the :func:`~tkinter.systray.notify` function which sends a
desktop notification. They require Tk 8.7/9.0 or newer.