From 42fb7f2186aaead4300e5b004bedc7eeab27ef1e Mon Sep 17 00:00:00 2001 From: Tom Schimansky Date: Fri, 21 Oct 2022 21:28:31 +0200 Subject: [PATCH] finished CTkFont support for button, label, checkbox, created test_font.py --- CHANGELOG.md | 6 +- customtkinter/__init__.py | 2 +- customtkinter/utility/ctk_font.py | 88 ++++++++----------- customtkinter/widgets/ctk_button.py | 30 ++++++- customtkinter/widgets/ctk_checkbox.py | 32 +++++-- customtkinter/widgets/ctk_label.py | 32 ++++++- customtkinter/widgets/widget_base_class.py | 43 ++++++--- customtkinter/windows/ctk_tk.py | 7 +- .../complex_example_new.py | 2 +- test/manual_integration_tests/test_font.py | 63 +++++++++++++ test/manual_integration_tests/test_tabview.py | 2 +- 11 files changed, 225 insertions(+), 82 deletions(-) create mode 100644 test/manual_integration_tests/test_font.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4130d72..f9036de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ToDo: - - - - remove bg and background support for CTk and CTkToplevel (to be done) - - enforce font size in pixel - - enforce font to be tuple + - enforce font size in pixel and enforce CTkFont class - complete other theme files - auto scaling of images @@ -28,6 +25,7 @@ ToDo: ### Removed - Removed setter and getter functions like set_text in CTkButton + - Removed bg and background attribute from CTk and CTkToplevel, always use fg_color ### Fixed diff --git a/customtkinter/__init__.py b/customtkinter/__init__.py index 04fb2bf..99a9307 100644 --- a/customtkinter/__init__.py +++ b/customtkinter/__init__.py @@ -74,7 +74,7 @@ from .windows.ctk_toplevel import CTkToplevel from .windows.ctk_input_dialog import CTkInputDialog # util classes -from .utility.ctk_font import CTkFont +from .utility.ctk_font import CTkFont as _CTkFont def set_appearance_mode(mode_string: str): diff --git a/customtkinter/utility/ctk_font.py b/customtkinter/utility/ctk_font.py index 6d1aa8b..26cedda 100644 --- a/customtkinter/utility/ctk_font.py +++ b/customtkinter/utility/ctk_font.py @@ -1,15 +1,25 @@ from tkinter.font import Font import copy -import sys +from typing import List, Callable, Tuple -from ..scaling_tracker import ScalingTracker from ..theme_manager import ThemeManager class CTkFont(Font): """ - Font object with size in pixel independent of scaling. + Font object with size in pixel, independent of scaling. + To get scaled tuple representation use create_scaled_tuple() method. + + family The font family name as a string. + size The font height as an integer in pixel. + weight 'bold' for boldface, 'normal' for regular weight. + slant 'italic' for italic, 'roman' for unslanted. + underline 1 for underlined text, 0 for normal. + overstrike 1 for overstruck text, 0 for normal. + + Tkinter Font: https://anzeljg.github.io/rin2/book2/2405/docs/tkinter/fonts.html """ + def __init__(self, family: str = "default_theme", size: int = "default_theme", @@ -18,34 +28,30 @@ class CTkFont(Font): underline: bool = False, overstrike: bool = False): - # unscaled font size in px - self._size = ThemeManager.theme["text"]["size"] if size == "default_theme" else size + self._size_configure_callback_list: List[Callable] = [] - if self._size < 0: - sys.stderr.write(f"Warning: size {self._size} of CTkFont don't has to be negative, it's measured in pixel by default\n") + self._family = family + self._size = ThemeManager.theme["text"]["size"] if size == "default_theme" else size + self._tuple_style_string = f"{weight} {slant} {'underline' if underline else ''} {'overstrike' if overstrike else ''}" super().__init__(family=ThemeManager.theme["text"]["font"] if family == "default_theme" else family, - size=self._size, + size=-abs(self._size), weight=weight, slant=slant, underline=underline, overstrike=overstrike) - def _set_scaling(self, new_widget_scaling, new_spacing_scaling, new_window_scaling): - self._widget_scaling = new_widget_scaling - super().configure(size=round(self._apply_widget_scaling(self._size))) + def add_size_configure_callback(self, callback: Callable): + """ add function, that gets called when font got configured """ + self._size_configure_callback_list.append(callback) - def _apply_widget_scaling(self, value: int) -> int: - if isinstance(value, int): - return round(value * self._widget_scaling) - else: - raise ValueError(f"CTkFont can not scale size of type {type(value)}, only int allowed") + def remove_size_configure_callback(self, callback: Callable): + """ remove function, that gets called when font got configured """ + self._size_configure_callback_list.remove(callback) - def _reverse_widget_scaling(self, value: int) -> int: - if isinstance(value, int): - return round(value / self._widget_scaling) - else: - raise ValueError(f"CTkFont can not scale size of type {type(value)}, only int allowed") + def create_scaled_tuple(self, font_scaling: float) -> Tuple[str, int, str]: + """ return scaled tuple representation of font in the form (family: str, size: int, style: str)""" + return self._family, round(self._size * font_scaling), self._tuple_style_string def config(self, *args, **kwargs): raise AttributeError("'config' is not implemented for CTk widgets. For consistency, always use 'configure' instead.") @@ -53,42 +59,22 @@ class CTkFont(Font): def configure(self, **kwargs): if "size" in kwargs: self._size = kwargs.pop("size") - super().configure(size=self._apply_widget_scaling(self._size)) + super().configure(size=-abs(self._size)) super().configure(**kwargs) - def cget(self, attribute_name) -> any: + # update style string for create_scaled_tuple() method + self._tuple_style_string = f"{super().cget('weight')} {super().cget('slant')} {'underline' if super().cget('underline') else ''} {'overstrike' if super().cget('overstrike') else ''}" + + # call all functions registered with add_size_configure_callback() + for callback in self._size_configure_callback_list: + callback() + + def cget(self, attribute_name: str) -> any: if attribute_name == "size": return self._size else: - super().cget(attribute_name) + return super().cget(attribute_name) def copy(self) -> "CTkFont": return copy.deepcopy(self) - - def measure(self, text, displayof=None) -> int: - """ measure width of text in px independent of scaling """ - return self._reverse_widget_scaling(super().measure(text, displayof=displayof)) - - def metrics(self, *options: any, **kw: any) -> dict: - """ metrics of font, all values independent of scaling """ - metrics_dict = super().metrics(*options, **kw) - - if "ascent" in metrics_dict: - metrics_dict["ascent"] = self._reverse_widget_scaling(metrics_dict["ascent"]) - if "descent" in metrics_dict: - metrics_dict["descent"] = self._reverse_widget_scaling(metrics_dict["descent"]) - if "linespace" in metrics_dict: - metrics_dict["linespace"] = self._reverse_widget_scaling(metrics_dict["linespace"]) - - return metrics_dict - - def actual(self, option: any = None, displayof: any = None) -> dict: - """ get back a dictionary of the font's actual attributes, which may differ from the ones you requested, size independent of scaling """ - actual_dict = super().actual(option, displayof) - - if "size" in actual_dict: - actual_dict["size"] = self._reverse_widget_scaling(actual_dict["size"]) - - return actual_dict - diff --git a/customtkinter/widgets/ctk_button.py b/customtkinter/widgets/ctk_button.py index 0dae91c..79f6341 100644 --- a/customtkinter/widgets/ctk_button.py +++ b/customtkinter/widgets/ctk_button.py @@ -63,12 +63,16 @@ class CTkButton(CTkBaseClass): self._corner_radius = min(self._corner_radius, round(self._current_height/2)) - # text, font, image + # text, image self._image = image self._image_label: Union[tkinter.Label, None] = None self._text = text self._text_label: Union[tkinter.Label, None] = None - self._font = CTkFont() if font == "default_theme" else self._check_font_type_and_values(font) + + # font + self._font = CTkFont() if font == "default_theme" else self._check_font_type(font) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) # callback and hover functionality self._command = command @@ -120,6 +124,21 @@ class CTkButton(CTkBaseClass): height=self._apply_widget_scaling(self._desired_height)) self._draw() + def _update_font(self): + """ pass font to tkinter widgets with applied font scaling and update grid with workaround """ + if self._text_label is not None: + self._text_label.configure(font=self._apply_font_scaling(self._font)) + + # Workaround to force grid to be resized when text changes size. + # Otherwise grid will lag and only resizes if other mouse action occurs. + self._canvas.grid_forget() + self._canvas.grid(row=0, column=0, rowspan=2, columnspan=2, sticky="nsew") + + def destroy(self): + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + super().destroy() + def _draw(self, no_color_updates=False): if self._background_corner_colors is not None: self._draw_engine.draw_background_corners(self._apply_widget_scaling(self._current_width), @@ -269,7 +288,12 @@ class CTkButton(CTkBaseClass): self._text_label.configure(text=self._text) if "font" in kwargs: - self._font = kwargs.pop("font") + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + self._font = self._check_font_type(kwargs.pop("font")) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + if self._text_label is not None: self._text_label.configure(font=self._apply_font_scaling(self._font)) diff --git a/customtkinter/widgets/ctk_checkbox.py b/customtkinter/widgets/ctk_checkbox.py index 49fb665..d49544f 100644 --- a/customtkinter/widgets/ctk_checkbox.py +++ b/customtkinter/widgets/ctk_checkbox.py @@ -7,6 +7,7 @@ from ..theme_manager import ThemeManager from ..settings import Settings from ..draw_engine import DrawEngine from .widget_base_class import CTkBaseClass +from ..utility.ctk_font import CTkFont class CTkCheckBox(CTkBaseClass): @@ -33,7 +34,7 @@ class CTkCheckBox(CTkBaseClass): text_color_disabled: Union[str, Tuple[str, str]] = "default_theme", text: str = "CTkCheckBox", - font: any = "default_theme", + font: Union[tuple, CTkFont] = "default_theme", textvariable: tkinter.Variable = None, state: str = tkinter.NORMAL, hover: bool = True, @@ -65,7 +66,11 @@ class CTkCheckBox(CTkBaseClass): self._text_label: Union[tkinter.Label, None] = None self._text_color = ThemeManager.theme["color"]["text"] if text_color == "default_theme" else text_color self._text_color_disabled = ThemeManager.theme["color"]["text_disabled"] if text_color_disabled == "default_theme" else text_color_disabled - self._font = (ThemeManager.theme["text"]["font"], ThemeManager.theme["text"]["size"]) if font == "default_theme" else font + + # font + self._font = CTkFont() if font == "default_theme" else self._check_font_type(font) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) # callback and hover functionality self._command = command @@ -145,10 +150,23 @@ class CTkCheckBox(CTkBaseClass): self._bg_canvas.configure(width=self._apply_widget_scaling(self._desired_width), height=self._apply_widget_scaling(self._desired_height)) + def _update_font(self): + """ pass font to tkinter widgets with applied font scaling and update grid with workaround """ + if self._text_label is not None: + self._text_label.configure(font=self._apply_font_scaling(self._font)) + + # Workaround to force grid to be resized when text changes size. + # Otherwise grid will lag and only resizes if other mouse action occurs. + self._bg_canvas.grid_forget() + self._bg_canvas.grid(row=0, column=0, sticky="nswe") + def destroy(self): if self._variable is not None: self._variable.trace_remove("write", self._variable_callback_name) + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + super().destroy() def _draw(self, no_color_updates=False): @@ -212,9 +230,13 @@ class CTkCheckBox(CTkBaseClass): self._text_label.configure(text=self._text) if "font" in kwargs: - self._font = kwargs.pop("font") - if self._text_label is not None: - self._text_label.configure(font=self._apply_font_scaling(self._font)) + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + self._font = self._check_font_type(kwargs.pop("font")) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + + self._update_font() if "state" in kwargs: self._state = kwargs.pop("state") diff --git a/customtkinter/widgets/ctk_label.py b/customtkinter/widgets/ctk_label.py index 4422547..ac5be5a 100644 --- a/customtkinter/widgets/ctk_label.py +++ b/customtkinter/widgets/ctk_label.py @@ -5,6 +5,7 @@ from .ctk_canvas import CTkCanvas from ..theme_manager import ThemeManager from ..draw_engine import DrawEngine from .widget_base_class import CTkBaseClass +from ..utility.ctk_font import CTkFont from customtkinter.utility.utility_functions import pop_from_dict_by_set, check_kwargs_empty @@ -30,7 +31,7 @@ class CTkLabel(CTkBaseClass): text_color: Union[str, Tuple[str, str]] = "default_theme", text: str = "CTkLabel", - font: any = "default_theme", + font: Union[tuple, CTkFont] = "default_theme", anchor: str = "center", # label anchor: center, n, e, s, w **kwargs): @@ -47,7 +48,11 @@ class CTkLabel(CTkBaseClass): # text self._anchor = anchor self._text = text - self._font = (ThemeManager.theme["text"]["font"], ThemeManager.theme["text"]["size"]) if font == "default_theme" else font + + # font + self._font = CTkFont() if font == "default_theme" else self._check_font_type(font) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) # configure grid system (1x1) self.grid_rowconfigure(0, weight=1) @@ -97,6 +102,20 @@ class CTkLabel(CTkBaseClass): height=self._apply_widget_scaling(self._desired_height)) self._draw() + def _update_font(self): + """ pass font to tkinter widgets with applied font scaling and update grid with workaround """ + self._text_label.configure(font=self._apply_font_scaling(self._font)) + + # Workaround to force grid to be resized when text changes size. + # Otherwise grid will lag and only resizes if other mouse action occurs. + self._canvas.grid_forget() + self._canvas.grid(row=0, column=0, sticky="nswe") + + def destroy(self): + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + super().destroy() + def _draw(self, no_color_updates=False): requires_recoloring = self._draw_engine.draw_rounded_rect_with_border(self._apply_widget_scaling(self._current_width), self._apply_widget_scaling(self._current_height), @@ -133,8 +152,13 @@ class CTkLabel(CTkBaseClass): self._text_label.configure(text=self._text) if "font" in kwargs: - self._font = kwargs.pop("font") - self._text_label.configure(font=self._apply_font_scaling(self._font)) + if isinstance(self._font, CTkFont): + self._font.remove_size_configure_callback(self._update_font) + self._font = self._check_font_type(kwargs.pop("font")) + if isinstance(self._font, CTkFont): + self._font.add_size_configure_callback(self._update_font) + + self._update_font() if "fg_color" in kwargs: self._fg_color = kwargs.pop("fg_color") diff --git a/customtkinter/widgets/widget_base_class.py b/customtkinter/widgets/widget_base_class.py index 3200529..848fd35 100644 --- a/customtkinter/widgets/widget_base_class.py +++ b/customtkinter/widgets/widget_base_class.py @@ -1,3 +1,4 @@ +import sys import tkinter import tkinter.ttk as ttk import copy @@ -118,7 +119,7 @@ class CTkBaseClass(tkinter.Frame): super().configure(**pop_from_dict_by_set(kwargs, self._valid_tk_frame_attributes)) # configure tkinter.Frame # if there are still items in the kwargs dict, raise ValueError - self._check_kwargs_empty(kwargs, raise_error=True) + check_kwargs_empty(kwargs, raise_error=True) if require_redraw: self._draw() @@ -139,10 +140,23 @@ class CTkBaseClass(tkinter.Frame): raise ValueError(f"'{attribute_name}' is not a supported argument. Look at the documentation for supported arguments.") @staticmethod - def _check_font_type_and_values(font: any): - if not isinstance(font, CTkFont): - raise ValueError(f"\nFor consistency, Customtkinter requires the font argument {font} to be an instance of CTkFont.\n" + - f"\nUsage example: font=customtkinter.CTkFont(family='name', size='size in px')\n(other arguments in the documentation)\n") + def _check_font_type(font: any): + if isinstance(font, CTkFont): + return font + + elif type(font) == tuple and len(font) == 1: + sys.stderr.write(f"Warning: font {font} given without size, will be extended with default text size of current theme\n") + return font[0], ThemeManager.theme["text"]["size"] + + elif type(font) == tuple and 2 <= len(font) <= 3: + return font + + else: + raise ValueError(f"Wrong font type {type(font)}\n" + + f"For consistency, Customtkinter requires the font argument to be a tuple of len 2 or 3 or an instance of CTkFont.\n" + + f"\nUsage example:\n" + + f"font=customtkinter.CTkFont(family='', size=)\n" + + f"font=('', )\n") def _update_dimensions_event(self, event): # only redraw if dimensions changed (for performance), independent of scaling @@ -219,15 +233,22 @@ class CTkBaseClass(tkinter.Frame): else: return value - def _apply_font_scaling(self, font: Union[tuple, CTkFont]) -> Union[tuple, CTkFont]: + def _apply_font_scaling(self, font: Union[Tuple, CTkFont]) -> tuple: + """ Takes CTkFont object and returns tuple font with scaled size, has to be called again for every change of font object """ if type(font) == tuple: - font_list = list(font) - if len(font_list) >= 2 and type(font_list[1]) == int: - font_list[1] = int(font_list[1] * self._widget_scaling) - return tuple(font_list) + if len(font) == 1: + return font + elif len(font) == 2: + return font[0], round(font[1] * self._widget_scaling) + elif len(font) == 3: + return font[0], round(font[1] * self._widget_scaling), font[2] + else: + raise ValueError(f"Can not scale font {font}. font needs to be tuple of len 1, 2 or 3") elif isinstance(font, CTkFont): - return font + return font.create_scaled_tuple(self._widget_scaling) + else: + raise ValueError(f"Can not scale font '{font}' of type {type(font)}. font needs to be tuple or instance of CTkFont") def _apply_argument_scaling(self, kwargs: dict) -> dict: scaled_kwargs = copy.copy(kwargs) diff --git a/customtkinter/windows/ctk_tk.py b/customtkinter/windows/ctk_tk.py index ffda428..5a5c7f2 100644 --- a/customtkinter/windows/ctk_tk.py +++ b/customtkinter/windows/ctk_tk.py @@ -82,10 +82,15 @@ class CTk(tkinter.Tk): def _update_dimensions_event(self, event=None): if not self._block_update_dimensions_event: - self.update_idletasks() + + # removed this because of python stackoverflow error with many label widgets + # self.update_idletasks() detected_width = self.winfo_width() # detect current window size detected_height = self.winfo_height() + # detected_width = event.width + # detected_height = event.height + if self._current_width != round(detected_width / self._window_scaling) or self._current_height != round(detected_height / self._window_scaling): self._current_width = round(detected_width / self._window_scaling) # adjust current size according to new size given by event self._current_height = round(detected_height / self._window_scaling) # _current_width and _current_height are independent of the scale diff --git a/test/manual_integration_tests/complex_example_new.py b/test/manual_integration_tests/complex_example_new.py index 1085911..a02d099 100644 --- a/test/manual_integration_tests/complex_example_new.py +++ b/test/manual_integration_tests/complex_example_new.py @@ -138,7 +138,7 @@ class App(customtkinter.CTk): self.seg_button_1.configure(values=["CTkSegmentedButton", "Value 2", "Value 3"]) self.seg_button_1.set("Value 2") - self.attributes("-fullscreen", True) + # self.attributes("-fullscreen", True) def open_input_dialog(self): dialog = customtkinter.CTkInputDialog(master=self, text="Type in a number:", title="CTkInputDialog") diff --git a/test/manual_integration_tests/test_font.py b/test/manual_integration_tests/test_font.py new file mode 100644 index 0000000..357f56b --- /dev/null +++ b/test/manual_integration_tests/test_font.py @@ -0,0 +1,63 @@ +import customtkinter + + +app = customtkinter.CTk() +app.geometry("1200x1000") +app.grid_rowconfigure(0, weight=1) +app.grid_columnconfigure((0, 1), weight=1) + +frame_1 = customtkinter.CTkFrame(app) +frame_1.grid(row=0, column=0, sticky="nsew", padx=10, pady=10) +frame_2 = customtkinter.CTkFrame(app) +frame_2.grid(row=0, column=1, sticky="nsew", padx=10, pady=10) + +def set_scaling(scaling): + customtkinter.set_widget_scaling(scaling) + customtkinter.set_spacing_scaling(scaling) + +scaling_button = customtkinter._CTkSegmentedButton(frame_1, values=[0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.5, 2.0], command=set_scaling) +scaling_button.pack(pady=(2, 10)) + +b = customtkinter.CTkButton(frame_1, text="single name", font=("Times", )) +b.pack(pady=2) +b = customtkinter.CTkButton(frame_1, text="name with size", font=("Times", 18)) +b.pack(pady=2) +b = customtkinter.CTkButton(frame_1, text="name with negative size", font=("Times", -18)) +b.pack(pady=2) +b = customtkinter.CTkButton(frame_1, text="extra keywords", font=("Times", -18, "bold italic underline overstrike")) +b.pack(pady=2) + +b = customtkinter.CTkButton(frame_1, text="object default") +b.pack(pady=(10, 2)) +b = customtkinter.CTkButton(frame_1, text="object single name", font=customtkinter._CTkFont("Times")) +b.pack(pady=2) +b = customtkinter.CTkButton(frame_1, text="object with name and size", font=customtkinter._CTkFont("Times", 18)) +b.pack(pady=2) +b = customtkinter.CTkButton(frame_1, text="object with name and negative size", font=customtkinter._CTkFont("Times", -18)) +b.pack(pady=2) +b = customtkinter.CTkButton(frame_1, text="object with extra keywords", + font=customtkinter._CTkFont("Times", -18, weight="bold", slant="italic", underline=True, overstrike=True)) +b.pack(pady=2) + +b1 = customtkinter.CTkButton(frame_1, text="object default modified") +b1.pack(pady=(10, 2)) +b1.cget("font").configure(size=9) +print(b1.cget("font").cget("size"), b1.cget("font").cget("family")) + +b2 = customtkinter.CTkButton(frame_1, text="object default overridden") +b2.pack(pady=10) +b2.configure(font=customtkinter._CTkFont(family="Times")) + +label_font = customtkinter._CTkFont(size=5) +for i in range(30): + l = customtkinter.CTkLabel(frame_2, font=label_font, height=0) + l.grid(row=i, column=0) + b = customtkinter.CTkButton(frame_2, font=label_font, height=5) + b.grid(row=i, column=1, pady=2) + c = customtkinter.CTkCheckBox(frame_2, font=label_font) + c.grid(row=i, column=2, pady=2) +frame_2.grid_columnconfigure((0, 1, 2), weight=1) + +app.after(1500, lambda: label_font.configure(size=10)) +# app.after(1500, lambda: l.configure(text="dshgfldjskhfjdslafhdjsgkkjdaslö")) +app.mainloop() diff --git a/test/manual_integration_tests/test_tabview.py b/test/manual_integration_tests/test_tabview.py index 1e6400d..7b18df3 100644 --- a/test/manual_integration_tests/test_tabview.py +++ b/test/manual_integration_tests/test_tabview.py @@ -23,7 +23,7 @@ b1.pack(pady=20) b2 = customtkinter.CTkButton(master=tabview_1.tab("tab 2"), text="button tab 2") b2.pack() -tabview_1.tab("tab 2").configure(fg_color="red") +# tabview_1.tab("tab 2").configure(fg_color="red") tabview_1.configure(state="normal") # tabview_1.delete("tab 1")