diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 279a67f..2d3e598 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,5 +13,5 @@ jobs: steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v3 - - name: Ruff lint - run: uv run ruff check . \ No newline at end of file + - name: Ruff check + run: uv run ruff check . --diff \ No newline at end of file diff --git a/.python-version b/.python-version index 6324d40..24ee5b1 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.14 +3.13 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3149cb1 --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +# bump and tag version +bump-patch: + @echo "Updating patch version..." + bumpver update --patch + +bump-minor: + @echo "Updating minor version..." + bumpver update --minor + +bump-major: + @echo "Updating major version..." + bumpver update --major + +# generate changelog since last version tag +changelog: + @echo "## Changelog since $(shell git describe --tags --match 'v[0-9]*.[0-9]*.[0-9]*' --abbrev=0)" && \ + echo "" && \ + git log --oneline $(shell git describe --tags --match 'v[0-9]*.[0-9]*.[0-9]*' --abbrev=0)..HEAD | sed 's/^/- /' + +# run linting checks +lint: + @echo "Running linting checks..." + uv run ruff check . --diff + +# push tag and create PR (for when we've already bumped the version) +push-release: + @echo "Creating release tag with v prefix..." + git tag "v$(shell bumpver get current)" $(shell bumpver get current) + @echo "Pushing tags to trigger build..." + git push origin --tags + @echo "Creating PR to sync dev to main..." + gh pr create --base main --head dev --title "Sync dev to main after release" --body "Automated PR to sync dev branch changes to main after release v$(shell bumpver get current)\n\n## Changelog\n$$(make changelog)" + @echo "Release pushed successfully!" + +# create release with specified level: make release LEVEL=patch|minor|major +release: lint + @echo "Creating release..." + @echo "Changelog for this release:" && \ + $(MAKE) changelog + @read -p "Continue with release? [y/N] " confirm && \ + if [ "$$confirm" != "y" ] && [ "$$confirm" != "Y" ]; then \ + echo "Release cancelled."; exit 1; \ + fi + @echo "Updating $(LEVEL) version..." + bumpver update --$(LEVEL) + $(MAKE) push-release \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e115dfc..48cfeee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "reskinner" requires-python = ">=3.7" description = "Instantaneous theme changing for PySimpleGUI and FreeSimpleGUI windows." -version = "4.0.1" +version = "4.1.3" authors = [{ name = "Divine U. Afam-Ifediogor" }] license = { file = "LICENSE" } readme = "./res/DESCRIPTION.md" @@ -41,8 +41,8 @@ dependencies = [ ] [project.urls] -"Homepage" = "https://github.com/definite-d/psg_reskinner/" -"Bug Tracker" = "https://github.com/definite-d/psg_reskinner/issues/" +"Homepage" = "https://github.com/definite-d/reskinner/" +"Bug Tracker" = "https://github.com/definite-d/reskinner/issues/" [project.optional-dependencies] psg = [ @@ -78,14 +78,19 @@ build-backend = "uv_build" [dependency-groups] dev = [ "bumpver", + "freesimplegui>=5.2.0.post1", "isort", "ruff", ] +[tool.ruff] +target-version = "py37" +line-length = 88 + [tool.bumpver] -current_version = "4.0.1" +current_version = "4.1.3" version_pattern = "MAJOR.MINOR.PATCH" -commit_message = "bump version v{old_version} -> v{new_version}" +commit_message = "bump version {old_version} -> {new_version}" tag_message = "{new_version}" tag_scope = "default" pre_commit_hook = "" diff --git a/src/reskinner/__main__.py b/src/reskinner/__main__.py index 23119c0..f213569 100644 --- a/src/reskinner/__main__.py +++ b/src/reskinner/__main__.py @@ -54,9 +54,10 @@ def _reskin_job(): theme_function=sg.theme, lf_table=sg.LOOK_AND_FEEL_TABLE, duration=450, - interpolation_mode="hsl", + interpolation_mode="hue", ) - window.TKroot.after(2000, _reskin_job) + if window.TKroot: + window.TKroot.after(2000, _reskin_job) started = False diff --git a/src/reskinner/__version__.py b/src/reskinner/__version__.py index a7fd80b..b3bf376 100644 --- a/src/reskinner/__version__.py +++ b/src/reskinner/__version__.py @@ -1,7 +1,4 @@ -try: - from importlib.metadata import PackageNotFoundError, version -except ModuleNotFoundError: - from importlib_metadata import PackageNotFoundError, version +from ._compat import PackageNotFoundError, version try: __version__ = version("reskinner") diff --git a/src/reskinner/_compat.py b/src/reskinner/_compat.py new file mode 100644 index 0000000..10f3697 --- /dev/null +++ b/src/reskinner/_compat.py @@ -0,0 +1,17 @@ +import sys + +v = sys.version_info + +if v >= (3, 8): + from importlib.metadata import PackageNotFoundError, version + from typing import Literal, Protocol +else: + from importlib_metadata import PackageNotFoundError, version + from typing_extensions import Literal, Protocol + +if v >= (3, 11): + from enum import StrEnum +else: + from strenum import StrEnum + +__all__ = ["PackageNotFoundError", "version", "Literal", "Protocol", "StrEnum"] diff --git a/src/reskinner/colorizer.py b/src/reskinner/colorizer.py index f16e938..e323cd8 100644 --- a/src/reskinner/colorizer.py +++ b/src/reskinner/colorizer.py @@ -6,14 +6,11 @@ from tkinter import Widget from tkinter.ttk import Style from typing import Any, Callable, Dict, Optional, Tuple, TypeVar, Union -try: - from typing import Literal -except ImportError: - from typing_extensions import Literal from colour import Color # type: ignore[import-untyped] -from .constants import LRU_MAX_SIZE, ElementName, ScrollbarColorKey +from ._compat import Literal +from .constants import LRU_MAX_SIZE, ScrollbarColorKey from .default_window import DEFAULT_ELEMENTS, DEFAULT_WINDOW from .easing import EasingName, ease from .interpolation import INTERPOLATION_MODES, InterpolationMethod @@ -107,19 +104,31 @@ def _default_window_cget(attribute: str) -> Any: @lru_cache(maxsize=LRU_MAX_SIZE) -def _default_element_cget(element_name: str, attribute: str) -> Union[str, Widget]: - """Get an element attribute using cget. +def _default_element_cget(element_class: Type, attribute: str) -> Union[str, Widget]: + """ + Get the default value for an element's attribute. Internal use only. - :param element_name: The name of the element - :type element_name: str + :param element_class: The class of the element + :type element_class: Type :param attribute: The attribute to pass to the cget function :type attribute: str :return: The result of the cget function :rtype: Union[str, Widget] """ - return DEFAULT_ELEMENTS[element_name].widget[attribute] + # Try to find the element in DEFAULT_ELEMENTS, checking base classes if needed + for cls in element_class.__mro__: + if cls in DEFAULT_ELEMENTS: + return DEFAULT_ELEMENTS[cls].widget[attribute] + + # Fallback: try to create a temporary element + try: + temp_element = element_class() + return temp_element.widget[attribute] + except Exception: + # Final fallback: return a reasonable default + return "black" def _run_progressbar_computation(theme_dict: ThemeDict) -> ThemeDict: @@ -147,7 +156,7 @@ def _get_checkbox_radio_selectcolor(background_color, text_color) -> str: # PySimpleGUI's color conversion functions give different results than those of the colour module # due to floating point truncation, so I can't use the color module's functionality for everything here. if not all([_is_valid_color(background_color), _is_valid_color(text_color)]): - return _default_element_cget(ElementName.CHECKBOX, "selectcolor") or "black" + return _default_element_cget(sg.Checkbox, "selectcolor") or "black" background_color: str = Color(background_color).get_hex_l() text_color: str = Color(text_color).get_hex_l() background_hsl: Tuple[float, float, float] = sg._hex_to_hsl(background_color) @@ -177,7 +186,7 @@ def _default_combo_popdown_cget(attribute: str) -> str: """ DEFAULT_WINDOW.TKroot.tk.call( "eval", - f"set defaultcombo [ttk::combobox::PopdownWindow {DEFAULT_ELEMENTS['combo'].widget}]", + f"set defaultcombo [ttk::combobox::PopdownWindow {DEFAULT_ELEMENTS[sg.Combo].widget}]", ) return DEFAULT_WINDOW.TKroot.tk.call("eval", f"$defaultcombo.f.l cget -{attribute}") @@ -257,7 +266,7 @@ def element( configuration, element.widget.configure, lambda attribute: _default_element_cget( - ElementName.from_element(element), + type(element), attribute, ), ) @@ -314,7 +323,12 @@ def window( window: sg.Window, configuration: ThemeConfiguration, ): - self._configure(configuration, window.TKroot.configure, _default_window_cget) + if window.TKroot: + self._configure( + configuration, + window.TKroot.configure, + _default_window_cget, + ) # Specific @@ -326,7 +340,7 @@ def parent_row_frame( self._configure( configuration, parent_row_frame.configure, - getattr(DEFAULT_ELEMENTS["text"], "ParentRowFrame").cget, + getattr(DEFAULT_ELEMENTS[sg.Text], "ParentRowFrame").cget, ) def menu_entry( @@ -335,10 +349,18 @@ def menu_entry( index: int, configuration: ThemeConfiguration, ): + configuration = dict( + filter( + lambda item: item[0] in menu.entryconfigure(index).keys(), + configuration.items(), + ) + ) + # Filter the configs for menu entries that don't accept the full config dict. Fixes issue #11. + # Brought back in v4.0.2 after its omission caused a regression leading to issue #22. self._configure( configuration, lambda **_configurations: menu.entryconfigure(index, _configurations), - lambda attribute: _default_element_cget("menu", attribute), + lambda attribute: _default_element_cget(sg.Menu, attribute), ) def optionmenu_menu( @@ -349,7 +371,7 @@ def optionmenu_menu( self._configure( configuration, optionmenu.widget["menu"].configure, - _default_element_cget(ElementName.OPTIONMENU, "menu").cget, + _default_element_cget(sg.OptionMenu, "menu").cget, ) def scrollbar( @@ -385,7 +407,7 @@ def scrollbar( default_style, ) - def recurse_menu(self, tkmenu: Union[TKMenu, Widget]): + def recurse_menu(self, tkmenu: TKMenu): """ Internal use only. @@ -397,12 +419,13 @@ def recurse_menu(self, tkmenu: Union[TKMenu, Widget]): :param tkmenu: The Tkinter menu object. :return: None """ + end_menu_index = tkmenu.index("end") # This fixes issue #8. Thank you, @richnanney for reporting! - if tkmenu.index("end") is None: + if end_menu_index is None: return - for index in range(0, tkmenu.index("end") + 1): + for index in range(0, end_menu_index + 1): self.menu_entry( tkmenu, index, @@ -422,15 +445,23 @@ def scrollable_column(self, column: sg.Column): self._configure( {"background": "BACKGROUND"}, column.TKColFrame.configure, - DEFAULT_ELEMENTS["column"].TKColFrame.cget, - ) - self._configure( - {"background": "BACKGROUND"}, - getattr(column.TKColFrame, "canvas").children["!frame"].configure, - getattr(DEFAULT_ELEMENTS["column"].TKColFrame, "canvas") - .children["!frame"] - .cget, + DEFAULT_ELEMENTS[sg.Column].TKColFrame.cget, ) + # Handle the inner frame if it exists + canvas = getattr(column.TKColFrame, "canvas", None) + if canvas and hasattr(canvas, "children") and "!frame" in canvas.children: + self._configure( + {"background": "BACKGROUND"}, + canvas.children["!frame"].configure, + getattr(DEFAULT_ELEMENTS[sg.Column].TKColFrame, "canvas") + .children.get("!frame") + .cget + if "!frame" + in getattr( + DEFAULT_ELEMENTS[sg.Column].TKColFrame, "canvas", {} + ).children + else lambda _: "white", + ) def combo(self, combo: sg.Combo): # Configuring the listbox (popdown) of the combo. @@ -468,7 +499,7 @@ def _configure_combo_popdown(**kwargs): "background": ("BUTTON", 1), "arrowcolor": ("BUTTON", 0), }, - _default_element_cget("combo", "style"), + _default_element_cget(sg.Combo, "style"), ) self.map( style_name, @@ -476,21 +507,21 @@ def _configure_combo_popdown(**kwargs): "foreground": {"readonly": "TEXT_INPUT"}, "fieldbackground": {"readonly": "INPUT"}, }, - _default_element_cget("combo", "style"), + _default_element_cget(sg.Combo, "style"), True, ) def checkbox_or_radio(self, element: Union[sg.Checkbox, sg.Radio]): - element_name = ElementName.from_element(element) + element_type = type(element) toggle = ( _get_checkbox_radio_selectcolor( self._color( "BACKGROUND", - lambda: _default_element_cget(element_name, "selectcolor"), + lambda: _default_element_cget(element_type, "selectcolor"), ), self._color( "TEXT", - lambda: _default_element_cget(element_name, "selectcolor"), + lambda: _default_element_cget(element_type, "selectcolor"), ), ), ) @@ -509,7 +540,7 @@ def checkbox_or_radio(self, element: Union[sg.Checkbox, sg.Radio]): def table_or_tree(self, element: Union[sg.Table, sg.Tree]): style_name = element.widget["style"] - element_name = ElementName.from_element(element) + element_type = type(element) default_style = element.widget.winfo_class() self.style( style_name, @@ -545,7 +576,7 @@ def table_or_tree(self, element: Union[sg.Table, sg.Tree]): f"{default_style}.Heading", ) - if element_name == "table": + if element_type == sg.Table: self.map( f"{style_name}.Heading", { @@ -561,5 +592,5 @@ def progressbar(self, element: sg.ProgressBar): self.style( style_name, {"background": ("PROGRESS", 0), "troughcolor": ("PROGRESS", 1)}, - _default_element_cget("progressbar", "style"), + _default_element_cget(sg.ProgressBar, "style"), ) diff --git a/src/reskinner/constants.py b/src/reskinner/constants.py index 231da00..ddcf6ff 100644 --- a/src/reskinner/constants.py +++ b/src/reskinner/constants.py @@ -1,11 +1,6 @@ from enum import Enum -try: - from enum import StrEnum -except ImportError: - # Python < 3.11 - from strenum import StrEnum - +from ._compat import StrEnum from .sg import sg ALTER_MENU_ACTIVE_COLORS = True @@ -19,54 +14,30 @@ class InterpolationMode(StrEnum): RGB = "rgb" -class ElementName(StrEnum): - BUTTON = "button" - BUTTONMENU = "buttonmenu" - CANVAS = "canvas" - CHECKBOX = "checkbox" - COLUMN = "column" - COMBO = "combo" - FRAME = "frame" - GRAPH = "graph" - HORIZONTALSEPARATOR = "horizontalseparator" - IMAGE = "image" - INPUT = "input" - LISTBOX = "listbox" - MENU = "menu" - MULTILINE = "multiline" - OPTIONMENU = "optionmenu" - PANE = "pane" - PROGRESSBAR = "progressbar" - RADIO = "radio" - SIZEGRIP = "sizegrip" - SLIDER = "slider" - SPIN = "spin" - STATUSBAR = "statusbar" - TAB = "tab" - TABGROUP = "tabgroup" - TABLE = "table" - TEXT = "text" - TREE = "tree" - VERTICALSEPARATOR = "verticalseparator" +def is_element_type(element, element_class): + """ + Check if an element is of a specific PySimpleGUI type (including subclasses). - @staticmethod - def from_element(element: sg.Element): - return ElementName(type(element).__name__.lower()) + :param element: The element to check + :param element_class: The PySimpleGUI class to check against (e.g., sg.Button) + :return: True if the element is of that type + """ + return isinstance(element, element_class) NON_GENERIC_ELEMENTS = [ - ElementName.BUTTON, - ElementName.HORIZONTALSEPARATOR, - ElementName.LISTBOX, - ElementName.MULTILINE, - ElementName.PROGRESSBAR, - ElementName.SIZEGRIP, - ElementName.SPIN, - ElementName.TABGROUP, - ElementName.TABLE, - ElementName.TEXT, - ElementName.TREE, - ElementName.VERTICALSEPARATOR, + sg.Button, + sg.HorizontalSeparator, + sg.Listbox, + sg.Multiline, + sg.ProgressBar, + sg.Sizegrip, + sg.Spin, + sg.TabGroup, + sg.Table, + sg.Text, + sg.Tree, + sg.VerticalSeparator, ] _COLOR_MAPPING = { diff --git a/src/reskinner/default_window.py b/src/reskinner/default_window.py index ed753a8..89ccc43 100644 --- a/src/reskinner/default_window.py +++ b/src/reskinner/default_window.py @@ -1,4 +1,4 @@ -from .constants import DEFAULT_THEME_NAME, ElementName +from .constants import DEFAULT_THEME_NAME from .sg import sg _previous_theme = sg.theme() @@ -13,46 +13,49 @@ ) DEFAULT_ELEMENTS = { - ElementName.BUTTON: sg.Button(), - ElementName.BUTTONMENU: sg.ButtonMenu("", sg.MENU_RIGHT_CLICK_EDITME_EXIT), - ElementName.CANVAS: sg.Canvas(), - ElementName.CHECKBOX: sg.Checkbox(""), - ElementName.COLUMN: sg.Column([[sg.Text()]], scrollable=True), - ElementName.COMBO: sg.Combo([""]), - ElementName.FRAME: sg.Frame("", [[sg.Text()]]), - ElementName.GRAPH: sg.Graph((2, 2), (0, 2), (2, 0)), - ElementName.HORIZONTALSEPARATOR: sg.HorizontalSeparator(), # 'image': sg.Image(), - ElementName.INPUT: sg.Input(), - ElementName.IMAGE: sg.Image(), - ElementName.LISTBOX: sg.Listbox([""]), - ElementName.MENU: sg.Menu([["File", ["Exit"]], ["Edit", ["Edit Me"]]]), - ElementName.MULTILINE: sg.Multiline(), - ElementName.OPTIONMENU: sg.OptionMenu([""]), - ElementName.PANE: sg.Pane([sg.Column([[sg.Text()]]), sg.Column([[sg.Text()]])]), - ElementName.PROGRESSBAR: sg.ProgressBar(0), - ElementName.RADIO: sg.Radio("", 0), - ElementName.SIZEGRIP: sg.Sizegrip(), - ElementName.SLIDER: sg.Slider(), - ElementName.SPIN: sg.Spin([0]), - ElementName.STATUSBAR: sg.StatusBar(""), - ElementName.TABGROUP: sg.TabGroup([[sg.Tab("", [[sg.Text()]], key="tab")]]), - ElementName.TABLE: sg.Table([["asdf"]]), - ElementName.TEXT: sg.Text(), - ElementName.TREE: sg.Tree(_tree_data, [""], num_rows=1), - ElementName.VERTICALSEPARATOR: sg.VerticalSeparator(), + sg.Button: sg.Button(size=(1, 1)), + sg.ButtonMenu: sg.ButtonMenu("", sg.MENU_RIGHT_CLICK_EDITME_EXIT, size=(1, 1)), + sg.Canvas: sg.Canvas(size=(1, 1)), + sg.Checkbox: sg.Checkbox("", size=(1, 1)), + sg.Column: sg.Column([[sg.Text()]], scrollable=True, size=(1, 1)), + sg.Combo: sg.Combo([""], size=(1, 1)), + sg.Frame: sg.Frame("", [[sg.Text(size=(1, 1))]], size=(1, 1)), + sg.Graph: sg.Graph((1, 1), (0, 0), (1, 1)), + sg.HorizontalSeparator: sg.HorizontalSeparator(), + sg.Input: sg.Input(size=(1, 1)), + sg.Image: sg.Image(size=(1, 1)), + sg.Listbox: sg.Listbox([""], size=(1, 1)), + sg.Menu: sg.Menu([["File", ["Exit"]], ["Edit", ["Edit Me"]]], size=(1, 1)), + sg.Multiline: sg.Multiline(size=(1, 1)), + sg.OptionMenu: sg.OptionMenu([""], size=(1, 1)), + sg.Pane: sg.Pane([sg.Column([[sg.Text(size=(1, 1))]], size=(1, 1))]), + sg.ProgressBar: sg.ProgressBar(0, size=(1, 1)), + sg.Radio: sg.Radio("", 0, size=(1, 1)), + sg.Sizegrip: sg.Sizegrip(), + sg.Slider: sg.Slider(size=(1, 1)), + sg.Spin: sg.Spin([0], size=(1, 1)), + sg.StatusBar: sg.StatusBar("", size=(1, 1)), + sg.TabGroup: sg.TabGroup([[sg.Tab("", [[sg.Text()]], key="tab")]], size=(1, 1)), + sg.Table: sg.Table([["asdf"]], size=(1, 1)), + sg.Text: sg.Text(size=(1, 1)), + sg.Tree: sg.Tree(_tree_data, [""], num_rows=1), + sg.VerticalSeparator: sg.VerticalSeparator(), } -# A completely invisible window, which should at worst show a -# small line at the top-right of the left display if -# viewed on a Raspberry Pi with multiple monitors. Unlikely. -DEFAULT_WINDOW = sg.Window( +# A completely invisible window (on most platforms), which should at worst +# flash the default window, then show a small line at the top-right of the +# left display. +DEFAULT_WINDOW: sg.Window = sg.Window( "", [[element] for element in DEFAULT_ELEMENTS.values()], - size=(1, 1), + size=(0, 0), no_titlebar=True, + finalize=True, + element_padding=(0, 0), alpha_channel=0, location=(-1, -1), -).finalize() +) +DEFAULT_WINDOW.hide() DEFAULT_ELEMENTS["tab"] = DEFAULT_WINDOW["tab"] sg.theme(_previous_theme) diff --git a/src/reskinner/easing.py b/src/reskinner/easing.py index f004678..40e7630 100644 --- a/src/reskinner/easing.py +++ b/src/reskinner/easing.py @@ -1,9 +1,7 @@ from math import cos, pi, sin, sqrt from typing import Callable, Dict, Optional, Union -try: - from typing import Literal -except ImportError: - from typing_extensions import Literal + +from ._compat import Literal c1 = 1.70158 c2 = c1 * 1.525 @@ -150,11 +148,15 @@ def ease( if function is None: return progress - if callable(function): + elif callable(function): return function(progress) - easing_func = EASING_FUNCTIONS.get(function) - if easing_func is None: - raise ValueError(f"Unknown easing function: {function}") + elif isinstance(function, EasingName): + easing_func = EASING_FUNCTIONS.get(function) + if easing_func is None: + raise ValueError(f"Unknown easing function: {function}") + + else: + raise ValueError("Invalid value passed for easing function") return easing_func(progress) diff --git a/src/reskinner/elements.py b/src/reskinner/elements.py index 7cf10f5..0f9b6fb 100644 --- a/src/reskinner/elements.py +++ b/src/reskinner/elements.py @@ -1,11 +1,54 @@ from tkinter.ttk import Widget as TTKWidget -from typing import Union +from typing import Callable, Dict, List, Tuple, Type, Union from .colorizer import Colorizer -from .constants import ALTER_MENU_ACTIVE_COLORS, ElementName +from .constants import ALTER_MENU_ACTIVE_COLORS, is_element_type from .sg import sg +class ElementDispatcher: + """Efficient element handler dispatcher with pre-computed type mappings.""" + + def __init__(self): + # Direct type mappings for O(1) lookup + self._type_handlers: Dict[Type, List[Callable]] = {} + # Conditional handlers for special cases + self._conditional_handlers: List[Tuple[Callable, Callable]] = [] + # Generic handlers that apply to all elements + self._generic_handlers: List[Callable] = [] + + def register_generic(self, handler: Callable) -> None: + """Register a handler that applies to all elements.""" + self._generic_handlers.append(handler) + + def register_conditional(self, condition: Callable, handler: Callable) -> None: + """Register a handler with a custom condition.""" + self._conditional_handlers.append((condition, handler)) + + def register_type(self, element_type: Type, handler: Callable) -> None: + """Register a handler for a specific element type.""" + if element_type not in self._type_handlers: + self._type_handlers[element_type] = [] + self._type_handlers[element_type].append(handler) + + def dispatch(self, element) -> None: + """Dispatch element to all appropriate handlers.""" + # Apply generic handlers first + for handler in self._generic_handlers: + handler(element) + + # Apply conditional handlers + for condition, handler in self._conditional_handlers: + if condition(element): + handler(element) + + # Apply type-specific handlers + for type_class, handlers in self._type_handlers.items(): + if isinstance(element, type_class): + for handler in handlers: + handler(element) + + class ElementReskinner: def __init__(self, colorizer: Colorizer): """ @@ -16,18 +59,62 @@ def __init__(self, colorizer: Colorizer): """ self._titlebar_row_frame = "Not Set" self.colorizer: Colorizer = colorizer + self._dispatcher = ElementDispatcher() + self._register_handlers() + + def _register_handlers(self) -> None: + """Register all element handlers.""" + # Generic handlers that apply to all elements + self._dispatcher.register_generic(self._handle_generic_tweaks) + self._dispatcher.register_generic(self._handle_right_click_menus) + self._dispatcher.register_generic(self._handle_ttk_scrollbars) + + # Conditional handlers for special cases + self._dispatcher.register_conditional( + lambda e: e.metadata == sg.TITLEBAR_METADATA_MARKER, + self._reskin_custom_titlebar, + ) + self._dispatcher.register_conditional( + lambda e: str(e.widget).startswith(f"{self._titlebar_row_frame}."), + self._reskin_titlebar_child, + ) + self._dispatcher.register_conditional( + lambda e: ( + is_element_type(e, sg.Column) + and (getattr(e, "TKColFrame", "Not Set") != "Not Set") + ), + self._reskin_scrollable_column, + ) - def reskin_element(self, element: sg.Element): - """ - Reskin an element based on its type. - - :param element: The PySimpleGUI element to reskin - :type element: sg.Element - """ - - element_name = ElementName.from_element(element) - - # Generic tweaks + # Type-specific handlers + self._dispatcher.register_type(sg.Button, self._reskin_button) + self._dispatcher.register_type(sg.ButtonMenu, self._reskin_buttonmenu) + self._dispatcher.register_type(sg.Canvas, self._reskin_canvas) + self._dispatcher.register_type(sg.Combo, self._reskin_combo) + self._dispatcher.register_type(sg.Frame, self._reskin_frame) + self._dispatcher.register_type(sg.Listbox, self._reskin_listbox) + self._dispatcher.register_type(sg.Menu, self._reskin_menu) + self._dispatcher.register_type(sg.ProgressBar, self._reskin_progressbar) + self._dispatcher.register_type(sg.OptionMenu, self._reskin_optionmenu) + self._dispatcher.register_type(sg.Sizegrip, self._reskin_sizegrip) + self._dispatcher.register_type(sg.Slider, self._reskin_slider) + self._dispatcher.register_type(sg.Spin, self._reskin_spin) + self._dispatcher.register_type(sg.TabGroup, self._reskin_tabgroup) + + # Multi-type handlers + self._dispatcher.register_type(sg.Checkbox, self._reskin_checkbox) + self._dispatcher.register_type(sg.Radio, self._reskin_checkbox) + self._dispatcher.register_type(sg.HorizontalSeparator, self._reskin_separator) + self._dispatcher.register_type(sg.VerticalSeparator, self._reskin_separator) + self._dispatcher.register_type(sg.Input, self._reskin_input) + self._dispatcher.register_type(sg.Multiline, self._reskin_input) + self._dispatcher.register_type(sg.Text, self._reskin_text) + self._dispatcher.register_type(sg.StatusBar, self._reskin_text) + self._dispatcher.register_type(sg.Table, self._reskin_table) + self._dispatcher.register_type(sg.Tree, self._reskin_table) + + def _handle_generic_tweaks(self, element: sg.Element) -> None: + """Handle generic tweaks that apply to most elements.""" if ( getattr(element, "ParentRowFrame", False) and element.metadata != sg.TITLEBAR_METADATA_MARKER @@ -39,11 +126,14 @@ def reskin_element(self, element: sg.Element): if "background" in element.widget.keys() and element.widget.cget("background"): self.colorizer.element(element, {"background": "BACKGROUND"}) - # Right Click Menus (thanks for pointing this out @dwelden!) + def _handle_right_click_menus(self, element: sg.Element) -> None: + """Handle right-click menus.""" + # Thanks for pointing this out @dwelden! if element.TKRightClickMenu: self.colorizer.recurse_menu(element.TKRightClickMenu) - # TTK Scrollbars + def _handle_ttk_scrollbars(self, element: sg.Element) -> None: + """Handle TTK scrollbars.""" if getattr(element, "vsb_style_name", False): self.colorizer.scrollbar(element.vsb_style_name, "Vertical.TScrollbar") if getattr(element, "hsb_style_name", False): @@ -62,47 +152,14 @@ def reskin_element(self, element: sg.Element): self.colorizer.scrollbar(vertical_style, "TScrollbar") self.colorizer.scrollbar(element.ttk_style_name, "TScrollbar") - # Python 3.7-compatible match/case. - element_specific_reskin_function = { - (element.metadata == sg.TITLEBAR_METADATA_MARKER): self._titlebar_row_frame, - ( - str(element.widget).startswith(f"{self._titlebar_row_frame}.") - ): self._reskin_titlebar_child, - ( - (element_name == ElementName.COLUMN) - and (getattr(element, "TKColFrame", "Not Set") != "Not Set") - ): self._reskin_scrollable_column, - (element_name == ElementName.BUTTON): self._reskin_button, - (element_name == ElementName.BUTTONMENU): self._reskin_buttonmenu, - (element_name == ElementName.CANVAS): self._reskin_canvas, - (element_name == ElementName.COMBO): self._reskin_combo, - (element_name == ElementName.FRAME): self._reskin_frame, - (element_name == ElementName.LISTBOX): self._reskin_listbox, - (element_name == ElementName.MENU): self._reskin_menu, - (element_name == ElementName.PROGRESSBAR): self._reskin_progressbar, - (element_name == ElementName.OPTIONMENU): self._reskin_optionmenu, - (element_name == ElementName.SIZEGRIP): self._reskin_sizegrip, - (element_name == ElementName.SLIDER): self._reskin_slider, - (element_name == ElementName.SPIN): self._reskin_spin, - (element_name == ElementName.TABGROUP): self._reskin_tabgroup, - ( - element_name in (ElementName.CHECKBOX, ElementName.RADIO) - ): self._reskin_checkbox, - ( - element_name - in (ElementName.HORIZONTALSEPARATOR, ElementName.VERTICALSEPARATOR) - ): self._reskin_separator, - ( - element_name in (ElementName.INPUT, ElementName.MULTILINE) - ): self._reskin_input, - ( - element_name in (ElementName.TEXT, ElementName.STATUSBAR) - ): self._reskin_text, - (element_name in (ElementName.TABLE, ElementName.TREE)): self._reskin_table, - }.get(True, False) - - if element_specific_reskin_function: - element_specific_reskin_function(element) + def reskin_element(self, element: sg.Element): + """ + Reskin an element. + + :param element: The PySimpleGUI element to reskin + :type element: sg.Element + """ + self._dispatcher.dispatch(element) # Specific Elements diff --git a/src/reskinner/interpolation.py b/src/reskinner/interpolation.py index d33d177..8b28c63 100644 --- a/src/reskinner/interpolation.py +++ b/src/reskinner/interpolation.py @@ -1,11 +1,9 @@ from typing import Dict -try: - from typing import Protocol -except ImportError: - from typing_extensions import Protocol from colour import Color +from ._compat import Protocol + def _clamp(v: float): return min(max(v, 0), 1) diff --git a/src/reskinner/reskinner.py b/src/reskinner/reskinner.py index 8034ae3..a6b5442 100644 --- a/src/reskinner/reskinner.py +++ b/src/reskinner/reskinner.py @@ -3,12 +3,9 @@ from datetime import datetime, timedelta from tkinter import TclError from typing import Callable, Dict, Optional, TypeVar, Union -try: - from typing import Literal -except ImportError: - from typing_extensions import Literal from warnings import warn +from ._compat import Literal from .colorizer import Colorizer, ThemeDict from .easing import EasingName from .elements import ElementReskinner @@ -126,7 +123,8 @@ def reskin( return raise # Re-raise other TclErrors - window.TKroot.update_idletasks() + if window.TKroot: + window.TKroot.update_idletasks() except Exception as e: warn(f"Error during animated reskin: {str(e)}") @@ -188,9 +186,10 @@ def toggle_transparency(window: sg.Window) -> None: raise AttributeError("Window does not support transparency") try: - window_bg = window.TKroot.cget("background") - transparent_color = window.TKroot.attributes("-transparentcolor") - window.set_transparent_color(window_bg if transparent_color == "" else "") + if window.TKroot: + window_bg = window.TKroot.cget("background") + transparent_color = window.TKroot.attributes("-transparentcolor") + window.set_transparent_color(window_bg if transparent_color == "" else "") except TclError as e: if "unknown color name" in str(e): raise ValueError(f"Invalid color value: {window_bg}") from e diff --git a/src/reskinner/sg.py b/src/reskinner/sg.py index 2856f74..3129dea 100644 --- a/src/reskinner/sg.py +++ b/src/reskinner/sg.py @@ -1,7 +1,9 @@ prompt = """ -Neither PySimpleGUI (https://github.com/PySimpleGUI/PySimpleGUI/) nor FreeSimpleGUI (https://github.com/spyoungtech/FreeSimpleGUI) are installed. +Neither PySimpleGUI (https://github.com/PySimpleGUI/PySimpleGUI/) +nor FreeSimpleGUI (https://github.com/spyoungtech/FreeSimpleGUI) are installed. -You should install one or the other with the `psg` and `fsg` optional dependency groups respectively, e.g.: +You should install one or the other with the `psg` and `fsg` optional dependency +groups respectively, e.g.: `pip install reskinner[psg]` for PySimpleGUI support. """ SG_LIB = "psg"