psgtray - a new System Tray Icon feature for use with tkinter port!!
This commit is contained in:
parent
436cb397ce
commit
cbb2be3a65
|
@ -0,0 +1,282 @@
|
|||
import pystray, io, base64, threading, time
|
||||
from PIL import Image
|
||||
import PySimpleGUI as sg
|
||||
|
||||
"""
|
||||
A System Tray Icon implementation that can work with the tkinter port of PySimpleGUI!
|
||||
|
||||
To use, add this import to your code:
|
||||
from psgtray import SystemTray
|
||||
|
||||
Make sure the psgtray.py file is in the same folder as your app or is on your Python path
|
||||
|
||||
Because this code is entirely in the user's space it's possible to use the pystray package
|
||||
to implement the system tray icon feature. You need to install pystray and PIL.
|
||||
|
||||
As of this date, the latest version of pystray is 0.17.3
|
||||
|
||||
This code works well under Windows.
|
||||
|
||||
On Linux there are some challenges. Some changes were
|
||||
needed in order to get pystray to run as a thread using gtk as the backend.
|
||||
The separator '---' caused problems so it is now ignored. Unknown why it caused the
|
||||
menu to not show at all, but it does.
|
||||
|
||||
A sample bit of code is at the bottom for your reference.
|
||||
|
||||
Your window will receive events from the system tray thread.
|
||||
|
||||
In addition to the init, these are the class methods available:
|
||||
change_icon
|
||||
hide_icon
|
||||
show_icon
|
||||
set_tooltip
|
||||
notify
|
||||
|
||||
In your code, you will receive events from tray with key SystemTray.key
|
||||
The value will be the choice made or a click event. This is the magic statement:
|
||||
window.write_event_value(tray.key, item.text)
|
||||
|
||||
Extra Special thanks to FireDM for the design pattern that made this work.
|
||||
(https://github.com/firedm/FireDM)
|
||||
Copyright 2021 PySimpleGUI
|
||||
"""
|
||||
|
||||
|
||||
class SystemTray:
|
||||
DOUBLE_CLICK_THRESHOLD = 500 # time in milliseconds to determine double clicks
|
||||
DEFAULT_KEY = '-TRAY-' # the default key that will be used to send events to your window
|
||||
key_counter = 0
|
||||
|
||||
def __init__(self, menu=None, icon=None, tooltip='', single_click_events=False, window=None, key=DEFAULT_KEY):
|
||||
"""
|
||||
A System Tray Icon
|
||||
|
||||
Initializing the object is all that is required to make the tray icon and start the thread.
|
||||
|
||||
:param menu: The PySimpleGUI menu data structure
|
||||
:type menu: List[List[Tuple[str, List[str]]]
|
||||
:param icon: Icon to show in the system tray. Can be a file or a BASE64 byte string
|
||||
:type icon: str | bytes
|
||||
:param tooltip: Tooltip that is shown when mouse hovers over the system tray icon
|
||||
:type tooltip: str
|
||||
:param single_click_events: If True then both single click and double click events will be generated
|
||||
:type single_click_events: bool
|
||||
:param window: The window where the events will be sent using window.write_event_value
|
||||
:type window: sg.Window
|
||||
"""
|
||||
self.title = tooltip
|
||||
self.tray_icon = None # type: pystray.Icon
|
||||
self.window = window
|
||||
self.tooltip = tooltip
|
||||
self.menu_items = self._convert_psg_menu_to_tray(menu[1])
|
||||
self.key = key if SystemTray.key_counter == 0 else key+str(SystemTray.key_counter)
|
||||
SystemTray.key_counter += 1
|
||||
self.double_click_timer = 0
|
||||
self.single_click_events_enabled = single_click_events
|
||||
if icon is None:
|
||||
self.icon = sg.DEFAULT_BASE64_ICON
|
||||
else:
|
||||
self.icon = icon
|
||||
|
||||
self.thread_started = False
|
||||
self.thread = threading.Thread(target=self._pystray_thread, daemon=True)
|
||||
self.thread.start()
|
||||
while not self.thread_started: # wait for the thread to begin
|
||||
time.sleep(.2)
|
||||
time.sleep(.2) # one more slight delay to allow things to actually get running
|
||||
|
||||
def change_icon(self, icon=None):
|
||||
"""
|
||||
Change the icon shown in the tray to a file or a BASE64 byte string.
|
||||
:param icon: The icon to change to
|
||||
:type icon: str | bytes
|
||||
"""
|
||||
if icon is not None:
|
||||
self.tray_icon.icon = self._create_image(icon)
|
||||
|
||||
def hide_icon(self):
|
||||
"""
|
||||
Hides the icon
|
||||
"""
|
||||
self.tray_icon.visible = False
|
||||
|
||||
def show_icon(self):
|
||||
"""
|
||||
Shows a previously hidden icon
|
||||
"""
|
||||
self.tray_icon.visible = True
|
||||
|
||||
def set_tooltip(self, tooltip):
|
||||
"""
|
||||
Set the tooltip that is shown when hovering over the icon in the system tray
|
||||
"""
|
||||
self.tray_icon.title = tooltip
|
||||
|
||||
def show_message(self, title=None, message=None):
|
||||
"""
|
||||
Show a notification message balloon in the system tray
|
||||
:param title: Title that is shown at the top of the balloon
|
||||
:type title: str
|
||||
:param message: Main message to be displayed
|
||||
:type message: str
|
||||
"""
|
||||
self.tray_icon.notify(title=str(title) if title is not None else '', message=str(message) if message is not None else '')
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Whlie not required, calling close will remove the icon from the tray right away.
|
||||
"""
|
||||
self.tray_icon.visible = False # hiding will close any message bubbles that may hold up the removal of icon from tray
|
||||
self.tray_icon.stop()
|
||||
|
||||
# --------------------------- The methods below this point are not meant to be user callable ---------------------------
|
||||
def _on_clicked(self, icon, item: pystray.MenuItem):
|
||||
self.window.write_event_value(self.key, item.text)
|
||||
|
||||
def _convert_psg_menu_to_tray(self, psg_menu):
|
||||
menu_items = []
|
||||
i = 0
|
||||
if isinstance(psg_menu, list):
|
||||
while i < len(psg_menu):
|
||||
item = psg_menu[i]
|
||||
look_ahead = item
|
||||
if i != (len(psg_menu) - 1):
|
||||
look_ahead = psg_menu[i + 1]
|
||||
if not isinstance(item, list) and not isinstance(look_ahead, list):
|
||||
disabled = False
|
||||
if item == sg.MENU_SEPARATOR_LINE:
|
||||
item = pystray.Menu.SEPARATOR
|
||||
elif item.startswith(sg.MENU_DISABLED_CHARACTER):
|
||||
disabled = True
|
||||
item = item[1:]
|
||||
if not (item == pystray.Menu.SEPARATOR and sg.running_linux()):
|
||||
menu_items.append(pystray.MenuItem(item, self._on_clicked, enabled=not disabled, default=False))
|
||||
elif look_ahead != item:
|
||||
if isinstance(look_ahead, list):
|
||||
if menu_items is None:
|
||||
menu_items = pystray.MenuItem(item, pystray.Menu(*self._convert_psg_menu_to_tray(look_ahead)))
|
||||
else:
|
||||
menu_items.append(pystray.MenuItem(item, pystray.Menu(*self._convert_psg_menu_to_tray(look_ahead))))
|
||||
i += 1
|
||||
# important item - this is where clicking the icon itself will go
|
||||
menu_items.append(pystray.MenuItem('default', self._default_action_callback, enabled=True, default=True, visible=False))
|
||||
|
||||
return menu_items
|
||||
|
||||
def _default_action_callback(self):
|
||||
delta = (time.time() - self.double_click_timer) * 1000
|
||||
if delta < self.DOUBLE_CLICK_THRESHOLD: # if last click was recent, then this click is a double-click
|
||||
self.window.write_event_value(self.key, sg.EVENT_SYSTEM_TRAY_ICON_DOUBLE_CLICKED)
|
||||
self.double_click_timer = 0
|
||||
else:
|
||||
if self.single_click_events_enabled:
|
||||
self.window.write_event_value(self.key, sg.EVENT_SYSTEM_TRAY_ICON_ACTIVATED)
|
||||
self.double_click_timer = time.time()
|
||||
|
||||
def _pystray_thread(self):
|
||||
self.tray_icon = pystray.Icon(self.title, self._create_image(self.icon))
|
||||
self.tray_icon.default_action = self._default_action_callback
|
||||
self.tray_icon.menu = pystray.Menu(*self.menu_items)
|
||||
self.tray_icon.title = self.tooltip # tooltip for the icon
|
||||
self.thread_started = True
|
||||
self.tray_icon.run()
|
||||
|
||||
def _create_image(self, icon):
|
||||
if isinstance(icon, bytes):
|
||||
buffer = io.BytesIO(base64.b64decode(icon))
|
||||
img = Image.open(buffer)
|
||||
elif isinstance(icon, str):
|
||||
img = Image.open(icon)
|
||||
else:
|
||||
img = None
|
||||
return img
|
||||
|
||||
|
||||
# MM""""""""`M dP
|
||||
# MM mmmmmmmM 88
|
||||
# M` MMMM dP. .dP .d8888b. 88d8b.d8b. 88d888b. 88 .d8888b.
|
||||
# MM MMMMMMMM `8bd8' 88' `88 88'`88'`88 88' `88 88 88ooood8
|
||||
# MM MMMMMMMM .d88b. 88. .88 88 88 88 88. .88 88 88. ...
|
||||
# MM .M dP' `dP `88888P8 dP dP dP 88Y888P' dP `88888P'
|
||||
# MMMMMMMMMMMM 88
|
||||
# dP
|
||||
# M""MMMMM""M
|
||||
# M MMMMM M
|
||||
# M MMMMM M .d8888b. .d8888b.
|
||||
# M MMMMM M Y8ooooo. 88ooood8
|
||||
# M `MMM' M 88 88. ...
|
||||
# Mb dM `88888P' `88888P'
|
||||
# MMMMMMMMMMM
|
||||
|
||||
|
||||
def main():
|
||||
# This example shows using TWO tray icons together
|
||||
|
||||
menu = ['', ['Show Window', 'Hide Window', '---', '!Disabled Item', 'Change Icon', ['Happy', 'Sad', 'Plain'], 'Exit']]
|
||||
tooltip = 'Tooltip'
|
||||
|
||||
layout = [[sg.Text('My PySimpleGUI Window with a Tray Icon - X will minimize to tray')],
|
||||
[sg.Text('Note - you are running a file that is meant to be imported')],
|
||||
[sg.T('Change Icon Tooltip:'), sg.Input(tooltip, key='-IN-', s=(20,1)), sg.B('Change Tooltip')],
|
||||
[sg.Multiline(size=(60,10), reroute_stdout=False, reroute_cprint=True, write_only=True, key='-OUT-')],
|
||||
[sg.Button('Go'), sg.B('Hide Icon'), sg.B('Show Icon'), sg.B('Hide Window'), sg.B('Close Tray'), sg.Button('Exit')]]
|
||||
|
||||
window = sg.Window('Window Title', layout, finalize=True, enable_close_attempted_event=True)
|
||||
|
||||
|
||||
tray1 = SystemTray(menu, single_click_events=False, window=window, tooltip=tooltip, icon=sg.DEFAULT_BASE64_ICON)
|
||||
tray2 = SystemTray(menu, single_click_events=False, window=window, tooltip=tooltip, icon=sg.EMOJI_BASE64_HAPPY_JOY)
|
||||
time.sleep(.5) # wait just a little bit since TWO are being started at once
|
||||
tray2.show_message('Started', 'Both tray icons started')
|
||||
|
||||
while True:
|
||||
event, values = window.read()
|
||||
print(event, values)
|
||||
# IMPORTANT step. It's not required, but convenient.
|
||||
# if it's a tray event, change the event variable to be whatever the tray sent
|
||||
# This will make your event loop homogeneous with event conditionals all using the same event variable
|
||||
if event in (tray1.key, tray2.key):
|
||||
sg.cprint(f'System Tray Event = ', values[event], c='white on red')
|
||||
tray = tray1 if event == tray1.key else tray2
|
||||
event = values[event] # use the System Tray's event as if was from the window
|
||||
else:
|
||||
tray = tray1 # if wasn't a tray event, there's still a tray varaible used, so default to "first" tray created
|
||||
|
||||
if event in (sg.WIN_CLOSED, 'Exit'):
|
||||
break
|
||||
|
||||
sg.cprint(event, values)
|
||||
tray.show_message(title=event, message=values)
|
||||
tray.set_tooltip(values['-IN-'])
|
||||
|
||||
if event in ('Show Window', sg.EVENT_SYSTEM_TRAY_ICON_DOUBLE_CLICKED):
|
||||
window.un_hide()
|
||||
window.bring_to_front()
|
||||
elif event in ('Hide Window', sg.WIN_CLOSE_ATTEMPTED_EVENT):
|
||||
window.hide()
|
||||
tray.show_icon() # if hiding window, better make sure the icon is visible
|
||||
# tray.notify('System Tray Item Chosen', f'You chose {event}')
|
||||
elif event == 'Happy':
|
||||
tray.change_icon(sg.EMOJI_BASE64_HAPPY_JOY)
|
||||
elif event == 'Sad':
|
||||
tray.change_icon(sg.EMOJI_BASE64_FRUSTRATED)
|
||||
elif event == 'Plain':
|
||||
tray.change_icon(sg.DEFAULT_BASE64_ICON)
|
||||
elif event == 'Hide Icon':
|
||||
tray.hide_icon()
|
||||
elif event == 'Show Icon':
|
||||
tray.show_icon()
|
||||
elif event == 'Close Tray':
|
||||
tray.close()
|
||||
elif event == 'Change Tooltip':
|
||||
tray.set_tooltip(values['-IN-'])
|
||||
|
||||
tray1.close()
|
||||
tray2.close()
|
||||
window.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Normally this file is not "run"
|
||||
main()
|
Loading…
Reference in New Issue