Skip to content

Commit 342e5a4

Browse files
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>
1 parent 45fa07b commit 342e5a4

7 files changed

Lines changed: 350 additions & 0 deletions

File tree

Doc/library/tk.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ alternative `GUI frameworks and tools <https://wiki.python.org/moin/GuiProgrammi
3636
dialog.rst
3737
tkinter.messagebox.rst
3838
tkinter.scrolledtext.rst
39+
tkinter.systray.rst
3940
tkinter.dnd.rst
4041
tkinter.ttk.rst
4142
idle.rst

Doc/library/tkinter.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,9 @@ The modules that provide Tk support include:
155155
:mod:`tkinter.simpledialog`
156156
Basic dialogs and convenience functions.
157157

158+
:mod:`tkinter.systray`
159+
System tray icon and desktop notifications.
160+
158161
:mod:`tkinter.ttk`
159162
Themed widget set introduced in Tk 8.5, providing modern alternatives
160163
for many of the classic widgets in the main :mod:`!tkinter` module.

Doc/library/tkinter.systray.rst

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
:mod:`!tkinter.systray` --- System tray icon and notifications
2+
==============================================================
3+
4+
.. module:: tkinter.systray
5+
:synopsis: System tray icon and desktop notifications
6+
7+
**Source code:** :source:`Lib/tkinter/systray.py`
8+
9+
.. versionadded:: next
10+
11+
--------------
12+
13+
The :mod:`!tkinter.systray` module provides the :class:`SysTrayIcon` class
14+
as an interface to the system tray (or taskbar) icon,
15+
and the :func:`notify` function which sends a desktop notification.
16+
They require Tk 8.7/9.0 or newer.
17+
18+
Only one system tray icon is supported per Tcl interpreter.
19+
20+
.. class:: SysTrayIcon(master=None, *, exists=False, **options)
21+
22+
The class implementing the system tray icon.
23+
24+
With *exists* false (the default), a new icon is created;
25+
creating a second one raises :exc:`~tkinter.TclError`.
26+
With *exists* true, the instance refers to the already-existing icon
27+
instead of creating one, reconfiguring it with any given options.
28+
29+
The supported configuration options are:
30+
31+
* *image* --- the image displayed in the system tray
32+
(required when creating an icon).
33+
On Windows, it must be a :class:`!PhotoImage`.
34+
* *text* --- the text displayed in the tooltip of the icon.
35+
* *button1* --- a callback that is called without arguments
36+
when the icon is clicked with the left mouse button.
37+
* *button3* --- a callback that is called without arguments
38+
when the icon is clicked with the right mouse button.
39+
40+
.. method:: configure(**options)
41+
config(**options)
42+
43+
Query or modify the options of the system tray icon.
44+
With no arguments, return a dict of all option values.
45+
With a string argument, return the value of that option.
46+
Otherwise, set the given options.
47+
48+
.. method:: cget(option)
49+
50+
Return the value of the given option of the system tray icon.
51+
52+
.. method:: exists()
53+
54+
Return whether the system tray icon exists.
55+
56+
.. method:: destroy()
57+
58+
Destroy the system tray icon.
59+
A new icon can be created afterwards.
60+
61+
.. method:: notify(title, message)
62+
63+
Send a desktop notification with the given title and message.
64+
65+
66+
.. function:: notify(title, message, *, master=None)
67+
68+
Send a desktop notification with the given title and message
69+
without creating a system tray icon first.
70+
On Windows, sending a notification requires an existing system
71+
tray icon, which is also displayed in the notification;
72+
use the :meth:`SysTrayIcon.notify` method instead.

Doc/whatsnew/3.16.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,12 @@ tkinter
461461
them.
462462
(Contributed by Serhiy Storchaka in :gh:`59396`.)
463463

464+
* Added the :mod:`tkinter.systray` module which provides the
465+
:class:`~tkinter.systray.SysTrayIcon` class as an interface to the system
466+
tray icon and the :func:`~tkinter.systray.notify` function which sends a
467+
desktop notification. They require Tk 8.7/9.0 or newer.
468+
(Contributed by Serhiy Storchaka in :gh:`153259`.)
469+
464470
* :class:`tkinter.scrolledtext.ScrolledText` gained a *use_ttk* parameter to use
465471
the themed :mod:`tkinter.ttk` frame and scroll bar instead of the classic
466472
:mod:`tkinter` widgets.
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import unittest
2+
import tkinter
3+
from tkinter.systray import SysTrayIcon, notify
4+
from test.support import requires
5+
from test.test_tkinter.support import (AbstractTkTest,
6+
AbstractDefaultRootTest,
7+
requires_tk,
8+
setUpModule) # noqa: F401
9+
10+
requires('gui')
11+
12+
13+
class SysTrayIconTest(AbstractTkTest, unittest.TestCase):
14+
15+
def setUp(self):
16+
super().setUp()
17+
self.image = tkinter.PhotoImage(master=self.root,
18+
width=16, height=16)
19+
20+
def create(self, **kwargs):
21+
try:
22+
icon = SysTrayIcon(self.root, image=self.image, **kwargs)
23+
except tkinter.TclError as e:
24+
self.skipTest(f'cannot create a system tray icon: {e}')
25+
self.addCleanup(self._destroy, icon)
26+
return icon
27+
28+
def _destroy(self, icon):
29+
try:
30+
icon.destroy()
31+
except tkinter.TclError:
32+
pass
33+
34+
@requires_tk(8, 7)
35+
def test_create(self):
36+
icon = self.create(text='tooltip')
37+
self.assertTrue(icon.exists())
38+
self.assertEqual(str(icon.cget('image')), str(self.image))
39+
self.assertEqual(icon.cget('text'), 'tooltip')
40+
41+
@requires_tk(8, 7)
42+
def test_create_requires_image(self):
43+
with self.assertRaises(TypeError):
44+
SysTrayIcon(self.root)
45+
46+
@requires_tk(8, 7)
47+
def test_exists_argument(self):
48+
icon = self.create(text='tooltip')
49+
# exists=True refers to the already-created icon without creating
50+
# a new one (which would raise the singleton error).
51+
icon2 = SysTrayIcon(self.root, exists=True)
52+
self.assertTrue(icon2.exists())
53+
self.assertEqual(icon2.cget('text'), 'tooltip')
54+
# It can reconfigure the existing icon.
55+
SysTrayIcon(self.root, exists=True, text='new')
56+
self.assertEqual(icon.cget('text'), 'new')
57+
58+
@requires_tk(8, 7)
59+
def test_singleton(self):
60+
self.create()
61+
with self.assertRaisesRegex(tkinter.TclError,
62+
'only one system tray icon'):
63+
SysTrayIcon(self.root, image=self.image)
64+
65+
@requires_tk(8, 7)
66+
def test_configure(self):
67+
icon = self.create(text='old')
68+
icon.configure(text='new')
69+
self.assertEqual(icon.cget('text'), 'new')
70+
options = icon.configure()
71+
self.assertIsInstance(options, dict)
72+
self.assertLessEqual({'image', 'text', 'button1', 'button3'},
73+
options.keys())
74+
75+
@requires_tk(8, 7)
76+
def test_callbacks(self):
77+
clicks = []
78+
icon = self.create(button1=lambda: clicks.append(1))
79+
name = icon._command_names['button1']
80+
# The registered callback is called without arguments.
81+
self.root.tk.call(name)
82+
self.assertEqual(clicks, [1])
83+
# Replacing the callback deletes the old Tcl command.
84+
icon.configure(button1=lambda: clicks.append(2))
85+
self.assertFalse(self.root.tk.call('info', 'commands', name))
86+
self.root.tk.call(icon._command_names['button1'])
87+
self.assertEqual(clicks, [1, 2])
88+
# Removing the callback deletes the Tcl command.
89+
name = icon._command_names['button1']
90+
icon.configure(button1=None)
91+
self.assertNotIn('button1', icon._command_names)
92+
self.assertFalse(self.root.tk.call('info', 'commands', name))
93+
94+
@requires_tk(8, 7)
95+
def test_destroy(self):
96+
icon = self.create(button1=lambda: None)
97+
name = icon._command_names['button1']
98+
icon.destroy()
99+
self.assertFalse(icon.exists())
100+
self.assertFalse(self.root.tk.call('info', 'commands', name))
101+
# A new icon can be created after the old one was destroyed.
102+
icon2 = self.create()
103+
self.assertTrue(icon2.exists())
104+
105+
@requires_tk(8, 7)
106+
def test_notify(self):
107+
if self.root._windowingsystem != 'x11':
108+
self.skipTest('cannot safely send a native notification')
109+
icon = self.create()
110+
# Sends a real desktop notification.
111+
icon.notify('Python test', 'tkinter.systray test notification')
112+
113+
114+
class DefaultRootTest(AbstractDefaultRootTest, unittest.TestCase):
115+
116+
@requires_tk(8, 7)
117+
def test_systray(self):
118+
root = tkinter.Tk()
119+
image = tkinter.PhotoImage(master=root, width=16, height=16)
120+
try:
121+
icon = SysTrayIcon(image=image)
122+
except tkinter.TclError as e:
123+
root.destroy()
124+
self.skipTest(f'cannot create a system tray icon: {e}')
125+
self.assertIs(icon.master, root)
126+
icon.destroy()
127+
root.destroy()
128+
tkinter.NoDefaultRoot()
129+
self.assertRaises(RuntimeError, SysTrayIcon, image='none')
130+
self.assertRaises(RuntimeError, notify, 'title', 'message')
131+
132+
133+
if __name__ == "__main__":
134+
unittest.main()

Lib/tkinter/systray.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""Interface to the system tray icon and desktop notifications.
2+
3+
The SysTrayIcon class gives access to the "tk systray" command and
4+
the notify() function gives access to the "tk sysnotify" command.
5+
They require Tk 8.7/9.0 or newer.
6+
7+
Only one system tray icon is supported per Tcl interpreter.
8+
9+
On Windows, sending a notification requires that the system tray icon
10+
has been created first; the icon is also displayed in the
11+
notification.
12+
"""
13+
14+
import tkinter
15+
16+
__all__ = ["SysTrayIcon", "notify"]
17+
18+
19+
class SysTrayIcon:
20+
"""The system tray icon.
21+
22+
Only one system tray icon is supported per Tcl interpreter.
23+
With exists false (the default) a new icon is created, and creating
24+
a second one raises TclError. With exists true this refers to the
25+
already-existing icon instead of creating one.
26+
27+
Supported configuration options are:
28+
29+
image: the image displayed in the system tray (required when
30+
creating an icon; it must be a photo image on Windows)
31+
text: the text displayed in the tooltip of the icon
32+
button1: a callback that is called when the icon is clicked
33+
with the left mouse button
34+
button3: a callback that is called when the icon is clicked
35+
with the right mouse button
36+
"""
37+
38+
def __init__(self, master=None, *, exists=False, **options):
39+
if master is None:
40+
master = tkinter._get_default_root('use the system tray icon')
41+
self.master = master
42+
self._command_names = {}
43+
if exists:
44+
# Refer to the already-existing icon, reconfiguring it if
45+
# any options are given.
46+
if options:
47+
self._call('configure', options)
48+
else:
49+
if options.get('image') is None:
50+
raise TypeError(
51+
"the 'image' argument is required to create an icon")
52+
self._call('create', options)
53+
54+
def _call(self, subcommand, cnf):
55+
# Call "tk systray" with the given options, registering and
56+
# unregistering the callback commands as needed.
57+
master = self.master
58+
cnf = dict(cnf)
59+
new_names = {}
60+
for key in ('button1', 'button3'):
61+
if key in cnf:
62+
command = cnf[key]
63+
if command is None:
64+
cnf[key] = ''
65+
elif callable(command):
66+
new_names[key] = cnf[key] = master._register(command)
67+
try:
68+
master.tk.call('tk', 'systray', subcommand,
69+
*master._options(cnf))
70+
except tkinter.TclError:
71+
for name in new_names.values():
72+
master.deletecommand(name)
73+
raise
74+
for key in ('button1', 'button3'):
75+
if key in cnf:
76+
old_name = self._command_names.pop(key, None)
77+
if old_name is not None:
78+
master.deletecommand(old_name)
79+
if key in new_names:
80+
self._command_names[key] = new_names[key]
81+
82+
def configure(self, cnf=None, **kw):
83+
"""Query or modify the options of the system tray icon.
84+
85+
With no arguments, return a dict of all option values. With a
86+
string argument, return the value of that option. Otherwise,
87+
set the given options.
88+
"""
89+
if kw:
90+
cnf = tkinter._cnfmerge((cnf, kw))
91+
elif cnf:
92+
cnf = tkinter._cnfmerge(cnf)
93+
tk = self.master.tk
94+
if cnf is None:
95+
items = tk.splitlist(tk.call('tk', 'systray', 'configure'))
96+
return {items[i][1:]: items[i+1] for i in range(0, len(items), 2)}
97+
if isinstance(cnf, str):
98+
return tk.call('tk', 'systray', 'configure', '-' + cnf)
99+
self._call('configure', cnf)
100+
config = configure
101+
102+
def cget(self, option):
103+
"""Return the value of the given option of the system tray icon."""
104+
return self.master.tk.call('tk', 'systray', 'configure', '-' + option)
105+
106+
def exists(self):
107+
"""Return whether the system tray icon exists."""
108+
tk = self.master.tk
109+
return tk.getboolean(tk.call('tk', 'systray', 'exists'))
110+
111+
def destroy(self):
112+
"""Destroy the system tray icon."""
113+
self.master.tk.call('tk', 'systray', 'destroy')
114+
for name in self._command_names.values():
115+
self.master.deletecommand(name)
116+
self._command_names.clear()
117+
118+
def notify(self, title, message):
119+
"""Send a desktop notification with the given title and message."""
120+
self.master.tk.call('tk', 'sysnotify', title, message)
121+
122+
123+
def notify(title, message, *, master=None):
124+
"""Send a desktop notification with the given title and message.
125+
126+
On Windows, the system tray icon must have been created first.
127+
"""
128+
if master is None:
129+
master = tkinter._get_default_root('send a notification')
130+
master.tk.call('tk', 'sysnotify', title, message)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Added the :mod:`tkinter.systray` module which provides the
2+
:class:`~tkinter.systray.SysTrayIcon` class as an interface to the system
3+
tray icon and the :func:`~tkinter.systray.notify` function which sends a
4+
desktop notification. They require Tk 8.7/9.0 or newer.

0 commit comments

Comments
 (0)