Repository: MyreMylar/pygame_gui (python)

Issue:
It would be nice to pass in the theme as a variable defined rather than a file
**Is your feature request related to a problem? Please describe.**
So that users cannot change the themes in my game once packaged, I'd like to be able to pass in a theme as a variable defined in Python

**Describe the solution you'd like**
Example in my games_themes.py:
```json

game_themes = {
    "defaults": {
            "colours": {
                    "normal_bg": "#45494e",
                    "hovered_bg": "#35393e",
                    "disabled_bg": "#25292e",
                    "selected_bg": "#193754",
                    "dark_bg": "#15191e",
                    "normal_text": "#c5cbd8",
                    "hovered_text": "#FFFFFF",
                    "selected_text": "#FFFFFF",
                    "disabled_text": "#6d736f",
                    "link_text": "#0000EE",
                    "link_hover": "#2020FF",
                    "link_selected": "#551A8B",
                    "text_shadow": "#777777",
                    "normal_border": "#DDDDDD",
                    "hovered_border": "#B0B0B0",
                    "disabled_border": "#808080",
                    "selected_border": "#8080B0",
                    "active_border": "#8080B0",
                    "filled_bar": "#f4251b",
                    "unfilled_bar": "#CCCCCC"
                }
        },
    "button": {
        "misc": {
                "border_width": "1",
                "shadow_width": "2"
        }
    }
}
```



Pull request description:
Theme adds support for dicts
fixes #400

I found that update_theming and load_theme perform similar functions... Do they need to be refactored?



My other goal is to enable UIManager to dynamically replace ui_theme during runtime. If this is necessary, I may try to do it in the future.

Patch:
diff --git a/pygame_gui/core/interfaces/appearance_theme_interface.py b/pygame_gui/core/interfaces/appearance_theme_interface.py
index bf74243..d198086 100644
--- a/pygame_gui/core/interfaces/appearance_theme_interface.py
+++ b/pygame_gui/core/interfaces/appearance_theme_interface.py
@@ -147,12 +147,12 @@ class IUIAppearanceThemeInterface(metaclass=ABCMeta):
         """
 
     @abstractmethod
-    def load_theme(self, file_path: Union[str, PathLike, io.StringIO]):
+    def load_theme(self, file_path: Union[str, PathLike, io.StringIO, 'PackageResource', dict]):
         """
-        Loads a theme file, and currently, all associated data like fonts and images required
+        Loads a theme, and currently, all associated data like fonts and images required
         by the theme.
 
-        :param file_path: The path to the theme we want to load.
+        :param file_path: The location of the theme, or the theme data we want to load.
 
         """
 
diff --git a/pygame_gui/core/ui_appearance_theme.py b/pygame_gui/core/ui_appearance_theme.py
index 72a2ae0..7a3c6c6 100644
--- a/pygame_gui/core/ui_appearance_theme.py
+++ b/pygame_gui/core/ui_appearance_theme.py
@@ -103,16 +103,23 @@ class UIAppearanceTheme(IUIAppearanceThemeInterface):
             self.need_to_rebuild_data_manually_changed = False
             return True
         return False
+    
+    @staticmethod
+    def _json_to_dict(json_data):
+        if isinstance(json_data, dict):
+            return json_data
+        else:
+            return json.loads(json_data)
 
-    def update_theming(self, new_theming_data: str, rebuild_all: bool = True):
+    def update_theming(self, new_theming_data: Union[str, dict], rebuild_all: bool = True):
         # parse new_theming data
-        theme_dict = json.loads(new_theming_data)
+        theme_dict = self._json_to_dict(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)
+    def update_single_element_theming(self, element_name: str, new_theming_data: Union[str, dict]):
+        element_theming_dict = self._json_to_dict(new_theming_data)
 
         self._parse_single_element_data(element_name, element_theming_dict)
         self._load_fonts()
@@ -658,13 +665,31 @@ class UIAppearanceTheme(IUIAppearanceThemeInterface):
                 yield file, None
             finally:
                 file.close()
+                
+    def load_theme(self, file_path: Union[str, os.PathLike, io.StringIO, PackageResource, dict]):
+        """
+        Loads a theme, and currently, all associated data like fonts and images required
+        by the theme.
+
+        :param file_path: The location of the theme, or the theme data we want to load.
+        """
+        if isinstance(file_path, dict):
+            theme_dict = file_path
+        else:
+            theme_dict = self._load_theme_by_path(file_path)
+            if theme_dict is None:
+                return
+            
+        self._parse_theme_data_from_json_dict(theme_dict)
+            
 
-    def load_theme(self, file_path: Union[str, os.PathLike, io.StringIO, PackageResource]):
+    def _load_theme_by_path(self, file_path: Union[str, os.PathLike, io.StringIO, PackageResource]) -> dict:  
         """
         Loads a theme file, and currently, all associated data like fonts and images required
         by the theme.
 
         :param file_path: The path to the theme we want to load.
+        :return dict: The theme dictionary.
         """
         if isinstance(file_path, PackageResource):
             with (files(file_path.package) / file_path.resource).open('r', encoding='utf-8', errors='strict') as fp:
@@ -697,7 +722,7 @@ class UIAppearanceTheme(IUIAppearanceThemeInterface):
                     load_success = True
 
             if load_success:
-                self._parse_theme_data_from_json_dict(theme_dict)
+                return theme_dict
 
     def _parse_theme_data_from_json_dict(self, theme_dict):
         for element_name in theme_dict.keys():
diff --git a/pygame_gui/ui_manager.py b/pygame_gui/ui_manager.py
index 03339ea..f40e043 100644
--- a/pygame_gui/ui_manager.py
+++ b/pygame_gui/ui_manager.py
@@ -1,4 +1,5 @@
 import os
+import io
 from typing import Tuple, List, Dict, Union, Set, Optional
 
 import pygame
@@ -29,13 +30,13 @@ class UIManager(IUIManagerInterface):
     frame.
 
     :param window_resolution: window resolution.
-    :param theme_path: relative file path to theme.
+    :param theme_path: relative file path to theme or theme dictionary.
     :param enable_live_theme_updates: Lets the theme update in-game after we edit the theme file
     """
 
     def __init__(self,
                  window_resolution: Tuple[int, int],
-                 theme_path: Optional[Union[str, PackageResource]] = None,
+                 theme_path: Optional[Union[str, os.PathLike, io.StringIO, PackageResource, dict]] = None,
                  enable_live_theme_updates: bool = True,
                  resource_loader: Optional[IResourceLoader] = None,
                  starting_language: str = 'en',
@@ -66,9 +67,7 @@ class UIManager(IUIManagerInterface):
             self.resource_loader = resource_loader
 
         self.window_resolution = window_resolution
-        self.ui_theme = UIAppearanceTheme(self.resource_loader, self._locale)
-        if theme_path is not None:
-            self.ui_theme.load_theme(theme_path)
+        self.ui_theme = self.create_new_theme(theme_path)
 
         self.universal_empty_surface = pygame.surface.Surface((0, 0),
                                                               flags=pygame.SRCALPHA,
@@ -106,6 +105,16 @@ class UIManager(IUIManagerInterface):
             self.resource_loader.start()
             # If we are using a blocking loader this will only return when loading is complete
             self.resource_loader.update()
+        
+    def create_new_theme(self, theme_path: Union[str, os.PathLike, io.StringIO, PackageResource, dict]=None) -> UIAppearanceTheme:
+        """
+        Create a new theme using self information.
+        :param theme_path: relative file path to theme or theme dictionary.
+        """
+        theme = UIAppearanceTheme(self.resource_loader, self._locale)
+        if theme_path is not None:
+            theme.load_theme(theme_path)
+        return theme
 
     def get_double_click_time(self) -> float:
         """
@@ -232,6 +241,24 @@ class UIManager(IUIManagerInterface):
 
                             break
         return consumed_event
+    
+    def set_ui_theme(self, theme:IUIAppearanceThemeInterface, update_all_sprites:bool=False):
+        """
+        Set ui theme.
+        :param theme: The theme to set.
+        """
+        for sprite in self.ui_group.sprites():
+            if not update_all_sprites and sprite.ui_theme is not self.ui_theme:
+                continue
+            sprite.ui_theme = theme
+        self.ui_theme = theme
+        self.rebuild_all_from_changed_theme_data(self.ui_theme)
+    
+    def rebuild_all_from_changed_theme_data(self, theme:IUIAppearanceThemeInterface=None):
+        for sprite in self.ui_group.sprites():
+            if theme is not None and sprite.ui_theme is not theme:
+                continue
+            sprite.rebuild_from_changed_theme_data()
 
     def update(self, time_delta: float):
         """
@@ -253,12 +280,10 @@ class UIManager(IUIManagerInterface):
             if self.theme_update_acc > self.theme_update_check_interval:
                 self.theme_update_acc = 0.0
                 if self.ui_theme.check_need_to_reload():
-                    for sprite in self.ui_group.sprites():
-                        sprite.rebuild_from_changed_theme_data()
+                    self.rebuild_all_from_changed_theme_data(self.ui_theme)
 
         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.rebuild_all_from_changed_theme_data(self.ui_theme)
 
         self.ui_theme.update_caching(time_delta)
 


Test patch:
diff --git a/tests/test_core/test_ui_appearance_theme.py b/tests/test_core/test_ui_appearance_theme.py
index 9c77fa5..2d2c206 100644
--- a/tests/test_core/test_ui_appearance_theme.py
+++ b/tests/test_core/test_ui_appearance_theme.py
@@ -18,6 +18,13 @@ class TestUIAppearanceTheme:
         colour = theme.get_colour(colour_id='dark_bg', combined_element_ids=['text_box'])
         assert colour == pygame.Color('#25f92e')
 
+    def test_load_non_default_theme_from_dictionary(self, _init_pygame, _display_surface_return_none):
+        theme = UIAppearanceTheme(BlockingThreadedResourceLoader(), locale='en')
+        theme_dict = {"text_box": {"colours": {"dark_bg": "#25f92e"}}}
+        theme.load_theme(theme_dict)
+        colour = theme.get_colour(colour_id='dark_bg', combined_element_ids=['text_box'])
+        assert colour == pygame.Color('#25f92e')
+
     def test_load_images_from_package(self, _init_pygame, _display_surface_return_none: None):
         loader = BlockingThreadedResourceLoader()
         theme = UIAppearanceTheme(loader, locale='en')
diff --git a/tests/test_ui_manager.py b/tests/test_ui_manager.py
index f5a85f7..6fc6143 100644
--- a/tests/test_ui_manager.py
+++ b/tests/test_ui_manager.py
@@ -370,6 +370,13 @@ class TestUIManager:
             finished, _ = incremental_loader.update()
         assert finished
 
+    def test_set_ui_theme(self, _init_pygame, _display_surface_return_none):
+        manager = UIManager((800, 600))
+        theme_dict = {"text_box": {"colours": {"dark_bg": "#25f92e"}}}
+        theme = manager.create_new_theme(theme_dict)
+        manager.set_ui_theme(theme)
+        assert manager.ui_theme is theme
+
     def test_get_hovering_any_element(self, _init_pygame, _display_surface_return_none):
         manager = UIManager((800, 600))