]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-153259: Add tkinter.systray -- system tray icon and notifications (GH-153260)
authorSerhiy Storchaka <storchaka@gmail.com>
Sat, 11 Jul 2026 05:34:13 +0000 (08:34 +0300)
committerGitHub <noreply@github.com>
Sat, 11 Jul 2026 05:34:13 +0000 (08:34 +0300)
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>
Doc/library/tk.rst
Doc/library/tkinter.rst
Doc/library/tkinter.systray.rst [new file with mode: 0644]
Doc/whatsnew/3.16.rst
Lib/test/test_tkinter/test_systray.py [new file with mode: 0644]
Lib/tkinter/systray.py [new file with mode: 0644]
Misc/NEWS.d/next/Library/2026-07-06-14-05-40.gh-issue-153259.sysTr1.rst [new file with mode: 0644]

index fa3c7e910ce21f0779589805a8c1b6142678b40d..c835f2518f7bf63584113d0bee31dbc39c6bbcad 100644 (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
index f72912632deb92413ec4b3a0cf58737a7ee5e576..e989385aa802759161f9d78204725ba6bd52741f 100644 (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.
diff --git a/Doc/library/tkinter.systray.rst b/Doc/library/tkinter.systray.rst
new file mode 100644 (file)
index 0000000..22ecbf5
--- /dev/null
@@ -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.
index af4e7f6fda59fcaa0f965f875d1f9a76d62a0c06..48573eff9827fea56049f4eda570564d793686b7 100644 (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.
diff --git a/Lib/test/test_tkinter/test_systray.py b/Lib/test/test_tkinter/test_systray.py
new file mode 100644 (file)
index 0000000..8090347
--- /dev/null
@@ -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()
diff --git a/Lib/tkinter/systray.py b/Lib/tkinter/systray.py
new file mode 100644 (file)
index 0000000..680879c
--- /dev/null
@@ -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)
diff --git a/Misc/NEWS.d/next/Library/2026-07-06-14-05-40.gh-issue-153259.sysTr1.rst b/Misc/NEWS.d/next/Library/2026-07-06-14-05-40.gh-issue-153259.sysTr1.rst
new file mode 100644 (file)
index 0000000..2355826
--- /dev/null
@@ -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.