Repository: MyreMylar/pygame_gui (python)

Issue:
UITextEntryBox desyncs in an edge case
**Describe the bug**
Stumbled upon this while getting to know this lovely library (thank you for that!).

The behavior I observed was that sometimes when entering a newline and a character directly after it, the character would propagate to the widget's internal state, but not the widget. In the widget, selection is moved down a line instead.

**To Reproduce**
I've construct a small example of this behavior:

```py
import pygame
import pygame_gui

SIZE = (400, 200)
EXAMPLE_TEXT = """
while True:
    print("hello")
"""


pygame.init()

window_surface = pygame.display.set_mode(SIZE)

manager = pygame_gui.UIManager(SIZE)

text_entry_box = pygame_gui.elements.UITextEntryBox(
    initial_text=EXAMPLE_TEXT,
    relative_rect=pygame.Rect(0, 0, *SIZE),
)

clock = pygame.time.Clock()
is_running = True

while is_running:
    dt = clock.tick(60) / 1000

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            is_running = False

        if event.type == pygame_gui.UI_TEXT_ENTRY_CHANGED:
            print(text_entry_box.get_text())

        manager.process_events(event)

    manager.update(dt)
    manager.draw_ui(window_surface)
    pygame.display.update()
```

Steps to reproduce the behaviour:
1. Put the cursor on the first line (the empty one before `while True:`)
2. Press Enter
3. Press a character, like `j`

**Expected behaviour**
I expect the typed character to be visible in the widget, so there is no desync between state and widget state.

**Platform and software (please complete the following information):**
OS: Linux, Ubuntu 22.04.4 LTS

`pip freeze`:
```
pygame-ce==2.5.0
pygame_gui==0.6.12
python-i18n==0.3.9
```

Pull request:
Try to fix text entry new line bug
As title.

fixes #606

Patch:
diff --git a/pygame_gui/core/text/text_box_layout.py b/pygame_gui/core/text/text_box_layout.py
index 155b2dc..283cf21 100644
--- a/pygame_gui/core/text/text_box_layout.py
+++ b/pygame_gui/core/text/text_box_layout.py
@@ -1339,8 +1339,8 @@ class TextBoxLayout:
             row_index = bisect_left(self.row_lengths, text_box_index)
             if row_index >= len(self.layout_rows):
                 row_index = len(self.layout_rows) - 1
-            index_in_row = text_box_index - (self.row_lengths[row_index - 1]
-                                             if row_index != 0 else 0)
+            index_in_row = min(text_box_index - (self.row_lengths[row_index - 1]
+                                                 if row_index != 0 else 0), self.row_lengths[row_index])
             return self.layout_rows[row_index], index_in_row
 
         return None, 0
diff --git a/pygame_gui/core/text/text_box_layout_row.py b/pygame_gui/core/text/text_box_layout_row.py
index 0d3362e..32bf6c6 100644
--- a/pygame_gui/core/text/text_box_layout_row.py
+++ b/pygame_gui/core/text/text_box_layout_row.py
@@ -17,6 +17,7 @@ class TextBoxLayoutRow(pygame.Rect):
 
     def __init__(self, row_start_x, row_start_y, row_index, line_spacing, layout):
         super().__init__(row_start_x, row_start_y, 0, 0)
+        self.row_start_x = row_start_x
         self.line_spacing = line_spacing
         self.row_index = row_index
         self.layout = layout
@@ -44,6 +45,9 @@ class TextBoxLayoutRow(pygame.Rect):
     def __hash__(self):
         return self.row_index
 
+    def __repr__(self):
+        return "[" + str([item for item in self.items]) + "]"
+
     def at_start(self):
         """
         Returns true if this row has no items in it.
@@ -91,6 +95,46 @@ class TextBoxLayoutRow(pygame.Rect):
 
         self.letter_count += item.letter_count
 
+    def insert_new_item_at_start(self, new_item: TextLayoutRect):
+        """
+        Insert a new item to the row at the start
+
+        :param new_item: The new item to add to the text row
+        """
+        # rejig existing items in row to accommodate the new one
+        new_rhs = self.row_start_x + new_item.width
+        for old_item in self.items:
+            old_item.left = new_rhs
+            new_rhs = old_item.right
+        # add the new item at the beginning
+        self.items.insert(0, new_item)
+        self.width += new_item.width  # noqa pylint: disable=attribute-defined-outside-init; pylint getting confused
+
+        if new_item.row_chunk_height > self.text_chunk_height:
+            self.text_chunk_height = new_item.row_chunk_height
+            if not self.layout.dynamic_height:
+                self.height = min(self.layout.layout_rect.height, # noqa pylint: disable=attribute-defined-outside-init; pylint getting confused
+                                  new_item.row_chunk_height)
+                self.line_spacing_height = min(self.layout.layout_rect.height, # noqa pylint: disable=attribute-defined-outside-init; pylint getting confused
+                                  int(round(new_item.row_chunk_height * self.line_spacing)))
+            else:
+                self.height = int(item.row_chunk_height) # noqa pylint: disable=attribute-defined-outside-init; pylint getting confused
+                self.line_spacing_height = int(round(item.row_chunk_height * self.line_spacing)) # noqa pylint: disable=attribute-defined-outside-init; pylint getting confused
+            self.cursor_rect = pygame.Rect(self.x, self.y, self.layout.edit_cursor_width, self.height - 2)
+
+        if isinstance(new_item, TextLineChunkFTFont):
+            if new_item.y_origin > self.y_origin:
+                self.y_origin = new_item.y_origin
+
+            for origin_item in self.items:
+                if isinstance(origin_item, TextLineChunkFTFont):
+                    origin_item.origin_row_y_adjust = self.y_origin - origin_item.y_origin
+                    origin_item.top = origin_item.pre_row_rect.top + origin_item.origin_row_y_adjust
+
+            self.fall_back_font = new_item.font
+
+        self.letter_count += new_item.letter_count
+
     def rewind_row(self, layout_rect_queue):
         """
         Use this to add items from the row back onto a layout queue, useful if we've added
@@ -503,21 +547,28 @@ class TextBoxLayoutRow(pygame.Rect):
         :param parser: An optional HTML parser for text styling data
         """
         letter_acc = 0
+        inserted_text = False
         if len(self.items) > 0:
             for chunk in self.items:
-                if isinstance(chunk, TextLineChunkFTFont):
-                    if letter_row_index <= letter_acc + chunk.letter_count:
+                if letter_row_index <= letter_acc + chunk.letter_count:
+                    if isinstance(chunk, TextLineChunkFTFont):
                         chunk_index = letter_row_index - letter_acc
                         chunk.insert_text(text, chunk_index)
+                        inserted_text = True
                         break
 
                 letter_acc += chunk.letter_count
-        elif parser is not None:
-            text_chunk = parser.create_styled_text_chunk(text)
-            self.add_item(text_chunk)
-        else:
+        if parser is None and not len(self.items):
             raise AttributeError("Trying to insert into empty text row with no Parser"
                                  " for style data - fix this later?")
+        if not inserted_text and letter_row_index == 0:
+            # have not managed to insert text into existing text chunk, create a new one.
+            text_chunk = parser.create_styled_text_chunk(text)
+            self.insert_new_item_at_start(text_chunk)
+            inserted_text = True
+
+        if not inserted_text:
+            raise RuntimeError("Failed to insert text at index: %d", letter_row_index)
 
     def insert_linebreak_after_chunk(self, chunk_to_insert_after: Union[TextLineChunkFTFont, LineBreakLayoutRect], parser: HTMLParser):
         if len(self.items) > 0:
diff --git a/pygame_gui/core/text/text_line_chunk.py b/pygame_gui/core/text/text_line_chunk.py
index 0b59ea2..ec53753 100644
--- a/pygame_gui/core/text/text_line_chunk.py
+++ b/pygame_gui/core/text/text_line_chunk.py
@@ -110,6 +110,9 @@ class TextLineChunkFTFont(TextLayoutRect):
     def __repr__(self):
         return "< '" + self.text + "' " + super().__repr__() + " >"
 
+    def __str__(self):
+        return "< '" + self.text + "' " + super().__str__() + " >"
+
     def _clamp_dimensions_to_maximums(self, text_width, text_height):
         if self.max_dimensions is not None:
             if not (self.max_dimensions[0] == -1 or self.max_dimensions[1] == -1):
diff --git a/pygame_gui/core/ui_appearance_theme.py b/pygame_gui/core/ui_appearance_theme.py
index 2fe3d84..700e0bb 100644
--- a/pygame_gui/core/ui_appearance_theme.py
+++ b/pygame_gui/core/ui_appearance_theme.py
@@ -103,7 +103,7 @@ 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):
@@ -574,19 +574,16 @@ class UIAppearanceTheme(IUIAppearanceThemeInterface):
 
         :return IGUIFontInterface: An interface to a pygame font object wrapper.
         """
-        font = None
-        
+        # set the default font as the final fall back
+        font = self.font_dict.get_default_font()
+
         for combined_element_id in combined_element_ids:
             if combined_element_id in self.ele_font_res:
                 if self._locale in self.ele_font_res[combined_element_id]:
-                    font = self.ele_font_res[combined_element_id][self._locale].loaded_font
+                    return self.ele_font_res[combined_element_id][self._locale].loaded_font
                 else:
-                    font = self.ele_font_res[combined_element_id]['en'].loaded_font
-                break
+                    return self.ele_font_res[combined_element_id]['en'].loaded_font
 
-        # set the default font as the final fall back
-        if font is None:
-            font = self.font_dict.get_default_font()
         return font
 
     def get_misc_data(self, misc_data_id: str, combined_element_ids: List[str]) -> Union[str, Dict]:
@@ -770,7 +767,7 @@ class UIAppearanceTheme(IUIAppearanceThemeInterface):
                     self._load_element_font_data_from_theme(file_dict,
                                                             element_name)
 
-            if data_type == 'colours' or data_type == 'colors':
+            if data_type == 'colours':
                 self._load_element_colour_data_from_theme(data_type,
                                                           element_name,
                                                           element_theming)
@@ -1048,7 +1045,7 @@ class UIAppearanceTheme(IUIAppearanceThemeInterface):
         :param theme_dict: The data dictionary from the theming file to load data from.
         """
         for data_type in theme_dict['defaults']:
-            if data_type == 'colours' or data_type == 'colors':
+            if data_type == 'colours':
                 colours_dict = theme_dict['defaults'][data_type]
                 for colour_key in colours_dict:
                     colour = self._load_colour_or_gradient_from_theme(colours_dict,
diff --git a/pygame_gui/elements/ui_image.py b/pygame_gui/elements/ui_image.py
index 22abbf4..232893e 100644
--- a/pygame_gui/elements/ui_image.py
+++ b/pygame_gui/elements/ui_image.py
@@ -37,8 +37,7 @@ class UIImage(UIElement):
                  anchors: Optional[Dict[str, Union[str, UIElement]]] = None,
                  visible: int = 1,
                  *,
-                 starting_height: int = 1,
-                 scale_func=pygame.transform.smoothscale):
+                 starting_height: int = 1,):
 
         super().__init__(relative_rect, manager, container,
                          starting_height=starting_height,
@@ -51,7 +50,7 @@ class UIImage(UIElement):
 
         self.original_image = None
 
-        self.set_image(image_surface, image_is_alpha_premultiplied, scale_func)
+        self.set_image(image_surface, image_is_alpha_premultiplied)
         self.rebuild_from_changed_theme_data()
 
     def rebuild_from_changed_theme_data(self):
@@ -80,8 +79,7 @@ class UIImage(UIElement):
 
     def set_image(self,
                   new_image: Union[pygame.surface.Surface, None],
-                  image_is_alpha_premultiplied: bool = False,
-                  scale_func = pygame.transform.smoothscale) -> None:
+                  image_is_alpha_premultiplied: bool = False) -> None:
         """
         Allows users to change the image displayed on a UIImage element during run time, without recreating
         the element.
@@ -99,6 +97,6 @@ class UIImage(UIElement):
         if (image_surface.get_width() != self.rect.width or
                 image_surface.get_height() != self.rect.height):
             self.original_image = image_surface
-            self._set_image(scale_func(self.original_image, self.rect.size))
+            self._set_image(pygame.transform.smoothscale(self.original_image, self.rect.size))
         else:
             self._set_image(image_surface)


Test patch:
diff --git a/tests/test_core/test_ui_appearance_theme.py b/tests/test_core/test_ui_appearance_theme.py
index 3920cbe..2d2c206 100644
--- a/tests/test_core/test_ui_appearance_theme.py
+++ b/tests/test_core/test_ui_appearance_theme.py
@@ -192,11 +192,8 @@ class TestUIAppearanceTheme:
         assert shadow_width == '0'
 
     def test_get_font(self, _init_pygame, _display_surface_return_none: None):
-        resource_loader = BlockingThreadedResourceLoader()
-        theme = UIAppearanceTheme(resource_loader, locale='nonsense_locale')
+        theme = UIAppearanceTheme(BlockingThreadedResourceLoader(), locale='nonsense_locale')
         theme.load_theme(os.path.join("tests", "data", "themes", "ui_button_non_default.json"))
-        resource_loader.start()
-        resource_loader.update()
         assert theme.get_font_info(['button']) == theme.ui_element_fonts_info['button']['en']
         assert theme.get_font(['button']) == theme.ele_font_res['button']['en'].loaded_font