├── Source Code ├── Images │ ├── error.png │ ├── info.png │ ├── warning.png │ ├── file_dark.png │ ├── file_light.png │ ├── folder_dark.png │ ├── folder_light.png │ ├── back_arrow_dark.png │ ├── new_folder_dark.png │ ├── back_arrow_light.png │ └── new_folder_light.png ├── __init__.py ├── AskDialog.py ├── AskValue.py ├── Message.py ├── BetterCTkImage.py ├── Selector.py ├── AnimatedImage.py ├── Separator.py ├── SmoothFrame.py ├── FileExplorer.py └── DateSelector.py ├── Example files ├── Message_example.png ├── AskDialog_example.png ├── AskValue_example.png ├── Selector_example.png ├── Separator_example.png ├── DateSelector_example.png ├── FileDialog_example.png ├── FileExplorer_example.png ├── SmoothFrame_example.gif ├── AnimatedImage_example.gif └── BetterCTkImage_example.png ├── LICENSE ├── Patch notes.md ├── README.md └── Examples.md /Source Code/Images/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastattackv/MoreCustomTkinterWidgets/HEAD/Source Code/Images/error.png -------------------------------------------------------------------------------- /Source Code/Images/info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastattackv/MoreCustomTkinterWidgets/HEAD/Source Code/Images/info.png -------------------------------------------------------------------------------- /Source Code/Images/warning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastattackv/MoreCustomTkinterWidgets/HEAD/Source Code/Images/warning.png -------------------------------------------------------------------------------- /Example files/Message_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastattackv/MoreCustomTkinterWidgets/HEAD/Example files/Message_example.png -------------------------------------------------------------------------------- /Source Code/Images/file_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastattackv/MoreCustomTkinterWidgets/HEAD/Source Code/Images/file_dark.png -------------------------------------------------------------------------------- /Source Code/Images/file_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastattackv/MoreCustomTkinterWidgets/HEAD/Source Code/Images/file_light.png -------------------------------------------------------------------------------- /Example files/AskDialog_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastattackv/MoreCustomTkinterWidgets/HEAD/Example files/AskDialog_example.png -------------------------------------------------------------------------------- /Example files/AskValue_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastattackv/MoreCustomTkinterWidgets/HEAD/Example files/AskValue_example.png -------------------------------------------------------------------------------- /Example files/Selector_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastattackv/MoreCustomTkinterWidgets/HEAD/Example files/Selector_example.png -------------------------------------------------------------------------------- /Example files/Separator_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastattackv/MoreCustomTkinterWidgets/HEAD/Example files/Separator_example.png -------------------------------------------------------------------------------- /Source Code/Images/folder_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastattackv/MoreCustomTkinterWidgets/HEAD/Source Code/Images/folder_dark.png -------------------------------------------------------------------------------- /Source Code/Images/folder_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastattackv/MoreCustomTkinterWidgets/HEAD/Source Code/Images/folder_light.png -------------------------------------------------------------------------------- /Example files/DateSelector_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastattackv/MoreCustomTkinterWidgets/HEAD/Example files/DateSelector_example.png -------------------------------------------------------------------------------- /Example files/FileDialog_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastattackv/MoreCustomTkinterWidgets/HEAD/Example files/FileDialog_example.png -------------------------------------------------------------------------------- /Example files/FileExplorer_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastattackv/MoreCustomTkinterWidgets/HEAD/Example files/FileExplorer_example.png -------------------------------------------------------------------------------- /Example files/SmoothFrame_example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastattackv/MoreCustomTkinterWidgets/HEAD/Example files/SmoothFrame_example.gif -------------------------------------------------------------------------------- /Source Code/Images/back_arrow_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastattackv/MoreCustomTkinterWidgets/HEAD/Source Code/Images/back_arrow_dark.png -------------------------------------------------------------------------------- /Source Code/Images/new_folder_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastattackv/MoreCustomTkinterWidgets/HEAD/Source Code/Images/new_folder_dark.png -------------------------------------------------------------------------------- /Example files/AnimatedImage_example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastattackv/MoreCustomTkinterWidgets/HEAD/Example files/AnimatedImage_example.gif -------------------------------------------------------------------------------- /Example files/BetterCTkImage_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastattackv/MoreCustomTkinterWidgets/HEAD/Example files/BetterCTkImage_example.png -------------------------------------------------------------------------------- /Source Code/Images/back_arrow_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastattackv/MoreCustomTkinterWidgets/HEAD/Source Code/Images/back_arrow_light.png -------------------------------------------------------------------------------- /Source Code/Images/new_folder_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fastattackv/MoreCustomTkinterWidgets/HEAD/Source Code/Images/new_folder_light.png -------------------------------------------------------------------------------- /Source Code/__init__.py: -------------------------------------------------------------------------------- 1 | from .AskValue import AskValue, askstring, askinteger, askfloat 2 | from .AskDialog import AskDialog, askyesno 3 | from .Message import Message, showinfo, showwarning, showerror 4 | from .FileExplorer import FileExplorer, Filedialog, askfile, askdir 5 | from .Selector import Selector 6 | from .SmoothFrame import SmoothFrame, get_coordinates_from_grid 7 | from .BetterCTkImage import BetterCTkImage 8 | from .AnimatedImage import AnimatedImage 9 | from .Separator import Separator 10 | from .DateSelector import Date, DateSelector, DateSelectorButton 11 | 12 | 13 | _version = "5.2.0" 14 | __version__ = "5.2.0" 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024-2025 Fastattack 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Source Code/AskDialog.py: -------------------------------------------------------------------------------- 1 | import customtkinter as ctk 2 | 3 | 4 | class AskDialog(ctk.CTkToplevel): 5 | def __init__(self, title: str, message: str, yes="yes", no="no", *args, **kwargs): 6 | """TopLevel widget already configured to ask true / false 7 | 8 | :param title: title of the toplevel widget 9 | :param message: message to show 10 | :param yes: text to show in the yes button, "yes" by default 11 | :param no: text to show in the no button, "no" by default 12 | :param args: args for ctk.CTkToplevel 13 | :param kwargs: kwargs for ctk.CTkToplevel 14 | """ 15 | super().__init__(*args, **kwargs) 16 | self.lift() # lift window on top 17 | self.attributes("-topmost", True) # stay on top 18 | self.resizable(False, False) 19 | self.grab_set() # make other windows not clickable 20 | 21 | self.title(title) 22 | 23 | self.label = ctk.CTkLabel(self, text=message, font=ctk.CTkFont(size=15)) 24 | self.label.grid(row=0, column=0, columnspan=2, padx=10, pady=15) 25 | 26 | self.yes_button = ctk.CTkButton(self, text=yes, command=self.yes) 27 | self.yes_button.grid(row=1, column=0, padx=10, pady=15) 28 | 29 | self.no_button = ctk.CTkButton(self, text=no, command=self.no) 30 | self.no_button.grid(row=1, column=1, padx=10, pady=15) 31 | 32 | self.response = None 33 | 34 | self.bind("", self.yes) 35 | self.bind("", self.no) 36 | 37 | def yes(self, event=None): 38 | self.response = True 39 | self.grab_release() 40 | self.destroy() 41 | 42 | def no(self, event=None): 43 | self.response = False 44 | self.grab_release() 45 | self.destroy() 46 | 47 | def get_response(self) -> bool | None: 48 | """ Waits until the dialog is closed and returns the response the user gave (True if yes was selected, False if 49 | no was selected, None if the user cancelled) """ 50 | self.master.wait_window(self) 51 | return self.response 52 | 53 | 54 | def askyesno(title: str, message: str) -> bool | None: 55 | """Shows a TopLevel widget to ask a question 56 | 57 | :param title: title of the dialog 58 | :param message: message in the dialog 59 | :return: True if the "Yes" button was clicked, False if the "No" button was clicked, None if the dialog was closed 60 | """ 61 | dialog = AskDialog(title, message) 62 | return dialog.get_response() 63 | -------------------------------------------------------------------------------- /Source Code/AskValue.py: -------------------------------------------------------------------------------- 1 | import customtkinter as ctk 2 | from typing import Literal 3 | 4 | from .Message import showwarning 5 | 6 | 7 | class AskValue(ctk.CTkInputDialog): 8 | def __init__(self, typ: Literal["str", "int", "float"], allow_none=True, *args, **kwargs): 9 | """Modified instance of CTkInputDialog, allows verifying entered values 10 | 11 | :param typ: type of the value to enter, should be "str", "int" or "float" 12 | :param allow_none: if set to False, the user will not be allowed to enter "" (only useful for entering str type) 13 | :param args: args for CTkInputDialog 14 | :param kwargs: kwargs for CTkInputDialog 15 | """ 16 | super().__init__(*args, **kwargs) 17 | self.type = typ 18 | self.allow_none = allow_none 19 | 20 | # overrides _ok_event method to add verification 21 | def _ok_event(self, event=None): 22 | value = self._entry.get() 23 | if self.type == "str": 24 | if not self.allow_none and value == "": 25 | showwarning("Entering value", "Enter a value") 26 | else: # data is verified 27 | self._user_input = value 28 | self.grab_release() 29 | self.destroy() 30 | elif self.type == "int": 31 | if not value.isnumeric(): 32 | showwarning("Entering value", "Enter an integer") 33 | else: # data is verified 34 | self._user_input = value 35 | self.grab_release() 36 | self.destroy() 37 | elif self.type == "float": 38 | try: 39 | value = float(value) 40 | except ValueError: 41 | showwarning("Entering value", "Enter a decimal number") 42 | else: # data is verified 43 | self._user_input = value 44 | self.grab_release() 45 | self.destroy() 46 | else: 47 | self._user_input = value 48 | self.grab_release() 49 | self.destroy() 50 | 51 | 52 | def askstring(title: str, message: str, allow_none=True) -> str: 53 | """Asks a string from the user 54 | 55 | :param title: title of the TopLevel window 56 | :param message: message to show in the TopLevel window 57 | :param allow_none: optional: if set to False, the user will not be able to enter "" 58 | :return: string entered by the user, None if the user canceled 59 | """ 60 | dialog = AskValue("str", allow_none, title=title, text=message) 61 | return dialog.get_input() 62 | 63 | 64 | def askinteger(title: str, message: str) -> int: 65 | """Asks an int from the user 66 | 67 | :param title: title of the TopLevel window 68 | :param message: message to show in the TopLevel window 69 | :return: int entered by the user, None if the user canceled 70 | """ 71 | dialog = AskValue("int", title=title, text=message) 72 | return dialog.get_input() 73 | 74 | 75 | def askfloat(title: str, message: str) -> float: 76 | """Asks a string from the user 77 | 78 | :param title: title of the TopLevel window 79 | :param message: message to show in the TopLevel window 80 | :return: int entered by the user, None if the user canceled 81 | """ 82 | dialog = AskValue("float", title=title, text=message) 83 | return dialog.get_input() 84 | -------------------------------------------------------------------------------- /Patch notes.md: -------------------------------------------------------------------------------- 1 | # Patch notes for MoreCustomTkinterWidgets 2 | 3 | 4 | ## v5.2.0 5 | 05/07/2025 6 | 7 | Novelties: 8 | - FileExplorer 9 | - Allowed separator to expand automatically using the fill (if using pack) or sticky (if using grid) parameters 10 | 11 | 12 | ## v5.1.0 13 | 03/07/2025 14 | 15 | Novelties: 16 | - Separator 17 | - Allowed separator to expand automatically using the fill (if using pack) or sticky (if using grid) parameters 18 | 19 | Corrections: 20 | - Removed test code from the DateSelector file (again...) 21 | 22 | 23 | ## v5.0.0 24 | 01/07/2025 25 | 26 | Novelties: 27 | - New widgets: `DateSelector` and `DateSelectorButton` 28 | - Used to ask the user to select a date with an easy interface 29 | 30 | 31 | ## v4.1.2 32 | 26/03/2025 33 | 34 | Corrections: 35 | - Selector 36 | - Corrected the `Selector.get_selections()` method (it returned widgets instead of their names) 37 | 38 | ## v4.1.1 39 | 26/03/2025 40 | 41 | Corrections: 42 | - Selector 43 | - could not configure the items in the selector using the `Selector.configure_selector()` method. 44 | 45 | 46 | ## v4.1.0 47 | 21/12/2024 48 | 49 | Novelties: 50 | - Selector: 51 | - New functionnality: the search bar. Allows to easily narrow the search of an item. 52 | 53 | 54 | ## v4.0.1 55 | 26/08/2024 56 | 57 | Corrections: 58 | - `Separator`: removed the test code (prevented the entire module from being used) 59 | 60 | 61 | ## v4.0.0 62 | 26/08/2024 63 | 64 | Novelties: 65 | - New widget: `Separator` 66 | - Used just like the separator in classic tkinter, this widget allows to separate other widgets more clearly (draws a line to separate widgets). 67 | - For now the separator cannot expand by itself using `Separator.pack(expand=True, fill="both")` or `Separator.grid(sticky="nswe")`, you have to enter the size of the separator manually. 68 | 69 | 70 | ## v3.0.1 71 | 23/08/2024 72 | 73 | Corrections: 74 | - `AnimatedImage`: removed the test code (prevented the entire module from being used) 75 | 76 | 77 | ## v3.0.0 78 | 02/08/2024 79 | 80 | Novelties: 81 | - New utility class: `AnimatedImage` 82 | - Used like `CTkImage` but allows to run an animation if an image sequence (FLI/FLC, GIF) was given. 83 | - You can start the animation, and it will run until you call the method to stop it or you can run it for a given time. 84 | 85 | 86 | ## v2.0.0 87 | 29/03/2024 88 | 89 | Novelties: 90 | - New utility class: `BetterCTkImage` 91 | - Used like `CTkImage` but allows for rounded corners for the image (and you can directly pass the image path instead of an Image instance when creating the class) 92 | - You can configure the radius of corner rounding by using `BetterCTkImage.configure()` and the image will update automatically everywhere it is used. 93 | 94 | 95 | ## v1.1.0 96 | 17/03/2024 97 | 98 | Novelties: 99 | - Selector: 100 | - new parameter: `multiple_choices`: if set to False, only one item can be selected 101 | - new method: `get_all_items()`: returns all the items in the selector 102 | - new method: `configure_selector()`: allows to change the items in the selector or the multiple_choices parameter 103 | - new method: `clear_selections()`: clears the items selections 104 | - FileExplorer: 105 | - new method: `move_to()`: changes the current directory of the explorer to the given one 106 | 107 | 108 | ## v1.0.1 109 | 16/03/2024 110 | 111 | Corrections: 112 | - `Selector`: args and kwargs can now be passed to the super CTkFrame class 113 | 114 | 115 | ## v1.0.0 116 | 16/03/2024 117 | 118 | Initial creation, contains `AskDialog`, `AskValue`, `FileExplorer`, `Message`, `Selector`, `SmoothFrame` 119 | -------------------------------------------------------------------------------- /Source Code/Message.py: -------------------------------------------------------------------------------- 1 | import customtkinter as ctk 2 | import os 3 | from PIL import Image 4 | from typing import Literal 5 | try: 6 | import winsound 7 | winsound_activated = True 8 | except ModuleNotFoundError: 9 | winsound_activated = False 10 | 11 | 12 | class Message(ctk.CTkToplevel): 13 | def __init__(self, title: str, message: str, typ: Literal["info", "warning", "error"], sound=True, *args, **kwargs): 14 | """TopLevel widget already configured for displaying messages 15 | 16 | :param title: title of the toplevel widget 17 | :param message: message to show 18 | :param typ: type of message to show ("info" / "warning" / "error") 19 | :param sound: optional: if set to True (True by default), the beep windows sound will be played as the message shows 20 | :param args: args for ctk.CTkToplevel 21 | :param kwargs: kwargs for ctk.CTkToplevel 22 | """ 23 | if not winsound_activated: 24 | sound = False 25 | 26 | super().__init__(*args, **kwargs) 27 | self.lift() # lift window on top 28 | self.attributes("-topmost", True) # stay on top 29 | self.resizable(False, False) 30 | self.grab_set() # make other windows not clickable 31 | 32 | self.title(title) 33 | 34 | if typ == "info": 35 | pil_image = Image.open(os.path.join(os.path.dirname(__file__), "Images/info.png")) 36 | ctk_image = ctk.CTkImage(pil_image, pil_image, (85, 85)) 37 | self.image = ctk.CTkLabel(self, text="", image=ctk_image, width=85, height=85) 38 | self.image.grid(row=0, column=0, padx=10, pady=15) 39 | pil_image.close() 40 | elif typ == "warning": 41 | pil_image = Image.open(os.path.join(os.path.dirname(__file__), "Images/warning.png")) 42 | ctk_image = ctk.CTkImage(pil_image, pil_image, (85, 85)) 43 | self.image = ctk.CTkLabel(self, text="", image=ctk_image, width=85, height=85) 44 | self.image.grid(row=0, column=0, padx=10, pady=15) 45 | pil_image.close() 46 | elif typ == "error": 47 | pil_image = Image.open(os.path.join(os.path.dirname(__file__), "Images/error.png")) 48 | ctk_image = ctk.CTkImage(pil_image, pil_image, (85, 85)) 49 | self.image = ctk.CTkLabel(self, text="", image=ctk_image, width=85, height=85) 50 | self.image.grid(row=0, column=0, padx=10, pady=15) 51 | pil_image.close() 52 | 53 | self.label = ctk.CTkLabel(self, text=message, font=ctk.CTkFont(size=15)) 54 | self.label.grid(row=0, column=1, padx=10, pady=15) 55 | 56 | self.ok_button = ctk.CTkButton(self, text="Ok", command=self.ok) 57 | self.ok_button.grid(row=1, column=0, columnspan=2, padx=10, pady=15) 58 | 59 | if sound: 60 | if typ == "error": 61 | winsound.MessageBeep(16) 62 | else: 63 | winsound.MessageBeep() 64 | 65 | self.bind("", self.ok) 66 | 67 | def ok(self, event=None): 68 | self.grab_release() 69 | self.destroy() 70 | 71 | def wait_end(self): 72 | """ When called, waits until the message is closed """ 73 | self.master.wait_window(self) 74 | 75 | 76 | def showinfo(title: str, message: str): 77 | """Shows a TopLevel widget to tell an information 78 | 79 | :param title: title of the dialog 80 | :param message: message in the dialog 81 | """ 82 | m = Message(title, message, "info") 83 | m.wait_end() 84 | 85 | 86 | def showwarning(title: str, message: str): 87 | """Shows a TopLevel widget to tell a warning 88 | 89 | :param title: title of the dialog 90 | :param message: message in the dialog 91 | """ 92 | m = Message(title, message, "warning") 93 | m.wait_end() 94 | 95 | 96 | def showerror(title: str, message: str): 97 | """Shows a TopLevel widget to tell an error 98 | 99 | :param title: title of the dialog 100 | :param message: message in the dialog 101 | """ 102 | m = Message(title, message, "error") 103 | m.wait_end() 104 | -------------------------------------------------------------------------------- /Source Code/BetterCTkImage.py: -------------------------------------------------------------------------------- 1 | import customtkinter as ctk 2 | from PIL import Image, ImageDraw, ImageTk 3 | import os 4 | 5 | 6 | def draw_corners(image: Image.Image, rad: int) -> Image.Image: 7 | """ Draws corners on the given image """ 8 | if rad > 0: 9 | image = image.copy() 10 | circle = Image.new('L', (rad * 2, rad * 2), 0) 11 | draw = ImageDraw.Draw(circle) 12 | draw.ellipse((0, 0, rad * 2 - 1, rad * 2 - 1), fill=255) 13 | alpha = Image.new('L', image.size, 255) 14 | w, h = image.size 15 | alpha.paste(circle.crop((0, 0, rad, rad)), (0, 0)) 16 | alpha.paste(circle.crop((0, rad, rad, rad * 2)), (0, h - rad)) 17 | alpha.paste(circle.crop((rad, 0, rad * 2, rad)), (w - rad, 0)) 18 | alpha.paste(circle.crop((rad, rad, rad * 2, rad * 2)), (w - rad, h - rad)) 19 | image.putalpha(alpha) 20 | return image 21 | else: 22 | return image 23 | 24 | 25 | class BetterCTkImage(ctk.CTkImage): 26 | def __init__(self, 27 | light_image: Image.Image | str = None, 28 | dark_image: Image.Image | str = None, 29 | size: tuple[int, int] = (20, 20), 30 | 31 | rounded_corner_radius=0): 32 | """Upgraded CTkImage class with more functionalities 33 | 34 | :param light_image: light image or path to light image file 35 | :param dark_image: dark image or path to light dark file 36 | :param size: size of the images 37 | :param rounded_corner_radius: radius of the rounding of the corners, 0 means no rounding 38 | """ 39 | if type(light_image) is str: 40 | if os.path.isfile(light_image): 41 | light_image = Image.open(light_image) 42 | else: 43 | raise ValueError(f"Path to light image file is incorrect: {light_image}") 44 | if type(dark_image) is str: 45 | if os.path.isfile(dark_image): 46 | dark_image = Image.open(dark_image) 47 | else: 48 | raise ValueError(f"Path to dark image file is incorrect: {dark_image}") 49 | 50 | super().__init__(light_image, dark_image, size) 51 | # keys of the dicts self._scaled_light_photo_images and self._scaled_dark_photo_images: 52 | # ((size_x, size_y), rounded_corner_radius) 53 | 54 | if type(rounded_corner_radius) is int: 55 | if rounded_corner_radius > 0: 56 | self.rounded_corners_radius = rounded_corner_radius 57 | else: 58 | self.rounded_corners_radius = 0 59 | else: 60 | raise ValueError(f"Type of rounded_corner_radius should be int, not: {type(rounded_corner_radius)}") 61 | 62 | def _get_scaled_light_photo_image(self, scaled_size: tuple[int, int]) -> "ImageTk.PhotoImage": 63 | if (scaled_size, self.rounded_corners_radius) in self._scaled_light_photo_images: 64 | return self._scaled_light_photo_images[(scaled_size, self.rounded_corners_radius)] 65 | else: 66 | image = draw_corners(self._light_image, self.rounded_corners_radius) 67 | image = ImageTk.PhotoImage(image.resize(scaled_size)) 68 | self._scaled_light_photo_images[(scaled_size, self.rounded_corners_radius)] = image 69 | return self._scaled_light_photo_images[(scaled_size, self.rounded_corners_radius)] 70 | 71 | def _get_scaled_dark_photo_image(self, scaled_size: tuple[int, int]) -> "ImageTk.PhotoImage": 72 | if (scaled_size, self.rounded_corners_radius) in self._scaled_dark_photo_images: 73 | return self._scaled_dark_photo_images[(scaled_size, self.rounded_corners_radius)] 74 | else: 75 | image = draw_corners(self._dark_image, self.rounded_corners_radius) 76 | image = ImageTk.PhotoImage(image.resize(scaled_size)) 77 | self._scaled_dark_photo_images[(scaled_size, self.rounded_corners_radius)] = image 78 | return self._scaled_dark_photo_images[(scaled_size, self.rounded_corners_radius)] 79 | 80 | def configure(self, **kwargs): 81 | """ Changes the given arguments to the given values """ 82 | if "rounded_corner_radius" in kwargs: 83 | value = kwargs.pop("rounded_corner_radius") 84 | if value > 0: 85 | self.rounded_corners_radius = value 86 | else: 87 | self.rounded_corners_radius = 0 88 | 89 | super().configure(**kwargs) 90 | 91 | def cget(self, attribute_name: str) -> any: 92 | """ Returns the value of the given argument """ 93 | if attribute_name == "rounded_corner_radius": 94 | return self.rounded_corners_radius 95 | else: 96 | return super().cget(attribute_name) 97 | -------------------------------------------------------------------------------- /Source Code/Selector.py: -------------------------------------------------------------------------------- 1 | import customtkinter as ctk 2 | 3 | 4 | class Selector(ctk.CTkFrame): 5 | def __init__(self, master, items: list[str], multiple_choices=True, *args, **kwargs): 6 | """Selector widgets to select options in a list of options. Includes a search bar to find different elements faster. 7 | 8 | :param master: master window for the widget 9 | :param items: list of the possible options, they should all be different 10 | :param multiple_choices: Optional: if set to False, the user will be allowed to select only one item (default=True) 11 | :param args: args for the CTkFrame widget 12 | :param kwargs: kwargs for the CTkFrame widget 13 | """ 14 | super().__init__(master, *args, **kwargs) 15 | 16 | self.search_var = ctk.StringVar(self) 17 | self.search_var.trace_add("write", self._search_modified) 18 | self.search_bar = ctk.CTkEntry(self, textvariable=self.search_var) 19 | color = kwargs.pop("fg_color") if "fg_color" in kwargs else "transparent" 20 | self.checkboxes_frame = ctk.CTkScrollableFrame(self, fg_color=color, *args, **kwargs) 21 | self.search_bar.pack(anchor="n", fill="x") 22 | self.checkboxes_frame.pack(expand=True, fill="both", side="bottom") 23 | 24 | self.checkboxes = [] 25 | self.selected_indexes = [] 26 | self.multiple_choices = multiple_choices 27 | 28 | if len(set(items)) == len(items): # not 2 times the same item 29 | for index in range(len(items)): 30 | self.checkboxes.append(ctk.CTkCheckBox(self.checkboxes_frame, text=items[index], command=lambda a=index: self._selection(a))) 31 | self._search_modified() 32 | else: 33 | raise ValueError("There is two times or more the same item in the given items list") 34 | 35 | def _selection(self, index: int): 36 | """ Internal method: selects / unselects the given index """ 37 | if index in self.selected_indexes: 38 | self.selected_indexes.remove(index) 39 | else: 40 | if self.multiple_choices: 41 | self.selected_indexes.append(index) 42 | else: 43 | if self.selected_indexes: # list not empty 44 | for i in self.selected_indexes: 45 | self.checkboxes[i].deselect() 46 | self.selected_indexes.clear() 47 | self.selected_indexes.append(index) 48 | else: 49 | self.selected_indexes.append(index) 50 | 51 | def _reset_scroll(self): 52 | """ Internal method: scrolls back to the starting position """ 53 | self.checkboxes_frame._parent_canvas.yview_moveto(0) 54 | 55 | def _search_modified(self, *args): 56 | """ Internal method: modifies the search """ 57 | value = self.search_var.get() 58 | row = 0 59 | for x in range(len(self.checkboxes)): 60 | if self.checkboxes[x].cget("text").startswith(value): 61 | self.checkboxes[x].grid(row=row, column=0, padx=3, pady=3) 62 | row += 1 63 | else: 64 | self.checkboxes[x].grid_forget() 65 | self._reset_scroll() 66 | 67 | def get_all_items(self) -> list: 68 | """ Returns all the items in the selector """ 69 | return [checkbox.cget("text") for checkbox in self.checkboxes] 70 | 71 | def configure_selector(self, items: list = None, multiple_choices: bool = None): 72 | """Changes the given arguments 73 | 74 | :param items: new items to show, if [] is given: deletes all old items 75 | :param multiple_choices: if set to False, the user will be allowed to select only one item 76 | """ 77 | if items is not None: 78 | if len(set(items)) == len(items): # not 2 times the same item 79 | # destroy old widgets 80 | for checkbox in self.checkboxes: 81 | checkbox.destroy() 82 | self.checkboxes.clear() 83 | self.selected_indexes.clear() 84 | 85 | # create new ones 86 | for index in range(len(items)): 87 | self.checkboxes.append(ctk.CTkCheckBox(self.checkboxes_frame, text=items[index], command=lambda a=index: self._selection(a))) 88 | self._search_modified() 89 | else: 90 | raise ValueError("There is two times or more the same item in the given items list") 91 | 92 | if multiple_choices is not None: 93 | self.multiple_choices = multiple_choices 94 | 95 | def clear_selections(self): 96 | """ Clears the selections """ 97 | for index in self.selected_indexes: 98 | self.checkboxes[index].deselect() 99 | self.selected_indexes.clear() 100 | 101 | def get_selections(self) -> list: 102 | """Returns the selected items 103 | 104 | :return: selected items, empty list if none were selected 105 | """ 106 | return [self.checkboxes[index].cget("text") for index in self.selected_indexes] 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MoreCustomTkinterWidgets 2 | By Fastattack, 2024 3 | 4 | [![Pypi](https://img.shields.io/pypi/v/MoreCustomTkinterWidgets?label=Pypi)](https://pypi.org/project/MoreCustomTkinterWidgets) 5 | [![GitHub - Total commits](https://img.shields.io/github/commit-activity/t/fastattackv/MoreCustomTkinterWidgets?label=Total%20GitHub%20commits&color=darkblue)](https://github.com/fastattackv/MoreCustomTkinterWidgets) 6 | [![GitHub - Last commit](https://img.shields.io/github/last-commit/fastattackv/MoreCustomTkinterWidgets?label=Last%20GitHub%20commit&color=darkblue)](https://github.com/fastattackv/MoreCustomTkinterWidgets) 7 | [![Pypi - Monthly downloads](https://img.shields.io/pypi/dm/MoreCustomTkinterWidgets)](https://pypi.org/project/MoreCustomTkinterWidgets) 8 | [![Pypi - Total downloads](https://static.pepy.tech/personalized-badge/MoreCustomTkinterWidgets?period=total&units=international_system&left_color=grey&right_color=green&left_text=Total%20Downloads)](https://pypi.org/project/MoreCustomTkinterWidgets) 9 | [![Lines number](https://tokei.rs/b1/github/fastattackv/MoreCustomTkinterWidgets?category=lines)](https://github.com/fastattackv/MoreCustomTkinterWidgets/tree/main/Source%20Code) 10 | 11 | > [!NOTE] 12 | > The package is cross-platform, but the Message class will only be able to create sounds if the winsound module is installed (only available on windows) 13 | 14 | ## Introduction 15 | This module contains more, easy to use, customtkinter widgets. 16 | 17 | This file will present you the best ones. 18 | 19 | ## How to install 20 | To install the package, you should use pip. Install the package with the following command: `pip install MoreCustomTkinterWidgets` 21 | 22 | ## Widgets 23 | 24 | [![Examples](https://img.shields.io/badge/Examples_for_all_widgets-red)](Examples.md) 25 | [![Patch Notes](https://img.shields.io/badge/Patch_Notes-yellow)](Patch%20notes.md) 26 | 27 | ### SmoothFrame 28 | This widget is a normal frame but with one upgrade: animations ! 29 | 30 | If you want to move the frame from one grid cell to another or one placing coordinates to another, you can use smooth_grid or smooth_place to move the frame to the given spot in a given time. 31 | 32 | Inside the frame, you can still put other widgets 33 | 34 | ### FileExplorer 35 | This class is used to navigate and select files directly in the interface (not a TopLevel). 36 | 37 | It also comes with a class to ask for a file in a TopLevel (FileDialog). 38 | 39 | The FileDialog class comes with prebuilt functions to ask for files more easily: askdir, askfile 40 | 41 | ### AnimatedImage 42 | This class is used like `CTkImage` but allows to run an animation if an image sequence (FLI/FLC, GIF) was given. 43 | 44 | You can start the animation, and it will run until you call the method to stop it, or you can run it for a given time. 45 | 46 | ### DateSelector 47 | These classes allow to easily create widgets to ask the user to select a date. 48 | 49 | ### Separator 50 | This widget is used just like the separator in classic tkinter: it allows to separate other widgets more clearly (draws a line to separate widgets). 51 | 52 | ### BetterCTkImage 53 | This class is used like CTkImage, but you can round the images corners ! 54 | 55 | You can configure the radius of corner rounding by using `BetterCTkImage.configure()` and the image will update automatically everywhere it is used. 56 | 57 | ### Selector 58 | This class is used to select values in a list. 59 | 60 | You give a list of values to the widget, and it creates a scrollable frame containing multiple checkboxes. When you want to retrieve the entries, you just call the get_selections method. 61 | 62 | ### Askdialog and Askvalue 63 | Those two classes are TopLevels used to ask values (string / integer / float / boolean) to the user. 64 | 65 | They come with functions to use them more easily: askstring, askinteger, askfloat, askyesno 66 | 67 | ### Message 68 | This class is used to display messages (TopLevel) to inform the user about errors / what is happening in the app. 69 | 70 | It comes with prebuilt functions to use it more easily: showinfo, showwarning, showerror 71 | 72 | These functions existed in normal tkinter but didn't go in customtkinter. 73 | 74 | 75 | ## Mentions 76 | 77 | The icons used in the module were collected on flaticon: [Flaticon](https://www.flaticon.com/) 78 | 79 | Mentions to the artists of these icons: 80 | 81 | - New folder icon: Mehwish: [Flaticon](https://www.flaticon.com/free-icon/folder_3307447) & [Flaticon](https://www.flaticon.com/free-icon/folder_3360755) 82 | - Folder icon: Icongeek26: [Flaticon](https://www.flaticon.com/free-icon/folder_1250635) & [Flaticon](https://www.flaticon.com/free-icon/folder_1250945) 83 | - File icon: Freepik: [Flaticon](https://www.flaticon.com/free-icon/document_2258853) & [Flaticon](https://www.flaticon.com/free-icon/document_2258843) 84 | - Left arrow (back): Gravisio: [Flaticon](https://www.flaticon.com/free-icon/back_11502464) & [Flaticon](https://www.flaticon.com/free-icon/back_11502534) 85 | - Right arrow (forward): Gravisio: [Flaticon](https://www.flaticon.com/free-icon/forward_11502458) & [Flaticon](https://www.flaticon.com/free-icon/forward_11502527) 86 | 87 | Used modules: customtkinter, Pillow 88 | 89 | Used services for README statistics: [shields.io](https://img.shields.io), [tokei.rs](https://github.com/XAMPPRocky/tokei), [pepy.tech](https://github.com/psincraian/pepy) 90 | -------------------------------------------------------------------------------- /Examples.md: -------------------------------------------------------------------------------- 1 | # Examples for MoreCustomTkinterWidgets 2 | 3 | By Fastattack, for version 1.0 4 | 5 | ## Introduction 6 | 7 | This file will teach and show you how to use the widgets in the MoreCustomTkinterWidgets module. 8 | 9 | ## Examples 10 | 11 | ### SmoothFrame 12 | 13 | The `SmoothFrame` is used like a normal frame and is moved using the smooth_grid or smooth_place methods. 14 | ```python 15 | import customtkinter as ctk 16 | import MoreCustomTkinterWidgets as mctk 17 | 18 | win = ctk.CTk() 19 | win.title("MoreCustomTkinterWidgets examples !") 20 | win.geometry("500x500") 21 | 22 | frame = mctk.SmoothFrame(win, fg_color="red") 23 | button = ctk.CTkButton(frame, text="Click me!", command=lambda: frame.smooth_place(1, 200, 200)) 24 | button.pack(padx=10, pady=10) 25 | frame.place(x=0, y=0) 26 | 27 | win.mainloop() 28 | ``` 29 | This code produces the following result (but at 120Hz of course ;) ): 30 | 31 | ![SmoothFrame example](Example%20files/SmoothFrame_example.gif) 32 | 33 | --- 34 | 35 | ### FileExplorer 36 | 37 | The `FileExplorer` class is a widget used to select files or directories. 38 | ```python 39 | import customtkinter as ctk 40 | import MoreCustomTkinterWidgets as mctk 41 | 42 | win = ctk.CTk() 43 | win.title("MoreCustomTkinterWidgets examples !") 44 | win.geometry("500x500") 45 | 46 | file_explorer = mctk.FileExplorer(win, "file", width=300, height=400) 47 | file_explorer.pack() 48 | 49 | win.mainloop() 50 | ``` 51 | This code produces the following result: 52 | 53 | ![FileExplorer example](Example%20files/FileExplorer_example.png) 54 | 55 | ```python 56 | import MoreCustomTkinterWidgets as mctk 57 | 58 | selected_path = mctk.askfile("Title") 59 | ``` 60 | And this code produces the following result (in a TopLevel): 61 | 62 | ![FileDialog example](Example%20files/FileDialog_example.png) 63 | 64 | --- 65 | 66 | ### AnimatedImage 67 | 68 | The `AnimatedImage` class allows to easily use animated images (like .gif). 69 | ```python 70 | import customtkinter as ctk 71 | import MoreCustomTkinterWidgets as mctk 72 | 73 | win = ctk.CTk() 74 | win.title("MoreCustomTkinterWidgets examples !") 75 | win.geometry("400x300") 76 | 77 | image = mctk.AnimatedImage("loading.gif", size=(100, 100)) 78 | label = ctk.CTkLabel(win, text="", image=image) 79 | label.pack() 80 | 81 | image.start_animation() 82 | 83 | win.mainloop() 84 | ``` 85 | This code produces the following result: 86 | 87 | ![AnimatedImage example](Example%20files/AnimatedImage_example.gif) 88 | 89 | --- 90 | 91 | ### DateSelector 92 | 93 | The `DateSelector` and the `DateSelectorButton` classes allow to easily create widgets to ask the user to select a date. 94 | ```python 95 | import customtkinter as ctk 96 | import MoreCustomTkinterWidgets as mctk 97 | 98 | win = ctk.CTk() 99 | win.title("MoreCustomTkinterWidgets examples !") 100 | win.geometry("500x500") 101 | 102 | date_selector = mctk.DateSelector(win, default_date=mctk.Date(1, 1, 2000, date_format="dmy")) 103 | date_selector.pack() 104 | 105 | win.mainloop() 106 | ``` 107 | This code produces the following result: 108 | 109 | ![DateSelector example](Example%20files/DateSelector_example.png) 110 | 111 | --- 112 | 113 | ### Separator 114 | 115 | The `Separator` widget allows to clearly separate widgets. 116 | ```python 117 | import customtkinter as ctk 118 | import MoreCustomTkinterWidgets as mctk 119 | 120 | win = ctk.CTk() 121 | win.title("MoreCustomTkinterWidgets examples !") 122 | win.geometry("300x200") 123 | 124 | b1 = ctk.CTkButton(win, text="First button") 125 | b1.grid(row=0, column=0) 126 | 127 | sep = mctk.Separator(win, length=150, orientation="vertical") 128 | sep.grid(row=0, column=1, padx=8) 129 | 130 | b2 = ctk.CTkButton(win, text="Second Button") 131 | b2.grid(row=0, column=2) 132 | 133 | win.mainloop() 134 | ``` 135 | This code produces the following result: 136 | 137 | ![Separator example](Example%20files/Separator_example.png) 138 | 139 | --- 140 | 141 | ### BetterCTkImage 142 | 143 | The `BetterCTkImage` class allows to round the corners of a given image. 144 | ```python 145 | import customtkinter as ctk 146 | import MoreCustomTkinterWidgets as mctk 147 | 148 | win = ctk.CTk() 149 | win.title("MoreCustomTkinterWidgets examples !") 150 | win.geometry("400x300") 151 | 152 | image = mctk.BetterCTkImage(light_image="duck_image.png", size=(200, 200), rounded_corner_radius=50) 153 | label = ctk.CTkLabel(win, text="", image=image) 154 | label.pack() 155 | 156 | win.mainloop() 157 | ``` 158 | This code produces the following result: 159 | 160 | ![BetterCTkImage example](Example%20files/BetterCTkImage_example.png) 161 | 162 | --- 163 | 164 | ### Selector 165 | 166 | The `Selector` class allows to easily create a list of checkboxes to ask the user to select one or multiple choices. 167 | The selector contains a searchbar to easily narrow the search of items for the user. 168 | ```python 169 | import customtkinter as ctk 170 | import MoreCustomTkinterWidgets as mctk 171 | 172 | win = ctk.CTk() 173 | win.title("MoreCustomTkinterWidgets examples !") 174 | win.geometry("500x500") 175 | 176 | selector = mctk.Selector(win, ["a", "b", "c", "d", "e", "f", "g", "h", "j", "i", "k", "l", "m", "n"]) 177 | selector.pack() 178 | 179 | win.mainloop() 180 | ``` 181 | This code produces the following result: 182 | 183 | ![Selector example](Example%20files/Selector_example.png) 184 | 185 | --- 186 | 187 | ### AskDialog 188 | 189 | The `AskDialog` class allows to ask yes / no to the user. 190 | ```python 191 | import MoreCustomTkinterWidgets as mctk 192 | 193 | response = mctk.askyesno("Title", "Message") 194 | ``` 195 | This code produces the following result: 196 | 197 | ![AskDialog example](Example%20files/AskDialog_example.png) 198 | 199 | --- 200 | 201 | ### AskValue 202 | 203 | The `AskValue` class allows to ask a value to the user. 204 | ````python 205 | import MoreCustomTkinterWidgets as mctk 206 | 207 | response = mctk.askstring("Title", "Message") 208 | ```` 209 | This code produces the following result: 210 | 211 | ![AskValue example](Example%20files/AskValue_example.png) 212 | 213 | --- 214 | 215 | ### Message 216 | 217 | The `Message` class allows to show messages (info / warning / error) to the user: 218 | ```python 219 | import MoreCustomTkinterWidgets as mctk 220 | 221 | mctk.showerror("Title", "Error message") 222 | ``` 223 | This code produces the following result: 224 | 225 | ![Message example](Example%20files/Message_example.png) 226 | -------------------------------------------------------------------------------- /Source Code/AnimatedImage.py: -------------------------------------------------------------------------------- 1 | import customtkinter as ctk 2 | from PIL import Image, ImageTk 3 | import os 4 | from typing import Tuple 5 | 6 | 7 | class AnimatedImage(ctk.CTkImage): 8 | def __init__(self, 9 | light_image: Image.Image | str = None, 10 | dark_image: Image.Image | str = None, 11 | size: Tuple[int, int] = (20, 20), 12 | speed_multiplier=1. 13 | ): 14 | """Image object functioning like CTkImage but allows to animate Images sequences (FLI/FLC, GIF) 15 | 16 | :param light_image: PIL.Image.Image (FLI/FLC or GIF format) or path to image for light mode 17 | :param dark_image: PIL.Image.Image (FLI/FLC or GIF format) or path to image for dark mode 18 | :param size: tuple (, ) with display size for both images 19 | :param speed_multiplier: allows to change the speed of the animation: below 1 speeds the animation up and above 1 slows the animation down 20 | """ 21 | if type(light_image) is str: 22 | if os.path.isfile(light_image): 23 | light_image = Image.open(light_image) 24 | else: 25 | raise ValueError(f"Path to light image file is incorrect: {light_image}") 26 | if type(dark_image) is str: 27 | if os.path.isfile(dark_image): 28 | dark_image = Image.open(dark_image) 29 | else: 30 | raise ValueError(f"Path to dark image file is incorrect: {dark_image}") 31 | 32 | super().__init__(light_image, dark_image, size) 33 | 34 | self._currently_animating = False 35 | self._time_between_frames = 0 36 | self._speed_multiplier = speed_multiplier 37 | self.reset_after_complete = False 38 | 39 | # New keys of the dicts self._scaled_light_photo_images and self._scaled_dark_photo_images: 40 | # ((size_x, size_y), frame_index) 41 | 42 | def _get_scaled_light_photo_image(self, scaled_size: Tuple[int, int]) -> "ImageTk.PhotoImage": 43 | if (scaled_size, self._light_image.tell()) in self._scaled_light_photo_images: 44 | return self._scaled_light_photo_images[(scaled_size, self._light_image.tell())] 45 | else: 46 | self._scaled_light_photo_images[(scaled_size, self._light_image.tell())] = ImageTk.PhotoImage(self._light_image.resize(scaled_size)) 47 | return self._scaled_light_photo_images[(scaled_size, self._light_image.tell())] 48 | 49 | def _get_scaled_dark_photo_image(self, scaled_size: Tuple[int, int]) -> "ImageTk.PhotoImage": 50 | if (scaled_size, self._dark_image.tell()) in self._scaled_dark_photo_images: 51 | return self._scaled_dark_photo_images[(scaled_size, self._dark_image.tell())] 52 | else: 53 | self._scaled_dark_photo_images[(scaled_size, self._dark_image.tell())] = ImageTk.PhotoImage(self._dark_image.resize(scaled_size)) 54 | return self._scaled_dark_photo_images[(scaled_size, self._dark_image.tell())] 55 | 56 | def configure(self, **kwargs): 57 | if "speed_multiplier" in kwargs: 58 | self._speed_multiplier = kwargs.pop("speed_multiplier") 59 | time_between_2_frames = self._light_image.info["duration"] if self._light_image is not None else self._dark_image.info["duration"] 60 | self._time_between_frames = int(time_between_2_frames * self._speed_multiplier) 61 | 62 | super().configure(**kwargs) 63 | 64 | def cget(self, attribute_name: str) -> any: 65 | if "speed_multiplier" == attribute_name: 66 | return self._speed_multiplier 67 | else: 68 | return super().cget(attribute_name) 69 | 70 | def _next_frame(self): 71 | """ Internal func: changes the current frame to the next one""" 72 | if self._currently_animating: 73 | try: # light image 74 | if self._light_image is not None: 75 | self._light_image.seek(self._light_image.tell() + 1) 76 | except EOFError: 77 | self._light_image.seek(0) 78 | 79 | try: # dark image 80 | if self._dark_image is not None: 81 | self._dark_image.seek(self._dark_image.tell() + 1) 82 | except EOFError: 83 | self._dark_image.seek(0) 84 | 85 | for callback in self._configure_callback_list: 86 | callback() 87 | 88 | self._configure_callback_list[0].__self__.after(self._time_between_frames, self._next_frame) 89 | 90 | def get_animation_state(self) -> bool: 91 | """Returns information about the animation 92 | 93 | :return: True if the animation is on over, False otherwise 94 | """ 95 | return self._currently_animating 96 | 97 | def start_animation(self): 98 | """ Starts the animation loop """ 99 | self._currently_animating = True 100 | time_between_2_frames = self._light_image.info["duration"] if self._light_image is not None else self._dark_image.info["duration"] 101 | self._time_between_frames = int(time_between_2_frames * self._speed_multiplier) 102 | self._next_frame() 103 | 104 | def stop_animation(self): 105 | """ Stops the animation loop """ 106 | if self._currently_animating: 107 | self._currently_animating = False 108 | if self.reset_after_complete: 109 | self.set_to_frame(0) 110 | self.reset_after_complete = False 111 | else: 112 | raise RuntimeError("Tried to stop the animation but it was not already running") 113 | 114 | def start_animation_for(self, ms: int, reset_after_complete=False): 115 | """Starts the animation and stops automatically after the given time 116 | 117 | :param ms: time to run the animation for (in ms) 118 | :param reset_after_complete: if set to True: resets to the first frame of the animation when the animation stops 119 | """ 120 | try: 121 | self._configure_callback_list[0].__self__.after(ms, self.stop_animation) 122 | except AttributeError: 123 | pass 124 | else: 125 | self.start_animation() 126 | self.reset_after_complete = reset_after_complete 127 | 128 | def set_to_frame(self, frame_index: int): 129 | """Sets the animation to the given frame 130 | 131 | :param frame_index: frame index to set the animation to (1st frame is index 0) 132 | """ 133 | if self._light_image is not None: 134 | self._light_image.seek(frame_index) 135 | if self._dark_image is not None: 136 | self._dark_image.seek(frame_index) 137 | for callback in self._configure_callback_list: 138 | callback() 139 | -------------------------------------------------------------------------------- /Source Code/Separator.py: -------------------------------------------------------------------------------- 1 | import customtkinter as ctk 2 | from typing import Literal, Union, Tuple, Optional 3 | 4 | 5 | class Separator(ctk.CTkBaseClass): 6 | """ 7 | Separator widget to mark a separation between 2 other widgets. 8 | Using Separator.pack(expand=True, fill="both") or Separator.grid(sticky="nswe") doesn't work for now: you have to enter the size of the separator manually. 9 | """ 10 | 11 | def __init__(self, 12 | master: any, 13 | length: int = 100, 14 | width: float = 4, 15 | corner_radius: Optional[int] = None, 16 | bg_color: Union[str, Tuple[str, str]] = "transparent", 17 | fg_color: Optional[Union[str, Tuple[str, str]]] = None, 18 | orientation: Literal["vertical", "horizontal"] = "vertical" 19 | ): 20 | 21 | if orientation == "vertical": 22 | height = length 23 | elif orientation == "horizontal": 24 | height = width 25 | width = length 26 | else: 27 | raise ValueError(f"The value for orientation is incorrect: \"{orientation}\". Should be \"vertical\" or \"horizontal\"") 28 | 29 | super().__init__(master=master, width=width, height=height, bg_color=bg_color) 30 | 31 | self._corner_radius = 6 if corner_radius is None else corner_radius 32 | self._fg_color = ctk.ThemeManager.theme["CTkFrame"]["border_color"] if fg_color is None else self._check_color_type(fg_color) 33 | self._orientation = orientation 34 | 35 | self._canvas = ctk.CTkCanvas(self, highlightthickness=0) 36 | self._canvas.place(x=0, y=0, relwidth=1, relheight=1) 37 | self._canvas.configure(bg=self._apply_appearance_mode(self._detect_color_of_master()), width=self._apply_widget_scaling(width), height=self._apply_widget_scaling(height)) 38 | self._draw_engine = ctk.DrawEngine(self._canvas) 39 | 40 | self._draw(no_color_updates=True) 41 | 42 | def _set_scaling(self, *args, **kwargs): 43 | super()._set_scaling(*args, **kwargs) 44 | 45 | self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), 46 | height=self._apply_widget_scaling(self._desired_height)) 47 | self._draw() 48 | 49 | def _set_dimensions(self, width=None, height=None): 50 | super()._set_dimensions(width, height) 51 | 52 | self._canvas.configure(width=self._apply_widget_scaling(self._desired_width), 53 | height=self._apply_widget_scaling(self._desired_height)) 54 | self._draw() 55 | 56 | def _draw(self, no_color_updates=False): 57 | super()._draw(no_color_updates) 58 | 59 | requires_recoloring = self._draw_engine.draw_rounded_rect_with_border(self._apply_widget_scaling(self._current_width), 60 | self._apply_widget_scaling(self._current_height), 61 | self._apply_widget_scaling(self._corner_radius), 62 | 0, 63 | ) 64 | 65 | if no_color_updates is False or requires_recoloring: 66 | self._canvas.itemconfig("inner_parts", 67 | outline=self._apply_appearance_mode(self._fg_color), 68 | fill=self._apply_appearance_mode(self._fg_color)) 69 | 70 | def configure(self, require_redraw=False, **kwargs): 71 | """ Reconfigures the given arguments (length, width, corner_radius, bg_color, fg_color) """ 72 | if "height" in kwargs: 73 | raise ValueError("Cannot modify directly the height of the widget. Use the length and width arguments instead.") 74 | 75 | if "length" in kwargs or "width" in kwargs: 76 | width, height = None, None 77 | 78 | if "length" in kwargs: 79 | if self._orientation == "vertical": 80 | height = kwargs.pop("length") 81 | else: # horizontal 82 | width = kwargs.pop("length") 83 | if "width" in kwargs: 84 | if self._orientation == "vertical": 85 | width = kwargs.pop("width") 86 | else: # horizontal 87 | height = kwargs.pop("width") 88 | 89 | if width is not None: 90 | kwargs["width"] = width 91 | if height is not None: 92 | kwargs["height"] = height 93 | 94 | if "corner_radius" in kwargs: 95 | corner_radius = kwargs.pop("corner_radius") 96 | if type(corner_radius) is int: 97 | self._corner_radius = corner_radius 98 | require_redraw = True 99 | elif corner_radius is None: 100 | self._corner_radius = 1000 101 | require_redraw = True 102 | else: 103 | raise ValueError(f"corner_radius should be int or NoneType, not {type(corner_radius)}") 104 | 105 | if "fg_color" in kwargs: 106 | fg_color = kwargs.pop("fg_color") 107 | if isinstance(fg_color, (str, Tuple[str, str])): 108 | self._fg_color = self._check_color_type(fg_color) 109 | require_redraw = True 110 | elif fg_color is None: 111 | self._fg_color = ctk.ThemeManager.theme["CTkFrame"]["border_color"] 112 | require_redraw = True 113 | else: 114 | raise ValueError(f"fg_color should be str, Tuple[str, str] or NoneType, not {type(fg_color)}") 115 | 116 | super().configure(require_redraw=require_redraw, **kwargs) 117 | 118 | def cget(self, attribute_name: str): 119 | """ Returns the value of the given argument (length, width, corner_radius, fg_color, orientation, bg_color) """ 120 | if attribute_name == "height": 121 | raise ValueError("Cannot directly get height of the widget. Use the length and width arguments instead.") 122 | 123 | elif attribute_name == "length": 124 | if self._orientation == "vertical": 125 | return self._desired_height 126 | else: 127 | return self._desired_width 128 | 129 | elif attribute_name == "width": 130 | if self._orientation == "vertical": 131 | return self._desired_width 132 | else: 133 | return self._desired_height 134 | 135 | elif attribute_name == "corner_radius": 136 | return self._corner_radius 137 | 138 | elif attribute_name == "fg_color": 139 | return self._fg_color 140 | 141 | elif attribute_name == "orientation": 142 | return self._orientation 143 | 144 | else: 145 | return super().cget(attribute_name) 146 | 147 | def bind(self, sequence=None, command=None, add=True): 148 | """ called on the tkinter.Canvas """ 149 | if not (add == "+" or add is True): 150 | raise ValueError("'add' argument can only be '+' or True to preserve internal callbacks") 151 | self._canvas.bind(sequence, command, add=True) 152 | 153 | def unbind(self, sequence=None, funcid=None): 154 | """ called on the tkinter.Canvas """ 155 | if funcid is not None: 156 | raise ValueError("'funcid' argument can only be None, because there is a bug in" + 157 | " tkinter and its not clear whether the internal callbacks will be unbinded or not") 158 | self._canvas.unbind(sequence, None) 159 | -------------------------------------------------------------------------------- /Source Code/SmoothFrame.py: -------------------------------------------------------------------------------- 1 | import customtkinter as ctk 2 | from typing import Optional, Union, Tuple 3 | import threading 4 | import time 5 | 6 | 7 | _x, _y = 0, 0 # variables for the get_coordinates_from_grid function 8 | 9 | 10 | def _get_coordinates_of_frame(frame): 11 | """ Internal function, assigns the coordinates of the given frame to _x and _y """ 12 | global _x, _y 13 | _x = frame.winfo_x() 14 | _y = frame.winfo_y() 15 | frame.destroy() 16 | 17 | 18 | def get_coordinates_from_grid(master, column: int, row: int, padx=0, pady=0, ipadx=0, ipady=0) -> tuple[int, int]: 19 | """Returns the top left corner coordinates of given grid cell 20 | 21 | :param master: master to search the coordinates in 22 | :param column: column of the cell to obtain the coordinates from 23 | :param row: row of the cell to obtain the coordinates from 24 | :param padx: padding in x direction 25 | :param pady: padding in y direction 26 | :param ipadx: internal padding in x direction 27 | :param ipady: internal padding in y direction 28 | :return: tuple containing the x and y coordinates of the top left corner of the given grid cell 29 | """ 30 | temp_frame = ctk.CTkFrame(master, fg_color="transparent", height=1, width=1) 31 | temp_frame.grid(row=row, column=column, sticky="nw", padx=padx, pady=pady, ipadx=ipadx, ipady=ipady) 32 | temp_frame.after(20, lambda: _get_coordinates_of_frame(temp_frame)) # wait until the frame has been gridded and obtains coordinates 33 | temp_frame.wait_window() 34 | return _x, _y 35 | 36 | 37 | class SmoothFrame(ctk.CTkFrame): 38 | """ Basic CTkFrame but with animations for moving the frame using grid and place """ 39 | def __init__(self, 40 | master: any, 41 | width: int = 200, 42 | height: int = 200, 43 | corner_radius: Optional[Union[int, str]] = None, 44 | border_width: Optional[Union[int, str]] = None, 45 | 46 | bg_color: Union[str, Tuple[str, str]] = "transparent", 47 | fg_color: Optional[Union[str, Tuple[str, str]]] = None, 48 | border_color: Optional[Union[str, Tuple[str, str]]] = None, 49 | 50 | background_corner_colors: Union[Tuple[Union[str, Tuple[str, str]]], None] = None, 51 | overwrite_preferred_drawing_method: Union[str, None] = None, 52 | **kwargs): 53 | # transfers frame arguments to CTkFrame class 54 | super().__init__(master, width, height, corner_radius, border_width, bg_color, fg_color, border_color, background_corner_colors, overwrite_preferred_drawing_method, **kwargs) 55 | 56 | self.time_between_each_cycle = 0 57 | self.remaining_cycles = 0 58 | self.current_coordinates = (0, 0) 59 | self.coordinates_modifiers = (0, 0) # values to add to the coordinates of the frame on each cycle 60 | self.final_parameters = [] 61 | 62 | def _smooth_place(self): 63 | """ Internal method, creates the movement animation """ 64 | # animation 65 | while self.remaining_cycles > 0: 66 | self.current_coordinates = (self.current_coordinates[0] + self.coordinates_modifiers[0], self.current_coordinates[1] + self.coordinates_modifiers[1]) 67 | self.place(x=self.current_coordinates[0], y=self.current_coordinates[1]) 68 | self.remaining_cycles -= 1 69 | time.sleep(self.time_between_each_cycle) 70 | 71 | # final place 72 | self.place( 73 | x=self.final_parameters[0], 74 | y=self.final_parameters[1], 75 | relx=self.final_parameters[2], 76 | rely=self.final_parameters[3], 77 | anchor=self.final_parameters[4], 78 | relwidth=self.final_parameters[5], 79 | relheight=self.final_parameters[6], 80 | bordermode=self.final_parameters[7] 81 | ) 82 | 83 | # resetting variables 84 | self.time_between_each_cycle = 0 85 | self.remaining_cycles = 0 86 | self.current_coordinates = (0, 0) 87 | self.coordinates_modifiers = (0, 0) 88 | self.final_parameters.clear() 89 | 90 | def smooth_place(self, time_to_move: float, x: int, y: int, relx=0, rely=0, anchor="nw", relwidth: int = "", relheight: int = "", bordermode="inside", frequency=120): 91 | """Moves the frame at the given coordinates in the given time 92 | 93 | :param time_to_move: duration of the animation in seconds (do not put less than 0.05 or the animation might go crazy) 94 | :param frequency: frequency of the places per second = "smoothness" of the animation (120Hz by default), should be set to the monitor's refresh rate if possible 95 | :param x: x coordinates to place the frame at 96 | :param y: y coordinates to place the frame at 97 | :param relx: locate anchor of this widget between 0.0 and 1.0 relative to width of master (1.0 is right edge) 98 | :param rely: locate anchor of this widget between 0.0 and 1.0 relative to height of master (1.0 is bottom edge) 99 | :param anchor: "nsew" (or subset) - position anchor according to given direction 100 | :param relwidth: width of this widget between 0.0 and 1.0 relative to width of master (1.0 is the same width as the master) 101 | :param relheight: height of this widget between 0.0 and 1.0 relative to height of master (1.0 is the same height as the master) 102 | :param bordermode: "inside" or "outside" - whether to take border width of master widget into account 103 | """ 104 | place_infos = self.place_info() 105 | if place_infos: # has been placed 106 | self.time_between_each_cycle = 1 / frequency - 0.000337 # the 0.000337 is the approximate time each placement takes, it is substracted to the time between each cycle so the global time is closer to the time_to_move 107 | self.remaining_cycles = time_to_move * frequency 108 | self.current_coordinates = (int(place_infos["x"]), int(place_infos["y"])) 109 | self.coordinates_modifiers = ((x - self.current_coordinates[0]) / self.remaining_cycles, (y - self.current_coordinates[1]) / self.remaining_cycles) 110 | self.final_parameters = [x, y, relx, rely, anchor, relwidth, relheight, bordermode] 111 | threading.Thread(target=self._smooth_place).start() 112 | else: # has not already been placed 113 | self.place( 114 | x=x, 115 | y=y, 116 | relx=relx, 117 | rely=rely, 118 | anchor=anchor, 119 | relwidth=relwidth, 120 | relheight=relheight, 121 | bordermode=bordermode 122 | ) 123 | 124 | def _smooth_grid(self): 125 | """ Internal method, creates the movement animation """ 126 | # animation 127 | while self.remaining_cycles > 0: 128 | self.current_coordinates = (self.current_coordinates[0] + self.coordinates_modifiers[0], self.current_coordinates[1] + self.coordinates_modifiers[1]) 129 | self.place(x=self.current_coordinates[0], y=self.current_coordinates[1]) 130 | self.remaining_cycles -= 1 131 | time.sleep(self.time_between_each_cycle) 132 | self.place_forget() 133 | 134 | # final grid 135 | self.grid( 136 | column=self.final_parameters[0], 137 | row=self.final_parameters[1], 138 | columnspan=self.final_parameters[2], 139 | rowspan=self.final_parameters[3], 140 | sticky=self.final_parameters[4], 141 | padx=self.final_parameters[5], 142 | pady=self.final_parameters[6], 143 | ipadx=self.final_parameters[7], 144 | ipady=self.final_parameters[8] 145 | ) 146 | 147 | # resetting variables 148 | self.time_between_each_cycle = 0 149 | self.remaining_cycles = 0 150 | self.current_coordinates = (0, 0) 151 | self.coordinates_modifiers = (0, 0) 152 | self.final_parameters.clear() 153 | 154 | def smooth_grid(self, time_to_move: float, column: int, row: int, columnspan=1, rowspan=1, sticky="", padx=0, pady=0, ipadx=0, ipady=0, frequency=120): 155 | """Moves the widget to the given coordinates with an animation 156 | 157 | :param time_to_move: time (in s) to move the widget to the new position 158 | :param frequency: frequency of the places per second = "smoothness" of the animation (120Hz by default), should be set to the monitor's refresh rate if possible 159 | :param column: column (x axis) to grid the frame to 160 | :param row: row (y axis) to grid the frame to 161 | :param columnspan: number of columns the frame will expand in (1 by default) 162 | :param rowspan: number of rows the frame will expand in (1 by default) 163 | :param sticky: which sides to expand to if cell is too large ("n", "s", "e" or "w" or multiple at once) 164 | :param padx: padding in x direction 165 | :param pady: padding in y direction 166 | :param ipadx: internal padding in x direction 167 | :param ipady: internal padding in y direction 168 | """ 169 | if self.grid_info(): # has been gridded 170 | start_x = self.winfo_x() 171 | start_y = self.winfo_y() 172 | self.place(x=start_x, y=start_y) # removes the widget from the grid so the end coordinates are correct 173 | x, y = get_coordinates_from_grid(self.master, column, row, padx, pady, ipadx, ipady) 174 | 175 | self.time_between_each_cycle = 1 / frequency - 0.00034 # the 0.00034 is the approximate time each placement takes, it is substracted to the time between each cycle so the global time is closer to the time_to_move 176 | self.remaining_cycles = time_to_move * frequency 177 | self.current_coordinates = (start_x, start_y) 178 | self.coordinates_modifiers = ((x - self.current_coordinates[0]) / self.remaining_cycles, (y - self.current_coordinates[1]) / self.remaining_cycles) 179 | self.final_parameters = [column, row, columnspan, rowspan, sticky, padx, pady, ipadx, ipady] 180 | threading.Thread(target=self._smooth_grid).start() 181 | else: # has not already been gridded 182 | self.grid( 183 | column=column, 184 | row=row, 185 | columnspan=columnspan, 186 | rowspan=rowspan, 187 | sticky=sticky, 188 | padx=padx, 189 | pady=pady, 190 | ipadx=ipadx, 191 | ipady=ipady 192 | ) 193 | -------------------------------------------------------------------------------- /Source Code/FileExplorer.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file contains custom messagebox / simpledialog for the APY! launcher 3 | """ 4 | 5 | import customtkinter as ctk 6 | import os 7 | from PIL import Image 8 | from typing import Literal, Optional, Union, Tuple 9 | 10 | from .Message import showwarning 11 | from .AskValue import askstring 12 | 13 | 14 | def join_paths(path: str, *paths: str) -> str: 15 | """Joins the given paths and returns the joined path 16 | 17 | :param path: first path to join 18 | :param paths: other paths to add 19 | :return: final path 20 | """ 21 | return os.path.join(path, *paths).replace("\\", "/") 22 | 23 | 24 | class FileExplorer(ctk.CTkFrame): 25 | def __init__(self, 26 | master: any, 27 | responsetype: Literal["file", "directory"], 28 | 29 | # file explorer parameters 30 | filetypes: list[str] = None, 31 | initialdir: str = None, 32 | initialfile: str = None, 33 | 34 | # customtkinter frame parameters 35 | width: int = 200, 36 | height: int = 200, 37 | corner_radius: Optional[Union[int, str]] = None, 38 | border_width: Optional[Union[int, str]] = None, 39 | 40 | bg_color: Union[str, Tuple[str, str]] = "transparent", 41 | fg_color: Optional[Union[str, Tuple[str, str]]] = None, 42 | border_color: Optional[Union[str, Tuple[str, str]]] = None, 43 | 44 | background_corner_colors: Union[Tuple[Union[str, Tuple[str, str]]], None] = None, 45 | overwrite_preferred_drawing_method: Union[str, None] = None, 46 | **kwargs): 47 | """File explorer widget to select a path that already contains scrollbars, see ctk.CTkFrame for widget arguments 48 | 49 | :param responsetype: type of the path to select ("file" / "directory") 50 | :param filetypes: extensions of the files that can be selected (ex: [".txt", ".csv"]), None if a directory should be selected or if all files can be selected 51 | :param initialdir: directory to start the selection from, if both initialdir and initialfile are None, the initialdir will be os.getcwd() 52 | :param initialfile: path of the file selected at the start of the search 53 | """ 54 | # checking arguments 55 | if responsetype not in ["file", "directory"]: 56 | raise ValueError(f"responsetype should be \"file\" or \"directory\", not {responsetype}") 57 | if initialdir is not None and initialfile is not None: 58 | raise ValueError(f"Cannot use initialdir and initialfile at the same time, please set only one") 59 | if initialdir is not None and not os.path.isdir(initialdir): 60 | raise ValueError(f"Path of initialdir is unknown: {initialdir}") 61 | if initialfile is not None: 62 | if responsetype != "file": 63 | raise ValueError("Cannot use initialfile is responsetype is directory") 64 | if not os.path.isfile(initialfile): 65 | raise ValueError(f"Path of initialfile is unknown: {initialfile}") 66 | if filetypes is not None and initialfile.split(".")[-1] not in filetypes: 67 | raise ValueError(f"initialfile extension is not in filetypes: {initialfile.split(".")[-1]}") 68 | 69 | # creating widget 70 | super().__init__(master, width, height, corner_radius, border_width, bg_color, fg_color, border_color, 71 | background_corner_colors, overwrite_preferred_drawing_method, **kwargs) 72 | self.grid_propagate(False) 73 | self.grid_columnconfigure(0, weight=0) 74 | self.grid_columnconfigure(1, weight=1) 75 | self.grid_columnconfigure(2, weight=0) 76 | self.grid_columnconfigure(3, weight=0) 77 | self.grid_rowconfigure(0, weight=0) 78 | self.grid_rowconfigure(1, weight=1) 79 | self.grid_rowconfigure(2, weight=0) 80 | 81 | self.response_type = responsetype 82 | self.filetypes = filetypes 83 | self.change_path = True # if set to false, the tracing on self.selected_path will be disabled 84 | 85 | if initialdir is not None: 86 | self.path_to_show = ctk.StringVar(self, value=initialdir) # always a directory, path to show in the explorer 87 | self.selected_path = ctk.StringVar(self, value=initialdir) # selected path by the user 88 | elif initialfile is not None: 89 | self.path_to_show = ctk.StringVar(self, value=os.path.dirname(initialfile)) 90 | self.selected_path = ctk.StringVar(self, value=initialfile) 91 | else: 92 | self.path_to_show = ctk.StringVar(self, value=os.getcwd()) 93 | self.selected_path = ctk.StringVar(self, value=os.getcwd()) 94 | self.selected_path.trace_add("write", self._user_path_changed) 95 | 96 | self.folder_image = ctk.CTkImage(Image.open(os.path.join(os.path.dirname(__file__), "Images/folder_light.png")), Image.open(os.path.join(os.path.dirname(__file__), "Images/folder_dark.png"))) 97 | self.file_image = ctk.CTkImage(Image.open(os.path.join(os.path.dirname(__file__), "Images/file_light.png")), Image.open(os.path.join(os.path.dirname(__file__), "Images/file_dark.png"))) 98 | 99 | self.canvas = ctk.CTkCanvas(self, highlightthickness=0) 100 | if fg_color == "transparent": 101 | self.canvas.configure(bg=self._apply_appearance_mode(self.cget("bg"))) 102 | else: 103 | self.canvas.configure(bg=self._apply_appearance_mode(self.cget("fg_color"))) 104 | 105 | self.explorer_frame = ctk.CTkFrame(self.canvas, fg_color=fg_color) 106 | 107 | self.back_button = ctk.CTkButton(self, image=ctk.CTkImage(Image.open(os.path.join(os.path.dirname(__file__), "Images/back_arrow_light.png")), Image.open(os.path.join(os.path.dirname(__file__), "Images/back_arrow_dark.png"))), text="", command=self._move_back, width=35) 108 | self.path_entry = ctk.CTkEntry(self, textvariable=self.selected_path) 109 | self.create_dir_button = ctk.CTkButton(self, image=ctk.CTkImage(Image.open(os.path.join(os.path.dirname(__file__), "Images/new_folder_light.png")), Image.open(os.path.join(os.path.dirname(__file__), "Images/new_folder_dark.png"))), text="", command=self._create_directory, width=35) 110 | self.y_scrollbar = ctk.CTkScrollbar(self, command=self.canvas.yview) 111 | self.x_scrollbar = ctk.CTkScrollbar(self, orientation="horizontal", command=self.canvas.xview) 112 | self.canvas.configure(yscrollcommand=self.y_scrollbar.set) 113 | self.after(100, lambda: self.canvas.configure(xscrollcommand=self.x_scrollbar.set)) # you have to bind the other scrollbar at least 50ms after the first one, idk why but it works 114 | 115 | self.back_button.grid(row=0, column=0, padx=3, pady=3, sticky="nw") 116 | self.path_entry.grid(row=0, column=1, padx=3, pady=3, sticky="new") 117 | self.create_dir_button.grid(row=0, column=2, columnspan=2, padx=3, pady=3, sticky="ne") 118 | self.canvas.grid(row=1, column=0, columnspan=3, padx=3, pady=3, sticky="nsew") 119 | self.y_scrollbar.grid(row=1, column=3, rowspan=2, sticky="nse") 120 | self.x_scrollbar.grid(row=2, column=0, columnspan=3, sticky="sew") 121 | 122 | self.canvas.create_window((1, 1), window=self.explorer_frame, anchor="nw") 123 | 124 | self.explorer_frame.bind("", self._configure_frame) 125 | self.canvas.bind("", self._mousewheel) 126 | self.explorer_frame.bind("", self._mousewheel) 127 | 128 | self._fill_explorer() 129 | 130 | def _configure_frame(self, event): 131 | """ Handles the event when self.explorer_frame is configured """ 132 | self.canvas.configure(scrollregion=self.canvas.bbox("all")) 133 | 134 | def _mousewheel(self, event): 135 | """ Handles the mousewheel event on the explorer_frame """ 136 | self.canvas.yview_scroll(int(-1*(event.delta/120)), "units") 137 | 138 | def _reset_scrolling(self): 139 | """ Resets the scrolling of the explorer_frame to the beginning """ 140 | self.canvas.yview_moveto(0) 141 | self.canvas.xview_moveto(0) 142 | 143 | def _move_back(self): 144 | """ Moves to the parent directory """ 145 | self.change_path = False 146 | self.selected_path.set(os.path.dirname(self.path_to_show.get())) 147 | self.change_path = True 148 | self.path_to_show.set(os.path.dirname(self.path_to_show.get())) 149 | self._empty_explorer() 150 | self._fill_explorer() 151 | 152 | def _create_directory(self): 153 | """ Creates a new directory at the self.path_to_show location """ 154 | while True: 155 | name = askstring("Creating directory", f"Enter the name of the directory to create\nThe directory will be created in: {self.path_to_show.get()}", allow_none=False) 156 | if name is None: 157 | break 158 | elif os.path.exists(join_paths(self.path_to_show.get(), name)): 159 | showwarning("Creating directory", "The given name already exists, please enter another one") 160 | else: 161 | os.mkdir(join_paths(self.path_to_show.get(), name)) 162 | self.path_to_show.set(join_paths(self.path_to_show.get(), name)) 163 | self.change_path = False 164 | self.selected_path.set(join_paths(self.path_to_show.get())) 165 | self.change_path = True 166 | self._empty_explorer() 167 | self._fill_explorer() 168 | break 169 | 170 | def _select(self, path: str): 171 | """ Changes the self.selected_path to the given path """ 172 | self.change_path = False 173 | self.selected_path.set(path) 174 | self.change_path = True 175 | 176 | def _move_to(self, path: str): 177 | """ Changes the current directory to the given path """ 178 | self.path_to_show.set(path) 179 | self.change_path = False 180 | self.selected_path.set(path) 181 | self.change_path = True 182 | self._empty_explorer() 183 | self._fill_explorer() 184 | 185 | def _empty_explorer(self): 186 | """ Empties the explorer_frame """ 187 | for children in self.explorer_frame.winfo_children(): 188 | children.destroy() 189 | 190 | def _fill_explorer(self): 191 | """ Fills the explorer_frame with the files at self.path_to_show """ 192 | row = 0 193 | path = self.path_to_show.get() 194 | if os.path.isdir(path): 195 | for item in os.listdir(path): 196 | if os.path.isdir(join_paths(path, item)): # directory 197 | label = ctk.CTkLabel(self.explorer_frame, text=f" {item}", compound="left", image=self.folder_image) 198 | label.bind("", lambda event, p=join_paths(path, item): self._select(p)) # left click 199 | label.bind("", lambda event, p=join_paths(path, item): self._move_to(p)) # double left click 200 | label.bind("", self._mousewheel) 201 | label.grid(row=row, column=0, sticky="w", padx=3, pady=3) 202 | else: # file 203 | if not self.response_type == "directory": 204 | if self.filetypes is None or self.filetypes is not None and f".{item.split(".")[-1]}" in self.filetypes: 205 | label = ctk.CTkLabel(self.explorer_frame, text=f" {item}", compound="left", image=self.file_image) 206 | label.bind("", lambda event, p=join_paths(path, item): self._select(p)) # left click 207 | label.bind("", self._mousewheel) 208 | label.grid(row=row, column=0, sticky="w", padx=3, pady=3) 209 | row += 1 210 | self._reset_scrolling() 211 | 212 | def _user_path_changed(self, *args): 213 | """ Handles the event when self.selected_path is modified """ 214 | if self.change_path: 215 | if os.path.isdir(self.selected_path.get()) and self.selected_path.get() != self.path_to_show.get(): 216 | if not self.selected_path.get().endswith(" "): 217 | self.path_to_show.set(self.selected_path.get()) 218 | self._empty_explorer() 219 | self._fill_explorer() 220 | elif os.path.isfile(self.selected_path.get()) and os.path.dirname(self.selected_path.get()) != self.path_to_show.get(): # path to show changed 221 | if not os.path.dirname(self.selected_path.get()).endswith(" "): 222 | self.path_to_show.set(os.path.dirname(self.selected_path.get())) 223 | self._empty_explorer() 224 | self._fill_explorer() 225 | 226 | def get_path(self): 227 | """Returns the selected path 228 | 229 | :return: selected path, "" if no paths were selected 230 | """ 231 | if self.response_type == "file" and os.path.isfile(self.selected_path.get()): 232 | return self.selected_path.get() 233 | elif self.response_type == "directory" and os.path.isdir(self.selected_path.get()): 234 | return self.selected_path.get() 235 | else: 236 | return "" 237 | 238 | def move_to(self, path: str): 239 | """Changes the current directory to the given one 240 | 241 | :param path: path of the directory to move to 242 | """ 243 | if os.path.isdir(path): 244 | self._move_to(path) 245 | else: 246 | raise ValueError(f"The given path isn't a directory: {path}") 247 | 248 | 249 | class Filedialog(ctk.CTkToplevel): 250 | def __init__(self, responsetype: Literal["file", "directory"], title: str, filetypes: list[str] = None, 251 | initialdir: str = None, initialfile: str = None, geometry: str = "400x550"): 252 | """Creates a filedialog instance to ask for a file / directory 253 | 254 | :param responsetype: type of the selected response: "file" / "directory" 255 | :param title: title of the widget 256 | :param filetypes: extension of the file to enter: ("text", ".txt"), None if the response should be a directory 257 | :param initialdir: initial directory to start the search from, None if initialfile is not None 258 | :param initialfile: initial file selected 259 | :param geometry: initial geometry of the toplevel, default is "400x500" 260 | """ 261 | self.path = None 262 | 263 | super().__init__() 264 | self.lift() # lift window on top 265 | self.attributes("-topmost", True) # stay on top 266 | self.grab_set() # make other windows not clickable 267 | 268 | self.title(title) 269 | self.geometry(geometry) 270 | 271 | self.protocol("WM_DELETE_WINDOW", self._kill_event) 272 | 273 | self.explorer = FileExplorer(self, responsetype, filetypes, initialdir, initialfile) 274 | self.ok_button = ctk.CTkButton(self, text="Ok", command=self._ok_event) 275 | self.cancel_button = ctk.CTkButton(self, text="Cancel", command=self._cancel_event) 276 | 277 | self.grid_columnconfigure(0, weight=1) 278 | self.grid_columnconfigure(1, weight=1) 279 | self.grid_rowconfigure(0, weight=1) 280 | self.grid_rowconfigure(1, weight=0) 281 | 282 | self.explorer.grid(row=0, column=0, columnspan=2, sticky="nsew") 283 | self.ok_button.grid(row=1, column=0, sticky="ew", padx=5, pady=5) 284 | self.cancel_button.grid(row=1, column=1, sticky="ew", padx=5, pady=5) 285 | 286 | self.bind("", self._ok_event) 287 | self.bind("", self._cancel_event) 288 | 289 | def _ok_event(self, event=None): 290 | path = self.explorer.get_path() 291 | if path != "": 292 | self.path = path 293 | self.grab_release() 294 | self.destroy() 295 | else: 296 | showwarning("Entering path", "Please select a path") 297 | 298 | def _cancel_event(self, event=None): 299 | self.path = None 300 | self.grab_release() 301 | self.destroy() 302 | 303 | def _kill_event(self, event=None): 304 | self.path = None 305 | self.grab_release() 306 | self.destroy() 307 | 308 | def get_response(self) -> str | None: 309 | """ Waits until the dialog is closed and returns the path the user selected or None if the user cancelled """ 310 | self.master.wait_window(self) 311 | return self.path 312 | 313 | 314 | def askfile(title: str, filetypes: list[str] = None, initialdir: str = None, initialfile: str = None): 315 | """Asks for a file to select 316 | 317 | :param title: title of the widget 318 | :param filetypes: extension of the file to enter: ("text", ".txt"), None if the response should be a directory 319 | :param initialdir: initial directory to start the search from, None if initialfile is not None 320 | :param initialfile: initial file selected 321 | :return: path of the selected file, None if the user cancelled 322 | """ 323 | dialog = Filedialog("file", title, filetypes, initialdir, initialfile) 324 | return dialog.get_response() 325 | 326 | 327 | def askdir(title: str, initialdir: str = None): 328 | """Asks for a directory / folder to select 329 | 330 | :param title: title of the widget 331 | :param initialdir: initial directory to start the search from 332 | :return: path of the selected file, None if the user cancelled 333 | """ 334 | dialog = Filedialog("directory", title, initialdir=initialdir) 335 | return dialog.get_response() 336 | -------------------------------------------------------------------------------- /Source Code/DateSelector.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file contains the Calendar and DateSelector widgets 3 | """ 4 | 5 | import customtkinter as ctk 6 | import tkinter as tk 7 | import datetime 8 | import os 9 | from PIL import Image 10 | from typing import Literal, Optional, Union, Tuple, Callable 11 | 12 | 13 | def week_days_list_when_week_starts_with(weekday: Literal["mon", "tue", "wed", "thu", "fri", "sat", "sun"]) -> list[str]: 14 | """ Returns the list of the days of the week starting with the given day """ 15 | full_days_list = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] 16 | if weekday in full_days_list: 17 | return_list = [weekday] 18 | split_index = full_days_list.index(weekday) 19 | return_list.extend(full_days_list[split_index + 1:]) 20 | return_list.extend(full_days_list[:split_index]) 21 | return return_list 22 | else: 23 | raise ValueError(f"The given weekday is not valid: {weekday}") 24 | 25 | 26 | class Date: 27 | def __init__(self, day: int, month: int, year: int, hour: int = None, minute: int = None, date_format: Literal["dmy", "mdy", "ymd"] = "dmy"): 28 | """Class to verify if a date is correct and store it (minute, hour, day, month, year) 29 | 30 | :param day: day of the event (takes into account the rules for the leap years) 31 | :param month: month of the event 32 | :param year: year of the event 33 | :param hour: optional: hour of the event 34 | :param minute: optional: minute of the event (an hour must be entered to use the minute parameter) 35 | :param date_format: format of the date ("dmy", "mdy" or "ymd") 36 | """ 37 | if type(year) is int: 38 | self.year = year 39 | else: 40 | raise TypeError(f"The value entered for the year is not an int: {type(year)}") 41 | 42 | if type(month) is int: 43 | if 1 <= month <= 12: 44 | self.month = month 45 | else: 46 | raise ValueError(f"The value given for the month is invalid: {month}") 47 | else: 48 | raise TypeError(f"The value entered for the month is not an int: {type(month)}") 49 | 50 | if type(day) is int: 51 | leap_year = True if year % 400 == 0 else False if year % 100 == 0 else True if year % 4 == 0 else False 52 | if (month in (1, 3, 5, 7, 8, 10, 12) and 1 <= day <= 31) or (month in (4, 6, 9, 11) and 1 <= day <= 30) or (month == 2 and not leap_year and 1 <= day <= 28) or (month == 2 and leap_year and 1 <= day <= 29): 53 | self.day = day 54 | else: 55 | raise ValueError(f"The value given for the day is invalid: {day} for the month {month} and year {year}") 56 | else: 57 | raise TypeError(f"The value entered for the day is not an int: {type(day)}") 58 | 59 | if hour is not None: 60 | if type(hour) is int: 61 | if 0 <= hour <= 23: 62 | self.hour = hour 63 | else: 64 | raise ValueError(f"The value given for the hour is invalid: {hour}") 65 | else: 66 | raise TypeError(f"The value entered for the hour is not an int: {type(hour)}") 67 | else: 68 | self.hour = None 69 | 70 | if minute is not None: 71 | if hour is not None: 72 | if type(minute) is int: 73 | if 0 <= minute <= 59: 74 | self.minute = minute 75 | else: 76 | raise ValueError(f"The value given for the minute is invalid: {minute}") 77 | else: 78 | raise TypeError(f"The value entered for the minute is not an int: {type(minute)}") 79 | else: 80 | raise ValueError(f"Cannot enter a minute without an hour") 81 | else: 82 | self.minute = None 83 | 84 | if type(date_format) is str: 85 | if date_format in ["dmy", "mdy", "ymd"]: 86 | self.format = date_format 87 | else: 88 | raise ValueError(f"The given date_format is incorrect: {date_format}") 89 | else: 90 | raise TypeError(f"The value entered for the date_format is not a string: {type(date_format)}") 91 | 92 | def __str__(self): 93 | if self.format == "dmy": 94 | date = f"Date: {self.day}/{self.month}/{self.year}" # "Date: d/m/y, h:m" 95 | elif self.format == "mdy": 96 | date = f"Date: {self.month}/{self.day}/{self.year}" # "Date: m/d/y, h:m" 97 | else: 98 | date = f"Date: {self.year}/{self.month}/{self.day}" # "Date: y/m/d, h:m" 99 | 100 | if self.hour is not None and self.minute is not None: 101 | return f"{date}, {self.hour}:{self.minute}" 102 | elif self.hour is not None: 103 | return f"{date}, {self.hour}h" 104 | else: 105 | return date 106 | 107 | def __eq__(self, other): 108 | if isinstance(other, Date): 109 | if self.day == other.day and self.month == other.month and self.year == other.year: 110 | if self.hour is not None and other.hour is not None: 111 | if self.hour == other.hour: 112 | if self.minute is not None and other.minute is not None: 113 | if self.minute == other.minute: 114 | return True 115 | else: 116 | return False 117 | else: 118 | return True 119 | else: 120 | return False 121 | else: 122 | return True 123 | else: 124 | return False 125 | else: 126 | raise TypeError(f"Cannot compare {type(self)} and {type(other)}") 127 | 128 | def __lt__(self, other): 129 | if isinstance(other, Date): 130 | if self.year < other.year: 131 | return True 132 | elif self.year == other.year: 133 | if self.month < other.month: 134 | return True 135 | elif self.month == other.month: 136 | if self.day < other.day: 137 | return True 138 | elif self.day == other.day: 139 | if self.hour is not None and other.hour is not None: 140 | if self.hour < other.hour: 141 | return True 142 | elif self.hour == other.hour: 143 | if self.minute is not None and other.minute is not None: 144 | if self.minute < other.minute: 145 | return True 146 | return False 147 | else: 148 | raise TypeError(f"Cannot compare {type(self)} and {type(other)}") 149 | 150 | def __le__(self, other): 151 | if isinstance(other, Date): 152 | if self.year <= other.year: 153 | return True 154 | elif self.year == other.year: 155 | if self.month <= other.month: 156 | return True 157 | elif self.month == other.month: 158 | if self.day <= other.day: 159 | return True 160 | elif self.day == other.day: 161 | if self.hour is not None and other.hour is not None: 162 | if self.hour <= other.hour: 163 | return True 164 | elif self.hour == other.hour: 165 | if self.minute is not None and other.minute is not None: 166 | if self.minute <= other.minute: 167 | return True 168 | return False 169 | else: 170 | raise TypeError(f"Cannot compare {type(self)} and {type(other)}") 171 | 172 | def weekday(self) -> str: 173 | """ Returns the day of the week the date is ("mon", "tue", "wed", "thu", "fri", "sat" or "sun") """ 174 | int_to_str_day_of_the_week = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] 175 | return int_to_str_day_of_the_week[datetime.date(self.year, self.month, self.day).weekday()] 176 | 177 | def length_of_current_month(self) -> int: 178 | """ Returns the number of days in the current month """ 179 | if self.month in (1, 3, 5, 7, 8, 10, 12): 180 | return 31 181 | elif self.month in (4, 6, 9, 11): 182 | return 30 183 | else: # february 184 | if True if self.year % 400 == 0 else False if self.year % 100 == 0 else True if self.year % 4 == 0 else False: # leap year 185 | return 29 186 | else: 187 | return 28 188 | 189 | 190 | class DateSelector(ctk.CTkFrame): 191 | def __init__(self, 192 | master: any, 193 | width: int = 200, 194 | height: int = 200, 195 | corner_radius: Optional[Union[int, str]] = None, 196 | border_width: Optional[Union[int, str]] = None, 197 | 198 | bg_color: Union[str, Tuple[str, str]] = "transparent", 199 | fg_color: Optional[Union[str, Tuple[str, str]]] = None, 200 | border_color: Optional[Union[str, Tuple[str, str]]] = None, 201 | 202 | background_corner_colors: Union[Tuple[Union[str, Tuple[str, str]]], None] = None, 203 | overwrite_preferred_drawing_method: Union[str, None] = None, 204 | 205 | week_starts_with: Literal["mon", "tue", "wed", "thu", "fri", "sat", "sun"] = "mon", 206 | default_date: Date = None, 207 | callback: Callable[[], None] = None, 208 | button_hover_color: Optional[Union[str, Tuple[str, str]]] = None, 209 | **kwargs): 210 | """DateSelector widget to select a date. Uses the same arguments as CTkFrame 211 | 212 | :param week_starts_with: day the week starts with, can be: "mon", "tue", "wed", "thu", "fri", "sat" or "sun" 213 | :param default_date: date selected by default, if None is given, the default date is today 214 | :param callback: function to call when a new date is selected 215 | """ 216 | super().__init__(master, width, height, corner_radius, border_width, bg_color, fg_color, border_color, background_corner_colors, overwrite_preferred_drawing_method, **kwargs) 217 | 218 | self._button_hover_color = ctk.ThemeManager.theme["CTkButton"]["hover_color"] if button_hover_color is None else self._check_color_type(button_hover_color) 219 | 220 | if type(week_starts_with) is str: 221 | if week_starts_with in ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]: 222 | self.week_starts_with = week_starts_with 223 | else: 224 | raise ValueError(f"The given week_starts_with argument is invalid: {week_starts_with}") 225 | else: 226 | raise TypeError(f"The given week_starts_with argument is not a string: {type(week_starts_with)}") 227 | 228 | self.months_list = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"] 229 | 230 | if isinstance(default_date, Date): 231 | self.date = default_date 232 | elif default_date is None: 233 | date = datetime.datetime.now() 234 | self.date = Date(date.day, date.month, date.year) 235 | else: 236 | raise TypeError(f"The given default_date argument is not a Date instance: {type(default_date)}") 237 | 238 | if callable(callback) or callback is None: 239 | self._callback = callback 240 | else: 241 | raise ValueError(f"The given callback is not callable: {callback}") 242 | 243 | self.back_arrow_image = ctk.CTkImage(Image.open(os.path.join(os.path.dirname(__file__), "Images/back_arrow_light.png")), Image.open(os.path.join(os.path.dirname(__file__), "Images/back_arrow_dark.png"))) 244 | self.forward_arrow_image = ctk.CTkImage(Image.open(os.path.join(os.path.dirname(__file__), "Images/forward_arrow_light.png")), Image.open(os.path.join(os.path.dirname(__file__), "Images/forward_arrow_dark.png"))) 245 | 246 | self.top_frame = ctk.CTkFrame(self, fg_color=self._fg_color) 247 | self.back_button = ctk.CTkButton(self.top_frame, text="", image=self.back_arrow_image, command=self._back, fg_color=self._fg_color, border_color=self._border_color, border_width=2, width=30) 248 | self.forward_button = ctk.CTkButton(self.top_frame, text="", image=self.forward_arrow_image, command=self._forward, fg_color=self._fg_color, border_color=self._border_color, border_width=2, width=30) 249 | self.year_var = ctk.IntVar(self.top_frame, self.date.year) 250 | self.year_var.trace_add("write", self._year_changed) 251 | self.year_entry = ctk.CTkEntry(self.top_frame, textvariable=self.year_var, width=100, justify="center") 252 | self.month_label = ctk.CTkLabel(self.top_frame, text=self.months_list[self.date.month - 1]) 253 | self.back_button.grid(row=0, column=0, padx=2, pady=2) 254 | self.year_entry.grid(row=0, column=1, padx=2, pady=2) 255 | self.forward_button.grid(row=0, column=2, padx=2, pady=2) 256 | self.month_label.grid(row=1, column=0, columnspan=3, padx=2, pady=2) 257 | 258 | self.days_frame = ctk.CTkFrame(self, fg_color=self._fg_color) 259 | self.week_days_labels_dict = {day: ctk.CTkLabel(self.days_frame, text=day) for day in ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]} 260 | self.regrid_weekdays() 261 | self.days_buttons_list = [ctk.CTkButton(self.days_frame, text=str(day), fg_color=self._fg_color, hover_color=self._button_hover_color, width=25, command=lambda x=day: self._selected_day(x)) for day in range(1, 32)] 262 | self.previous_selected_day_index = self.date.day - 1 263 | 264 | self.top_frame.grid(row=0, column=0, padx=1, pady=1) 265 | self.days_frame.grid(row=1, column=0, padx=1, pady=1) 266 | self._actualize_days() 267 | 268 | def configure(self, require_redraw=False, **kwargs): 269 | if "fg_color" in kwargs: 270 | fg_color = kwargs["fg_color"] 271 | self.top_frame.configure(fg_color=fg_color) 272 | self.back_button.configure(fg_color=fg_color) 273 | self.forward_button.configure(fg_color=fg_color) 274 | self.days_frame.configure(fg_color=fg_color) 275 | for index in range(len(self.days_buttons_list)): 276 | self.days_buttons_list[index].configure(fg_color=fg_color) 277 | 278 | if "button_hover_color" in kwargs: 279 | button_hover_color = kwargs.pop("button_hover_color") 280 | self._button_hover_color = ctk.ThemeManager.theme["CTkButton"]["hover_color"] if button_hover_color is None else self._check_color_type(button_hover_color) 281 | self.days_buttons_list[self.previous_selected_day_index].configure(fg_color=self._button_hover_color) 282 | 283 | if "week_starts_with" in kwargs: 284 | week_starts_with = kwargs.pop("week_starts_with") 285 | if type(week_starts_with) is str: 286 | if week_starts_with in ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]: 287 | self.week_starts_with = week_starts_with 288 | self.regrid_weekdays() 289 | self._actualize_days() 290 | else: 291 | raise ValueError(f"The given week_starts_with argument is invalid: {week_starts_with}") 292 | else: 293 | raise TypeError(f"The given week_starts_with argument is not a string: {type(week_starts_with)}") 294 | 295 | if "callback" in kwargs: 296 | callback = kwargs.pop("callback") 297 | if callable(callback) or callback is None: 298 | self._callback = callback 299 | else: 300 | raise ValueError(f"The given callback is not callable: {callback}") 301 | 302 | super().configure(require_redraw=require_redraw, **kwargs) 303 | 304 | def _check_if_day_is_correct(self): 305 | """ Changes the day if it not possible (ex: june 31st is retransformed in june 30th) """ 306 | if self.date.day > self.date.length_of_current_month(): 307 | self.date.day = self.date.length_of_current_month() 308 | 309 | def _back(self): 310 | """ Called when the back button is pressed """ 311 | if self.date.month == 1: 312 | self.date.month = 12 313 | self.date.year -= 1 314 | self.year_var.set(self.date.year) 315 | else: 316 | self.date.month -= 1 317 | self.month_label.configure(text=self.months_list[self.date.month - 1]) 318 | self._check_if_day_is_correct() 319 | self._actualize_days() 320 | 321 | def _forward(self): 322 | """ Called when the forward button is pressed """ 323 | if self.date.month == 12: 324 | self.date.month = 1 325 | self.date.year += 1 326 | self.year_var.set(self.date.year) 327 | else: 328 | self.date.month += 1 329 | self.month_label.configure(text=self.months_list[self.date.month - 1]) 330 | self._check_if_day_is_correct() 331 | self._actualize_days() 332 | 333 | def _year_changed(self, *args): 334 | """ Called when the year is changed """ 335 | self._check_if_day_is_correct() 336 | self._actualize_days() 337 | 338 | def _selected_day(self, day: int): 339 | """ Called when a day button is pressed """ 340 | self.date.day = day 341 | self._actualize_days() 342 | 343 | def _actualize_days(self): 344 | """ Actualizes the days buttons """ 345 | temp_date = Date(1, self.date.month, self.date.year) 346 | row = 1 347 | column = week_days_list_when_week_starts_with(self.week_starts_with).index(temp_date.weekday()) 348 | for day in range(self.date.length_of_current_month()): 349 | self.days_buttons_list[day].grid(row=row, column=column) 350 | column += 1 351 | if column > 6: 352 | column = 0 353 | row += 1 354 | for day in range(self.date.length_of_current_month(), 31): 355 | self.days_buttons_list[day].grid_forget() 356 | self.days_buttons_list[self.previous_selected_day_index].configure(fg_color=self._fg_color) 357 | self.days_buttons_list[self.date.day - 1].configure(fg_color=self._button_hover_color) 358 | self.previous_selected_day_index = self.date.day - 1 359 | if self._callback is not None: 360 | self._callback() 361 | 362 | def regrid_weekdays(self): 363 | days_list = week_days_list_when_week_starts_with(self.week_starts_with) 364 | column = 0 365 | for day in days_list: 366 | self.week_days_labels_dict[day].grid(row=0, column=column) 367 | column += 1 368 | 369 | def get(self) -> Date: 370 | """ Returns the current selected date """ 371 | return self.date 372 | 373 | 374 | class DateSelectorButton(ctk.CTkButton): 375 | def __init__(self, 376 | master: any, 377 | 378 | callback: Union[Callable[[], None], None] = None, # called when the selected date is changed 379 | restrained_to_master: bool = False, 380 | default_date: Date | None = None, 381 | 382 | width: int = 140, 383 | height: int = 28, 384 | corner_radius: Optional[int] = None, 385 | border_width: Optional[int] = None, 386 | border_spacing: int = 2, 387 | 388 | bg_color: Union[str, Tuple[str, str]] = "transparent", 389 | fg_color: Optional[Union[str, Tuple[str, str]]] = None, 390 | hover_color: Optional[Union[str, Tuple[str, str]]] = None, 391 | border_color: Optional[Union[str, Tuple[str, str]]] = None, 392 | text_color: Optional[Union[str, Tuple[str, str]]] = None, 393 | text_color_disabled: Optional[Union[str, Tuple[str, str]]] = None, 394 | 395 | background_corner_colors: Union[Tuple[Union[str, Tuple[str, str]]], None] = None, 396 | round_width_to_even_numbers: bool = True, 397 | round_height_to_even_numbers: bool = True, 398 | 399 | text: str = "MCTkDatePicker", 400 | font: Optional[Union[tuple, ctk.CTkFont]] = None, 401 | textvariable: Union[tk.Variable, None] = None, 402 | image: Union[ctk.CTkImage, None] = None, 403 | state: str = "normal", 404 | hover: bool = True, 405 | compound: str = "left", 406 | anchor: str = "center", 407 | **kwargs): 408 | """DatePicker widget to select a date. All arguments for ctk.CTkButton are valid except "command" 409 | 410 | :param callback: function to call when a new date is selected 411 | :param restrained_to_master: if set to True, the popup frame will not be able to get out of the direct master of the widget, otherwise the popup menu will be able to expand in the whole window 412 | :param default_date: date selected by default 413 | """ 414 | super().__init__(master, width, height, corner_radius, border_width, border_spacing, 415 | bg_color, fg_color, hover_color, border_color, text_color, text_color_disabled, 416 | background_corner_colors, round_width_to_even_numbers, round_height_to_even_numbers, 417 | text, font, textvariable, image, state, hover, self._on_clicked, compound, anchor, **kwargs) 418 | if callback is not None: 419 | if callable(callback): 420 | self._callback = callback 421 | else: 422 | raise TypeError(f"The given callback is not callable: {callback}") 423 | 424 | if type(restrained_to_master) is bool: 425 | self._restrained_to_master = restrained_to_master 426 | else: 427 | raise TypeError(f"The given restrained_to_master argument is not a boolean: {type(restrained_to_master)}") 428 | 429 | if isinstance(default_date, Date): 430 | self._date = default_date 431 | elif default_date is None: 432 | date = datetime.datetime.now() 433 | self._date = Date(date.day, date.month, date.year) 434 | else: 435 | raise TypeError(f"The given default_date argument is not a Date instance: {type(default_date)}") 436 | 437 | if restrained_to_master: 438 | self._popup_frame = ctk.CTkFrame(master) 439 | else: 440 | while not isinstance(master, (tk.Tk, ctk.CTk, tk.Toplevel, ctk.CTkToplevel)): 441 | master_name = master.winfo_parent() 442 | master = master._nametowidget(master_name) 443 | self._popup_frame = DateSelector(master) 444 | 445 | def _on_clicked(self): 446 | """ Called when the button is clicked """ 447 | if self._popup_frame.place_info(): # is currently shown 448 | self._popup_frame.place_forget() 449 | else: # is not currently shown 450 | x = self.winfo_x() + self.winfo_width() 451 | y = self.winfo_y() + self.winfo_height() 452 | self._popup_frame.place(x=x, y=y) 453 | self._popup_frame.update() 454 | width = self._popup_frame.winfo_width() 455 | height = self._popup_frame.winfo_height() 456 | master_width = self._popup_frame.master.winfo_width() 457 | master_height = self._popup_frame.master.winfo_height() 458 | 459 | if x + width > master_width or y + height > master_height: # does not fit: search where can it fit 460 | if x + width > master_width: # does not fit to the right of the button 461 | new_x = master_width - width 462 | else: # fits to the right of the button 463 | new_x = x 464 | if y + height > master_height: # cannot fit under the button 465 | if height > self.winfo_y(): # cannot fit above the button 466 | new_y = y 467 | else: # can fit above the button 468 | new_y = self.winfo_y() - height 469 | else: # can fit under the button 470 | new_y = y 471 | self._popup_frame.place(x=new_x, y=new_y) 472 | 473 | def get(self) -> Date: 474 | return self._date 475 | --------------------------------------------------------------------------------