diff --git a/tray_manager.py b/tray_manager.py new file mode 100644 index 0000000..ff95117 --- /dev/null +++ b/tray_manager.py @@ -0,0 +1,544 @@ +from os.path import exists as __os_path_exists +from pystray import Icon as pystray_Icon, Menu as pystray_Menu, MenuItem as pystray_MenuItem +from PIL import Image +from threading import Thread as threading_Thread +from enum import Enum +from types import FunctionType +from functools import partial + + + + +class Menu(): + class Default(Enum): + DEFAULT = "DefaultValue" + + + class ItemType(Enum): + LABEL = "LabelType" + BUTTON = "ButtonType" + CHECK = "CheckType" + SUBMENU = "SubmenuType" + SEPARATOR = "SeparatorType" + + + def __init__(self): + """Create a pystray.MenuItem \n + Used to set the options of the app in the notification tray""" + + self.id = 0 + self.ItemsFromID = {} # id: pystray.MenuItem, (DicKey, callback, item_type, {"current": bool | None, "requested": bool | None}, index) + self.IDsFromItem = {} # pystray.MenuItem: id + + + + def create_item(self, text: str, callback: FunctionType | None = None, item_type: ItemType = ItemType.LABEL, checkdefault: bool = False, index: int | None = None, FORCE_ID: int | None = None): + """Create an item that will be displayed in the options of the app notification in the notification tray\n + Parameters + ---------- + * DicKey\n + The key of the word you want to display from the Lang dict + * callback (Facultative)\n + The function to callback after the button is clicked + * item_type (Facultative)\n + The type of the item to create (see Menu.ItemType) + * checkdefault (Facultative)\n + Only use with item_type as Menu.ItemType.CHECK, define the default status of the item, True = checked, False = not checked + * index (Facultative)\n + The place of the item when it display, if None add at the end, if conflict, first added will use the correct index, followed by the ones in conflict + + Returns + ------- + * Item ID\n + The ID of the item, used to remove it with delete_item""" + + #Check item type + if item_type == self.ItemType.LABEL: + #Reserve id + if FORCE_ID: + id = FORCE_ID + + else: + self.id += 1 + id = self.id + + + #Create item and add it to lists + self.ItemsFromID[id] = (pystray_MenuItem(text, lambda icon, item: None), (text, callback, item_type, {"current": None, "requested": checkdefault}, index)) + self.IDsFromItem[self.ItemsFromID[id][0]] = id + + return id + + + elif item_type == self.ItemType.BUTTON: + #Check if callback is a callable object + if callable(callback): + #Reserve id + if FORCE_ID: + id = FORCE_ID + + else: + self.id += 1 + id = self.id + + #Use partial to pass our arguments to our callback + menu_callback = partial(self.__menu_callback, id, callback) + + #Create item and add it to lists + self.ItemsFromID[id] = (pystray_MenuItem(text, menu_callback), (text, callback, item_type, {"current": None, "requested": checkdefault}, index)) + self.IDsFromItem[self.ItemsFromID[id][0]] = id + return id + + else: + raise ValueError("callback is not callable") + + + elif item_type == self.ItemType.CHECK: + #Check if callback is a callable object + if callable(callback): + #Reserve id + if FORCE_ID: + id = FORCE_ID + + else: + self.id += 1 + id = self.id + + #Use partial to pass our arguments to our callback + menu_callback = partial(self.__menu_callback, id, callback) + + #Create item and add it to lists + self.ItemsFromID[id] = (pystray_MenuItem(text, menu_callback, checked=lambda item: self.__change_status(item)), (text, callback, item_type, {"current": None, "requested": checkdefault}, index)) + self.IDsFromItem[self.ItemsFromID[id][0]] = id + return id + + else: + raise ValueError("callback isn't callable ! Please link callback to a function") + + + + elif item_type == self.ItemType.SUBMENU: + raise NotImplementedError + + + + elif item_type == self.ItemType.SEPARATOR: + #Reserve id + if FORCE_ID: + id = FORCE_ID + + else: + self.id += 1 + id = self.id + + self.ItemsFromID[id] = (pystray_Menu.SEPARATOR, (None, None, None, None, index)) + self.IDsFromItem[self.ItemsFromID[id][0]] = id + return id + + else: + #Unknow item + raise ValueError("Unknow ItemType") + + + + def delete_item(self, item_id: int): + #Check if item exist + if item_id in self.ItemsFromID: + + #Remove item from list + self.IDsFromItem.pop(self.ItemsFromID[item_id][0]) + item = self.ItemsFromID.pop(item_id) + + #Return Item and parameters (Used by edit_item and if user wants to get the parameters back) + return item + + else: + raise IndexError("Item doesn't exist") + + + + def edit_item(self, item_id: int, text: str | Default = Default.DEFAULT, callback: FunctionType | None | Default = Default.DEFAULT, item_type: ItemType | Default = Default.DEFAULT, checkdefault: bool | Default = Default.DEFAULT, index: int | None | Default = Default.DEFAULT): + """Combination of delete_item() and create_item() Note: Does not change the id of the item\n + Parameters + ---------- + * item_id (Required)\n + The id of the item you want to edit + * DicKey (Facultative)\n + The key of the word you want to display from the Lang dict, if Default.DEFAULT don't change value + * callback (Facultative)\n + The function to callback after the button is clicked, if None, don't link to a callback, if Default.DEFAULT, don't change value + * item_type (Facultative)\n + The type of the item to create (see Menu.ItemType), if Default.DEFAULT don't change value + * checkdefault (Facultative)\n + Only use with item_type as ItemType.CHECK, define the default status of the item, True = checked, False = not checked, if Default.DEFAULT don't change value + * index (Facultative)\n + The place of the item when it display, if add at the end, if conflict, first added will use the correct index, followed by the ones in conflict, if Default.DEFAULT don't change value + * FORCE_ID (Usage not recommended)\n + Force the item to use a specific id. WARNING MIGHT OVERWRITE EXISTING ITEMS IF NOT DELETED CORRECTLY + """ + + #Delete item and get the parameters + item = self.delete_item(item_id) + + #For every test : if new value = Default.DEFAULT, use previous one + if text == self.Default.DEFAULT: + text = item[1][0] + + if callback == self.Default.DEFAULT: + callback = item[1][1] + + if item_type == self.Default.DEFAULT: + item_type = item[1][2] + + if checkdefault == self.Default.DEFAULT: + checkdefault = item[1][3] + + if index == self.Default.DEFAULT: + index = item[1][4] + + #Return function + return self.create_item(text, callback, item_type, checkdefault, index, FORCE_ID=item_id) + + + + def get_items(self, get_parameters : bool = False): + """Get all the items in the dict of items and return them as a list of pystray.Menu objects\n + Parameter + --------- + * get_parameters (Facultative)\n + Return the items and parameters as a list of tuples""" + + NonIndexedItems = [] + NonIndexedTuples = [] + IndexedItems = [] + + #Get all ids in the dic + for key in self.ItemsFromID.keys(): + + #Check for index key + + if self.ItemsFromID[key][1][4] == None: + #Sort items that don't have a specified value for index + if get_parameters: + #Add both item and parameters + NonIndexedItems.append(self.ItemsFromID[key]) + else: + #Add only items + NonIndexedItems.append(self.ItemsFromID[key][0]) + else: + #Sort items that have a specified value for index + NonIndexedTuples.append(self.ItemsFromID[key]) + + IndexedTuples = sorted(NonIndexedTuples, key= lambda x: x[1][4]) + + #Add indexed tuples to the list + for Item in IndexedTuples: + if get_parameters: + #Add both item and parameters + IndexedItems.append(Item) + else: + #Add only item + IndexedItems.append(Item[0]) + + #Add non-indexed tuple to the end of the list + for Item in NonIndexedItems: + IndexedItems.append(Item) + + return IndexedItems + + + + def set_status(self, item_id: int, new_value: bool| None): + """Set a check box status manually\n + Parameters + ---------- + * item_id (Requiered) + The id of the item you want to edit + * new_value (Requiered)\n + The value to set for the item (True: Checked, False: Not Checked, None: Disabled)""" + + #Check if item is in list + if item_id in self.ItemsFromID: + #Check if item is a checkable object + if self.ItemsFromID[item_id][1][2] == self.ItemType.CHECK: + #Set new value + self.ItemsFromID[item_id][1][3]["requested"] = new_value + return True + else: + raise ValueError("Item doesn't have check attribute") + + raise ValueError("Item doesn't exist") + + + + def get_status(self, item_id: int): + """Get the status of checkbox item using id + Parameters + ---------- + * item_id (Requiered)\n + The id of the item to check\n + Return + ------ + True: Box is checked | False: Box isn't checked | None: Box ins't enabled""" + + + if item_id in self.ItemsFromID: + item = self.ItemsFromID[item_id][0] + return item.checked + + raise ValueError("Item doesn't exist") + + + def __change_status(self, item: pystray_MenuItem): + """Private function DO NOT USE""" + + #Get dic + CheckStatusDic = self.ItemsFromID[self.IDsFromItem[item]][1][3] + + #Get requested status + Requested = CheckStatusDic["requested"] + + #If requestedd is None, it means it've been trigerred by a menu update, don't change anything + if Requested == None: + return CheckStatusDic["current"] + + #Set new value and set requested to None + elif Requested == True: + CheckStatusDic["current"] = True + CheckStatusDic["requested"] = None + return True + + #Set new value and set requested to None + elif Requested == False: + CheckStatusDic["current"] = False + CheckStatusDic["requested"] = None + return False + + else: + raise ValueError("Invalid value requested") + + + + def __menu_callback(self, item_id: int, callback: FunctionType, tray: pystray_Icon, item: pystray_MenuItem): + """Private function DO NOT USE""" + + #if item is checkable, update it's stauts + if item.checked != None: + self.ItemsFromID[item_id][1][3]["requested"] = not item.checked + + #Call the callback functionk + callback() + + + + + +class TrayManager(): + def __init__(self, AppName: str, ImagePath: str | None = None, Icon: Image.Image | None = None, default_show: bool = False): + """Create a pystray.Icon object linked to a Menu() object\n + Parameters + ---------- + * Menu (Required)\n + Menu() instance + * AppName (Facultative)\n + AppName is the name of your app in the notification tray (default = "Test App") + * Path or Icon or Index (Facultative)\n + Import the image from one of those 3 ways :\n + Using ImagePath, ImagePath is the image path to use as icon (str)\n + Using Icon, Icon is the PIL.Image.Image object\n + Note : Your first image imported is the index 1 NOT 0\n + If None is provided, use 'default' icon (white square of 32*32) which IS ALWAYS index 0""" + + #Self object : + #self.Menu Menu() object + #self.Icons List of PIL.Image.Image objects, first one is a white square of 32*32 + #self.tray pystray.Icon() object + + self.Menu = Menu() + + default_image = Image.new("L", (32, 32), 255) + self.Icons = [default_image] + + + + #Set the icon of the app in the notification tray + if ImagePath: + if __os_path_exists(ImagePath): + #Open image as PIL.Image.Image object and add it to Icons list + image = Image.open(ImagePath) + self.Icons.append(image) + else: + raise FileNotFoundError(f"Can't find image at : '{ImagePath}'") + + elif Icon is Image.Image: + #Add image to Icons list + self.Icons.append(Icon) + + + #Create pystray_Icon object + self.tray = pystray_Icon(AppName, self.Icons[-1], title=AppName, menu=pystray_Menu(lambda: self.Menu.get_items())) + + + #Run it in different thread (pystray.Icon.run() is a blocking function) + Thread = threading_Thread(target=self.__run, args=(default_show,)) + Thread.start() + + + + def set_name(self, name: str): + self.tray.title = name + + + + def set_icon(self, ImagePath: str | None = None, Icon: Image.Image | None = None, Index: int | None = None, Show: bool = True): + """Set icon of app in the notification tray\n + Parameters + ---------- + * Self (Requiered)\n + pystray.Icon object + * Path or Icon or Index (Only one Requiered)\n + Import the image from one of those 3 ways :\n + Using ImagePath, ImagePath is the image path to use as icon (str)\n + Using Icon, Icon is the PIL.Image.Image object\n + Using Index, Index is the custom key of one of the previous imported images (Note : the 'default' key is a white square of 32*32)\n + If None is provided, don't change anything and retutn False\n + * Show (Facultative)\n + Define if whether the icon should be displayed in the notification tray if .hide() was called before (Simillar as .show())\n + Return + ------- + False | Index of loaded Image + """ + + + #Check if IconPath exist + if ImagePath: + if __os_path_exists(ImagePath): + #Open image as PIL.Image.Image object and add it to Icons list + + image = Image.open(ImagePath) + self.Icons.append(image) + Index = len(self.Icons) - 1 + else: + raise FileNotFoundError(f"Can't find image at : '{ImagePath}'") + + + elif Image.isImageType(Icon): + #Add image to Icons list + image = Icon + self.Icons.append(image) + Index = len(self.Icons) - 1 + + + elif Index != None: + if len(self.Icons) - 1 >= Index: + image = self.Icons[Index] + else: + raise IndexError("Index of icons out of range") + + else: + return False + + #Set image as icon + self.tray.icon = image + + if Show: + #Call Tray.Show() to be sure only .Show() and .Hide() have control over whether the icon is visible or not + self.show() + + return Index + + + + def delete_icon(self, Index: int, All: bool = False): + """Remove the icon of Index or All the icons (Except the default one) from memory\n + Parameters + ---------- + * Index\n + The index of the icon to remove from memory + * All\n + If True, remove all icons except the default one from memory\n + Return + ------ + PIL.Image.Image object of icon removed, if All = True, return list of PIL.Image.Image object of icons removed""" + + if All: + Images = [] + + #Append all images to list + for x in self.Icons[1:-1]: + Images.append(self.Icons.pop(x)) + + return Images + + #If Index is correct, return value + if len(self.Icons) - 1 >= Index and Index != 0: + return self.Icons.pop(Index) + + else: + raise IndexError("Index of icon out of range") + + + + def show(self, IconPath: str = None): + """Show the icon in the notification tray, if icon is already displayed, don't change anything\n + + Parameters + ---------- + * Self (Requiered)\n + pystray.Icon object + * IconPath (Facultatives)\n + IconPath is the path of the image you want to use as the icon of your app in the notification tray, if not foud, raise FileNotFoundError + """ + + if IconPath: + #Use Tray.SetIcon to set the icon + self.set_icon(IconPath) + + #Set status to visible + self.tray.visible = True + return + + + + def hide(self): + """Hide the icon in the notification tray, if icon is already hidden, don't have an effect""" + + #Set status to visible + self.tray.visible = False + return + + + + def kill(self): + """Kill the app notification in notification tray and all the menu items, returns them as list of tuple (item, parameters)\n + Note : TrayManager() and Menu() objetcs become useless""" + + #Stop the tray process and kill every menu instances, returns them as list of tuple + Items = self.Menu.get_items(get_parameters=True) + self.tray.stop() + return Items + + + + def __run(self, default_show): + if default_show == False: + #Use lamda _: To avoid the problem related to the number of arguments + callback = lambda _: self.hide() + + #Run pystray.Icon object with callback + self.tray.run(callback) + + else: + self.tray.run() + + + class Lang(): + def __init__(self, lang: str): + self.lang = "" + +if __name__ == "__main__": + from os import chdir as __os_chdir + from os.path import dirname as __os_path_dirname + + __os_chdir(__os_path_dirname(__file__)) + tray = TrayManager() \ No newline at end of file