Repository: MyreMylar/pygame_gui

Issue:
Typing Text Effect not typing sequentially when using an effect tag
**Describe the bug**
Typing text effect _using tags_ will type each line immediately rather than sequentially

**To Reproduce**
Create a UIText Box with a lot of text, a multi-line section of it surrounded by the `<effect id=typing> ..... </effect> `and later activate the typing text affect with `.set_active_effect(TEXT_EFFECT_TYPING_APPEAR, effect_tag="typing")`

**Expected behaviour**
There are two ways to create the typing text effect, one on the entire element, and one on a tagged section within the UITextBox. The entire element way treats multi-line text correctly, tying out the first line of text, then the following line, etc. The tagged chunk method treats multi-line text incorrectly, typing out each line immediately.  As per the discussion in Discord, this is not the intended behavior

Discussion:

Solution patch:
diff --git a/pygame_gui/core/interfaces/text_owner_interface.py b/pygame_gui/core/interfaces/text_owner_interface.py
index 06b18a0..edbce6f 100644
--- a/pygame_gui/core/interfaces/text_owner_interface.py
+++ b/pygame_gui/core/interfaces/text_owner_interface.py
@@ -1,5 +1,5 @@
 from abc import ABCMeta, abstractmethod
-from typing import Dict, Any, Optional, Tuple, TYPE_CHECKING
+from typing import Dict, Any, Optional, Tuple, List, TYPE_CHECKING
 
 from pygame_gui._constants import UITextEffectType
 
@@ -164,8 +164,8 @@ class IUITextOwnerInterface(metaclass=ABCMeta):
     @abstractmethod
     def stop_finished_effect(
         self,
-        sub_chunk: Optional[
-            "pygame_gui.core.text.text_line_chunk.TextLineChunkFTFont"
+        sub_chunks: Optional[
+            List["pygame_gui.core.text.text_line_chunk.TextLineChunkFTFont"]
         ] = None,
     ):
         """
@@ -173,19 +173,19 @@ class IUITextOwnerInterface(metaclass=ABCMeta):
         ended. Used when an effect reaches a natural end where we might want to keep it in
         the end of effect state (e.g. a fade out)
 
-        :param sub_chunk: An optional chunk so we only clear the effect from this chunk.
+        :param sub_chunks: An optional list of chunks, so we only stop the effect on these chunks.
         """
 
     @abstractmethod
     def clear_all_active_effects(
         self,
-        sub_chunk: Optional[
-            "pygame_gui.core.text.text_line_chunk.TextLineChunkFTFont"
+        sub_chunks: Optional[
+            List["pygame_gui.core.text.text_line_chunk.TextLineChunkFTFont"]
         ] = None,
     ):
         """
         Clears any active effects and redraws the text. A full reset, usually called before
         firing off a new effect if one is already in progress.
 
-        :param sub_chunk: An optional chunk so we only clear the effect from this chunk.
+        :param sub_chunks: An optional list of chunks, so we only clear the effect from these chunks.
         """
diff --git a/pygame_gui/core/text/html_parser.py b/pygame_gui/core/text/html_parser.py
index 6ee81a7..486e5dc 100644
--- a/pygame_gui/core/text/html_parser.py
+++ b/pygame_gui/core/text/html_parser.py
@@ -39,7 +39,7 @@ class TextStyle(TypedDict):
     underline: bool
     link: bool
     link_href: str | None
-    effect_id: str | None
+    effect_id: List[str] | None
     antialiased: bool
     script: str
     direction: int
@@ -158,7 +158,7 @@ class HTMLParser(html.parser.HTMLParser):
             if "href" in attributes:
                 style["link_href"] = attributes["href"]
         elif element == "effect":
-            HTMLParser._handle_effect_tag(attributes, style)
+            self._handle_effect_tag(attributes, style)
         elif element == "shadow":
             self._handle_shadow_tag(attributes, style)
         elif element == "font":
@@ -180,10 +180,16 @@ class HTMLParser(html.parser.HTMLParser):
 
         self.push_style(element, style)
 
-    @classmethod
-    def _handle_effect_tag(cls, attributes, style):
+    def _handle_effect_tag(self, attributes, style):
         if "id" in attributes:
-            style["effect_id"] = str(attributes["id"])
+            if (
+                "effect_id" in self.current_style
+                and self.current_style["effect_id"] is not None
+            ):
+                style["effect_id"] = self.current_style["effect_id"].copy()
+                style["effect_id"].append(str(attributes["id"]))
+            else:
+                style["effect_id"] = [str(attributes["id"])]
 
     def _handle_shadow_tag(self, attributes, style):
         shadow_size = 0
@@ -485,7 +491,7 @@ class HTMLParser(html.parser.HTMLParser):
                 active_colour=self.link_style["link_selected"],
                 hover_underline=self.link_style["link_hover_underline"],
                 text_shadow_data=self.current_style["shadow_data"],
-                effect_id=self.current_style["effect_id"],
+                effect_ids=self.current_style["effect_id"],
             )
         else:
             using_default_text_colour = (
@@ -500,7 +506,7 @@ class HTMLParser(html.parser.HTMLParser):
                 using_default_text_colour=using_default_text_colour,
                 bg_colour=self.current_style["bg_colour"],
                 text_shadow_data=self.current_style["shadow_data"],
-                effect_id=self.current_style["effect_id"],
+                effect_ids=self.current_style["effect_id"],
             )
 
     @staticmethod
diff --git a/pygame_gui/core/text/hyperlink_text_chunk.py b/pygame_gui/core/text/hyperlink_text_chunk.py
index 88cbbaf..ad80f19 100644
--- a/pygame_gui/core/text/hyperlink_text_chunk.py
+++ b/pygame_gui/core/text/hyperlink_text_chunk.py
@@ -1,4 +1,4 @@
-from typing import Optional, Tuple
+from typing import Optional, Tuple, List
 
 from pygame.color import Color
 
@@ -25,7 +25,7 @@ class HyperlinkTextChunk(TextLineChunkFTFont):
         active_colour: Color | ColourGradient,
         hover_underline: bool,
         text_shadow_data: Optional[Tuple[int, int, int, Color, bool]] = None,
-        effect_id: Optional[str] = None,
+        effect_ids: Optional[List[str]] = None,
     ):
         super().__init__(
             text,
@@ -35,7 +35,7 @@ class HyperlinkTextChunk(TextLineChunkFTFont):
             False,
             bg_colour,
             text_shadow_data,
-            effect_id=effect_id,
+            effect_ids=effect_ids,
         )
 
         self.href = href
@@ -110,7 +110,7 @@ class HyperlinkTextChunk(TextLineChunkFTFont):
             self.active_colour,
             self.hover_underline,
             self.text_shadow_data,
-            self.effect_id,
+            self.effect_ids,
         )
 
         right_side_chunk.topleft = split_pos  # pylint: disable=attribute-defined-outside-init
diff --git a/pygame_gui/core/text/text_effects/bounce_effect.py b/pygame_gui/core/text/text_effects/bounce_effect.py
index 828cdc0..dc8cf29 100644
--- a/pygame_gui/core/text/text_effects/bounce_effect.py
+++ b/pygame_gui/core/text/text_effects/bounce_effect.py
@@ -1,4 +1,4 @@
-from typing import Dict, Any, Optional
+from typing import Dict, Any, Optional, List
 
 import pygame
 
@@ -17,17 +17,23 @@ class BounceEffect(TextEffect):
         self,
         text_owner: IUITextOwnerInterface,
         params: Optional[Dict[str, Any]] = None,
-        text_sub_chunk: Optional[TextLineChunkFTFont] = None,
+        text_sub_chunks: List[TextLineChunkFTFont] | None = None,
+        effect_id_tag: str | None = None,
     ):
         super().__init__()
         self.text_owner = text_owner
-        self.text_sub_chunk = text_sub_chunk
+        self.text_sub_chunks = text_sub_chunks
+        self.effect_id_tag = effect_id_tag
         self.loop = True
         self.bounce_max_height = 5
         self.time_to_complete_bounce = 0.5
         self.time_acc = 0.0
         self.bounce_height = 0
-        self.text_owner.set_text_offset_pos((0, 0), self.text_sub_chunk)
+        if self.text_sub_chunks is not None:
+            for chunk in self.text_sub_chunks:
+                self.text_owner.set_text_offset_pos((0, 0), chunk)
+        else:
+            self.text_owner.set_text_offset_pos((0, 0))
         self._load_params(params)
 
     def _load_params(self, params: Optional[Dict[str, Any]]):
@@ -72,21 +78,23 @@ class BounceEffect(TextEffect):
             self.time_acc = 0.0
         else:
             # finished effect
-            self.text_owner.stop_finished_effect(self.text_sub_chunk)
+            self.text_owner.stop_finished_effect(self.text_sub_chunks)
 
             event_data = {
                 "ui_element": self.text_owner,
                 "ui_object_id": self.text_owner.get_object_id(),
                 "effect": TEXT_EFFECT_BOUNCE,
             }
-            if self.text_sub_chunk is not None:
-                event_data["effect_tag_id"] = self.text_sub_chunk.effect_id
+            if self.effect_id_tag is not None:
+                event_data["effect_tag_id"] = self.effect_id_tag
             pygame.event.post(pygame.event.Event(UI_TEXT_EFFECT_FINISHED, event_data))
 
     def apply_effect(self):
         """
         Apply the effect to the text
         """
-        self.text_owner.set_text_offset_pos(
-            (0, -self.bounce_height), self.text_sub_chunk
-        )
+        if self.text_sub_chunks is not None:
+            for chunk in self.text_sub_chunks:
+                self.text_owner.set_text_offset_pos((0, -self.bounce_height), chunk)
+        else:
+            self.text_owner.set_text_offset_pos((0, -self.bounce_height))
diff --git a/pygame_gui/core/text/text_effects/expand_contract_effect.py b/pygame_gui/core/text/text_effects/expand_contract_effect.py
index 8d11729..37cf651 100644
--- a/pygame_gui/core/text/text_effects/expand_contract_effect.py
+++ b/pygame_gui/core/text/text_effects/expand_contract_effect.py
@@ -1,4 +1,4 @@
-from typing import Dict, Any, Optional
+from typing import Dict, Any, Optional, List
 
 import pygame
 
@@ -17,17 +17,23 @@ class ExpandContractEffect(TextEffect):
         self,
         text_owner: IUITextOwnerInterface,
         params: Optional[Dict[str, Any]] = None,
-        text_sub_chunk: Optional[TextLineChunkFTFont] = None,
+        text_sub_chunks: List[TextLineChunkFTFont] | None = None,
+        effect_id_tag: str | None = None,
     ):
         super().__init__()
         self.text_owner = text_owner
-        self.text_sub_chunk = text_sub_chunk
+        self.text_sub_chunks = text_sub_chunks
+        self.effect_id_tag = effect_id_tag
         self.loop = True
         self.max_scale = 1.5
         self.time_to_complete_expand_contract = 2.0
         self.time_acc = 0.0
         self.current_scale = 1.0
-        self.text_owner.set_text_scale(self.current_scale, self.text_sub_chunk)
+        if self.text_sub_chunks is not None:
+            for chunk in self.text_sub_chunks:
+                self.text_owner.set_text_scale(self.current_scale, chunk)
+        else:
+            self.text_owner.set_text_scale(self.current_scale)
         self._load_params(params)
 
     def _load_params(self, params: Optional[Dict[str, Any]]):
@@ -74,19 +80,23 @@ class ExpandContractEffect(TextEffect):
             self.time_acc = 0.0
         else:
             # finished effect
-            self.text_owner.stop_finished_effect(self.text_sub_chunk)
+            self.text_owner.stop_finished_effect(self.text_sub_chunks)
 
             event_data = {
                 "ui_element": self.text_owner,
                 "ui_object_id": self.text_owner.get_object_id(),
                 "effect": TEXT_EFFECT_EXPAND_CONTRACT,
             }
-            if self.text_sub_chunk is not None:
-                event_data["effect_tag_id"] = self.text_sub_chunk.effect_id
+            if self.effect_id_tag is not None:
+                event_data["effect_tag_id"] = self.effect_id_tag
             pygame.event.post(pygame.event.Event(UI_TEXT_EFFECT_FINISHED, event_data))
 
     def apply_effect(self):
         """
         Apply the effect to the text
         """
-        self.text_owner.set_text_scale(self.current_scale, self.text_sub_chunk)
+        if self.text_sub_chunks is not None:
+            for chunk in self.text_sub_chunks:
+                self.text_owner.set_text_scale(self.current_scale, chunk)
+        else:
+            self.text_owner.set_text_scale(self.current_scale)
diff --git a/pygame_gui/core/text/text_effects/fade_in_effect.py b/pygame_gui/core/text/text_effects/fade_in_effect.py
index e57b0fa..c2bb146 100644
--- a/pygame_gui/core/text/text_effects/fade_in_effect.py
+++ b/pygame_gui/core/text/text_effects/fade_in_effect.py
@@ -1,4 +1,4 @@
-from typing import Dict, Any, Optional
+from typing import Dict, Any, Optional, List
 
 import pygame
 
@@ -19,15 +19,21 @@ class FadeInEffect(TextEffect):
         self,
         text_owner: IUITextOwnerInterface,
         params: Optional[Dict[str, Any]] = None,
-        text_sub_chunk: Optional[TextLineChunkFTFont] = None,
+        text_sub_chunks: List[TextLineChunkFTFont] | None = None,
+        effect_id_tag: str | None = None,
     ):
         super().__init__()
         self.text_owner = text_owner
-        self.text_sub_chunk = text_sub_chunk
+        self.text_sub_chunks = text_sub_chunks
+        self.effect_id_tag = effect_id_tag
         self.alpha_value = 0
         self.time_per_alpha_change = 0.01
         self.time_per_alpha_change_acc = 0.0
-        self.text_owner.set_text_alpha(0, self.text_sub_chunk)
+        if self.text_sub_chunks is not None:
+            for chunk in self.text_sub_chunks:
+                self.text_owner.set_text_alpha(0, chunk)
+        else:
+            self.text_owner.set_text_alpha(0)
         self._load_params(params)
         self.finished = False
 
@@ -54,15 +60,15 @@ class FadeInEffect(TextEffect):
         elif not self.finished:
             self.finished = True
             # finished effect
-            self.text_owner.stop_finished_effect(self.text_sub_chunk)
+            self.text_owner.stop_finished_effect(self.text_sub_chunks)
 
             event_data = {
                 "ui_element": self.text_owner,
                 "ui_object_id": self.text_owner.get_object_id(),
                 "effect": TEXT_EFFECT_FADE_IN,
             }
-            if self.text_sub_chunk is not None:
-                event_data["effect_tag_id"] = self.text_sub_chunk.effect_id
+            if self.effect_id_tag is not None:
+                event_data["effect_tag_id"] = self.effect_id_tag
             pygame.event.post(pygame.event.Event(UI_TEXT_EFFECT_FINISHED, event_data))
 
     def get_final_alpha(self) -> int:
@@ -77,4 +83,8 @@ class FadeInEffect(TextEffect):
         """
         Apply the effect to the text
         """
-        self.text_owner.set_text_alpha(self.alpha_value, self.text_sub_chunk)
+        if self.text_sub_chunks is not None:
+            for chunk in self.text_sub_chunks:
+                self.text_owner.set_text_alpha(self.alpha_value, chunk)
+        else:
+            self.text_owner.set_text_alpha(self.alpha_value)
diff --git a/pygame_gui/core/text/text_effects/fade_out_effect.py b/pygame_gui/core/text/text_effects/fade_out_effect.py
index 4472644..fbf93f1 100644
--- a/pygame_gui/core/text/text_effects/fade_out_effect.py
+++ b/pygame_gui/core/text/text_effects/fade_out_effect.py
@@ -1,4 +1,4 @@
-from typing import Dict, Any, Optional
+from typing import Dict, Any, Optional, List
 
 import pygame
 
@@ -19,15 +19,21 @@ class FadeOutEffect(TextEffect):
         self,
         text_owner: IUITextOwnerInterface,
         params: Optional[Dict[str, Any]] = None,
-        text_sub_chunk: Optional[TextLineChunkFTFont] = None,
+        text_sub_chunks: List[TextLineChunkFTFont] | None = None,
+        effect_id_tag: str | None = None,
     ):
         super().__init__()
         self.text_owner = text_owner
-        self.text_sub_chunk = text_sub_chunk
+        self.text_sub_chunks = text_sub_chunks
+        self.effect_id_tag = effect_id_tag
         self.alpha_value = 255
         self.time_per_alpha_change = 0.01
         self.time_per_alpha_change_acc = 0.0
-        self.text_owner.set_text_alpha(255, self.text_sub_chunk)
+        if self.text_sub_chunks is not None:
+            for chunk in self.text_sub_chunks:
+                self.text_owner.set_text_alpha(255, chunk)
+        else:
+            self.text_owner.set_text_alpha(255)
         self._load_params(params)
         self.finished = False
 
@@ -55,15 +61,15 @@ class FadeOutEffect(TextEffect):
         elif not self.finished:
             self.finished = True
             # finished effect
-            self.text_owner.stop_finished_effect(self.text_sub_chunk)
+            self.text_owner.stop_finished_effect(self.text_sub_chunks)
 
             event_data = {
                 "ui_element": self.text_owner,
                 "ui_object_id": self.text_owner.get_object_id(),
                 "effect": TEXT_EFFECT_FADE_OUT,
             }
-            if self.text_sub_chunk is not None:
-                event_data["effect_tag_id"] = self.text_sub_chunk.effect_id
+            if self.effect_id_tag is not None:
+                event_data["effect_tag_id"] = self.effect_id_tag
             pygame.event.post(pygame.event.Event(UI_TEXT_EFFECT_FINISHED, event_data))
 
     def get_final_alpha(self) -> int:
@@ -78,4 +84,8 @@ class FadeOutEffect(TextEffect):
         """
         Apply the effect to the text
         """
-        self.text_owner.set_text_alpha(self.alpha_value, self.text_sub_chunk)
+        if self.text_sub_chunks is not None:
+            for chunk in self.text_sub_chunks:
+                self.text_owner.set_text_alpha(self.alpha_value, chunk)
+        else:
+            self.text_owner.set_text_alpha(self.alpha_value)
diff --git a/pygame_gui/core/text/text_effects/shake_effect.py b/pygame_gui/core/text/text_effects/shake_effect.py
index 211ed02..9fe2000 100644
--- a/pygame_gui/core/text/text_effects/shake_effect.py
+++ b/pygame_gui/core/text/text_effects/shake_effect.py
@@ -18,17 +18,23 @@ class ShakeEffect(TextEffect):
         self,
         text_owner: IUITextOwnerInterface,
         params: Optional[Dict[str, Any]] = None,
-        text_sub_chunk: Optional[TextLineChunkFTFont] = None,
+        text_sub_chunks: List[TextLineChunkFTFont] | None = None,
+        effect_id_tag: str | None = None,
     ):
         super().__init__()
         self.text_owner = text_owner
-        self.text_sub_chunk = text_sub_chunk
+        self.text_sub_chunks = text_sub_chunks
+        self.effect_id_tag = effect_id_tag
         self.loop = True
         self.frequency = 24
         self.duration = 1.2
         self.time_acc = 0.0
         self.amplitude = 4
-        self.text_owner.set_text_offset_pos((0, 0), self.text_sub_chunk)
+        if self.text_sub_chunks is not None:
+            for chunk in self.text_sub_chunks:
+                self.text_owner.set_text_offset_pos((0, 0), chunk)
+        else:
+            self.text_owner.set_text_offset_pos((0, 0))
         self._load_params(params)
 
         sample_count = int(self.duration * self.frequency)
@@ -106,19 +112,23 @@ class ShakeEffect(TextEffect):
             self.time_acc = 0.0
         else:
             # finished effect
-            self.text_owner.stop_finished_effect(self.text_sub_chunk)
+            self.text_owner.stop_finished_effect(self.text_sub_chunks)
 
             event_data = {
                 "ui_element": self.text_owner,
                 "ui_object_id": self.text_owner.get_object_id(),
                 "effect": TEXT_EFFECT_SHAKE,
             }
-            if self.text_sub_chunk is not None:
-                event_data["effect_tag_id"] = self.text_sub_chunk.effect_id
+            if self.effect_id_tag is not None:
+                event_data["effect_tag_id"] = self.effect_id_tag
             pygame.event.post(pygame.event.Event(UI_TEXT_EFFECT_FINISHED, event_data))
 
     def apply_effect(self):
         """
         Apply the effect to the text
         """
-        self.text_owner.set_text_offset_pos(self.shake, self.text_sub_chunk)
+        if self.text_sub_chunks is not None:
+            for chunk in self.text_sub_chunks:
+                self.text_owner.set_text_offset_pos(self.shake, chunk)
+        else:
+            self.text_owner.set_text_offset_pos(self.shake)
diff --git a/pygame_gui/core/text/text_effects/tilt_effect.py b/pygame_gui/core/text/text_effects/tilt_effect.py
index 8cfb5e7..8d40681 100644
--- a/pygame_gui/core/text/text_effects/tilt_effect.py
+++ b/pygame_gui/core/text/text_effects/tilt_effect.py
@@ -1,4 +1,4 @@
-from typing import Dict, Any, Optional
+from typing import Dict, Any, Optional, List
 
 import pygame
 
@@ -17,17 +17,24 @@ class TiltEffect(TextEffect):
         self,
         text_owner: IUITextOwnerInterface,
         params: Optional[Dict[str, Any]] = None,
-        text_sub_chunk: Optional[TextLineChunkFTFont] = None,
+        text_sub_chunks: List[TextLineChunkFTFont] | None = None,
+        effect_id_tag: str | None = None,
     ):
         super().__init__()
         self.text_owner = text_owner
-        self.text_sub_chunk = text_sub_chunk
+        self.text_sub_chunks = text_sub_chunks
+        self.effect_id_tag = effect_id_tag
         self.loop = True
         self.max_rotation = 1080
         self.time_to_complete_rotation = 5.0
         self.time_acc = 0.0
         self.current_rotation = 0
-        self.text_owner.set_text_rotation(self.current_rotation, self.text_sub_chunk)
+        if self.text_sub_chunks is not None:
+            for chunk in self.text_sub_chunks:
+                self.text_owner.set_text_rotation(self.current_rotation, chunk)
+        else:
+            self.text_owner.set_text_rotation(self.current_rotation)
+
         self._load_params(params)
 
     def _load_params(self, params: Optional[Dict[str, Any]]):
@@ -70,19 +77,23 @@ class TiltEffect(TextEffect):
             self.time_acc -= self.time_to_complete_rotation
         else:
             # finished effect
-            self.text_owner.stop_finished_effect(self.text_sub_chunk)
+            self.text_owner.stop_finished_effect(self.text_sub_chunks)
 
             event_data = {
                 "ui_element": self.text_owner,
                 "ui_object_id": self.text_owner.get_object_id(),
                 "effect": TEXT_EFFECT_TILT,
             }
-            if self.text_sub_chunk is not None:
-                event_data["effect_tag_id"] = self.text_sub_chunk.effect_id
+            if self.effect_id_tag is not None:
+                event_data["effect_tag_id"] = self.effect_id_tag
             pygame.event.post(pygame.event.Event(UI_TEXT_EFFECT_FINISHED, event_data))
 
     def apply_effect(self):
         """
         Apply the effect to the text
         """
-        self.text_owner.set_text_rotation(self.current_rotation, self.text_sub_chunk)
+        if self.text_sub_chunks is not None:
+            for chunk in self.text_sub_chunks:
+                self.text_owner.set_text_rotation(self.current_rotation, chunk)
+        else:
+            self.text_owner.set_text_rotation(self.current_rotation)
diff --git a/pygame_gui/core/text/text_effects/typing_appear_effect.py b/pygame_gui/core/text/text_effects/typing_appear_effect.py
index 40598a0..65c52f4 100644
--- a/pygame_gui/core/text/text_effects/typing_appear_effect.py
+++ b/pygame_gui/core/text/text_effects/typing_appear_effect.py
@@ -1,5 +1,5 @@
 import random
-from typing import Dict, Any, Optional
+from typing import Dict, Any, Optional, List
 
 import pygame
 
@@ -20,19 +20,46 @@ class TypingAppearEffect(TextEffect):
         self,
         text_owner: IUITextOwnerInterface,
         params: Optional[Dict[str, Any]] = None,
-        text_sub_chunk: Optional[TextLineChunkFTFont] = None,
+        text_sub_chunks: List[TextLineChunkFTFont] | None = None,
+        effect_id_tag: str | None = None,
     ):
         super().__init__()
         self.text_owner = text_owner
-        self.text_sub_chunk = text_sub_chunk
+        self.text_sub_chunks = text_sub_chunks
+        self.effect_id_tag = effect_id_tag
+        self.text_sub_chunk_lengths = None
+        self.text_sub_chunk_progress = None
+        if self.text_sub_chunks is not None:
+            self.text_sub_chunk_lengths = []
+            self.text_sub_chunk_progress = []
+            for chunk in self.text_sub_chunks:
+                self.text_sub_chunk_lengths.append(
+                    self.text_owner.get_text_letter_count(chunk)
+                )
+                self.text_sub_chunk_progress.append(0)
+
+        self.effect_letter_count = 0
+        if self.text_sub_chunks is None:
+            self.effect_letter_count = self.text_owner.get_text_letter_count(None)
+        else:
+            for chunk in self.text_sub_chunks:
+                self.effect_letter_count += self.text_owner.get_text_letter_count(chunk)
+
         self.text_progress = 0
         self.time_per_letter = 0.05
         self.time_average_deviation = 0.0
         self.time_per_letter_acc = 0.0
-        self.text_owner.clear_text_surface(self.text_sub_chunk)
-        self.text_owner.update_text_end_position(
-            self.text_progress, self.text_sub_chunk
-        )
+        if self.text_sub_chunks is not None:
+            for chunk in self.text_sub_chunks:
+                self.text_owner.clear_text_surface(chunk)
+        else:
+            self.text_owner.clear_text_surface(None)
+
+        if self.text_sub_chunks is not None:
+            for chunk in self.text_sub_chunks:
+                self.text_owner.update_text_end_position(self.text_progress, chunk)
+        else:
+            self.text_owner.update_text_end_position(self.text_progress, None)
 
         self._load_params(params)
 
@@ -59,9 +86,8 @@ class TypingAppearEffect(TextEffect):
 
         :param time_delta: time in seconds since last frame.
         """
-        if self.text_progress < self.text_owner.get_text_letter_count(
-            self.text_sub_chunk
-        ):
+
+        if self.text_progress < self.effect_letter_count:
             self.time_per_letter_acc += time_delta
             if self.time_per_letter_acc >= self.current_time_per_letter:
                 self.time_per_letter_acc -= self.current_time_per_letter
@@ -77,23 +103,40 @@ class TypingAppearEffect(TextEffect):
                     ),
                     self.time_per_letter - self.time_average_deviation,
                 )
+            if (
+                self.text_sub_chunk_lengths is not None
+                and self.text_sub_chunk_progress is not None
+            ):
+                progress_decrement = self.text_progress
+                for index, chunk_length in enumerate(self.text_sub_chunk_lengths):
+                    chunk_progress = min(progress_decrement, chunk_length)
+                    progress_decrement -= chunk_progress
+                    self.text_sub_chunk_progress[index] = chunk_progress
+
         else:
             # finished effect
-            self.text_owner.stop_finished_effect(self.text_sub_chunk)
+            self.text_owner.stop_finished_effect(self.text_sub_chunks)
 
             event_data = {
                 "ui_element": self.text_owner,
                 "ui_object_id": self.text_owner.get_object_id(),
                 "effect": TEXT_EFFECT_TYPING_APPEAR,
             }
-            if self.text_sub_chunk is not None:
-                event_data["effect_tag_id"] = self.text_sub_chunk.effect_id
+            if self.effect_id_tag is not None:
+                event_data["effect_tag_id"] = self.effect_id_tag
             pygame.event.post(pygame.event.Event(UI_TEXT_EFFECT_FINISHED, event_data))
 
     def apply_effect(self):
         """
         Apply the effect to the text
         """
-        self.text_owner.update_text_end_position(
-            self.text_progress, self.text_sub_chunk
-        )
+        if (
+            self.text_sub_chunks is not None
+            and self.text_sub_chunk_progress is not None
+        ):
+            for index, chunk in enumerate(self.text_sub_chunks):
+                self.text_owner.update_text_end_position(
+                    self.text_sub_chunk_progress[index], chunk
+                )
+        else:
+            self.text_owner.update_text_end_position(self.text_progress, None)
diff --git a/pygame_gui/core/text/text_line_chunk.py b/pygame_gui/core/text/text_line_chunk.py
index 5167b88..5f82280 100644
--- a/pygame_gui/core/text/text_line_chunk.py
+++ b/pygame_gui/core/text/text_line_chunk.py
@@ -38,7 +38,7 @@ class TextLineChunkFTFont(TextLayoutRect):
         bg_colour: Color | ColourGradient,
         text_shadow_data: Optional[Tuple[int, int, int, Color, bool]] = None,
         max_dimensions: Optional[List[int]] = None,
-        effect_id: Optional[str] = None,
+        effect_ids: Optional[List[str]] = None,
     ):
         self.text_shadow_data = text_shadow_data
         self.max_dimensions = max_dimensions
@@ -91,7 +91,7 @@ class TextLineChunkFTFont(TextLayoutRect):
 
         self.should_centre_from_baseline = False
 
-        self.effect_id = effect_id
+        self.effect_ids = effect_ids
 
         # this is the rect before we add it to a row and start moving the origin
         self.pre_row_rect = Rect(self.topleft, self.size)
@@ -166,7 +166,7 @@ class TextLineChunkFTFont(TextLayoutRect):
         match_bg_color = self.bg_colour == other_text_chunk.bg_colour
         match_shadow_data = self.text_shadow_data == other_text_chunk.text_shadow_data
         match_active = self.is_active == other_text_chunk.is_active
-        match_effect_id = self.effect_id == other_text_chunk.effect_id
+        match_effect_id = self.effect_ids == other_text_chunk.effect_ids
 
         return (
             match_fonts
@@ -669,7 +669,7 @@ class TextLineChunkFTFont(TextLayoutRect):
         right_side_chunk.target_surface = target_surface
         right_side_chunk.target_surface_area = target_surface_area
         right_side_chunk.should_centre_from_baseline = baseline_centred
-        right_side_chunk.effect_id = self.effect_id
+        right_side_chunk.effect_ids = self.effect_ids
         return right_side_chunk
 
     def clear(self, optional_rect: Optional[Rect] = None):
diff --git a/pygame_gui/elements/ui_label.py b/pygame_gui/elements/ui_label.py
index c76058a..ce8aa47 100644
--- a/pygame_gui/elements/ui_label.py
+++ b/pygame_gui/elements/ui_label.py
@@ -1,5 +1,5 @@
 import warnings
-from typing import Union, Tuple, Dict, Optional, Any
+from typing import Union, Tuple, Dict, Optional, Any, List
 
 import pygame
 from pygame_gui.core.utility import translate
@@ -480,10 +480,14 @@ class UILabel(UIElement, IUITextOwnerInterface):
             self.active_text_effect.text_changed = True
             self.update_text_effect(0.0)
 
-    def stop_finished_effect(self, sub_chunk: Optional[TextLineChunkFTFont] = None):
+    def stop_finished_effect(
+        self, sub_chunks: Optional[List[TextLineChunkFTFont]] = None
+    ):
         self.active_text_effect = None
 
-    def clear_all_active_effects(self, sub_chunk: Optional[TextLineChunkFTFont] = None):
+    def clear_all_active_effects(
+        self, sub_chunks: Optional[List[TextLineChunkFTFont]] = None
+    ):
         if (
             self.drawable_shape is not None
             and self.drawable_shape.text_box_layout is not None
diff --git a/pygame_gui/elements/ui_text_box.py b/pygame_gui/elements/ui_text_box.py
index e9b703c..8e2a779 100644
--- a/pygame_gui/elements/ui_text_box.py
+++ b/pygame_gui/elements/ui_text_box.py
@@ -179,7 +179,7 @@ class UITextBox(UIElement, IUITextOwnerInterface):
 
         self.active_text_effect: Optional[TextEffect] = None
         self.active_text_chunk_effects: List[
-            Dict[str, TextLineChunkFTFont | TextEffect]
+            Dict[str, List[TextLineChunkFTFont] | TextEffect]
         ] = []
 
         self.scroll_bar_width = 20
@@ -1720,40 +1720,45 @@ class UITextBox(UIElement, IUITextOwnerInterface):
                 redrew_all_chunks = True
             # we have a tag so only want to apply our effect to tagged chunks
             # first see if we have any tagged chunks in the layout
+            chunks_with_tag = []
             for row in self.text_box_layout.layout_rows:
                 for chunk in row.items:
                     if (
                         isinstance(chunk, TextLineChunkFTFont)
-                        and chunk.effect_id == effect_tag
+                        and chunk.effect_ids is not None
+                        and effect_tag in chunk.effect_ids
                     ):
                         if not redrew_all_chunks:
-                            self.clear_all_active_effects(chunk)
-                        effect: Optional[TextEffect] = None
-                        if effect_type == TEXT_EFFECT_TYPING_APPEAR:
-                            effect = TypingAppearEffect(self, params, chunk)
-                        elif effect_type == TEXT_EFFECT_FADE_IN:
-                            effect = FadeInEffect(self, params, chunk)
-                        elif effect_type == TEXT_EFFECT_FADE_OUT:
-                            effect = FadeOutEffect(self, params, chunk)
-                        elif effect_type == TEXT_EFFECT_BOUNCE:
-                            effect = BounceEffect(self, params, chunk)
-                        elif effect_type == TEXT_EFFECT_TILT:
-                            effect = TiltEffect(self, params, chunk)
-                        elif effect_type == TEXT_EFFECT_EXPAND_CONTRACT:
-                            effect = ExpandContractEffect(self, params, chunk)
-                        elif effect_type == TEXT_EFFECT_SHAKE:
-                            effect = ShakeEffect(self, params, chunk)
-                        else:
-                            warnings.warn(
-                                f"Unsupported effect name: {str(effect_type)} for text chunk"
-                            )
-                        if effect is not None:
-                            chunk.grab_pre_effect_surface()
-                            self.active_text_chunk_effects.append(
-                                {"chunk": chunk, "effect": effect}
-                            )
-                            effect.text_changed = True
-                            self.update_text_effect(0.0)
+                            self.clear_all_active_effects([chunk])
+                        chunks_with_tag.append(chunk)
+
+            effect: Optional[TextEffect] = None
+            if effect_type == TEXT_EFFECT_TYPING_APPEAR:
+                effect = TypingAppearEffect(self, params, chunks_with_tag, effect_tag)
+            elif effect_type == TEXT_EFFECT_FADE_IN:
+                effect = FadeInEffect(self, params, chunks_with_tag, effect_tag)
+            elif effect_type == TEXT_EFFECT_FADE_OUT:
+                effect = FadeOutEffect(self, params, chunks_with_tag, effect_tag)
+            elif effect_type == TEXT_EFFECT_BOUNCE:
+                effect = BounceEffect(self, params, chunks_with_tag, effect_tag)
+            elif effect_type == TEXT_EFFECT_TILT:
+                effect = TiltEffect(self, params, chunks_with_tag, effect_tag)
+            elif effect_type == TEXT_EFFECT_EXPAND_CONTRACT:
+                effect = ExpandContractEffect(self, params, chunks_with_tag, effect_tag)
+            elif effect_type == TEXT_EFFECT_SHAKE:
+                effect = ShakeEffect(self, params, chunks_with_tag, effect_tag)
+            else:
+                warnings.warn(
+                    f"Unsupported effect name: {str(effect_type)} for text chunk"
+                )
+            if effect is not None:
+                for chunk in chunks_with_tag:
+                    chunk.grab_pre_effect_surface()
+                self.active_text_chunk_effects.append(
+                    {"chunks": chunks_with_tag, "effect": effect}
+                )
+                effect.text_changed = True
+                self.update_text_effect(0.0)
         else:
             if (
                 self.active_text_effect is not None
@@ -1786,42 +1791,51 @@ class UITextBox(UIElement, IUITextOwnerInterface):
                 self.active_text_effect.text_changed = True
                 self.update_text_effect(0.0)
 
-    def stop_finished_effect(self, sub_chunk: Optional[TextLineChunkFTFont] = None):
-        if sub_chunk is None:
+    def stop_finished_effect(
+        self, sub_chunks: Optional[List[TextLineChunkFTFont]] = None
+    ):
+        if sub_chunks is None:
             self.active_text_effect = None
         else:
-            self.active_text_chunk_effects = [
-                effect_chunk
-                for effect_chunk in self.active_text_chunk_effects
-                if effect_chunk["chunk"] != sub_chunk
-            ]
+            self._remove_chunks_from_active_effects(sub_chunks)
 
-    def clear_all_active_effects(self, sub_chunk: Optional[TextLineChunkFTFont] = None):
+    def _remove_chunks_from_active_effects(self, sub_chunks):
+        self.active_text_chunk_effects = [
+            effect_chunk
+            for effect_chunk in self.active_text_chunk_effects
+            if sub_chunks != effect_chunk["chunks"]
+        ]
+
+    def clear_all_active_effects(
+        self, sub_chunks: Optional[List[TextLineChunkFTFont]] = None
+    ):
         if self.text_box_layout is None:
             return
-        if sub_chunk is None:
+        if sub_chunks is None:
             self.active_text_effect = None
             self.text_box_layout.clear_effects()
             for effect_chunk in self.active_text_chunk_effects:
-                if isinstance(effect_chunk["chunk"], TextLineChunkFTFont):
-                    effect_chunk["chunk"].clear_effects()
+                chunk_list = effect_chunk["chunks"]
+                if isinstance(chunk_list, list):
+                    for chunk in chunk_list:
+                        if isinstance(chunk, TextLineChunkFTFont):
+                            chunk.clear_effects()
             self.active_text_chunk_effects = []
             self.text_box_layout.finalise_to_new()
         else:
-            self.active_text_chunk_effects = [
-                effect_chunk
-                for effect_chunk in self.active_text_chunk_effects
-                if effect_chunk["chunk"] != sub_chunk
-            ]
-            sub_chunk.clear_effects()
+            self._remove_chunks_from_active_effects(sub_chunks)
 
-            effect_chunks = [
-                affected_chunk["chunk"]
-                for affected_chunk in self.active_text_chunk_effects
-            ]
-            self.text_box_layout.redraw_other_chunks(effect_chunks)
+            chunks_to_redraw = []
+            for affected_chunk in self.active_text_chunk_effects:
+                chunk_list = affected_chunk["chunks"]
+                if isinstance(chunk_list, list):
+                    for chunk in chunk_list:
+                        chunks_to_redraw.append(chunk)
+            self.text_box_layout.redraw_other_chunks(chunks_to_redraw)
 
-            sub_chunk.redraw()
+            for sub_chunk in sub_chunks:
+                sub_chunk.clear_effects()
+                sub_chunk.redraw()
 
     def update_text_effect(self, time_delta: float):
         if self.active_text_effect is not None:
@@ -1845,15 +1859,17 @@ class UITextBox(UIElement, IUITextOwnerInterface):
                     ):
                         any_text_changed = True
             if any_text_changed:
-                effect_chunks = []
-                for affected_chunk in self.active_text_chunk_effects:
-                    chunk = affected_chunk["chunk"]
-                    if isinstance(chunk, TextLineChunkFTFont):
-                        chunk.clear(chunk.transform_effect_rect)
-                        effect_chunks.append(chunk)
+                chunks_to_redraw = []
+                for effect_chunks in self.active_text_chunk_effects:
+                    chunk_list = effect_chunks["chunks"]
+                    if isinstance(chunk_list, list):
+                        for chunk in chunk_list:
+                            if isinstance(chunk, TextLineChunkFTFont):
+                                chunk.clear(chunk.transform_effect_rect)
+                                chunks_to_redraw.append(chunk)
 
                 if self.text_box_layout is not None:
-                    self.text_box_layout.redraw_other_chunks(effect_chunks)
+                    self.text_box_layout.redraw_other_chunks(chunks_to_redraw)
 
                 for affected_chunk in self.active_text_chunk_effects:
                     if isinstance(affected_chunk["effect"], TextEffect):


Test patch:
diff --git a/tests/test_core/test_text/test_text_effects.py b/tests/test_core/test_text/test_text_effects.py
index 5ef70fc..ba0a8b9 100644
--- a/tests/test_core/test_text/test_text_effects.py
+++ b/tests/test_core/test_text/test_text_effects.py
@@ -481,7 +481,7 @@ class TestBounceEffect:
 
         text_box.set_active_effect(TEXT_EFFECT_BOUNCE, effect_tag='test')
         effect = text_box.active_text_chunk_effects[0]['effect']
-        chunk = text_box.active_text_chunk_effects[0]['chunk']
+        chunk = text_box.active_text_chunk_effects[0]['chunks'][0]
 
         assert chunk.effects_offset_pos == (0, 0)
 
@@ -599,7 +599,7 @@ class TestTiltEffect:
 
         text_box.set_active_effect(TEXT_EFFECT_TILT, effect_tag='test')
         effect: TiltEffect = text_box.active_text_chunk_effects[0]['effect']
-        chunk: TextLineChunkFTFont = text_box.active_text_chunk_effects[0]['chunk']
+        chunk: TextLineChunkFTFont = text_box.active_text_chunk_effects[0]['chunks'][0]
 
         assert chunk.effects_rotation == 0
 
@@ -706,7 +706,7 @@ class TestExpandContractEffect:
 
         text_box.set_active_effect(TEXT_EFFECT_EXPAND_CONTRACT, effect_tag='test')
         effect: ExpandContractEffect = text_box.active_text_chunk_effects[0]['effect']
-        chunk: TextLineChunkFTFont = text_box.active_text_chunk_effects[0]['chunk']
+        chunk: TextLineChunkFTFont = text_box.active_text_chunk_effects[0]['chunks'][0]
 
         assert chunk.effects_scale == 1.0
 
@@ -840,15 +840,15 @@ class TestShakeEffect:
 
         text_box.set_active_effect(TEXT_EFFECT_SHAKE, effect_tag='test')
         effect = text_box.active_text_chunk_effects[0]['effect']
-        chunk = text_box.active_text_chunk_effects[0]['chunk']
+        chunks = text_box.active_text_chunk_effects[0]['chunks']
 
-        assert chunk.effects_offset_pos == effect.shake
+        assert chunks[0].effects_offset_pos == effect.shake
 
         effect.update(time_delta=0.06)
         effect.update(time_delta=0.06)
         effect.apply_effect()
 
-        assert chunk.effects_offset_pos == effect.shake
+        assert chunks[0].effects_offset_pos == effect.shake
 
     def test_params(self, _init_pygame, _display_surface_return_none, default_ui_manager: UIManager):
         text_box = UITextBox('<effect id=test>Hello world</effect>',