Skip to content

Commit cbb2be3

Browse files
committed
psgtray - a new System Tray Icon feature for use with tkinter port!!
1 parent 436cb39 commit cbb2be3

File tree

1 file changed

+282
-0
lines changed

1 file changed

+282
-0
lines changed

psgtray.py

+282
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
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

Comments
 (0)