TomSchimansky / CustomTkinter

A modern and customizable python UI-library based on Tkinter
MIT License
11.52k stars 1.08k forks source link

Issue: Unintended Theme Changes with ttkbootstrap Toast Notifications in CustomTkinter #2165

Open DimaTepliakov opened 10 months ago

DimaTepliakov commented 10 months ago

Description: I'm encountering a problem when using ttkbootstrap toast notifications in my CustomTkinter application. The toast notifications work as expected, but they inadvertently modify the overall theme of my application. I've provided a simplified code snippet below:

import customtkinter as ctk
from ttkbootstrap.toast import ToastNotification

root = ctk.CTk()

root.title('Toast Notification Example')
root.geometry('300x150')

def show_toast():
    toast.show_toast()

toast = ToastNotification(
    title='This is a Toast Title',
    message='This is a Toast Message',
    duration=3000,
    alert=True, # for the ding
    icon='⚠', 
    bootstyle=ctk.get_appearance_mode().upper() # LIGHT/DARK
)

button = ctk.CTkButton(root, text='Show Toast', command=show_toast)
button.pack(padx=10, pady=40)

root.mainloop()

Issue: When the toast notification is displayed, it unexpectedly changes the theme of my entire application

Screenshot: image

Additional Information: From my attempts to fix this, I have noticed that explicitly changing the appearance mode after showing the toast notification restores the correct theme evaluation settings. For example:

def show_toast():
    toast.show_toast()
    ctk.set_appearance_mode('Light') # change to the opposite appearance mode
    ctk.set_appearance_mode('Dark') # back to the original appearance mode

I believe there might be a more logical solution than this workaround.

I'm seeking assistance in understanding and resolving this theme modification issue caused by ttkbootstrap toast notifications in CustomTkinter. Any insights or suggestions to maintain a consistent theme would be highly valuable.

Thank you for your assistance.

dipeshSam commented 10 months ago

@DimaTepliakov, This is because the ttkbootstrap tries to modify the color of the widgets placed in the main window. To efficiently change the color, ttkbootstrap recommends to use Window() class to initiate new window, and not Tk().

See, normal button behavior in Tkinter: normal_tk_btn_behaviour

And, Using ttkbootstrap button behavior in Tkinter: tk_btn_behaviour_on_ttkbootstrap

Customtkinter is built over tkinter and performs hight DPI and scaling operations by its own for each widget. In the other hand ttkbootstrap uses ttk (themed tkinter), and creates its own theme manager which is completely unsupported by customtkinter.

To use this functionality, you need to create a toast box using widgets provided by customtkinter which auto handles the CTkBaseClass for scaling, appearance mode, and theming.

Code for a basic toast box using customtkinter:

from typing import Literal
from customtkinter import (
    CTk,
    CTkToplevel,
    CTkFrame,
    CTkImage,
    CTkButton,
    CTkLabel,
    CTkFont,
    ThemeManager
)

class ToastNotification():
    """Toast notification functionality for `customtkinter` inspired by Windows' notifications and as an
        alternative to the `ttkbootstrap.toast.ToastNotification`.

        Methods:
        - show() -> Shows the toast box
        - hide() -> Hides the toast box
    """
    def __init__(self,
            master: CTk = None,         
            title: str = "Toast Notification Title",
            message: str = "This is the message that the Toast box contains.",
            duration: int | None = 1000,    # If None, will be disappear on click
            alert_sound: bool = False,
            icon: CTkImage | str | None = None,
            anchor: Literal["nw", "ne", "sw", "se"] = "se",
            size: tuple[int, int] = (400, 150),
            fg_color: str | tuple[str, str] | None = None,
        ):

        # Saving indicating variables
        self.master = master
        self._anchor = anchor
        self._size = size

        self._fg_color = fg_color
        self._duration = duration
        self._alert = alert_sound

        self._title = title
        self._message = message
        self._icon = icon # or ⚠
        self._opened: bool = False

        # Taking a color for toast box if None was provided
        if not fg_color: self._fg_color = ThemeManager.theme["CTkFrame"]["fg_color"]

    def show(self):
        if self._opened: return

        self.toplevel = CTkToplevel(self.master,
            fg_color=self._fg_color, width=self._size[0], height=self._size[1])
        self._opened = True

        self.__fill_items()
        self._applying_position()
        self.toplevel.bind("<ButtonPress>", lambda _: self.hide())

        if self._alert:
            self.toplevel.bell()

        if self._duration is not None:
            self.master.after(self._duration, lambda: self.hide())

    def __fill_items(self):
        self.container = CTkFrame(self.toplevel, fg_color="transparent")
        self.container.grid(padx=20, pady=20)

        icon_label = CTkLabel(self.container, text="⚠" if not self._icon else self._icon if isinstance(self._icon, str) else "", image=self._icon if not isinstance(self._icon, str) else None, font=CTkFont(size=30, weight="bold"))
        icon_label.grid(row=0, column=0, rowspan=2, sticky="w", padx=(0, 10))

        title_label = CTkLabel(self.container, text=self._title, font=CTkFont(weight="bold"), wraplength=self._size[0]-20)
        title_label.grid(row=0, column=1, sticky="w", padx=(0, 10))

        message_label = CTkLabel(self.container, text=self._message, justify="left", wraplength=self._size[0]-20)
        message_label.grid(row=1, column=1, padx=(0, 10))

    def _applying_position(self):
        self.toplevel.update_idletasks()
        self.toplevel.wm_overrideredirect(True)

        # Define position coordinates
        positions = {
            "nw": "+0+0",
            "ne": f"+{self.toplevel.winfo_screenwidth()-self.toplevel.winfo_reqwidth()//4}+0",
            "sw": f"+0+{self.toplevel.winfo_screenheight()-self.toplevel.winfo_reqheight()}",
            "se": f"+{self.toplevel.winfo_screenwidth()-self.toplevel.winfo_reqwidth()//3}+{self.toplevel.winfo_screenheight()-self.toplevel.winfo_reqheight()//3}"
        }

        # Place window at the specified position
        self.toplevel.geometry(positions.get(self._anchor, "+0+0"))

    def hide(self):
        self._opened = False
        self.toplevel.destroy()

# Example use case
if __name__ == "__main__":
    app = CTk()
    app.geometry("500x350")

    toast = ToastNotification(app, alert_sound=True)

    button = CTkButton(app, text="Show toast", command=lambda: toast.show())
    button.place(relx=0.5, anchor="center", rely=0.5)

    app.mainloop()

Output ctk_toast

In the code:

Hope, it will be helpful for you.

DimaTepliakov commented 10 months ago

@DimaTepliakov, This is because the ttkbootstrap tries to modify the color of the widgets placed in the main window. To efficiently change the color, ttkbootstrap recommends to use Window() class to initiate new window, and not Tk(). ...

Thank you very much! I observed the implementation of ToastNotification using ttkbootstrap.Window() in the example. I initially hoped to seamlessly integrate it into my customtkinter-based project with minimal adjustments.

I appreciate your effort in furnishing a comprehensive class that replicates ttkbootstrap.toast.ToastNotification within the customtkinter framework. Regrettably, upon pressing the button, the notification fails to appear. I will undertake debugging to identify the root cause of this issue.

dipeshSam commented 10 months ago

@DimaTepliakov, This is because the ttkbootstrap tries to modify the color of the widgets placed in the main window. To efficiently change the color, ttkbootstrap recommends to use Window() class to initiate new window, and not Tk(). ...

Thank you very much! I observed the implementation of ToastNotification using ttkbootstrap.Window() in the example. I initially hoped to seamlessly integrate it into my customtkinter-based project with minimal adjustments.

I appreciate your effort in furnishing a comprehensive class that replicates ttkbootstrap.toast.ToastNotification within the customtkinter framework. Regrettably, upon pressing the button, the notification fails to appear. I will undertake debugging to identify the root cause of this issue.

You are welcome. Is the provided class not working in your system? Please inform here further bugs if you are facing any.

Best regards.

DimaTepliakov commented 10 months ago

@DimaTepliakov, This is because the ttkbootstrap tries to modify the color of the widgets placed in the main window. To efficiently change the color, ttkbootstrap recommends to use Window() class to initiate new window, and not Tk(). ...

Thank you very much! I observed the implementation of ToastNotification using ttkbootstrap.Window() in the example. I initially hoped to seamlessly integrate it into my customtkinter-based project with minimal adjustments. I appreciate your effort in furnishing a comprehensive class that replicates ttkbootstrap.toast.ToastNotification within the customtkinter framework. Regrettably, upon pressing the button, the notification fails to appear. I will undertake debugging to identify the root cause of this issue.

You are welcome. Is the provided class not working in your system? Please inform here further bugs if you are facing any.

Best regards.

Ok, I have checked and it works, the only issue is with the _applying_position function, I will have to fix the geometry because I see the full notification only if I set anchor="nw": image

when I use anchor="ne": image

"sw": image

and with "se" I only hear the bell alert.

DimaTepliakov commented 10 months ago

I've improved the _applying_position function, ensuring that every anchor now functions correctly:

    def _applying_position(self):
        self.toplevel.update_idletasks()
        self.toplevel.wm_overrideredirect(True)

        screen_w = self.toplevel.winfo_screenwidth()
        screen_h = self.toplevel.winfo_screenheight()
        top_w    = self.toplevel.winfo_reqwidth()
        top_h    = self.toplevel.winfo_reqheight()
        padding_height = 40

        # Define position coordinates
        positions = {
            "nw": f"+0+{padding_height}",
            "ne": f"+{screen_w-top_w}+{padding_height}",
            "sw": f"+0+{screen_h-top_h-padding_height}",
            "se": f"+{screen_w-top_w}+{screen_h-top_h-padding_height}"
        }

        # # Place window at the specified position
        self.toplevel.geometry(positions.get(self._anchor, "+0+0"))

Thanks alot.

dipeshSam commented 10 months ago

@DimaTepliakov, Glad to hear that you have fixed the _applying_position function and it worked for you. Appreciable! Thank you :) Apologies for inconvenience, this class was made so quickly that's why there were bugs in the class. It also lacks some important options like transparency opacity, corner radius, padding etc.

Revised version with improved functionalities mentioned above: fixed_toast

Updated code:

from typing import Literal
from customtkinter import (
    CTk,
    CTkToplevel,
    CTkFrame,
    CTkImage,
    CTkButton,
    CTkLabel,
    CTkFont,
    ThemeManager
)

class ToastNotification():
    """Toast notification functionality for `customtkinter` inspired by Windows' notifications and as an
        alternative to the `ttkbootstrap.toast.ToastNotification`.

        Methods:
        - show() -> Shows the toast box
        - hide() -> Hides the toast box
    """
    def __init__(self,
            master: CTk = None,         
            title: str = "Toast Notification Title",
            message: str = "This is the message that the Toast box contains.",
            duration: int | None = 1000,    # If None, will be disappear on click
            alert_sound: bool = False,
            icon: CTkImage | str | None = None,
            size: tuple[int, int] = (350, 100),
            anchor: Literal["w", "e", "n", "s",  "nw", "ne", "se", "sw", "nsew"] = "se",    # Also, supports reverse
            padx: int = 20,
            pady: int = 120,
            opacity: float = 0.8,
            corner_radius: float = None,
            fg_color: str | tuple[str, str] | None = None
        ):

        # Saving indicating variables
        self._size     = size
        self._anchor   = anchor
        self.master    = master
        self._duration = duration
        self._fg_color = fg_color
        self._alert    = alert_sound

        # Saving co-operative variables
        self._padx    = padx
        self._pady    = pady
        self._icon    = icon
        self._opened  = False
        self._title   = title
        self._message = message

        # Getting the transparent color with radius
        self._opacity           = opacity
        self._corner_radius     = corner_radius
        self._transparent_color = ThemeManager.theme["CTkToplevel"]["fg_color"]

    def show(self):
        if self._opened:        return
        else: self._opened =    True

        self.toplevel = CTkToplevel(self.master,
            width=self._size[0], height=self._size[1])

        self.__fill_items()
        self._applying_position()
        self.toplevel.bind("<ButtonPress>", lambda _: self.hide())

        if self._alert:
            self.toplevel.bell()

        if self._duration is not None:
            self.master.after(self._duration, lambda: self.hide())

    def __fill_items(self):
        self.container = CTkFrame(self.toplevel, fg_color=self._fg_color, corner_radius=self._corner_radius)
        self.container.grid(sticky="nsew")

        icon_label = CTkLabel(self.container, text="⚠" if not self._icon else self._icon if isinstance(self._icon, str) else "",
            image=self._icon if not isinstance(self._icon, str) else None, font=CTkFont(size=30, weight="bold"))
        icon_label.grid(row=0, column=0, rowspan=2, sticky="w", padx=10)

        title_label = CTkLabel(self.container, text=self._title, font=CTkFont(weight="bold"), wraplength=self._size[0]/2)
        title_label.grid(row=0, column=1, sticky="w", padx=(0, 20), pady=(10, 0))

        message_label = CTkLabel(self.container, text=self._message, justify="left", wraplength=self._size[0]/1.25)
        message_label.grid(row=1, column=1, sticky="w", padx=(0, 20), pady=(0, 10))

    def _applying_position(self):
        self.toplevel.update_idletasks()
        self.toplevel.wm_overrideredirect(True)

        self.toplevel.wm_attributes("-transparentcolor",
            self._transparent_color[0 if self.toplevel._get_appearance_mode() == "light" else 1])
        self.toplevel.wm_attributes("-alpha", self._opacity)

        self._place_at_anchor()

    def _place_at_anchor(self):
        scaling = self.toplevel._get_window_scaling()
        screen_width = self.toplevel.winfo_screenwidth()*scaling
        screen_height = self.toplevel.winfo_screenheight()*scaling

        # Getting box width and height
        box_width = self.toplevel.winfo_reqwidth()
        box_height = self.toplevel.winfo_reqheight()
        self.toplevel.wm_overrideredirect(True)    

        anchors: dict = {
            "se":   (int(screen_width-box_width -self._padx),        int(screen_height-box_height -self._pady)),
            "sw":   (int(self._padx),                                int(screen_height-box_height -self._pady)),
            "ne":   (int(screen_width-box_width -self._padx),        int(self._pady/4)),
            "nw":   (int(self._padx),                                int(self._pady/4)),
            "w":    (int(self._padx),                                int(screen_height/2 - box_height) + self._pady),
            "e":    (int(screen_width-box_width -self._padx),        int(screen_height/2 - box_height) + self._pady),
            "n":    (int(screen_width/2 - box_width/2),              int(self._pady)),
            "s":    (int(screen_width/2 - box_width/2),              int(screen_height - box_height -self._pady)),
            "nsew": (int(screen_width/2 - box_width/2 + self._padx), int(screen_height/2 - box_height/2 + self._pady)),
        }

        # Getting the anchor handling the reverse case. NSEW if invalid anchor provided.
        x, y = anchors.get(self._anchor, anchors.get(self._anchor[::-1], anchors["nsew"]))
        self.toplevel.geometry(f"+{x}+{y}")

    def hide(self):
        self._opened = False
        self.toplevel.destroy()

# Sample use case
from customtkinter import CTkRadioButton, StringVar

if __name__ == "__main__":
    app = CTk()
    app.geometry("900x600")
    app.grid_rowconfigure((0, 1), weight=1)

    anchors = ['w', 'e', 'n', 's', 'nw', 'ne', 'se', 'sw', 'nsew']
    app.grid_columnconfigure(tuple(range(len(anchors))), weight=1)

    toast = ToastNotification(app, fg_color="green", anchor="nsew")

    button = CTkButton(app, text="Show toast", command=toast.show)
    button.grid(row=0, column=0, columnspan=len(anchors))

    # Placing options
    radio_value = StringVar(value="nsew")
    for col, anchor in enumerate(anchors):
        CTkRadioButton(app, variable=radio_value, value=anchor, text=anchor,
            command=lambda: setattr(toast, "_anchor", radio_value.get())).grid(row=1, column=col)

    app.mainloop()

In the code:

Important: Please do let me know whether it is still making issues on anchor positions or not.

Thank you for your co-operation. Hope, it will be helpful for you. Happy customtkinter :)

DimaTepliakov commented 10 months ago

@dipeshSam Nice! with this updated version there is not issues with the anchor position, all of them works perfectly!

dipeshSam commented 10 months ago

@dipeshSam Nice! with this updated version there is not issues with the anchor position, all of them works perfectly!

@DimaTepliakov, Glad to hear this! You are most welcome. Happy customtkinter :)