psgtray - a new System Tray Icon feature for use with tkinter port!!
This commit is contained in:
		
							parent
							
								
									436cb397ce
								
							
						
					
					
						commit
						cbb2be3a65
					
				
					 1 changed files with 282 additions and 0 deletions
				
			
		
							
								
								
									
										282
									
								
								psgtray.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										282
									
								
								psgtray.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -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…
	
	Add table
		Add a link
		
	
		Reference in a new issue