CustomTkinter icon indicating copy to clipboard operation
CustomTkinter copied to clipboard

Issue: Unintended Theme Changes with ttkbootstrap Toast Notifications in CustomTkinter

Open DimaTepliakov opened this issue 1 year ago • 8 comments

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.

DimaTepliakov avatar Dec 26 '23 11:12 DimaTepliakov

@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:

  • Specially designed for customtkinter.
  • Can change the size and color.
  • Takes icon as text and/or as CTkImage for effective use case.
  • Other functionalities that a basic toast box has.
  • If you need more functionalities, you can modify the class accordingly.

Hope, it will be helpful for you.

dipeshSam avatar Dec 26 '23 16:12 dipeshSam

@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.

DimaTepliakov avatar Dec 27 '23 08:12 DimaTepliakov

@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.

dipeshSam avatar Dec 27 '23 09:12 dipeshSam

@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 avatar Dec 27 '23 09:12 DimaTepliakov

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.

DimaTepliakov avatar Dec 27 '23 12:12 DimaTepliakov

@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:

  • Positions issue fixed.
  • All standard anchor options: ['w', 'e', 'n', 's', 'nsew'] added.
  • Inverse support for anchor value added.
  • corner_radius added.
  • Transparency opacity added.
  • padx and pady introduced for flexible positioning.
  • Memory efficient.

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 :)

dipeshSam avatar Dec 27 '23 14:12 dipeshSam

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

DimaTepliakov avatar Dec 27 '23 15:12 DimaTepliakov

@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 :)

dipeshSam avatar Dec 27 '23 15:12 dipeshSam