1
+ import pystray , io , base64 , threading , time
2
+ from PIL import Image
3
+ import PySimpleGUI as sg
4
+
5
+ """
6
+ A System Tray Icon implementation that can work with the tkinter port of PySimpleGUI!
7
+
8
+ To use, add this import to your code:
9
+ from psgtray import SystemTray
10
+
11
+ Make sure the psgtray.py file is in the same folder as your app or is on your Python path
12
+
13
+ Because this code is entirely in the user's space it's possible to use the pystray package
14
+ to implement the system tray icon feature. You need to install pystray and PIL.
15
+
16
+ As of this date, the latest version of pystray is 0.17.3
17
+
18
+ This code works well under Windows.
19
+
20
+ On Linux there are some challenges. Some changes were
21
+ needed in order to get pystray to run as a thread using gtk as the backend.
22
+ The separator '---' caused problems so it is now ignored. Unknown why it caused the
23
+ menu to not show at all, but it does.
24
+
25
+ A sample bit of code is at the bottom for your reference.
26
+
27
+ Your window will receive events from the system tray thread.
28
+
29
+ In addition to the init, these are the class methods available:
30
+ change_icon
31
+ hide_icon
32
+ show_icon
33
+ set_tooltip
34
+ notify
35
+
36
+ In your code, you will receive events from tray with key SystemTray.key
37
+ The value will be the choice made or a click event. This is the magic statement:
38
+ window.write_event_value(tray.key, item.text)
39
+
40
+ Extra Special thanks to FireDM for the design pattern that made this work.
41
+ (https://github.com/firedm/FireDM)
42
+ Copyright 2021 PySimpleGUI
43
+ """
44
+
45
+
46
+ class SystemTray :
47
+ DOUBLE_CLICK_THRESHOLD = 500 # time in milliseconds to determine double clicks
48
+ DEFAULT_KEY = '-TRAY-' # the default key that will be used to send events to your window
49
+ key_counter = 0
50
+
51
+ def __init__ (self , menu = None , icon = None , tooltip = '' , single_click_events = False , window = None , key = DEFAULT_KEY ):
52
+ """
53
+ A System Tray Icon
54
+
55
+ Initializing the object is all that is required to make the tray icon and start the thread.
56
+
57
+ :param menu: The PySimpleGUI menu data structure
58
+ :type menu: List[List[Tuple[str, List[str]]]
59
+ :param icon: Icon to show in the system tray. Can be a file or a BASE64 byte string
60
+ :type icon: str | bytes
61
+ :param tooltip: Tooltip that is shown when mouse hovers over the system tray icon
62
+ :type tooltip: str
63
+ :param single_click_events: If True then both single click and double click events will be generated
64
+ :type single_click_events: bool
65
+ :param window: The window where the events will be sent using window.write_event_value
66
+ :type window: sg.Window
67
+ """
68
+ self .title = tooltip
69
+ self .tray_icon = None # type: pystray.Icon
70
+ self .window = window
71
+ self .tooltip = tooltip
72
+ self .menu_items = self ._convert_psg_menu_to_tray (menu [1 ])
73
+ self .key = key if SystemTray .key_counter == 0 else key + str (SystemTray .key_counter )
74
+ SystemTray .key_counter += 1
75
+ self .double_click_timer = 0
76
+ self .single_click_events_enabled = single_click_events
77
+ if icon is None :
78
+ self .icon = sg .DEFAULT_BASE64_ICON
79
+ else :
80
+ self .icon = icon
81
+
82
+ self .thread_started = False
83
+ self .thread = threading .Thread (target = self ._pystray_thread , daemon = True )
84
+ self .thread .start ()
85
+ while not self .thread_started : # wait for the thread to begin
86
+ time .sleep (.2 )
87
+ time .sleep (.2 ) # one more slight delay to allow things to actually get running
88
+
89
+ def change_icon (self , icon = None ):
90
+ """
91
+ Change the icon shown in the tray to a file or a BASE64 byte string.
92
+ :param icon: The icon to change to
93
+ :type icon: str | bytes
94
+ """
95
+ if icon is not None :
96
+ self .tray_icon .icon = self ._create_image (icon )
97
+
98
+ def hide_icon (self ):
99
+ """
100
+ Hides the icon
101
+ """
102
+ self .tray_icon .visible = False
103
+
104
+ def show_icon (self ):
105
+ """
106
+ Shows a previously hidden icon
107
+ """
108
+ self .tray_icon .visible = True
109
+
110
+ def set_tooltip (self , tooltip ):
111
+ """
112
+ Set the tooltip that is shown when hovering over the icon in the system tray
113
+ """
114
+ self .tray_icon .title = tooltip
115
+
116
+ def show_message (self , title = None , message = None ):
117
+ """
118
+ Show a notification message balloon in the system tray
119
+ :param title: Title that is shown at the top of the balloon
120
+ :type title: str
121
+ :param message: Main message to be displayed
122
+ :type message: str
123
+ """
124
+ self .tray_icon .notify (title = str (title ) if title is not None else '' , message = str (message ) if message is not None else '' )
125
+
126
+ def close (self ):
127
+ """
128
+ Whlie not required, calling close will remove the icon from the tray right away.
129
+ """
130
+ self .tray_icon .visible = False # hiding will close any message bubbles that may hold up the removal of icon from tray
131
+ self .tray_icon .stop ()
132
+
133
+ # --------------------------- The methods below this point are not meant to be user callable ---------------------------
134
+ def _on_clicked (self , icon , item : pystray .MenuItem ):
135
+ self .window .write_event_value (self .key , item .text )
136
+
137
+ def _convert_psg_menu_to_tray (self , psg_menu ):
138
+ menu_items = []
139
+ i = 0
140
+ if isinstance (psg_menu , list ):
141
+ while i < len (psg_menu ):
142
+ item = psg_menu [i ]
143
+ look_ahead = item
144
+ if i != (len (psg_menu ) - 1 ):
145
+ look_ahead = psg_menu [i + 1 ]
146
+ if not isinstance (item , list ) and not isinstance (look_ahead , list ):
147
+ disabled = False
148
+ if item == sg .MENU_SEPARATOR_LINE :
149
+ item = pystray .Menu .SEPARATOR
150
+ elif item .startswith (sg .MENU_DISABLED_CHARACTER ):
151
+ disabled = True
152
+ item = item [1 :]
153
+ if not (item == pystray .Menu .SEPARATOR and sg .running_linux ()):
154
+ menu_items .append (pystray .MenuItem (item , self ._on_clicked , enabled = not disabled , default = False ))
155
+ elif look_ahead != item :
156
+ if isinstance (look_ahead , list ):
157
+ if menu_items is None :
158
+ menu_items = pystray .MenuItem (item , pystray .Menu (* self ._convert_psg_menu_to_tray (look_ahead )))
159
+ else :
160
+ menu_items .append (pystray .MenuItem (item , pystray .Menu (* self ._convert_psg_menu_to_tray (look_ahead ))))
161
+ i += 1
162
+ # important item - this is where clicking the icon itself will go
163
+ menu_items .append (pystray .MenuItem ('default' , self ._default_action_callback , enabled = True , default = True , visible = False ))
164
+
165
+ return menu_items
166
+
167
+ def _default_action_callback (self ):
168
+ delta = (time .time () - self .double_click_timer ) * 1000
169
+ if delta < self .DOUBLE_CLICK_THRESHOLD : # if last click was recent, then this click is a double-click
170
+ self .window .write_event_value (self .key , sg .EVENT_SYSTEM_TRAY_ICON_DOUBLE_CLICKED )
171
+ self .double_click_timer = 0
172
+ else :
173
+ if self .single_click_events_enabled :
174
+ self .window .write_event_value (self .key , sg .EVENT_SYSTEM_TRAY_ICON_ACTIVATED )
175
+ self .double_click_timer = time .time ()
176
+
177
+ def _pystray_thread (self ):
178
+ self .tray_icon = pystray .Icon (self .title , self ._create_image (self .icon ))
179
+ self .tray_icon .default_action = self ._default_action_callback
180
+ self .tray_icon .menu = pystray .Menu (* self .menu_items )
181
+ self .tray_icon .title = self .tooltip # tooltip for the icon
182
+ self .thread_started = True
183
+ self .tray_icon .run ()
184
+
185
+ def _create_image (self , icon ):
186
+ if isinstance (icon , bytes ):
187
+ buffer = io .BytesIO (base64 .b64decode (icon ))
188
+ img = Image .open (buffer )
189
+ elif isinstance (icon , str ):
190
+ img = Image .open (icon )
191
+ else :
192
+ img = None
193
+ return img
194
+
195
+
196
+ # MM""""""""`M dP
197
+ # MM mmmmmmmM 88
198
+ # M` MMMM dP. .dP .d8888b. 88d8b.d8b. 88d888b. 88 .d8888b.
199
+ # MM MMMMMMMM `8bd8' 88' `88 88'`88'`88 88' `88 88 88ooood8
200
+ # MM MMMMMMMM .d88b. 88. .88 88 88 88 88. .88 88 88. ...
201
+ # MM .M dP' `dP `88888P8 dP dP dP 88Y888P' dP `88888P'
202
+ # MMMMMMMMMMMM 88
203
+ # dP
204
+ # M""MMMMM""M
205
+ # M MMMMM M
206
+ # M MMMMM M .d8888b. .d8888b.
207
+ # M MMMMM M Y8ooooo. 88ooood8
208
+ # M `MMM' M 88 88. ...
209
+ # Mb dM `88888P' `88888P'
210
+ # MMMMMMMMMMM
211
+
212
+
213
+ def main ():
214
+ # This example shows using TWO tray icons together
215
+
216
+ menu = ['' , ['Show Window' , 'Hide Window' , '---' , '!Disabled Item' , 'Change Icon' , ['Happy' , 'Sad' , 'Plain' ], 'Exit' ]]
217
+ tooltip = 'Tooltip'
218
+
219
+ layout = [[sg .Text ('My PySimpleGUI Window with a Tray Icon - X will minimize to tray' )],
220
+ [sg .Text ('Note - you are running a file that is meant to be imported' )],
221
+ [sg .T ('Change Icon Tooltip:' ), sg .Input (tooltip , key = '-IN-' , s = (20 ,1 )), sg .B ('Change Tooltip' )],
222
+ [sg .Multiline (size = (60 ,10 ), reroute_stdout = False , reroute_cprint = True , write_only = True , key = '-OUT-' )],
223
+ [sg .Button ('Go' ), sg .B ('Hide Icon' ), sg .B ('Show Icon' ), sg .B ('Hide Window' ), sg .B ('Close Tray' ), sg .Button ('Exit' )]]
224
+
225
+ window = sg .Window ('Window Title' , layout , finalize = True , enable_close_attempted_event = True )
226
+
227
+
228
+ tray1 = SystemTray (menu , single_click_events = False , window = window , tooltip = tooltip , icon = sg .DEFAULT_BASE64_ICON )
229
+ tray2 = SystemTray (menu , single_click_events = False , window = window , tooltip = tooltip , icon = sg .EMOJI_BASE64_HAPPY_JOY )
230
+ time .sleep (.5 ) # wait just a little bit since TWO are being started at once
231
+ tray2 .show_message ('Started' , 'Both tray icons started' )
232
+
233
+ while True :
234
+ event , values = window .read ()
235
+ print (event , values )
236
+ # IMPORTANT step. It's not required, but convenient.
237
+ # if it's a tray event, change the event variable to be whatever the tray sent
238
+ # This will make your event loop homogeneous with event conditionals all using the same event variable
239
+ if event in (tray1 .key , tray2 .key ):
240
+ sg .cprint (f'System Tray Event = ' , values [event ], c = 'white on red' )
241
+ tray = tray1 if event == tray1 .key else tray2
242
+ event = values [event ] # use the System Tray's event as if was from the window
243
+ else :
244
+ tray = tray1 # if wasn't a tray event, there's still a tray varaible used, so default to "first" tray created
245
+
246
+ if event in (sg .WIN_CLOSED , 'Exit' ):
247
+ break
248
+
249
+ sg .cprint (event , values )
250
+ tray .show_message (title = event , message = values )
251
+ tray .set_tooltip (values ['-IN-' ])
252
+
253
+ if event in ('Show Window' , sg .EVENT_SYSTEM_TRAY_ICON_DOUBLE_CLICKED ):
254
+ window .un_hide ()
255
+ window .bring_to_front ()
256
+ elif event in ('Hide Window' , sg .WIN_CLOSE_ATTEMPTED_EVENT ):
257
+ window .hide ()
258
+ tray .show_icon () # if hiding window, better make sure the icon is visible
259
+ # tray.notify('System Tray Item Chosen', f'You chose {event}')
260
+ elif event == 'Happy' :
261
+ tray .change_icon (sg .EMOJI_BASE64_HAPPY_JOY )
262
+ elif event == 'Sad' :
263
+ tray .change_icon (sg .EMOJI_BASE64_FRUSTRATED )
264
+ elif event == 'Plain' :
265
+ tray .change_icon (sg .DEFAULT_BASE64_ICON )
266
+ elif event == 'Hide Icon' :
267
+ tray .hide_icon ()
268
+ elif event == 'Show Icon' :
269
+ tray .show_icon ()
270
+ elif event == 'Close Tray' :
271
+ tray .close ()
272
+ elif event == 'Change Tooltip' :
273
+ tray .set_tooltip (values ['-IN-' ])
274
+
275
+ tray1 .close ()
276
+ tray2 .close ()
277
+ window .close ()
278
+
279
+
280
+ if __name__ == '__main__' :
281
+ # Normally this file is not "run"
282
+ main ()
0 commit comments