diff --git a/customtkinter/__init__.py b/customtkinter/__init__.py index e2359d7..c2d0186 100644 --- a/customtkinter/__init__.py +++ b/customtkinter/__init__.py @@ -47,6 +47,7 @@ from .widgets.ctk_label import CTkLabel from .widgets.ctk_radiobutton import CTkRadioButton from .widgets.ctk_canvas import CTkCanvas from .widgets.ctk_switch import CTkSwitch +from .widgets.ctk_optionmenu import CTkOptionMenu # import windows from .windows.ctk_tk import CTk diff --git a/customtkinter/assets/themes/blue.json b/customtkinter/assets/themes/blue.json index 3d7f72f..84977f9 100644 --- a/customtkinter/assets/themes/blue.json +++ b/customtkinter/assets/themes/blue.json @@ -26,7 +26,9 @@ "switch": ["gray70", "gray35"], "switch_progress": ["#5B97D3", "#3373B8"], "switch_button": ["gray36", "#D5D9DE"], - "switch_button_hover": ["gray20", "gray100"] + "switch_button_hover": ["gray20", "gray100"], + "optionmenu_button": ["#4A7BAD", "#1D538D"], + "optionmenu_button_hover": ["#D7D8D9", "gray22"] }, "text": { "macOS": { diff --git a/customtkinter/draw_engine.py b/customtkinter/draw_engine.py index 5129ab8..0c530b0 100644 --- a/customtkinter/draw_engine.py +++ b/customtkinter/draw_engine.py @@ -17,6 +17,7 @@ class DrawEngine: Functions: - draw_rounded_rect_with_border() + - draw_rounded_rect_with_border_vertical_split() - draw_rounded_progress_bar_with_border() - draw_rounded_slider_with_border_and_button() - draw_checkmark() @@ -27,7 +28,6 @@ class DrawEngine: def __init__(self, canvas: CTkCanvas): self._canvas = canvas - self._existing_tags = set() def __calc_optimal_corner_radius(self, user_corner_radius: Union[float, int]) -> Union[float, int]: # optimize for drawing with polygon shapes @@ -53,7 +53,8 @@ class DrawEngine: else: return user_corner_radius - def draw_rounded_rect_with_border(self, width: int, height: int, corner_radius: Union[float, int], border_width: Union[float, int]) -> bool: + def draw_rounded_rect_with_border(self, width: int, height: int, corner_radius: Union[float, int], border_width: Union[float, int], + overwrite_preferred_drawing_method: str = None) -> bool: """ Draws a rounded rectangle with a corner_radius and border_width on the canvas. The border elements have a 'border_parts' tag, the main foreground elements have an 'inner_parts' tag to color the elements accordingly. @@ -74,19 +75,14 @@ class DrawEngine: else: inner_corner_radius = 0 - if overwrite_preferred_drawing_method is None: - preferred_drawing_method = self.preferred_drawing_method - else: - preferred_drawing_method = overwrite_preferred_drawing_method + if self.preferred_drawing_method == "polygon_shapes": + return self.__draw_rounded_rect_with_border_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius) + elif self.preferred_drawing_method == "font_shapes": + return self.__draw_rounded_rect_with_border_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, ()) + elif self.preferred_drawing_method == "circle_shapes": + return self.__draw_rounded_rect_with_border_circle_shapes(width, height, corner_radius, border_width, inner_corner_radius) - if preferred_drawing_method == "polygon_shapes": - return self._draw_rounded_rect_with_border_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius) - elif preferred_drawing_method == "font_shapes": - return self._draw_rounded_rect_with_border_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, ()) - elif preferred_drawing_method == "circle_shapes": - return self._draw_rounded_rect_with_border_circle_shapes(width, height, corner_radius, border_width, inner_corner_radius) - - def _draw_rounded_rect_with_border_polygon_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int) -> bool: + def __draw_rounded_rect_with_border_polygon_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int) -> bool: requires_recoloring = False # create border button parts (only if border exists) @@ -139,8 +135,8 @@ class DrawEngine: return requires_recoloring - def _draw_rounded_rect_with_border_font_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, - exclude_parts: tuple) -> bool: + def __draw_rounded_rect_with_border_font_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, + exclude_parts: tuple) -> bool: requires_recoloring = False # create border button parts @@ -277,7 +273,7 @@ class DrawEngine: return requires_recoloring - def _draw_rounded_rect_with_border_circle_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int) -> bool: + def __draw_rounded_rect_with_border_circle_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int) -> bool: requires_recoloring = False # border button parts @@ -351,6 +347,285 @@ class DrawEngine: return requires_recoloring + def draw_rounded_rect_with_border_vertical_split(self, width: int, height: int, corner_radius: Union[float, int], border_width: Union[float, int], + left_section_width: int) -> bool: + """ Draws a rounded rectangle with a corner_radius and border_width on the canvas which is split at left_section_width. + The border elements have the tags 'border_parts_left', 'border_parts_lright', + the main foreground elements have an 'inner_parts_left' and inner_parts_right' tag, + to color the elements accordingly. + + returns bool if recoloring is necessary """ + + width = math.floor(width / 2) * 2 # round (floor) current_width and current_height and restrict them to even values only + height = math.floor(height / 2) * 2 + corner_radius = round(corner_radius) + + if corner_radius > width / 2 or corner_radius > height / 2: # restrict corner_radius if it's too larger + corner_radius = min(width / 2, height / 2) + + border_width = round(border_width) + corner_radius = self.__calc_optimal_corner_radius(corner_radius) # optimize corner_radius for different drawing methods (different rounding) + + if corner_radius >= border_width: + inner_corner_radius = corner_radius - border_width + else: + inner_corner_radius = 0 + + if left_section_width > width - corner_radius * 2: + left_section_width = width - corner_radius * 2 + elif left_section_width < corner_radius * 2: + left_section_width = corner_radius * 2 + + if self.preferred_drawing_method == "polygon_shapes" or self.preferred_drawing_method == "circle_shapes": + return self.__draw_rounded_rect_with_border_vertical_split_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius, left_section_width) + elif self.preferred_drawing_method == "font_shapes": + return self.__draw_rounded_rect_with_border_vertical_split_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, left_section_width, ()) + + def __draw_rounded_rect_with_border_vertical_split_polygon_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, + left_section_width: int) -> bool: + requires_recoloring = False + + # create border button parts (only if border exists) + if border_width > 0: + if not self._canvas.find_withtag("border_parts"): + self._canvas.create_polygon((0, 0, 0, 0), tags=("border_line_left_1", "border_parts_left", "border_parts")) + self._canvas.create_polygon((0, 0, 0, 0), tags=("border_line_right_1", "border_parts_right", "border_parts")) + self._canvas.create_rectangle((0, 0, 0, 0), tags=("border_rect_left_1", "border_parts_left", "border_parts")) + self._canvas.create_rectangle((0, 0, 0, 0), tags=("border_rect_right_1", "border_parts_right", "border_parts")) + requires_recoloring = True + + self._canvas.coords("border_line_left_1", + (corner_radius, + corner_radius, + left_section_width - corner_radius, + corner_radius, + left_section_width - corner_radius, + height - corner_radius, + corner_radius, + height - corner_radius)) + self._canvas.coords("border_line_right_1", + (left_section_width + corner_radius, + corner_radius, + width - corner_radius, + corner_radius, + width - corner_radius, + height - corner_radius, + left_section_width + corner_radius, + height - corner_radius)) + self._canvas.coords("border_rect_left_1", + (left_section_width - corner_radius, + 0, + left_section_width, + height)) + self._canvas.coords("border_rect_right_1", + (left_section_width, + 0, + left_section_width + corner_radius, + height)) + self._canvas.itemconfig("border_line_left_1", joinstyle=tkinter.ROUND, width=corner_radius * 2) + self._canvas.itemconfig("border_line_right_1", joinstyle=tkinter.ROUND, width=corner_radius * 2) + + else: + self._canvas.delete("border_parts") + + # create inner button parts + if not self._canvas.find_withtag("inner_parts"): + self._canvas.create_polygon((0, 0, 0, 0), tags=("inner_line_left_1", "inner_parts_left", "inner_parts"), joinstyle=tkinter.ROUND) + self._canvas.create_polygon((0, 0, 0, 0), tags=("inner_line_right_1", "inner_parts_right", "inner_parts"), joinstyle=tkinter.ROUND) + self._canvas.create_rectangle((0, 0, 0, 0), tags=("inner_rect_left_1", "inner_parts_left", "inner_parts")) + self._canvas.create_rectangle((0, 0, 0, 0), tags=("inner_rect_right_1", "inner_parts_right", "inner_parts")) + requires_recoloring = True + + self._canvas.coords("inner_line_left_1", + corner_radius, + corner_radius, + left_section_width - corner_radius, + corner_radius, + left_section_width - corner_radius, + height - corner_radius, + corner_radius, + height - corner_radius) + self._canvas.coords("inner_line_right_1", + left_section_width + corner_radius, + corner_radius, + width - corner_radius, + corner_radius, + width - corner_radius, + height - corner_radius, + left_section_width + corner_radius, + height - corner_radius) + self._canvas.coords("inner_rect_left_1", + (left_section_width - inner_corner_radius, + border_width, + left_section_width, + height - border_width)) + self._canvas.coords("inner_rect_right_1", + (left_section_width, + border_width, + left_section_width + inner_corner_radius, + height - border_width)) + self._canvas.itemconfig("inner_line_left_1", width=inner_corner_radius * 2) + self._canvas.itemconfig("inner_line_right_1", width=inner_corner_radius * 2) + + if requires_recoloring: # new parts were added -> manage z-order + self._canvas.tag_lower("inner_parts") + self._canvas.tag_lower("border_parts") + + return requires_recoloring + + def __draw_rounded_rect_with_border_vertical_split_font_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, + left_section_width: int, exclude_parts: tuple) -> bool: + requires_recoloring = False + + # create border button parts + if border_width > 0: + if corner_radius > 0: + # create canvas border corner parts if not already created, but only if needed, and delete if not needed + if not self._canvas.find_withtag("border_oval_1_a") and "border_oval_1" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_1_a", "border_corner_part", "border_parts_left" "border_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_1_b", "border_corner_part", "border_parts_left", "border_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("border_oval_1_a") and "border_oval_1" in exclude_parts: + self._canvas.delete("border_oval_1_a", "border_oval_1_b") + + if not self._canvas.find_withtag("border_oval_2_a") and width > 2 * corner_radius and "border_oval_2" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_2_a", "border_corner_part", "border_parts_right", "border_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_2_b", "border_corner_part", "border_parts_right", "border_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("border_oval_2_a") and (not width > 2 * corner_radius or "border_oval_2" in exclude_parts): + self._canvas.delete("border_oval_2_a", "border_oval_2_b") + + if not self._canvas.find_withtag("border_oval_3_a") and height > 2 * corner_radius \ + and width > 2 * corner_radius and "border_oval_3" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_3_a", "border_corner_part", "border_parts_right", "border_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_3_b", "border_corner_part", "border_parts_right", "border_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("border_oval_3_a") and (not (height > 2 * corner_radius + and width > 2 * corner_radius) or "border_oval_3" in exclude_parts): + self._canvas.delete("border_oval_3_a", "border_oval_3_b") + + if not self._canvas.find_withtag("border_oval_4_a") and height > 2 * corner_radius and "border_oval_4" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_4_a", "border_corner_part", "border_parts_left", "border_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("border_oval_4_b", "border_corner_part", "border_parts_left", "border_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("border_oval_4_a") and (not height > 2 * corner_radius or "border_oval_4" in exclude_parts): + self._canvas.delete("border_oval_4_a", "border_oval_4_b") + + # change position of border corner parts + self._canvas.coords("border_oval_1_a", corner_radius, corner_radius, corner_radius) + self._canvas.coords("border_oval_1_b", corner_radius, corner_radius, corner_radius) + self._canvas.coords("border_oval_2_a", width - corner_radius, corner_radius, corner_radius) + self._canvas.coords("border_oval_2_b", width - corner_radius, corner_radius, corner_radius) + self._canvas.coords("border_oval_3_a", width - corner_radius, height - corner_radius, corner_radius) + self._canvas.coords("border_oval_3_b", width - corner_radius, height - corner_radius, corner_radius) + self._canvas.coords("border_oval_4_a", corner_radius, height - corner_radius, corner_radius) + self._canvas.coords("border_oval_4_b", corner_radius, height - corner_radius, corner_radius) + + else: + self._canvas.delete("border_corner_part") # delete border corner parts if not needed + + # create canvas border rectangle parts if not already created + if not self._canvas.find_withtag("border_rectangle_1"): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_left_1", "border_rectangle_part", "border_parts_left", "border_parts"), width=0) + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_left_2", "border_rectangle_part", "border_parts_left", "border_parts"), width=0) + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_right_1", "border_rectangle_part", "border_parts_right", "border_parts"), width=0) + self._canvas.create_rectangle(0, 0, 0, 0, tags=("border_rectangle_right_2", "border_rectangle_part", "border_parts_right", "border_parts"), width=0) + requires_recoloring = True + + # change position of border rectangle parts + self._canvas.coords("border_rectangle_left_1", (0, corner_radius, left_section_width, height - corner_radius)) + self._canvas.coords("border_rectangle_left_2", (corner_radius, 0, left_section_width, height)) + self._canvas.coords("border_rectangle_right_1", (left_section_width, corner_radius, width, height - corner_radius)) + self._canvas.coords("border_rectangle_right_2", (corner_radius, left_section_width, width - corner_radius, height)) + + else: + self._canvas.delete("border_parts") + + # create inner button parts + if inner_corner_radius > 0: + + # create canvas border corner parts if not already created, but only if they're needed and delete if not needed + if not self._canvas.find_withtag("inner_oval_1_a") and "inner_oval_1" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_1_a", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_1_b", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("inner_oval_1_a") and "inner_oval_1" in exclude_parts: + self._canvas.delete("inner_oval_1_a", "inner_oval_1_b") + + if not self._canvas.find_withtag("inner_oval_2_a") and width - (2 * border_width) > 2 * inner_corner_radius and "inner_oval_2" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_2_a", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_2_b", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("inner_oval_2_a") and (not width - (2 * border_width) > 2 * inner_corner_radius or "inner_oval_2" in exclude_parts): + self._canvas.delete("inner_oval_2_a", "inner_oval_2_b") + + if not self._canvas.find_withtag("inner_oval_3_a") and height - (2 * border_width) > 2 * inner_corner_radius \ + and width - (2 * border_width) > 2 * inner_corner_radius and "inner_oval_3" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_3_a", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_3_b", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("inner_oval_3_a") and (not (height - (2 * border_width) > 2 * inner_corner_radius + and width - (2 * border_width) > 2 * inner_corner_radius) or "inner_oval_3" in exclude_parts): + self._canvas.delete("inner_oval_3_a", "inner_oval_3_b") + + if not self._canvas.find_withtag("inner_oval_4_a") and height - (2 * border_width) > 2 * inner_corner_radius and "inner_oval_4" not in exclude_parts: + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_4_a", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER) + self._canvas.create_aa_circle(0, 0, 0, tags=("inner_oval_4_b", "inner_corner_part", "inner_parts"), anchor=tkinter.CENTER, angle=180) + requires_recoloring = True + elif self._canvas.find_withtag("inner_oval_4_a") and (not height - (2 * border_width) > 2 * inner_corner_radius or "inner_oval_4" in exclude_parts): + self._canvas.delete("inner_oval_4_a", "inner_oval_4_b") + + # change position of border corner parts + self._canvas.coords("inner_oval_1_a", border_width + inner_corner_radius, border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_1_b", border_width + inner_corner_radius, border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_2_a", width - border_width - inner_corner_radius, border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_2_b", width - border_width - inner_corner_radius, border_width + inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_3_a", width - border_width - inner_corner_radius, height - border_width - inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_3_b", width - border_width - inner_corner_radius, height - border_width - inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_4_a", border_width + inner_corner_radius, height - border_width - inner_corner_radius, inner_corner_radius) + self._canvas.coords("inner_oval_4_b", border_width + inner_corner_radius, height - border_width - inner_corner_radius, inner_corner_radius) + else: + self._canvas.delete("inner_corner_part") # delete inner corner parts if not needed + + # create canvas inner rectangle parts if not already created + if not self._canvas.find_withtag("inner_rectangle_1"): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("inner_rectangle_left_1", "inner_rectangle_part", "inner_parts_left", "inner_parts"), width=0) + self._canvas.create_rectangle(0, 0, 0, 0, tags=("inner_rectangle_right_1", "inner_rectangle_part", "inner_parts_right", "inner_parts"), width=0) + requires_recoloring = True + + if not self._canvas.find_withtag("inner_rectangle_2") and inner_corner_radius * 2 < height - (border_width * 2): + self._canvas.create_rectangle(0, 0, 0, 0, tags=("inner_rectangle_left_2", "inner_rectangle_part", "inner_parts_left", "inner_parts"), width=0) + self._canvas.create_rectangle(0, 0, 0, 0, tags=("inner_rectangle_right_2", "inner_rectangle_part", "inner_parts_right", "inner_parts"), width=0) + requires_recoloring = True + + elif self._canvas.find_withtag("inner_rectangle_2") and not inner_corner_radius * 2 < height - (border_width * 2): + self._canvas.delete("inner_rectangle_left_2") + self._canvas.delete("inner_rectangle_right_2") + + # change position of inner rectangle parts + self._canvas.coords("inner_rectangle_left_1", (border_width + inner_corner_radius, + border_width, + left_section_width, + height - border_width)) + self._canvas.coords("inner_rectangle_left_2", (border_width, + border_width + inner_corner_radius, + left_section_width, + height - inner_corner_radius - border_width)) + self._canvas.coords("inner_rectangle_right_1", (left_section_width, + border_width, + width - border_width, + height - border_width)) + self._canvas.coords("inner_rectangle_right_2", (left_section_width, + border_width + inner_corner_radius, + width - border_width - inner_corner_radius, + height - inner_corner_radius - border_width)) + + if requires_recoloring: # new parts were added -> manage z-order + self._canvas.tag_lower("inner_parts") + self._canvas.tag_lower("border_parts") + + return requires_recoloring + def draw_rounded_progress_bar_with_border(self, width: int, height: int, corner_radius: Union[float, int], border_width: Union[float, int], progress_value: float, orientation: str) -> bool: """ Draws a rounded bar on the canvas, which is split in half according to the argument 'progress_value' (0 - 1). @@ -366,7 +641,7 @@ class DrawEngine: corner_radius = min(width / 2, height / 2) border_width = round(border_width) - corner_radius = self._calc_optimal_corner_radius(corner_radius) # optimize corner_radius for different drawing methods (different rounding) + corner_radius = self.__calc_optimal_corner_radius(corner_radius) # optimize corner_radius for different drawing methods (different rounding) if corner_radius >= border_width: inner_corner_radius = corner_radius - border_width @@ -374,16 +649,16 @@ class DrawEngine: inner_corner_radius = 0 if self.preferred_drawing_method == "polygon_shapes" or self.preferred_drawing_method == "circle_shapes": - return self._draw_rounded_progress_bar_with_border_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius, - progress_value, orientation) + return self.__draw_rounded_progress_bar_with_border_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius, + progress_value, orientation) elif self.preferred_drawing_method == "font_shapes": - return self._draw_rounded_progress_bar_with_border_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, - progress_value, orientation) + return self.__draw_rounded_progress_bar_with_border_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, + progress_value, orientation) - def _draw_rounded_progress_bar_with_border_polygon_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, - progress_value: float, orientation: str) -> bool: + def __draw_rounded_progress_bar_with_border_polygon_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, + progress_value: float, orientation: str) -> bool: - requires_recoloring = self._draw_rounded_rect_with_border_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius) + requires_recoloring = self.__draw_rounded_rect_with_border_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius) if corner_radius <= border_width: bottom_right_shift = 0 # weird canvas rendering inaccuracy that has to be corrected in some cases @@ -422,8 +697,8 @@ class DrawEngine: return requires_recoloring - def _draw_rounded_progress_bar_with_border_font_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, - progress_value: float, orientation: str) -> bool: + def __draw_rounded_progress_bar_with_border_font_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, + progress_value: float, orientation: str) -> bool: requires_recoloring, requires_recoloring_2 = False, False @@ -457,8 +732,8 @@ class DrawEngine: # horizontal orientation from the bottom if orientation == "w": - requires_recoloring_2 = self._draw_rounded_rect_with_border_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, - ("inner_oval_1", "inner_oval_4")) + requires_recoloring_2 = self.__draw_rounded_rect_with_border_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, + ("inner_oval_1", "inner_oval_4")) # set positions of progress corner parts self._canvas.coords("progress_oval_1_a", border_width + inner_corner_radius, border_width + inner_corner_radius, inner_corner_radius) @@ -488,8 +763,8 @@ class DrawEngine: # vertical orientation from the bottom if orientation == "s": - requires_recoloring_2 = self._draw_rounded_rect_with_border_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, - ("inner_oval_3", "inner_oval_4")) + requires_recoloring_2 = self.__draw_rounded_rect_with_border_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, + ("inner_oval_3", "inner_oval_4")) # set positions of progress corner parts self._canvas.coords("progress_oval_1_a", border_width + inner_corner_radius, @@ -535,7 +810,7 @@ class DrawEngine: button_length = round(button_length) border_width = round(border_width) button_corner_radius = round(button_corner_radius) - corner_radius = self._calc_optimal_corner_radius(corner_radius) # optimize corner_radius for different drawing methods (different rounding) + corner_radius = self.__calc_optimal_corner_radius(corner_radius) # optimize corner_radius for different drawing methods (different rounding) if corner_radius >= border_width: inner_corner_radius = corner_radius - border_width @@ -543,18 +818,18 @@ class DrawEngine: inner_corner_radius = 0 if self.preferred_drawing_method == "polygon_shapes" or self.preferred_drawing_method == "circle_shapes": - return self._draw_rounded_slider_with_border_and_button_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius, - button_length, button_corner_radius, slider_value, orientation) + return self.__draw_rounded_slider_with_border_and_button_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius, + button_length, button_corner_radius, slider_value, orientation) elif self.preferred_drawing_method == "font_shapes": - return self._draw_rounded_slider_with_border_and_button_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, - button_length, button_corner_radius, slider_value, orientation) + return self.__draw_rounded_slider_with_border_and_button_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, + button_length, button_corner_radius, slider_value, orientation) - def _draw_rounded_slider_with_border_and_button_polygon_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, - button_length: int, button_corner_radius: int, slider_value: float, orientation: str) -> bool: + def __draw_rounded_slider_with_border_and_button_polygon_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, + button_length: int, button_corner_radius: int, slider_value: float, orientation: str) -> bool: # draw normal progressbar - requires_recoloring = self._draw_rounded_progress_bar_with_border_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius, - slider_value, orientation) + requires_recoloring = self.__draw_rounded_progress_bar_with_border_polygon_shapes(width, height, corner_radius, border_width, inner_corner_radius, + slider_value, orientation) # create slider button part if not self._canvas.find_withtag("slider_parts"): @@ -588,12 +863,12 @@ class DrawEngine: return requires_recoloring - def _draw_rounded_slider_with_border_and_button_font_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, - button_length: int, button_corner_radius: int, slider_value: float, orientation: str) -> bool: + def __draw_rounded_slider_with_border_and_button_font_shapes(self, width: int, height: int, corner_radius: int, border_width: int, inner_corner_radius: int, + button_length: int, button_corner_radius: int, slider_value: float, orientation: str) -> bool: # draw normal progressbar - requires_recoloring = self._draw_rounded_progress_bar_with_border_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, - slider_value, orientation) + requires_recoloring = self.__draw_rounded_progress_bar_with_border_font_shapes(width, height, corner_radius, border_width, inner_corner_radius, + slider_value, orientation) # create 4 circles (if not needed, then less) if not self._canvas.find_withtag("slider_oval_1_a"): diff --git a/customtkinter/widgets/ctk_button.py b/customtkinter/widgets/ctk_button.py index 6d3a90a..7aba4c7 100644 --- a/customtkinter/widgets/ctk_button.py +++ b/customtkinter/widgets/ctk_button.py @@ -21,7 +21,7 @@ class CTkButton(CTkBaseClass): command=None, textvariable=None, width=120, - height=30, + height=28, corner_radius="default_theme", text_font="default_theme", text_color="default_theme", diff --git a/customtkinter/widgets/ctk_entry.py b/customtkinter/widgets/ctk_entry.py index 5330d48..7ab787c 100644 --- a/customtkinter/widgets/ctk_entry.py +++ b/customtkinter/widgets/ctk_entry.py @@ -18,7 +18,7 @@ class CTkEntry(CTkBaseClass): border_width="default_theme", border_color="default_theme", width=120, - height=30, + height=28, state=tkinter.NORMAL, **kwargs): diff --git a/customtkinter/widgets/ctk_label.py b/customtkinter/widgets/ctk_label.py index d9fe241..7896b21 100644 --- a/customtkinter/widgets/ctk_label.py +++ b/customtkinter/widgets/ctk_label.py @@ -13,7 +13,7 @@ class CTkLabel(CTkBaseClass): text_color="default_theme", corner_radius="default_theme", width=120, - height=25, + height=28, text="CTkLabel", text_font="default_theme", **kwargs): diff --git a/customtkinter/widgets/ctk_optionmenu.py b/customtkinter/widgets/ctk_optionmenu.py new file mode 100644 index 0000000..ff36b64 --- /dev/null +++ b/customtkinter/widgets/ctk_optionmenu.py @@ -0,0 +1,261 @@ +import tkinter +import sys + +from .dropdown_menu import DropdownMenu + +from .ctk_canvas import CTkCanvas +from ..theme_manager import ThemeManager +from ..settings import Settings +from ..draw_engine import DrawEngine +from .widget_base_class import CTkBaseClass + + +class CTkOptionMenu(CTkBaseClass): + + def __init__(self, *args, + bg_color=None, + fg_color="default_theme", + button_color="default_theme", + button_hover_color="default_theme", + variable=None, + values=None, + command=None, + width=120, + height=28, + corner_radius="default_theme", + text_font="default_theme", + text_color="default_theme", + text_color_disabled="default_theme", + hover=True, + state=tkinter.NORMAL, + **kwargs): + + # transfer basic functionality (bg_color, size, appearance_mode, scaling) to CTkBaseClass + super().__init__(*args, bg_color=bg_color, width=width, height=height, **kwargs) + + # color variables + self.fg_color = ThemeManager.theme["color"]["button"] if fg_color == "default_theme" else fg_color + self.button_color = ThemeManager.theme["color"]["optionmenu_button"] if button_color == "default_theme" else button_color + self.button_hover_color = ThemeManager.theme["color"]["optionmenu_button_hover"] if button_hover_color == "default_theme" else button_hover_color + + # shape + self.corner_radius = ThemeManager.theme["shape"]["button_corner_radius"] if corner_radius == "default_theme" else corner_radius + + # text and font + self.text_label = None + self.text_color = ThemeManager.theme["color"]["text"] if text_color == "default_theme" else text_color + self.text_color_disabled = ThemeManager.theme["color"]["text_button_disabled"] if text_color_disabled == "default_theme" else text_color_disabled + self.text_font = (ThemeManager.theme["text"]["font"], ThemeManager.theme["text"]["size"]) if text_font == "default_theme" else text_font + + # callback and hover functionality + self.function = command + self.variable = variable + self.state = state + self.hover = hover + self.click_animation_running = False + if values is None: + self.values = ["CTkOptionMenu"] + else: + self.values = values + self.current_value = self.values[0] + + self.dropdown_menu = None + + # configure grid system (1x1) + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + + self.canvas = CTkCanvas(master=self, + highlightthickness=0, + width=self.apply_widget_scaling(self.desired_width), + height=self.apply_widget_scaling(self.desired_height)) + self.canvas.grid(row=0, column=0, rowspan=1, columnspan=1, sticky="nsew") + self.draw_engine = DrawEngine(self.canvas) + + # event bindings + self.canvas.bind("", self.on_enter) + self.canvas.bind("", self.on_leave) + self.canvas.bind("", self.clicked) + self.canvas.bind("", self.clicked) + self.bind('', self.update_dimensions_event) + + self.set_cursor() + self.draw() # initial draw + + def set_scaling(self, *args, **kwargs): + super().set_scaling(*args, **kwargs) + + if self.text_label is not None: + self.text_label.destroy() + self.text_label = None + + self.canvas.configure(width=self.apply_widget_scaling(self.desired_width), + height=self.apply_widget_scaling(self.desired_height)) + self.draw() + + def set_dimensions(self, width=None, height=None): + super().set_dimensions(width, height) + + self.canvas.configure(width=self.apply_widget_scaling(self.desired_width), + height=self.apply_widget_scaling(self.desired_height)) + self.draw() + + def draw(self, no_color_updates=False): + left_section_width = self.current_width - self.current_height + requires_recoloring = self.draw_engine.draw_rounded_rect_with_border_vertical_split(self.apply_widget_scaling(self.current_width), + self.apply_widget_scaling(self.current_height), + self.apply_widget_scaling(self.corner_radius), + 0, + self.apply_widget_scaling(left_section_width)) + + if self.text_label is None: + self.text_label = tkinter.Label(master=self, + font=self.apply_font_scaling(self.text_font)) + self.text_label.grid(row=0, column=0, rowspan=1, columnspan=1, sticky="w", + padx=(max(self.apply_widget_scaling(self.corner_radius), 3), + max(self.current_width - left_section_width + 3, 3))) + + self.text_label.bind("", self.on_enter) + self.text_label.bind("", self.on_leave) + self.text_label.bind("", self.clicked) + self.text_label.bind("", self.clicked) + + if self.current_value is not None: + self.text_label.configure(text=self.current_value) + + if no_color_updates is False or requires_recoloring: + + self.canvas.configure(bg=ThemeManager.single_color(self.bg_color, self.appearance_mode)) + + self.canvas.itemconfig("inner_parts_left", + outline=ThemeManager.single_color(self.fg_color, self.appearance_mode), + fill=ThemeManager.single_color(self.fg_color, self.appearance_mode)) + self.canvas.itemconfig("inner_parts_right", + outline=ThemeManager.single_color(self.button_color, self.appearance_mode), + fill=ThemeManager.single_color(self.button_color, self.appearance_mode)) + + self.text_label.configure(fg=ThemeManager.single_color(self.text_color, self.appearance_mode)) + + if self.state == tkinter.DISABLED: + self.text_label.configure(fg=(ThemeManager.single_color(self.text_color_disabled, self.appearance_mode))) + else: + self.text_label.configure(fg=ThemeManager.single_color(self.text_color, self.appearance_mode)) + + self.text_label.configure(bg=ThemeManager.single_color(self.fg_color, self.appearance_mode)) + + def open_dropdown_menu(self): + self.dropdown_menu = DropdownMenu(x_position=self.winfo_rootx(), + y_position=self.winfo_rooty() + self.current_height + 4, + width=self.current_width, + values=self.values, + command=self.set_value) + + def set_value(self, value): + print("set value", value) + self.current_value = value + + if self.text_label is not None: + self.text_label.configure(text=self.current_value) + else: + self.draw() + + def configure(self, *args, **kwargs): + require_redraw = False # some attribute changes require a call of self.draw() at the end + + if "state" in kwargs: + self.state = kwargs["state"] + self.set_cursor() + require_redraw = True + del kwargs["state"] + + if "fg_color" in kwargs: + self.fg_color = kwargs["fg_color"] + require_redraw = True + del kwargs["fg_color"] + + if "bg_color" in kwargs: + if kwargs["bg_color"] is None: + self.bg_color = self.detect_color_of_master() + else: + self.bg_color = kwargs["bg_color"] + require_redraw = True + del kwargs["bg_color"] + + if "hover_color" in kwargs: + self.hover_color = kwargs["hover_color"] + require_redraw = True + del kwargs["hover_color"] + + if "text_color" in kwargs: + self.text_color = kwargs["text_color"] + require_redraw = True + del kwargs["text_color"] + + if "command" in kwargs: + self.function = kwargs["command"] + del kwargs["command"] + + if "variable" in kwargs: + self.variable = kwargs["variable"] + if self.text_label is not None: + self.text_label.configure(textvariable=self.variable) + del kwargs["variable"] + + if "width" in kwargs: + self.set_dimensions(width=kwargs["width"]) + del kwargs["width"] + + if "height" in kwargs: + self.set_dimensions(height=kwargs["height"]) + del kwargs["height"] + + super().configure(*args, **kwargs) + + if require_redraw: + self.draw() + + def set_cursor(self): + if Settings.cursor_manipulation_enabled: + if self.state == tkinter.DISABLED: + if sys.platform == "darwin" and len(self.values) > 0 and Settings.cursor_manipulation_enabled: + self.configure(cursor="arrow") + elif sys.platform.startswith("win") and len(self.values) > 0 and Settings.cursor_manipulation_enabled: + self.configure(cursor="arrow") + + elif self.state == tkinter.NORMAL: + if sys.platform == "darwin" and len(self.values) > 0 and Settings.cursor_manipulation_enabled: + self.configure(cursor="pointinghand") + elif sys.platform.startswith("win") and len(self.values) > 0 and Settings.cursor_manipulation_enabled: + self.configure(cursor="hand2") + + def on_enter(self, event=0): + if self.hover is True and self.state == tkinter.NORMAL: + # set color of inner button parts to hover color + self.canvas.itemconfig("inner_parts_right", + outline=ThemeManager.single_color(self.button_hover_color, self.appearance_mode), + fill=ThemeManager.single_color(self.button_hover_color, self.appearance_mode)) + + def on_leave(self, event=0): + self.click_animation_running = False + + if self.hover is True: + # set color of inner button parts + self.canvas.itemconfig("inner_parts_right", + outline=ThemeManager.single_color(self.button_color, self.appearance_mode), + fill=ThemeManager.single_color(self.button_color, self.appearance_mode)) + + def click_animation(self): + if self.click_animation_running: + self.on_enter() + + def clicked(self, event=0): + if self.state is not tkinter.DISABLED: + self.open_dropdown_menu() + + if self.function is not None: + # click animation: change color with .on_leave() and back to normal after 100ms with click_animation() + self.on_leave() + self.click_animation_running = True + self.after(100, self.click_animation) + + self.function() diff --git a/customtkinter/widgets/dropdown_menu.py b/customtkinter/widgets/dropdown_menu.py new file mode 100644 index 0000000..19e9209 --- /dev/null +++ b/customtkinter/widgets/dropdown_menu.py @@ -0,0 +1,97 @@ +import customtkinter +import tkinter +import sys + +from ..theme_manager import ThemeManager +from ..appearance_mode_tracker import AppearanceModeTracker + + +class DropdownMenu(tkinter.Toplevel): + def __init__(self, *args, + fg_color="gray50", + button_color="gray50", + button_hover_color="gray35", + text_color="black", + corner_radius=6, + button_corner_radius=3, + width=120, + button_height=24, + x_position=0, + y_position=0, + x_spacing=3, + y_spacing=3, + command=None, + values=None, + **kwargs): + super().__init__(*args, **kwargs) + + self.values = values + self.command = command + + # color + self.appearance_mode = AppearanceModeTracker.get_mode() # 0: "Light" 1: "Dark" + self.fg_color = fg_color + self.button_color = button_color + self.button_hover_color = button_hover_color + self.text_color = text_color + + # shape + self.width = width + self.corner_radius = corner_radius + self.button_corner_radius = button_corner_radius + self.button_height = button_height + + self.geometry(f"{round(self.width)}x{round(len(self.values) * (self.button_height + y_spacing) + y_spacing)}+{round(x_position)}+{round(y_position)}") + self.grid_columnconfigure(0, weight=1) + + if sys.platform.startswith("darwin"): + self.overrideredirect(True) # remove title-bar + self.overrideredirect(False) + self.wm_attributes("-transparent", True) # turn off window shadow + self.config(bg='systemTransparent') # transparent bg + self.frame = customtkinter.CTkFrame(self, border_width=0, width=self.width, corner_radius=self.corner_radius, + fg_color=ThemeManager.single_color(self.fg_color, self.appearance_mode)) + + elif sys.platform.startswith("win"): + self.overrideredirect(True) # remove title-bar + self.configure(bg=ThemeManager.single_color(self.fg_color, self.appearance_mode)) + self.wm_attributes("-transparentcolor", "#FFFFF1") + self.focus() + self.frame = customtkinter.CTkFrame(self, border_width=0, width=120, corner_radius=self.corner_radius, + fg_color=self.fg_color, overwrite_preferred_drawing_method="circle_shapes") + else: + self.overrideredirect(True) # remove title-bar + self.configure(bg=ThemeManager.single_color(self.fg_color, self.appearance_mode)) + self.wm_attributes("-transparentcolor", "#FFFFF1") + self.frame = customtkinter.CTkFrame(self, border_width=0, width=120, corner_radius=self.corner_radius, + fg_color=self.fg_color, overwrite_preferred_drawing_method="circle_shapes") + + self.frame.grid(row=0, column=0, sticky="nsew", rowspan=len(self.values) + 1) + self.frame.grid_rowconfigure(len(self.values) + 1, minsize=y_spacing) # add spacing at the bottom + self.frame.grid_columnconfigure(0, weight=1) + + self.button_list = [] + for index, option in enumerate(self.values): + button = customtkinter.CTkButton(self.frame, text=option, height=self.button_height, width=self.width - 2 * x_spacing, + fg_color=self.button_color, text_color=self.text_color, + hover_color=self.button_hover_color, corner_radius=self.button_corner_radius, + command=lambda i=index: self.button_callback(i)) + button.text_label.grid(row=0, column=0, rowspan=2, columnspan=2, sticky="w") + button.grid(row=index, column=0, padx=x_spacing, pady=(y_spacing, 0), sticky="ew") + self.button_list.append(button) + + self.bind("", self.focus_loss_event) + self.frame.canvas.bind("", self.focus_loss_event) + + def focus_loss_event(self, event): + self.destroy() + if sys.platform.startswith("darwin"): + self.update() + + def button_callback(self, index): + self.destroy() + if sys.platform.startswith("darwin"): + self.update() + + if self.command is not None: + self.command(self.values[index]) diff --git a/examples/simple_example.py b/examples/simple_example.py index 683e7db..af3f210 100644 --- a/examples/simple_example.py +++ b/examples/simple_example.py @@ -5,7 +5,7 @@ customtkinter.set_appearance_mode("dark") # Modes: "System" (standard), "Dark", customtkinter.set_default_color_theme("blue") # Themes: "blue" (standard), "green", "dark-blue" app = customtkinter.CTk() # create CTk window like you do with the Tk window -app.geometry("400x480") +app.geometry("400x540") app.title("CustomTkinter simple_example.py") @@ -58,4 +58,7 @@ s_var = tkinter.StringVar(value="on") switch_1 = customtkinter.CTkSwitch(master=frame_1) switch_1.pack(pady=y_padding, padx=10) +optionmenu_1 = customtkinter.CTkOptionMenu(master=frame_1, values=["option 1", "option 2", "number 42"]) +optionmenu_1.pack(pady=y_padding, padx=10) + app.mainloop()