Repository: MyreMylar/pygame_gui (python)

Issue:
Rescale the image for the rect in UIButton
**Is your feature request related to a problem? Please describe.**
I think it will be very handy if the image(e.g. normal image, hover image) are auto-rescaled to the size of the rect in the UI Button.

**Describe the solution you'd like**

I have modified the code myself and I think this could be a good example.

in pygame_gui\elements\ui_button.py


 `def _set_any_images_from_theme(self):


        changed = False
        normal_image = None
        try:
            normal_image = self.ui_theme.get_image('normal_image', self.combined_element_ids)
            normal_image = pygame.transform.scale(normal_image, self.relative_rect.size)  # auto-rescale the image
        except LookupError:
            normal_image = None
        finally:
            if normal_image != self.normal_image:
                self.normal_image = normal_image
                self.hovered_image = normal_image
                self.selected_image = normal_image
                self.disabled_image = normal_image
                changed = True

        hovered_image = None
        try:
            hovered_image = self.ui_theme.get_image('hovered_image', self.combined_element_ids)
            hovered_image = pygame.transform.scale(hovered_image, self.relative_rect.size)  # auto-rescale the image
        except LookupError:
            hovered_image = self.normal_image
        finally:
            if hovered_image != self.hovered_image:
                self.hovered_image = hovered_image
                changed = True

        selected_image = None
        try:
            selected_image = self.ui_theme.get_image('selected_image', self.combined_element_ids)
            selected_image = pygame.transform.scale(selected_image, self.relative_rect.size)  # auto-rescale the image
        except LookupError:
            selected_image = self.normal_image
        finally:
            if selected_image != self.selected_image:
                self.selected_image = selected_image
                changed = True

        disabled_image = None
        try:
            disabled_image = self.ui_theme.get_image('disabled_image', self.combined_element_ids)
            disabled_image = pygame.transform.scale(disabled_image, self.relative_rect.size)  # auto-rescale the image
        except LookupError:
            disabled_image = self.normal_image
        finally:
            if disabled_image != self.disabled_image:
                self.disabled_image = disabled_image
                changed = True

        return changed`


Pull request description:
Add autoscaling images theming parameter to button
fixes #278

Patch:
diff --git a/docs/source/theme_reference/theme_button.rst b/docs/source/theme_reference/theme_button.rst
index f0bf062..01c5043 100644
--- a/docs/source/theme_reference/theme_button.rst
+++ b/docs/source/theme_reference/theme_button.rst
@@ -136,6 +136,7 @@ Misc
  - "**text_vert_alignment_padding**" - If vertical alignment is set to 'top' or 'bottom' this value will control the buffer between the edge of the button and where we start placing the text. Default is "1".
  - "**text_shadow_size**" - The increased size in pixels of the text's shadow/outline. Set to "0", "1" or "2", larger than that the effect breaks down and individual letters merge together. Defaults to "0", no shadow.
  - "**text_shadow_offset**" - Pixel offset in horizontal (x) and vertical (y) dimensions for where the text shadow is drawn. In the format "x,y". Defaults to "0,0".
+ - "**auto_scale_images**" - Set to "1" to automatically scale provided theming images to fit the button size while maintaining aspect ratio. The images will be scaled to the largest size that fits within the button's rectangle. Defaults to "0" (disabled).
  - "**state_transitions**" - A block of parameters that define any fade transitions between button states. Normally buttons states visually change instantly, if you setup values here the button will instead fade from one state to the next. Transition definitions are one way, if you want to go in both directions, use two parameters. Transition parameters have this format:
 
    - "**startstate_targetstate**" - Can be set to any positive floating point value, representing the transition time in seconds.
@@ -235,6 +236,7 @@ Here is an example of a button block in a JSON theme file using all the paramete
                 "text_vert_alignment_padding": "5",
                 "text_shadow_size": "1",
                 "text_shadow_offset": "0,0",
+                "auto_scale_images": "1",
                 "state_transitions":
                 {
                     "normal_hovered": "0.5",
diff --git a/pygame_gui/elements/ui_button.py b/pygame_gui/elements/ui_button.py
index eacfe94..40222d9 100644
--- a/pygame_gui/elements/ui_button.py
+++ b/pygame_gui/elements/ui_button.py
@@ -154,6 +154,9 @@ class UIButton(UIElement):
         self.text_shadow_offset = (0, 0)
         self.max_dynamic_width = max_dynamic_width
 
+        # Auto-scale images theming parameter
+        self.auto_scale_images = False
+
         self.state_transitions: Dict[Tuple[str, str], float] = {}
 
         self._handler: Dict[int, Callable] = {}
@@ -169,6 +172,41 @@ class UIButton(UIElement):
 
         self.rebuild_from_changed_theme_data()
 
+    @staticmethod
+    def _scale_image_to_fit(
+        image: pygame.Surface, target_size: Tuple[int, int]
+    ) -> pygame.Surface:
+        """
+        Scale an image to fit within the target size while maintaining aspect ratio.
+        The image will be scaled to the largest size that fits within the target dimensions.
+
+        :param image: The image surface to scale.
+        :param target_size: The target size (width, height) to fit the image within.
+        :return: The scaled image surface.
+        """
+        if image is None:
+            return None
+
+        image_width, image_height = image.get_size()
+        target_width, target_height = target_size
+
+        # Calculate scale factors for both dimensions
+        scale_x = target_width / image_width
+        scale_y = target_height / image_height
+
+        # Use the smaller scale factor to ensure the image fits within the target size
+        scale = min(scale_x, scale_y)
+
+        # Calculate new dimensions
+        new_width = int(image_width * scale)
+        new_height = int(image_height * scale)
+
+        # Scale the image
+        if new_width > 0 and new_height > 0:
+            return pygame.transform.smoothscale(image, (new_width, new_height))
+        else:
+            return image
+
     def _set_any_images_from_theme(self) -> bool:
         """
         Grabs images for this button from the UI theme if any are set.
@@ -186,6 +224,10 @@ class UIButton(UIElement):
         except LookupError:
             normal_image = None
         finally:
+            # Scale image if auto_scale_images is enabled
+            if normal_image is not None and self.auto_scale_images:
+                normal_image = self._scale_image_to_fit(normal_image, self.rect.size)
+
             if normal_image != self.normal_image:
                 self.normal_image = normal_image
                 self.hovered_image = normal_image
@@ -201,6 +243,10 @@ class UIButton(UIElement):
         except LookupError:
             hovered_image = self.normal_image
         finally:
+            # Scale image if auto_scale_images is enabled
+            if hovered_image is not None and self.auto_scale_images:
+                hovered_image = self._scale_image_to_fit(hovered_image, self.rect.size)
+
             if hovered_image != self.hovered_image:
                 self.hovered_image = hovered_image
                 changed = True
@@ -213,6 +259,12 @@ class UIButton(UIElement):
         except LookupError:
             selected_image = self.normal_image
         finally:
+            # Scale image if auto_scale_images is enabled
+            if selected_image is not None and self.auto_scale_images:
+                selected_image = self._scale_image_to_fit(
+                    selected_image, self.rect.size
+                )
+
             if selected_image != self.selected_image:
                 self.selected_image = selected_image
                 changed = True
@@ -225,6 +277,12 @@ class UIButton(UIElement):
         except LookupError:
             disabled_image = self.normal_image
         finally:
+            # Scale image if auto_scale_images is enabled
+            if disabled_image is not None and self.auto_scale_images:
+                disabled_image = self._scale_image_to_fit(
+                    disabled_image, self.rect.size
+                )
+
             if disabled_image != self.disabled_image:
                 self.disabled_image = disabled_image
                 changed = True
@@ -688,6 +746,17 @@ class UIButton(UIElement):
             self.colours = cols
             has_any_changed = True
 
+        def parse_to_bool(str_data: str):
+            return bool(int(str_data))
+
+        # Load auto_scale_images parameter BEFORE loading images so scaling can be applied
+        if self._check_misc_theme_data_changed(
+            attribute_name="auto_scale_images",
+            default_value=False,
+            casting_func=parse_to_bool,
+        ):
+            has_any_changed = True
+
         if self._set_any_images_from_theme():
             has_any_changed = True
 


Test patch:
diff --git a/tests/test_elements/test_ui_button.py b/tests/test_elements/test_ui_button.py
index ca824b1..20f2ec5 100644
--- a/tests/test_elements/test_ui_button.py
+++ b/tests/test_elements/test_ui_button.py
@@ -1029,6 +1029,244 @@ class TestUIButton:
         assert button.shadow_width == 4
         assert button.tool_tip_delay == 6.0
 
+    def test_auto_scale_images_functionality(self, _init_pygame, _display_surface_return_none):
+        """Test the auto_scale_images functionality."""
+        # Create a test image
+        test_image = pygame.Surface((64, 64))
+        test_image.fill((255, 0, 0))
+        
+        # Test scaling function directly
+        scaled_down = UIButton._scale_image_to_fit(test_image, (32, 32))
+        assert scaled_down.get_size() == (32, 32)
+        
+        scaled_up = UIButton._scale_image_to_fit(test_image, (128, 128))
+        assert scaled_up.get_size() == (128, 128)
+        
+        # Test aspect ratio preservation with non-square targets
+        scaled_wide = UIButton._scale_image_to_fit(test_image, (100, 50))
+        assert scaled_wide.get_size() == (50, 50)  # Should fit height, maintain square aspect
+        
+        scaled_tall = UIButton._scale_image_to_fit(test_image, (50, 100))
+        assert scaled_tall.get_size() == (50, 50)  # Should fit width, maintain square aspect
+        
+        # Test with None image
+        assert UIButton._scale_image_to_fit(None, (100, 100)) is None
+        
+        # Test with zero dimensions
+        scaled_zero = UIButton._scale_image_to_fit(test_image, (0, 100))
+        assert scaled_zero == test_image  # Should return original when target has zero dimension
+
+    def test_auto_scale_images_theme_parameter(self, _init_pygame, _display_surface_return_none):
+        """Test that auto_scale_images theme parameter is loaded correctly."""
+        # Create theme with auto_scale_images enabled
+        theme_data = {
+            "button": {
+                "misc": {
+                    "auto_scale_images": "1"
+                }
+            }
+        }
+        
+        import tempfile
+        import json
+        import os
+        
+        with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
+            json.dump(theme_data, f)
+            theme_file = f.name
+        
+        try:
+            manager = UIManager((800, 600), theme_file)
+            button = UIButton(relative_rect=pygame.Rect(100, 100, 150, 30),
+                            text="Test Button",
+                            manager=manager)
+            
+            # Check that auto_scale_images is enabled
+            assert button.auto_scale_images is True
+            
+        finally:
+            os.unlink(theme_file)
+
+    def test_auto_scale_images_disabled_by_default(self, _init_pygame, default_ui_manager, _display_surface_return_none):
+        """Test that auto_scale_images is disabled by default."""
+        button = UIButton(relative_rect=pygame.Rect(100, 100, 150, 30),
+                         text="Test Button",
+                         manager=default_ui_manager)
+        
+        assert button.auto_scale_images is False
+
+    def test_auto_scale_images_with_rectangular_image(self, _init_pygame, _display_surface_return_none):
+        """Test auto scaling with non-square images."""
+        # Create a rectangular test image (2:1 aspect ratio)
+        test_image = pygame.Surface((100, 50))
+        test_image.fill((0, 255, 0))
+        
+        # Test scaling to square target - should maintain aspect ratio
+        scaled_square = UIButton._scale_image_to_fit(test_image, (60, 60))
+        assert scaled_square.get_size() == (60, 30)  # Maintains 2:1 ratio, fits width
+        
+        # Test scaling to wide target - should fit height
+        scaled_wide = UIButton._scale_image_to_fit(test_image, (200, 40))
+        assert scaled_wide.get_size() == (80, 40)  # Maintains 2:1 ratio, fits height
+        
+        # Test scaling to tall target - should fit width
+        scaled_tall = UIButton._scale_image_to_fit(test_image, (80, 200))
+        assert scaled_tall.get_size() == (80, 40)  # Maintains 2:1 ratio, fits width
+
+    def test_auto_scale_images_aspect_ratio_preservation(self, _init_pygame, _display_surface_return_none):
+        """Test that aspect ratio is preserved during scaling."""
+        # Test with various aspect ratios
+        test_cases = [
+            ((100, 50), 2.0),    # 2:1 ratio
+            ((50, 100), 0.5),    # 1:2 ratio
+            ((120, 80), 1.5),    # 3:2 ratio
+            ((80, 120), 2/3),    # 2:3 ratio
+        ]
+        
+        for image_size, expected_ratio in test_cases:
+            test_image = pygame.Surface(image_size)
+            test_image.fill((255, 255, 0))
+            
+            # Scale to various target sizes
+            target_sizes = [(200, 200), (150, 100), (100, 150), (300, 200)]
+            
+            for target_size in target_sizes:
+                scaled = UIButton._scale_image_to_fit(test_image, target_size)
+                actual_ratio = scaled.get_width() / scaled.get_height()
+                
+                # Allow small floating point differences (due to integer pixel rounding)
+                assert abs(actual_ratio - expected_ratio) < 0.02, \
+                    f"Aspect ratio not preserved: expected {expected_ratio}, got {actual_ratio}"
+                
+                # Ensure scaled image fits within target
+                assert scaled.get_width() <= target_size[0]
+                assert scaled.get_height() <= target_size[1]
+
+    def test_auto_scale_images_integration_with_theming(self, _init_pygame, _display_surface_return_none):
+        """Test that auto_scale_images works with the theming system."""
+        # Create a theme with auto_scale_images and test images
+        theme_data = {
+            "button": {
+                "misc": {
+                    "auto_scale_images": "1"
+                },
+                "images": {
+                    "normal_image": {
+                        "path": "tests/data/images/splat.png"
+                    }
+                }
+            }
+        }
+        
+        import tempfile
+        import json
+        import os
+        
+        with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
+            json.dump(theme_data, f)
+            theme_file = f.name
+        
+        try:
+            manager = UIManager((800, 600), theme_file)
+            
+            # Create buttons of different sizes
+            small_button = UIButton(relative_rect=pygame.Rect(100, 100, 50, 50),
+                                  text="Small",
+                                  manager=manager)
+            
+            large_button = UIButton(relative_rect=pygame.Rect(200, 100, 200, 150),
+                                  text="Large", 
+                                  manager=manager)
+            
+            # Both buttons should have auto_scale_images enabled
+            assert small_button.auto_scale_images is True
+            assert large_button.auto_scale_images is True
+            
+            # Images should be loaded and potentially scaled
+            assert small_button.normal_image is not None
+            assert large_button.normal_image is not None
+            
+        finally:
+            os.unlink(theme_file)
+
+    def test_auto_scale_images_rebuild_behavior(self, _init_pygame, _display_surface_return_none):
+        """Test that auto_scale_images works correctly during rebuilds."""
+        # Create theme with auto_scale_images
+        theme_data = {
+            "button": {
+                "misc": {
+                    "auto_scale_images": "1"
+                }
+            }
+        }
+        
+        import tempfile
+        import json
+        import os
+        
+        with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
+            json.dump(theme_data, f)
+            theme_file = f.name
+        
+        try:
+            manager = UIManager((800, 600), theme_file)
+            button = UIButton(relative_rect=pygame.Rect(100, 100, 150, 30),
+                            text="Test Button",
+                            manager=manager)
+            
+            # Verify auto_scale_images is loaded
+            assert button.auto_scale_images is True
+            
+            # Trigger a rebuild
+            button.rebuild_from_changed_theme_data()
+            
+            # Should still have auto_scale_images enabled
+            assert button.auto_scale_images is True
+            
+        finally:
+            os.unlink(theme_file)
+
+    def test_auto_scale_images_edge_cases(self, _init_pygame, _display_surface_return_none):
+        """Test edge cases for auto_scale_images functionality."""
+        # Test with very small image
+        tiny_image = pygame.Surface((1, 1))
+        tiny_image.fill((255, 0, 255))
+        
+        scaled = UIButton._scale_image_to_fit(tiny_image, (100, 100))
+        assert scaled.get_size() == (100, 100)
+        
+        # Test with very large target
+        normal_image = pygame.Surface((50, 50))
+        normal_image.fill((0, 255, 255))
+        
+        scaled_large = UIButton._scale_image_to_fit(normal_image, (1000, 1000))
+        assert scaled_large.get_size() == (1000, 1000)
+        
+        # Test with target size of (1, 1)
+        scaled_tiny = UIButton._scale_image_to_fit(normal_image, (1, 1))
+        assert scaled_tiny.get_size() == (1, 1)
+
+    def test_auto_scale_images_performance(self, _init_pygame, _display_surface_return_none):
+        """Test that auto_scale_images doesn't cause performance issues."""
+        import time
+        
+        # Create a reasonably sized test image
+        test_image = pygame.Surface((256, 256))
+        test_image.fill((128, 128, 128))
+        
+        # Time multiple scaling operations
+        start_time = time.time()
+        
+        for i in range(100):
+            scaled = UIButton._scale_image_to_fit(test_image, (100 + i, 100 + i))
+            assert scaled is not None
+        
+        end_time = time.time()
+        duration = end_time - start_time
+        
+        # Should complete 100 scaling operations in reasonable time (less than 1 second)
+        assert duration < 1.0, f"Scaling took too long: {duration} seconds"
+
 
 if __name__ == '__main__':
     pytest.console_main()