Repository: MyreMylar/pygame_gui (python)

Issue:
Allow loading theme by string/dict.
**Is your feature request related to a problem? Please describe.**
I'm currently trying to change the font and background colour of some dynamically generated buttons.
Because I want these attributes to be dynamic (IE not defined in a theme file), the only way I've found to achieve this is with the following:

```python3
data = {}
# Here, i is the index of the button being generated.
but = pygame_gui.elements.UIButton(
    relative_rect=pygame.Rect(button_left, button_top_orig + button_top_inc * i, *button_size), 
    text=entry[0], 
    manager=self,
    object_id=pygame_gui.core.ObjectID(f"color-button-{i}")
)
col = genCol(i)
data[f"color-button-{i}"] = {
    "colours": {
        "normal_bg": col,
        "hovered_bg": col,
        "active_bg": col,
    },
    "font": {
        "name": "Poppins",
        "size": "24",
        "regular_resource": {
            "package": "<package-path>",
            "resource": "Poppins-Regular.ttf"
        }
    },
    "misc": {
        "border_width": "4"
    }
}
self.ui_theme._load_element_colour_data_from_theme("colours", f"color-button-{i}", data)
self.ui_theme._load_element_font_data_from_theme("font", f"color-button-{i}", data)
self.ui_theme._load_element_misc_data_from_theme("misc", f"color-button-{i}", data)
self.ui_theme._load_fonts()
but.rebuild_from_changed_theme_data()
```

Which is likely to fail in later versions and probably unsafe.

**Describe the solution you'd like**
Move the contents of `UIAppearanceTheme.load_theme` so that after `json.load` is called, we delegate to a new function, `load_theme_from_obj(self, obj: dict)`.

**Describe alternatives you've considered**
If there's a nice way to change the button attributes without digging into themes then this is great too!

**Additional context**
Thanks for the great library =)


Pull request description:
Add dynamic theming updates & start of text entry box
This fixes #182 and #253.

I need to add more tests for it in a later PR. 

Patch:
diff --git a/pygame_gui/core/interfaces/appearance_theme_interface.py b/pygame_gui/core/interfaces/appearance_theme_interface.py
index a7e3b61..47112d3 100644
--- a/pygame_gui/core/interfaces/appearance_theme_interface.py
+++ b/pygame_gui/core/interfaces/appearance_theme_interface.py
@@ -152,3 +152,22 @@ class IUIAppearanceThemeInterface(metaclass=ABCMeta):
         :param file_path: The path to the theme we want to load.
 
         """
+
+    @abstractmethod
+    def update_theming(self, new_theming_data: str, rebuild_all: bool = True):
+        """
+        Update theming data, via string - for the whole UI.
+
+        :param new_theming_data:
+        :param rebuild_all:
+        :return:
+        """
+
+    @abstractmethod
+    def update_single_element_theming(self, element_name: str, new_theming_data: str):
+        """
+        Update theming data, via string - for a single element.
+        :param element_name:
+        :param new_theming_data:
+        :return:
+        """
diff --git a/pygame_gui/core/ui_appearance_theme.py b/pygame_gui/core/ui_appearance_theme.py
index a3428ae..9da8c8e 100644
--- a/pygame_gui/core/ui_appearance_theme.py
+++ b/pygame_gui/core/ui_appearance_theme.py
@@ -78,6 +78,8 @@ class UIAppearanceTheme(IUIAppearanceThemeInterface):
         self.st_cache_duration = 10.0
         self.st_cache_clear_timer = 0.0
 
+        self.need_to_rebuild_data_manually_changed = False
+
     def _load_default_theme_file(self):
         """
         Loads the default theme file.
@@ -93,6 +95,27 @@ class UIAppearanceTheme(IUIAppearanceThemeInterface):
         """
         return self.font_dict
 
+    def check_need_to_rebuild_data_manually_changed(self) -> bool:
+        if self.need_to_rebuild_data_manually_changed:
+            self.need_to_rebuild_data_manually_changed = False
+            return True
+        return False
+
+    def update_theming(self, new_theming_data: str, rebuild_all: bool = True):
+        # parse new_theming data
+        theme_dict = json.loads(new_theming_data)
+        self._parse_theme_data_from_json_dict(theme_dict)
+        if rebuild_all:
+            self.need_to_rebuild_data_manually_changed = True
+
+    def update_single_element_theming(self, element_name: str, new_theming_data: str):
+        element_theming_dict = json.loads(new_theming_data)
+
+        self._parse_single_element_data(element_name, element_theming_dict)
+        self._load_fonts()
+        self._load_images()
+        self._preload_shadow_edges()
+
     def check_need_to_reload(self) -> bool:
         """
         Check if we need to reload our theme file because it's been modified. If so, trigger a
@@ -650,42 +673,47 @@ class UIAppearanceTheme(IUIAppearanceThemeInterface):
                     load_success = True
 
                 if load_success:
+                    self._parse_theme_data_from_json_dict(theme_dict)
 
-                    for element_name in theme_dict.keys():
-                        if element_name == 'defaults':
-                            self._load_colour_defaults_from_theme(theme_dict)
-                        else:
-                            self._load_prototype(element_name, theme_dict)
-                            for data_type in theme_dict[element_name]:
-                                if data_type == 'font':
-                                    file_dict = theme_dict[element_name][data_type]
-                                    if isinstance(file_dict, list):
-                                        for item in file_dict:
-                                            self._load_element_font_data_from_theme(item,
-                                                                                    element_name)
-                                    else:
-                                        self._load_element_font_data_from_theme(file_dict,
-                                                                                element_name)
-
-                                if data_type == 'colours':
-                                    self._load_element_colour_data_from_theme(data_type,
-                                                                              element_name,
-                                                                              theme_dict)
-
-                                elif data_type == 'images':
-                                    self._load_element_image_data_from_theme(data_type,
-                                                                             element_name,
-                                                                             theme_dict)
-
-                                elif data_type == 'misc':
-                                    self._load_element_misc_data_from_theme(data_type,
-                                                                            element_name,
-                                                                            theme_dict)
-
-        if load_success:
-            self._load_fonts()  # save to trigger load with the same data as it won't do anything
-            self._load_images()
-            self._preload_shadow_edges()
+    def _parse_theme_data_from_json_dict(self, theme_dict):
+        for element_name in theme_dict.keys():
+            if element_name == 'defaults':
+                self._load_colour_defaults_from_theme(theme_dict)
+            else:
+                self._load_prototype(element_name, theme_dict)
+                element_theming = theme_dict[element_name]
+                self._parse_single_element_data(element_name, element_theming)
+
+        self._load_fonts()  # save to trigger load with the same data as it won't do anything
+        self._load_images()
+        self._preload_shadow_edges()
+
+    def _parse_single_element_data(self, element_name, element_theming):
+        for data_type in element_theming:
+            if data_type == 'font':
+                file_dict = element_theming[data_type]
+                if isinstance(file_dict, list):
+                    for item in file_dict:
+                        self._load_element_font_data_from_theme(item,
+                                                                element_name)
+                else:
+                    self._load_element_font_data_from_theme(file_dict,
+                                                            element_name)
+
+            if data_type == 'colours':
+                self._load_element_colour_data_from_theme(data_type,
+                                                          element_name,
+                                                          element_theming)
+
+            elif data_type == 'images':
+                self._load_element_image_data_from_theme(data_type,
+                                                         element_name,
+                                                         element_theming)
+
+            elif data_type == 'misc':
+                self._load_element_misc_data_from_theme(data_type,
+                                                        element_name,
+                                                        element_theming)
 
     def _load_prototype(self, element_name: str, theme_dict: Dict[str, Any]):
         """
@@ -745,7 +773,7 @@ class UIAppearanceTheme(IUIAppearanceThemeInterface):
     def _load_element_misc_data_from_theme(self,
                                            data_type: str,
                                            element_name: str,
-                                           theme_dict: Dict[str, Any]):
+                                           element_theming: Dict[str, Any]):
         """
         Load miscellaneous theming data direct from the theme file's data dictionary into our
         misc data dictionary.
@@ -754,11 +782,11 @@ class UIAppearanceTheme(IUIAppearanceThemeInterface):
 
         :param data_type: The type of misc data as described by a string.
         :param element_name: The theming element ID that this data belongs to.
-        :param theme_dict: The data dictionary from the theming file to load data from.
+        :param element_theming: The data dictionary from the theming file to load data from.
         """
         if element_name not in self.ui_element_misc_data:
             self.ui_element_misc_data[element_name] = {}
-        misc_dict = theme_dict[element_name][data_type]
+        misc_dict = element_theming[data_type]
         for misc_data_key in misc_dict:
             if isinstance(misc_dict[misc_data_key], (dict, str)):
                 self.ui_element_misc_data[element_name][misc_data_key] = misc_dict[misc_data_key]
@@ -766,7 +794,7 @@ class UIAppearanceTheme(IUIAppearanceThemeInterface):
     def _load_element_image_data_from_theme(self,
                                             data_type: str,
                                             element_name: str,
-                                            theme_dict: Dict[str, Any]):
+                                            element_theming: Dict[str, Any]):
         """
         Load image theming data direct from the theme file's data dictionary into our image
         data dictionary.
@@ -775,11 +803,11 @@ class UIAppearanceTheme(IUIAppearanceThemeInterface):
 
         :param data_type: The type of image data as described by a string.
         :param element_name: The theming element ID that this data belongs to.
-        :param theme_dict: The data dictionary from the theming file to load data from.
+        :param element_theming: The data dictionary from the theming file to load data from.
         """
         if element_name not in self.ui_element_image_locs:
             self.ui_element_image_locs[element_name] = {}
-        loaded_img_dict = theme_dict[element_name][data_type]
+        loaded_img_dict = element_theming[data_type]
         for image_key in loaded_img_dict:
             if image_key not in self.ui_element_image_locs[element_name]:
                 self.ui_element_image_locs[element_name][image_key] = {}
@@ -823,7 +851,7 @@ class UIAppearanceTheme(IUIAppearanceThemeInterface):
     def _load_element_colour_data_from_theme(self,
                                              data_type: str,
                                              element_name: str,
-                                             theme_dict: Dict[str, Any]):
+                                             element_theming: Dict[str, Any]):
         """
         Load colour/gradient theming data direct from the theme file's data dictionary into our
         colour data dictionary.
@@ -832,11 +860,11 @@ class UIAppearanceTheme(IUIAppearanceThemeInterface):
 
         :param data_type: The type of colour data as described by a string.
         :param element_name: The theming element ID that this data belongs to.
-        :param theme_dict: The data dictionary from the theming file to load data from.
+        :param element_theming: The data dictionary from the theming file to load data from.
         """
         if element_name not in self.ui_element_colours:
             self.ui_element_colours[element_name] = {}
-        colours_dict = theme_dict[element_name][data_type]
+        colours_dict = element_theming[data_type]
         for colour_key in colours_dict:
             colour = self._load_colour_or_gradient_from_theme(colours_dict, colour_key)
             self.ui_element_colours[element_name][colour_key] = colour
diff --git a/pygame_gui/core/ui_element.py b/pygame_gui/core/ui_element.py
index accef15..88177b9 100644
--- a/pygame_gui/core/ui_element.py
+++ b/pygame_gui/core/ui_element.py
@@ -42,7 +42,7 @@ class UIElement(GUISprite, IUIElementInterface):
     :param visible: Whether the element is visible by default. Warning - container visibility may
                     override this.
     """
-    def __init__(self, relative_rect: pygame.Rect,
+    def __init__(self, relative_rect: Union[pygame.Rect, Tuple[int, int, int, int]],
                  manager: Optional[IUIManagerInterface],
                  container: Optional[IContainerLikeInterface],
                  *,
@@ -59,7 +59,10 @@ class UIElement(GUISprite, IUIElementInterface):
             raise ValueError("Need to create at least one UIManager to create UIElements")
 
         super().__init__(self.ui_manager.get_sprite_group())
-        self.relative_rect = relative_rect.copy()
+        if isinstance(relative_rect, pygame.Rect):
+            self.relative_rect = relative_rect.copy()
+        else:
+            self.relative_rect = pygame.Rect(relative_rect)
         self.rect = self.relative_rect.copy()
         self.ui_group = self.ui_manager.get_sprite_group()
         self.ui_theme = self.ui_manager.get_theme()
@@ -1101,3 +1104,15 @@ class UIElement(GUISprite, IUIElementInterface):
         # Used for parsing coordinate tuples in themes.
         x, y = str_data.split(',')
         return int(x), int(y)
+
+    def update_theming(self, new_theming_data: str):
+        """
+        Update the theming for this element using the most specific ID assigned to it.
+
+        If you have not given this element a unique ID, this function will also update the theming of other elements
+        of this theming class or of this element type.
+
+        :param new_theming_data: the new theming data in a json string
+        """
+        self.ui_theme.update_single_element_theming(self.most_specific_combined_id, new_theming_data)
+        self.rebuild_from_changed_theme_data()
diff --git a/pygame_gui/elements/__init__.py b/pygame_gui/elements/__init__.py
index 327e6d7..be4aa1d 100644
--- a/pygame_gui/elements/__init__.py
+++ b/pygame_gui/elements/__init__.py
@@ -16,6 +16,7 @@ from pygame_gui.elements.ui_status_bar import UIStatusBar
 from pygame_gui.elements.ui_world_space_health_bar import UIWorldSpaceHealthBar
 from pygame_gui.elements.ui_window import UIWindow
 from pygame_gui.elements.ui_scrolling_container import UIScrollingContainer
+from pygame_gui.elements.ui_text_entry_box import UITextEntryBox
 
 
 __all__ = ['UIImage',
@@ -36,4 +37,5 @@ __all__ = ['UIImage',
            'UIProgressBar',
            'UITextEntryLine',
            'UIWindow',
-           'UIScrollingContainer']
+           'UIScrollingContainer',
+           'UITextEntryBox']
diff --git a/pygame_gui/elements/ui_text_box.py b/pygame_gui/elements/ui_text_box.py
index 211fb90..b1605c5 100644
--- a/pygame_gui/elements/ui_text_box.py
+++ b/pygame_gui/elements/ui_text_box.py
@@ -74,7 +74,7 @@ class UITextBox(UIElement, IUITextOwnerInterface):
 
     def __init__(self,
                  html_text: str,
-                 relative_rect: pygame.Rect,
+                 relative_rect: Union[pygame.Rect, Tuple[int, int, int, int]],
                  manager: Optional[IUIManagerInterface] = None,
                  wrap_to_height: bool = False,
                  layer_starting_height: int = 1,
diff --git a/pygame_gui/elements/ui_text_entry_box.py b/pygame_gui/elements/ui_text_entry_box.py
new file mode 100644
index 0000000..2eff9a1
--- /dev/null
+++ b/pygame_gui/elements/ui_text_entry_box.py
@@ -0,0 +1,27 @@
+from typing import Union, Tuple, Dict, Optional, Any
+
+from pygame import Rect
+from pygame_gui.core import ObjectID
+from pygame_gui.core.ui_element import UIElement
+from pygame_gui.core.interfaces import IContainerLikeInterface, IUIManagerInterface
+from pygame_gui.elements.ui_text_box import UITextBox
+
+
+class UITextEntryBox(UITextBox):
+    def __init__(self,
+                 relative_rect: Union[Rect, Tuple[int, int, int, int]],
+                 initial_text: str,
+                 manager: Optional[IUIManagerInterface] = None,
+                 container: Optional[IContainerLikeInterface] = None,
+                 parent_element: Optional[UIElement] = None,
+                 object_id: Optional[Union[ObjectID, str]] = None,
+                 anchors: Optional[Dict[str, Union[str, UIElement]]] = None,
+                 visible: int = 1):
+        super().__init__(initial_text,
+                         relative_rect,
+                         manager=manager,
+                         container=container,
+                         parent_element=parent_element,
+                         object_id=object_id,
+                         anchors=anchors,
+                         visible=visible)
diff --git a/pygame_gui/ui_manager.py b/pygame_gui/ui_manager.py
index 52a1539..086d790 100644
--- a/pygame_gui/ui_manager.py
+++ b/pygame_gui/ui_manager.py
@@ -255,6 +255,10 @@ class UIManager(IUIManagerInterface):
                     for sprite in self.ui_group.sprites():
                         sprite.rebuild_from_changed_theme_data()
 
+        if self.ui_theme.check_need_to_rebuild_data_manually_changed():
+            for sprite in self.ui_group.sprites():
+                sprite.rebuild_from_changed_theme_data()
+
         self.ui_theme.update_caching(time_delta)
 
         self._update_mouse_position()
diff --git a/setup.py b/setup.py
index cd1c790..9698584 100644
--- a/setup.py
+++ b/setup.py
@@ -31,7 +31,7 @@ setup(
       setup_requires=[],
       install_requires=['pygame>=2.0.1',
                         'python-i18n>=0.3.9',
-                        'importlib_resources==5.9.0; python_version < "3.9"'],
+                        'importlib_resources>1.3; python_version < "3.9"'],
       include_package_data=True,
       classifiers=[
           'Development Status :: 3 - Alpha',


Test patch:
diff --git a/.github/workflows/run_tests_ci.yml b/.github/workflows/run_tests_ci.yml
index 4727ab6..53bfbc8 100644
--- a/.github/workflows/run_tests_ci.yml
+++ b/.github/workflows/run_tests_ci.yml
@@ -27,7 +27,7 @@ jobs:
         python -m pip install pytest-cov
         python -m pip install codecov
         python -m pip install -q pygame==${{ matrix.pygame-version }}
-        python setup.py -q install
+        python -m pip install . -U
         sudo apt-get install xvfb
         sudo apt-get install xclip
         sudo apt-get install xsel
@@ -67,7 +67,7 @@ jobs:
           python -m pip install pytest-cov
           python -m pip install codecov
           python -m pip install -q pygame==${{ matrix.pygame-version }}
-          python setup.py -q install
+          python -m pip install . -U
       - name: Test with pytest
         env:
           SDL_VIDEODRIVER: dummy
@@ -104,7 +104,7 @@ jobs:
           python -m pip install pytest-cov
           python -m pip install codecov
           python -m pip install -q pygame==${{ matrix.pygame-version }}
-          python setup.py -q install
+          python -m pip install . -U
       - name: Test with pytest
         env:
           SDL_VIDEODRIVER: dummy
diff --git a/tests/test_elements/test_ui_button.py b/tests/test_elements/test_ui_button.py
index c1ca3d1..ef559c8 100644
--- a/tests/test_elements/test_ui_button.py
+++ b/tests/test_elements/test_ui_button.py
@@ -854,6 +854,40 @@ class TestUIButton:
 
         assert dynamic_width_button.text == "Test Button"
 
+    def test_update_theming(self, _init_pygame, default_ui_manager, _display_surface_return_none):
+        button = UIButton(relative_rect=pygame.Rect(100, 100, 150, 30),
+                          text="Test Button",
+                          tool_tip_text="This is a test of the button's tool tip functionality.",
+                          manager=default_ui_manager)
+
+        button.update_theming('{'
+                              '"misc":'
+                              '{'
+                              '"shape": "rounded_rectangle",'
+                              '"shape_corner_radius": "10",'
+                              '"border_width": "4",'
+                              '"shadow_width": "4",'
+                              '"tool_tip_delay": "6.0",'
+                              '"text_horiz_alignment": "left",'
+                              '"text_vert_alignment": "top",'
+                              '"text_horiz_alignment_padding": "6",'
+                              '"text_vert_alignment_padding": "7",'
+                              '"text_shadow_size": "3",'
+                              '"text_shadow_offset": "0,2",'
+                              '"state_transitions":'
+                              '{'
+                              '    "normal_hovered": "0.2",'
+                              '    "hovered_normal": "0.5"'
+                              '}'
+                              '}'
+                              '}')
+
+        assert button.shape == "rounded_rectangle"
+        assert button.shape_corner_radius == 10
+        assert button.border_width == 4
+        assert button.shadow_width == 4
+        assert button.tool_tip_delay == 6.0
+
 
 if __name__ == '__main__':
     pytest.console_main()